前言
最近在研究ClickHouse的那套相当复杂的列存储与列管理方式,主要是我的另外一篇文章《ClickHouse 一次Schema修改造成的Merge阻塞问题的分析和解决过程》中涉及到了各种复合列的子流的构造过程,因此以IColumn这个类为起点,我开始对ClickHouse中列的管理发生兴趣。
ClickHouse 的查询性能和工程设计,很大程度上都建立在列对象的组织、共享、拷贝、修改和生命周期管理之上,而这些能力并不是通过一眼就能看懂的直白代码实现出来的。
为了达到极致的性能追求,我逐渐发现 ClickHouse 在很多关键位置都大量使用了模板、继承和静态分发等技巧,其中很典型的一种就是 CRTP。像 class IColumn : public COW<IColumn> 这样的写法,第一次看到时会觉得非常反直觉,但如果把它放回 ClickHouse 的列抽象、写时复制和对象管理语境里看,它又不是孤立的语法花活,而是一种服务于整体设计目标的实现手段。
所以,本文详细介绍了ClickHouse底层的列管理,其中大量基于CRTP的实现方式,从中我们可以看到一个追求极致性能的计算和存储引擎都做了哪些优化,其中一些代码和设计精美到让人叹为观止的程度。
因为我是Java出身,本来阅读C++代码就比较困难,更何况是阅读ClickHouse这种及其艰深、极致追求性能因此放弃代码可读性的底层引擎代码。但是好在我一边阅读,一边学习C++的各种语法,终于写成该文章。所以,这篇文章在解释和阐述ClickHouse的列管理的同时,详细解释了CRTP, 运行时多态、编译时多态、动态和静态类型转换等等很多的C++语法,我相信,攻克了这些语法,再看ClickHouse其他偏上层的设计代码,就容易很多了。。。。
CRTP和在ClickHouse中的应用
我在看ClickHouse的代码的时候,在看到 IColumn 相关代码的时候,发现了奇异递归模板的相关内容:
cpp
class IColumn;
using ColumnPtr = COW<IColumn>::Ptr;
然后,我看到 IColumn 和 COW 的声明和部分代码片段如下:
cpp
template <typename Derived>
class COW : public boost::intrusive_ref_counter<Derived>
{
private:
Derived * derived() { return static_cast<Derived *>(this); } // 编译期向下转换
const Derived * derived() const { return static_cast<const Derived *>(this); } // const重载,编译期向下转换
cpp
/// Declares interface to store columns in memory.
class IColumn : public COW<IColumn>
{
private:
friend class COW<IColumn>;
/// Creates the same column with the same data.
/// This is internal method to use from COW.
/// It performs shallow copy with copy-ctor and not useful from outside.
/// If you want to copy column for modification, look at 'mutate' method.
[[nodiscard]] virtual MutablePtr clone() const = 0;
整个代码看起来非常奇怪, 因为 IColumn 类的声明中居然继承了一个模板类 COW<IColumn>,这个模板类的模板参数居然是子类 IColumn 本身,看起来似乎是一个循环依赖:A(IColumn) 依赖 COW<IColumn>,而 COW<IColumn> 又反过来依赖 IColumn。如果真是这样,那么这种写法为什么没有在编译阶段直接报错?编译器在看到这里的时候,IColumn 明明还没有完整定义,为什么却已经可以作为 COW 的模板参数出现在继承列表里?更进一步,COW 内部那些明显依赖 Derived 具体能力的代码,并不知道Derived到底是哪个类,那么编译器是怎么做检查的?
CRTP(Curiously Recurring Template Pattern)是什么
CRTP(Curiously Recurring Template Pattern,奇异递归模板) 是我在看 ClickHouse、LLVM、folly 这类大型 C++ 项目时一定会反复遇到的东西,而且它正好是 Java 背景的人最容易"看懂但没吃透" 的那类特性。
我们看一下cppreference中对CRTP的解释:
-
The Curiously Recurring Template Pattern is an idiom in which a class X derives from a class template Y, taking a template parameter Z, where Y is instantiated with Z = X. For example,
cpptemplate<class Z> class Y {}; class X : public Y<X> {};
下面的例子是cppreference中给出的CRTP的一个典型例子:
cpp
#include <cstdio>
template <class Derived>
struct Base
{
void name() { static_cast<Derived*>(this)->impl(); } // 编译期向下转换
protected:
Base() = default; // prohibits the creation of Base objects, which is UB
};
struct D1 : public Base<D1> { void impl() { std::puts("D1::impl()"); } };
struct D2 : public Base<D2> { void impl() { std::puts("D2::impl()"); } };
int main()
{
D1 d1; d1.name(); // 在调用的时候,才会实例化name()方法的代码,而不是在编译期实例化
D2 d2; d2.name(); // 在调用的时候,才会实例化name()方法的代码,而不是在编译期实例化
// D3 d3; d3.name3(); 这个显然会在编译期报错
}
程序输出 :
cpp
D1::impl()
D2::impl()
其实,这里的一个关键疑惑点是: 在基类 Base<Derived> 中,基类 Base 本身其实根本不知道 Derived 的具体实例,更不知道 Derived 有一个什么叫做 impl() 的方法,为什么 static_cast<Derived*>(this)->impl(); 不会报错?它怎么确定 Derived 所代表的子类一定会有一个成员方法 impl() 呢?
-
成员函数只有在"被调用"时才会实例化
C++ 编译器处理类模板时,并不是一次性检查所有代码的。
类定义阶段:当编译器看到
Base<Derived>的定义时,它只进行基本的语法检查(比如括号是否匹配、关键字是否正确)。成员函数实例化阶段:基类的成员函数
name()只有在它真正被调用时(例如在 main 函数里执行d1.name()),编译器才会去实例化这个函数的代码 。 -
实例化时的上下文已完整
当我调用
d1.name()时,编译器已经处理到了派生类D1的定义。此时,模板参数
Derived已经被确定为D1。编译器在实例化
Base<D1>::name()的代码时,它已经完整地看到了 structD1的定义,因此它知道D1确实拥有一个名为impl()的成员函数 。如果此时
D1里没有impl(),编译器在这个阶段才会报错。 -
这种模式的本质,即CRTP的本质:
- 编译期多态:区别于普通的运行时多态,编译期多态利用了基类是模板的特性,在不使用虚函数(Virtual Function)的情况下实现了类似多态的效果。
- 显式转换:通过
static_cast<Derived*>(this),显式地告诉编译器:"虽然我现在是基类,但我保证我实际上是一个Derived类型的对象" 。- 在普通继承和运行时多态中,基类在编译期永远只知道自己类型(
Base),不知道具体派生类(Derived)。 - 而
CRTP的作用就是:把派生类类型在编译期传给基类。
- 在普通继承和运行时多态中,基类在编译期永远只知道自己类型(
所以,在 CRTP 中,类模板的成员函数(name(),name3())只有在被显式调用时才会被编译器实例化(即,被编译),这是 CRTP 的核心思想。
怎么理解这句话?可以看下面的例子,我对类模板 Base<Derived> 进行了扩展,增加了一个方法 name3(),name3() 中调用的是 Derived->impl_3():
cpp
#include <cstdio>
template <class Derived>
struct Base
{
void name() { static_cast<Derived*>(this)->impl(); }
void name3() { static_cast<Derived*>(this)->impl_3(); }
protected:
Base() = default; // prohibits the creation of Base objects, which is UB
};
struct D1 : public Base<D1> { void impl() { std::puts("D1::impl()"); } };
struct D2 : public Base<D2> { void impl() { std::puts("D2::impl()"); } };
struct D3 : public Base<D3> { void impl_3(){ std::puts("D3::impl_3()"); }};
int main()
{
D1 d1; d1.name();
D2 d2; d2.name();
D3 d3; d3.name3();
}
程序输出 :
cpp
D1::impl()
D2::impl()
D3::impl_3()
这说明:
- 当我调用
d1.name()时,编译器只会实例化Base<D1>::name()方法。在这个函数里,它会检查D1是否有impl(),但是绝对不会检查D1是否会有impl_3()方法; - 当我调用
d3.name3()时,编译器只会实例化Base<D3>::name3()。它会检查D3是否有impl_3(),但是绝对不会检查D3是否会有impl()方法;
- 为什么
D1不报错?
虽然Base<Derived>中定义了name3(),name3()中调用了Derived->impl_3()方法,并且,代码中D1根本就没有impl_3()方法,但是,对于D1来说:只要D1不调用d1.name3(),编译器就永远不会去尝试编译Base<D1>::name3()里面的代码。这里的根本原因是:-
对于CRTP模板,模板成员函数只有在"被用到(odr-used)"时才会实例化。 。那么,什么叫"被用到"? 比如,我们写:
cppD1 d1; d1.name3(); // ← 这里触发实例化 -
编译器会生成下面的代码并针对下面的代码进行语法检查,最直接的,检查D1是否含有成员方法impl_3():
cppvoid Base<D1>::name3() { static_cast<D1*>(this)->impl_3(); // 编译报错 }我们看到,这个实例化根本就没有理睬name()方法,只实例化了name3()方法,即按需实例化。
-
但是,只要D1从来没有调用过name3()方法,即使
D1缺失impl_3()方法,,编译器就会视而不见。
-
这种做法被称为可选接口(Optional Interface)。
-
基类(
Base) 提供了一个巨大的"功能超市"。 -
派生类(
D1,D2,D3) 就像顾客,只需要实现它们感兴趣的那部分功能对应的底层函数。 -
如果你不小心调用了一个派生类没实现的函数(比如
d1.name3()),编译器会在编译期间报错,提醒我D1缺少impl_3(),这是因为: -
编译器看到我调用了
d1::name3(),那么,编译期会进行模板实例化:
cpp void Base<D1>::name3() { static_cast<D1*>(this)->impl_3(); } -
然后,检查impl_3()是否存在,却发现D1中根本没有impl_3()方法:
cpp struct D1 { void impl(); };
于是报错。
dynamic_cast, static_cast, const_cast和assert_cast
向下类型转换和向上类型转换
-
向上类型转换
cppstruct Base { int x; virtual ~Base() = default; }; struct Derived : Base { int y; };其中,向上类型转换的含义就是,把"派生类指针/引用"转换成"基类指针/引用"
我们认为,向上类型转换是天然安全的,因为
因为:
-
Derived对象内部包含一个Base基类子对象 -
Base* 只是指向那块子对象 -
不会访问越界内存
所以,📌 向上转换是天然安全的Derived d;
Base* bp = &d;
为什么这行代码总是合法?
因为: bp 指向的,正是 d 对象内部的
Base基类子对象也就是说:
bp == (地址指向 d 内部 Base 子对象的起始位置)这不是"魔法",是对象模型的必然结果。
有多层继承时,基类子对象也会"层层嵌套"
cppstruct A { int a; }; struct B : A { int b; }; struct C : B { int c; };我们假设,int 占 4 字节, 无虚函数(没有 vptr), 无对齐填充(为简单说明)
其内存概念图如下所示:
text低地址 ┌──────────────┐ │ A::a │ ← 属于 A 子对象 ├──────────────┤ │ B::b │ ← 属于 B 子对象 ├──────────────┤ │ C::c │ ← 属于 C 自己 └──────────────┘ 高地址对应的内存逻辑视图如下所示:
textC 对象 ┌────────────────────────────┐ │ B 基类子对象 │ ← C 继承 B │ ┌────────────────────────┐ │ │ │ A 基类子对象 │ ← B 继承 A │ │ ┌────────────────────┐ │ │ │ │ │ int a │ │ │ │ │ └────────────────────┘ │ │ │ │ int b │ │ │ └────────────────────────┘ │ │ int c │ └────────────────────────────┘从内存图中我们必须看到: B 不是"包含一个 A 指针", B 是"物理上包含一个 A 子对象", C 也是"物理上包含一个完整的 B 子对象", 所以 A 就被间接包含进 C
-
-
向下类型转换
向下类型转换指的是把"基类指针/引用"转换成"派生类指针/引用"
cppBase* bp = new Derived; Derived* dp = ??? // 向下转换相比于安全的向上类型转换,向下类型转换是天然不安全的:
cppBase* bp = new Base; Derived* dp = static_cast<Derived*>(bp); // 危险!如果 bp 实际指向的是
Base对象,而不是Derived,那
dp->y会访问不存在的内存。 -
两种类型转换的内存图解
假设:
Derived d;
Derived对象 内存布局:text┌─────────────────────┐ │ Base 基类子对象 │ │ └─ x │ │ Derived 自己的成员 │ │ └─ y │ └─────────────────────┘向上转换
Derived* → Base*只是把指针改为指向:
cpp┌─> Base 子对象因此安全。
向下转换
cppBase* → Derived*编译器必须"假设":
这个 Base* 其实指向的是一个更大的 Derived 对象如果假设错了,那么就会转换失败,即Undefined Behavior(
UB)。 -
C++ 提供的几种转换方式
-
隐式向上转换(自动发生)
cppDerived d; Base* bp = &d; // 自动 -
编译期转换(
static_cast)-
编译期向上转换
等价于隐式向上转换Derived* dp; Base* bp = static_cast<Base*>(dp); -
编译期向下转换
cppBase* bp = new Derived; Derived* dp = static_cast<Derived*>(bp);这个转换不做运行时检查,如果bp实际上根本不是Derived,那么在运行时会导致Undefined Behavior(
UB)
-
-
运行时检查(
dynamic_cast)。这个涉及到虚函数表,我们下文会详细讲解
-
dynamic_cast和虚函数相关理解
dynamic_cast 用于在"多态类型"中进行安全的向下转换或横向转换,并在运行时检查类型是否匹配。关键信息是,在运行时检查,而不是编译期检查。
所以,由于dynamic_cast依赖RTII(运行时类型信息),因此,使用dynamic_cast的要求是,基类必须是多态类型,即:
cpp
struct Base {
virtual ~Base() {} // 必须至少有一个虚函数
};
这里,基类是多态类型的意思是,基类至少得有一个函数是虚函数(不要求所有函数都是虚函数)。在我们上面的例子中,基类的析构函数被定义成了虚函数。
cpp
struct Base {};
struct Derived : Base {};
Base* bp = new Derived;
dynamic_cast<Derived*>(bp); // ❌ 编译错误
dynamic_cast的最核心例子,是进行安全的向下类型转换:
cpp
#include <iostream>
struct Base { // 多态类型的基类
virtual ~Base() {} // 析构函数被定义成虚函数
};
struct Derived : Base {
void hello() { std::cout << "Derived\n"; }
};
int main() {
Base* bp = new Derived; // 隐式向上类型转换
Base* bp2 = new Base;
Derived* dp = dynamic_cast<Derived*>(bp); // dynamic_cast进行向下类型转换
Derived* dp2 = dynamic_cast<Derived*>(bp2);
if (dp) {
dp->hello(); // cast success
}
if (dp2) {
dp2->hello();
} else {
std::cout << "dynamic_cast not success\n"; // dynami_cast 失败,但是并不会在编译期失败,也不会在运行时失败,只是返回了一个null而已
}
}
在上面的例子中, 如果 bp 实际指向 Derived,那么就可以转换成功,并且返回非 nullptr。
而由于bp2并没有指向Derived,因此dynamic_cast失败,但是我们运行结果显示,这个失败并不会在运行时抛出异常,只是会返回一个nullptr(在上面的例子中,打印 dynamic_cast not success)
dynamic_cast和static_cast
dynamic_cast和static_cast二者都是主要用于继承体系中的类型转换。
cpp
struct Base {
virtual ~Base() {}
};
struct Derived : Base {};
Base* b = new Derived; // 向上转换,隐式类型转换,天然安全,隐式转换即可,不需要static_cast或者dynamic_cast
Derived* d = static_cast<Derived*>(b); // 向下转换,不安全
- 是否需要多态
他们之间最大的区别就是,dyncmic_cast必须要求基类是多态类(polymorphic class),原因是:
text
dynamic_cast 需要 RTTI
RTTI 在 vtable 中
vtable 只有 polymorphic class 才有
而static_cast是不需要RTTI的。
- 检查的时机和检查的机制
在上面的static_cast引发的向下类型转换的例子中,编译期在编译期间只检查Derived 是否继承Base,由于有继承关系,所以编译通过。但是有继承关系,并不代表运行时一定没问题,如下所示:
cpp
Base* b = new Derived;
Derived* d = static_cast<Derived*>(b); // 编译期通过,运行时也没问题,
Base* b1 = new Base;
Derived* d1 = static_cast<Derived*>(b1); // 编译期通过,运行时报错,这里,无论是static_cast还是dynamic_cast都会在编译期检查通过
在这里,把b1进行静态转换成d1会产生UB,可能crash,可能内存破坏,可能看起来正常但是数据不正确,因为这一块内存区域实际上是Base 对象。
dynamic_cast也会进行有限的编译期检查,主要包括:
-
语法层面检查
cppstruct A { virtual ~A() {} }; struct B { virtual ~B() {} }; A* a = nullptr; B* b = dynamic_cast<B*>(a); // 编译错误,因为对象a的静态类型和期待类型B之间没有任何继承关系 -
dynamic_cast由于依赖多态,因此要求对象是多态类:
cppstruct Base {}; struct Derived : Base {}; Base* bp = new Derived; Derived* dp = dynamic_cast<Derived*>(bp); // 编译错误,不是多态类
因此,对于下面的代码,dynamic_cast的行为是 :
cpp
Base* b = new Derived;
Derived* d = dynamic_cast<Derived*>(b);
text
1 读取b指针指向的对象的内存区域的vptr,(显然,这个内存区域是Derived对象的内存区域)
2 找到 vtable。(显然,这是Derived类的vtable)
3 vtable → RTTI (显然,这是Derived类的RTTI)
4 RTTI 判断真实类型,(显然,这个Derived类)
5 如果兼容 → 返回指针 (显然,我们的例子中是兼容,因为目标类型是Derived,实际类型也是Derived)
6 如果不兼容 → 返回 nullptr
可以看到,dynamic_cast的整个检查机制无论成功或者失败都是确定的,安全的,即使失败也根本不会发生UB,而是给出一个确定的空指针。
- 成本差异
所以,可以看到,dynamic_cast为了实现转换成功或者失败以后的确定性行为,运行期间的整个查找过程是非常复杂的,而static_cast在运行期间则没有任何查找过程,因此是不安全的,可能发生各种UB。
assert_cast
上面说过,写这篇文档的动因,是我在源码层面解决ClickHouse由于修改Schema以及HighCardinality列的默认值填充所带来的bug的过程,中看到IColumn频繁出现,因此想要探究IColumn的实现原理。
在那篇文章中,我看到大量的assert_cast代码用来进行列的类型转换:
void fillMissingColumns(...)
{
....
size_t data_size = assert_cast<const ColumnUInt64 &>(*current_offsets.back()).getData().back();
....
}
其中,ColumnUInt64其实是ColumnVector模板列的一个类型模板的实现:
cpo
using ColumnUInt64 = ColumnVector<UInt64>;
跟static_cast和dynamic_cast不同,这里的assert_cast并不是C++标准库中的函数,而是ClickHouse自己基于static_cast和dynamic_cast所实现的"契约编程"工具
assert_cast的实现源码清晰地展示了 ClickHouse 如何在极致性能与开发安全性之间取得平衡。它是 ClickHouse 内部一种特殊的"契约编程"工具。
简单来说,assert_cast 就是一个:在开发环境带红外检测,在生产环境直接撤掉所有关卡的快速通道。
在处理海量数据时,每一行数据都可能触发一次类型转换。
- dynamic_cast:虽然安全,但它在运行时需要查询虚函数表(vtable)和 RTTI,太重了。
- static_cast:只是简单的内存地址偏移计算,极快,但如果程序员搞错了类型,会产生未定义行为(UB),直接导致崩溃或数据错乱。
- assert_cast 解决了这个问题:我保证在开发测试阶段能抓到你的错误,但只要通过了测试,在正式发布版本里我就不再浪费任何 CPU 时间。
cpp
/** Perform static_cast in release build.
* Checks type by comparing typeid and throw an exception in debug build.
* The exact match of the type is checked. That is, cast to the ancestor will be unsuccessful.
*/
template <typename To, typename From>
inline To assert_cast(From && from)
{
#ifdef DEBUG_OR_SANITIZER_BUILD // 仅在开发、测试或洗消模式下启用安全检查
try
{
if constexpr (std::is_pointer_v<To>) // 编译期判定:如果目标转换类型是指针
{
// 核心物理校验:对比运行时裸指针指向的真实类型与目标类型是否【完全一致】
if (typeid(*from) == typeid(std::remove_pointer_t<To>))
return static_cast<To>(from); // 校验通过,执行物理地址转换
}
else // 编译期判定:如果目标转换类型是引用
{
// 核心物理校验:对比引用对象的运行时类型与目标类型
if (typeid(from) == typeid(To))
return static_cast<To>(from); // 校验通过,执行物理转换
}
}
catch (const std::exception & e) // 捕获如空指针解引用等底层异常
{
// 将标准异常包装为 ClickHouse 的逻辑错误抛出
throw DB::Exception::createDeprecated(e.what(), DB::ErrorCodes::LOGICAL_ERROR);
}
// 若执行到此,说明 typeid 不匹配,即程序员对对象类型的预期与物理现实不符
// 通过 demangle 将符号名转为可读类名(如 DB::ColumnUInt8),抛出详细的诊断信息
throw DB::Exception(DB::ErrorCodes::LOGICAL_ERROR, "Bad cast from type {} to {}",
demangle(typeid(from).name()), demangle(typeid(To).name()));
#else // Release 生产模式:撤掉所有围栏,直接放行
return static_cast<To>(from); // 没有任何运行时开销,直接进行内存地址的重新解释
#endif
}
虚函数表和RTTI
虚函数表的实现机制
vtable 不"属于"基类或子类。 vtable 是编译器为"每个多态类"生成的一张静态表,因此,我们说的虚函数表,属于类,不属于类的实例。但是,与之对应的,每个"多态对象"内部都会有一个 虚函数表指针(vptr) 指向"其动态类型对应的 vtable"。 所以,vtable 是类级别的,而 vptr 是对象级别的
1️⃣ vtable(虚函数表)
- 编译期生成
- 静态存储
- 每个多态类一张
2️⃣ vptr(虚指针)
- 存在于对象内部
- 每个多态对象都有
- 指向对应类的 vtable
3️⃣ 多态类
- 只要类里有至少一个 virtual 函数。
所以,对于以下例子:
cpp
Base* bp = new Derived;
bp->f();
- bp 静态类型是
Base* - 实际对象是
Derived Derived对象里的 vptr 指向 Derived_vtable- 所以
bp->f()调用Derived::f
RTTI 在 vtable 中的位置如下,可以看到,RTTI实际上位于对应的多态类的虚函数表中,每一个类都有对应的RTTI信息:
text
对象内存 (Heap) vtable 区域 RTTI 区域
───────────────────── ───────────────────── ───────────────────
Base object(基类对象的vptr指向基类的vtable)
┌───────────────┐
│ vptr ─────────┼──────────────▶ Base_vtable(Base类的vtable)
│ b │ ┌───────────────────────┐
└───────────────┘ │ RTTI ptr ─────────────┼──────────▶ type_info(Base)(Base类的RTTI)
│ &Base::f │
│ &Base::~Base │
└───────────────────────┘
Derived object(子类对象的vptr指向子类的vtable,而不是基类的vtable)
┌─────────────────────┐
│ vptr ───────────────┼──────────────▶ Derived_vtable(Derived类的vtable)
│ │ ┌────────────────────────┐
│ Base subobject │(基类子对象区域) │ RTTI ptr ──────────────┼────────▶ type_info(Derived)(Derived类的RTTI)
│ b │ │ &Derived::f │
│ │ │ &Derived::~Derived │
│ Derived members │ └────────────────────────┘
│ d │
└─────────────────────┘
基于虚函数表,我们看一下下面的这个典型的多态调用过程的处理流程:
cpp
#include <iostream>
struct Base {
virtual void f() { std::cout << "Base::f\n"; }
};
struct Derived : Base {
void f() override { std::cout << "Derived::f\n"; }
};
int main() {
Base* bp = new Derived;
bp->f();
}
我们首先看一下,Base* bp = new Derived 时的真实结构:
text
情况1: Base* bp = new Base 情况2: Base* bp = new Derived
bp bp
│ │
▼ ▼
Base object Derived object
┌───────────────┐ ┌─────────────────────┐
│ vptr ---------┼──────────┐ │ vptr ---------------┼──────────┐
│ b │ │ │ Base subobject │ │
└───────────────┘ │ │ b │ │
▼ │ │ ▼
Base_vtable │ Derived members │ Derived_vtable
┌───────────────┐ │ d │ ┌─────────────────┐
│ RTTI → Base │ └─────────────────────┘ │ RTTI → Derived │
│ &Base::f │ │ &Derived::f │
│ &Base::~Base │ │ &Derived::~Derived
└───────────────┘ └─────────────────┘
│ │
▼ ▼
Base::f() Derived::f()
它们的调用路径对比如下:
text
Base* bp = new Base Base* bp = new Derived
bp->f() bp->f()
bp bp
│ (指针指向了Base对象区域) │ (指针指向了Derived对象区域)
▼ ▼
Base object Derived object
│ │
▼ ▼
vptr vptr
│ (基类对象的vptr指向基类的vptr) │ (子类对象的vptr指向子类的vptr)
▼ ▼
Base_vtable Derived_vtable
│ │
▼ ▼
&Base::f (基类的vptr中的函数引用 &Derived::f (子类对应的vptr中的函数引用,引用子类的函数)
│ ,引用基类的函数) │
▼ ▼
调用 Base::f() 调用 Derived::f()
从上面的调用可以看到,在C++在进行函数调用的时候,其实是面临两个问题: 是否需要进行虚调用(virtual dispatch),以及,如果需要进行虚调用(virtual dispatch),到底调用哪个函数。
-
是否需要进行虚调用,这纯粹是在编译期决定的,即是由静态类型决定的。
比如:
cppBase* bp = new Derived; bp->f();编译器在编译
bp->f()时只知道bp 的静态类型是Base*然后它去查看
Base类定义发现Base::f 是 virtual, 于是编译器决定,这个调用必须通过 vtable 完成,因此生成的代码类似:load vptr load function pointer call而不是call
Base::f。如果
Base::f不是 virtual,比如:cppstruct Base { void f(); };编译器就直接生成,call
Base::f, 不会走 vtable的路径。所以,是否使用虚调用只看表达式的静态类型(在我们的例子中是Base)中,该函数是否是 virtual,不看对象的真实类型。
-
虚调用(virtual dispatch)时调用哪个函数(运行期决定)
一旦编译器决定要用虚调用(virtual dispatch),运行期就会通过对象里的 vptr 找到 vtable,然后通过vtable[
slot_of_f]找到真正要调用的函数:例如:
cppBase* bp = new Derived; bp->f();对象结构:
Derived object ┌────────────────────┐ │ vptr ──────────────┼────────▶ Derived_vtable │ Base subobject │ │ Derived members │ └────────────────────┘ Derived_vtable: Derived_vtable ┌────────────────────┐ │ RTTI │ │ &Derived::f │ │ &Derived::~Derived │ └────────────────────┘其调用流程如下所示,所以最终调用
Derived::f()。cppbp ↓ Derived object ↓ vptr ↓ Derived_vtable ↓ &Derived::f ↓ call Derived::f
🧠 深度思考
我们经常说 纯虚函数 ,那你知道它和 不纯虚函数 的区别吗?
如果一个类中只有不纯的虚函数,它还能被称为抽象类吗?
基于虚函数表的dynamic_cast的底层工作原理:
当类有虚函数时,编译器会生成:
- vtable(虚函数表),注意,虚函数表是属于某一个类的,即被所有类对象共享的,而虚函数表指针则是存放在每一个类对象中的,指针指向这个对象所属的类的虚函数表
RTTI(运行时类型信息,type_info),这也是属于类而不是属于对象的。vtable中有指针指向RTTI的内存区域。
注意,一个Derived类继承自含有virtual函数的基类Base,Derived类自动也是多态类。因此,在我们的例子中,Base和Derived都是多态类,即:
struct Derived : Base {
void f(); // 这里即使不写 virtual
};
这里,f()方法仍然是virtual的,因为virtual属性是被继承的:
cpp
virtual void f();
由于Base和Derived都是多态类,因此:
Base和Derived都有自己的 vtable(虚函数表)- 每个Base或者Derived对象里只有一个 vptr(虚函数表指针)(单继承情况下)
- Derived对象 里的 vptr 指向 Derived类 的 vtable,而不是
Base的。 Base对象 的vptr指向Base类的vtable,而不是Derived类的vtable。
所以,一个对象内存(概念化):
cpp
Derived 对象
┌────────────────────┐
│ vptr → vtable │ // 对象的虚函数表指针指向这个对象所属的类的虚函数表, Derived对象的vptr指向Derived类的vtable,Base对象的vptr指向Base类的vtable
│ Base 子对象 │ // 基类子对象
│ Derived 成员 │
└────────────────────┘
所以,对于以下例子:
cpp
Base* bp = new Derived();
Derived* dp = dynamic_cast<Derived*>(bp); // dynamic_cast进行向下类型转换
dynamic_cast 会:
- bp指针实际指向了Derived对象的内存区域,
- 在bp对象指向的内存区域中找到vptr,然后通过 vptr 找到
RTTI,显然,Derived对象的vptr指向的是Derived类的vtable,Derived类的vtable指向了Derived类的RTTI - 查询实际动态类型,很显然,由于是Derived类的RTTI,因此类型信息就是Derived
- 判断是否是目标类型。对于
dynamic_cast<Derived*>(bp),它的目标类型是Derived - 若是,则计算正确偏移并返回指针
- 若不是,返回 nullptr(不是抛出异常,而是返回nullptr)
重写 (Override) 与 隐藏 (Hiding) 深度对比
在C++中,非虚函数根本不存在被override,它只会被隐藏。只有虚函数才存在被重写。
下面的例子具体显式了普通成员函数在继承时候被hide(隐藏)的表现,同虚函数的虚派发在继承时候被override(重写)时候的表现差异。
cpp
#include <iostream>
class Base {
public:
// 非虚函数:流程守卫
void executeProtocol() {
std::cout << "[Base] 执行标准协议流程" << std::endl;
doWork();
}
virtual void doWork() const { std::cout << "[Base] 做底层工作" << std::endl; }
virtual ~Base() = default;
};
class DerivedB : public Base {
public:
// 【隐藏 (Hiding)】:注意,这里没有 virtual,也不是重写
// 子类定义了一个同名函数,把基类的"隐藏"掉了
void executeProtocol() {
std::cout << "[DerivedB] !!!跳过了标准协议,执行了自定义流程!!!" << std::endl;
doWork();
}
void doWork() const override { std::cout << "[DerivedB] 做特有底层工作" << std::endl; }
};
int main() {
DerivedB* realB = new DerivedB();
Base* polyB = realB; // 指向同一个对象,但身份不同
std::cout << "--- 1. 使用子类身份 (DerivedB*) 调用 ---";
// 绑定执行:编译器看到类型是 DerivedB,直接调用 DerivedB::executeProtocol
realB->executeProtocol();
std::cout << "\n--- 2. 使用基类身份 (Base*) 调用 ---";
// 绑定执行:编译器看到类型是 Base,直接调用 Base::executeProtocol
// 即使它指向的其实是一个 DerivedB 对象!
polyB->executeProtocol();
delete realB;
return 0;
}
对应输出如下所示:
text
--- 1. 使用子类身份 (DerivedB*) 调用 ---[DerivedB] !!!跳过了标准协议,执行了自定义流程!!!
[DerivedB] 做特有底层工作
--- 2. 使用基类身份 (Base*) 调用 ---[Base] 执行标准协议流程
[DerivedB] 做特有底层工作
- 静态绑定中的"身份优先":
- 对于非虚函数(如
executeProtocol()),编译器在派发时只看指针的类型,不看对象的实际类型。- 如果我手里拿的是
Base*,哪怕后面是一个DerivedB,即这个Base指针指向的事一个DerivedB对象,调用的依然是基类的逻辑。 - 在 ClickHouse 中的意义:ClickHouse 的算子(Operators)之间传递的通常是
IColumn*(基类指针)。这意味着,即使某个具体的列类偷偷定义了自己的mutate()或shallowMutate(),只要它被当做IColumn使用,系统依然会强制走基类定义的标准COW流程。
- 如果我手里拿的是
- 动态绑定中的"真实类型优先":
- 对于虚函数(如
doWork()),编译器在派发时只看对象的实际类型,不看指针的类型。 - 无论你用
Base* 还是DerivedB* 调用,最后都会精准落到DerivedB::doWork()上 - 我们在ClickHouse中看到,
IColumn::clone()方法被定义成virtual方法,因为可能需要IColumn的具体实现类比如COWHelper具体去override这个clone()方法
- 对于虚函数(如
C++ Override:_ The note content.
在C++中,override 关键字的作用是:显式声明我要重写基类中的一个"同名虚函数"。C++编译器在处理 override 时会进行极其严格的检查:
- 基类里有没有这个函数?
- 基类里的这个函数是不是 virtual 的?
- 两者的参数签名和返回值是否完全一致?
所以,override关键字一定是用于虚函数的继承。
总之,下表展示了override和hide的区别:
| 特性 | 重写 (Override) | 隐藏 (Hiding) |
|---|---|---|
| 前提条件 | 基类函数必须声明为 virtual | 基类函数是 普通函数 (Non-virtual) |
| 关键字 | 建议加 override,强制编译器进行一致性检查 | 绝对不能加 override,否则编译报错 |
| 派发机制 | 动态绑定(运行时通过虚函数表 vtable 查找) | 静态绑定(编译期根据指针/引用的类型决定) |
| 核心表现 | "对象决定路径":无论用基类还是子类指针,最终都调用子类的实现。 | "身份决定路径":用基类指针调基类的,用子类指针调子类的。 |
| ClickHouse 应用 | clone():基类只定义接口,必须由子类实现差异化的物理拷贝逻辑。 |
shallowMutate() :由基类统一锁定 COW 检查流程,强制所有列遵循同样的规则。 |
移动语义,左值右值以及 std::unique_ptr/std::shared_ptr
-
引入移动语义的动机以及移动语义的原理
在 C++11 之前,如果你想把一个函数返回的临时容器(比如
std::vector)赋值给一个变量,编译器通常会执行以下操作:- 申请新内存:在目标位置开辟一块同样大小的空间。
- 深拷贝:将临时对象里的所有数据逐一复制过去。
- 销毁原对象:临时对象运行析构函数,释放刚才那块内存。
对于拥有数百万个元素的容器,这种"先复制再销毁"的行为极其低下。而移动语义的原理,就是资源所有权的转移。移动语义改变了处理临时对象的方式。它不再复制数据,而是直接进行指针交换(Shallow Copy with Ownership Transfer):
-
操作过程:将目标对象的指针指向源对象的内存地址。
-
后续处理:将源对象(即将销毁的临时对象)内部的指针置为空(nullptr),防止它在析构时释放掉那块刚被接管的内存。
-
左值和右值
在 C++ 中,左值和右值的本质区别在于资源的拥有权是否"名花有主":
-
左值 (Lvalue): * 形象理解:它是你存在银行(内存)里的存折。
- 特点:它有名字(变量名),你后面还要用它。如果编译器偷偷把它里面的钱(资源)划走,你下一行代码用这个变量时就会崩溃。
- 原则:不能动,只能拷贝一份。
-
右值 (Rvalue): * 形象理解:它是掉在马路上的无主钞票。移动语义通过右值引用(T&&)来识别哪些对象是可以被"掠夺"的。
- 特点:它是临时的(比如函数返回的中间值),没有变量名。过了这一行,这块内存就会被系统回收(销毁)。
- 原则:既然它马上就要消失了,弃之可惜,不如在它消失前"掠夺"它的资源,据为己有。
-
-
std::move():将左值伪装成右值虽然移动构造函数接受的是右值,但是有时我们知道一个长寿命的左值不再需要了,想触发它的移动语义。这时可以使用
std::move()来完成移动语义。注意:
std::move()并不移动任何东西。它的本质是 一次强制类型转换,将左值强制转换为右值引用,从而告诉编译器:"你可以调用这个对象的移动构造函数了"。 -
移动构造函数和移动赋值运算符
我们通过重写这两个特殊的成员函数来定义具体的"掠夺"逻辑:
cppclass MyString { private: char* data; public: // 移动构造函数 MyString(MyString&& other) noexcept { data = other.data; // 直接接管指针 other.data = nullptr; // 将原对象置空,防止析构时双重释放 } };下面的例子显式了拷贝赋值和移动赋值的区别:
cppstd::string B = "Hello"; std::string A = B; // B 是左值,因此进行的是拷贝赋值 // 1. A 看到 B 有名字,不敢乱动。 // 2. A 只能在内存里申请一块新空间。 // 3. 把 B 里的 "Hello" 一个字符一个字符地复制过去。 // 4. 结果:内存里现在有两份 "Hello"。 std::string A = std::string("Hello"); // 这里的 string("Hello") 是右值 // 1. A 发现 string("Hello") 是个匿名临时对象(右值)。 // 2. 掠夺开始:A 直接把自己的指针指向了这个临时对象的内存地址。 // 3. 封口:A 把这个临时对象的内部指针改写为 nullptr。 // 4. 结果:没有发生任何字符复制!只是改了一个指针的指向。 -
基于移动语义的
std::unique_ptr和std::shared_ptr简单来说,移动语义是底层通用"工具",它可以作用于任何一个普通对象,而 智能指针(
std::unique_ptr和std::shared_ptr)是利用移动语义特殊工具来实现资源所有权管理的上层建筑。-
std::unique_ptr基于移动语义来表达资源独占
std::unique_ptr的核心定义是独占所有权。为了保证一个对象在同一时刻只能由一个指针拥有,它禁止拷贝,但支持移动。禁止拷贝:防止两个指针同时指向并试图释放同一块内存(导致 Double Free)。
支持移动:允许所有权从一个作用域转移到另一个作用域(例如从函数内部返回,或放入容器中)。
逻辑关系:移动语义是
std::unique_ptr能够工作的基石。如果没有移动语义,unique_ptr将无法作为函数的返回值,也无法存入std::vector,其实用性会大打折扣。 -
std::shared_ptr基于移动语义来实现性能优化
std::shared_ptr实现的是共享所有权。它内部维护一个引用计数(Reference Count)。- 拷贝时:引用计数 + 1 +1 +1。这涉及原子操作,在高并发或频繁操作时有明显的性能开销。
- 移动时:直接接管源指针的内存地址。直接接管源指针的引用计数。引用计数保持不变。
逻辑关系:对于
shared_ptr,移动语义不是必须的(因为它可以拷贝),但它是极其重要的性能优化手段。通过std::move(ptr) 传递智能指针,可以避免原子计数的增减,提高程序运行效率。
-
我们以一个极其简单的例子,来总结分析移动构造函数、std::move()以及std::unique_ptr/std::shared_ptr的背后逻辑:
cpp
#include <memory>
#include <vector>
void demo() {
auto uptr = std::make_unique<int>(100);
// std::unique_ptr<int> uptr2 = uptr; // 编译错误!不支持拷贝
std::unique_ptr<int> uptr2 = std::move(uptr); // OK! 移动语义转移了所有权
auto sptr = std::make_shared<int>(200);
// 拷贝:sptr 和 sptr2 共享所有权,引用计数变为 2
auto sptr2 = sptr;
// 移动:sptr3 接管资源,sptr2 变为空,引用计数依然是 2(省去了加减操作)
auto sptr3 = std::move(sptr2);
}
在上面的例子中,std::unique_ptr 利用移动语义实现了"唯一性"的资产流转,std::shared_ptr 利用移动语义实现了"低成本"的部分资产的接力。
对于auto sptr3 = std::move(sptr2);这一行代码,它的背后逻辑是 :
-
强制转换阶段:
std::move做了什么?在执行
std::move(sptr2) 之前: sptr2 是一个左值 (lvalue)。它有名字,有持久的内存地址,你可以反复使用它。执行
std::move(sptr2) 时:std::move()并不移动任何字节,它的本质是一个static_cast<T&&>,强制转换成右值: 它将 sptr2 从左值强制转换为右值引用 (rvalue reference),属于亡值 (xvalue)。语义变化:这行代码告诉编译器:虽然 sptr2 有名字,但我现在把它当成一个即将被销毁的'临时对象(右值)'来对待"。
-
函数匹配阶段:重载决策
当编译器看到 auto sptr3 = [右值表达式] 时,它会去查看
std::shared_ptr的构造函数重载:- 拷贝构造函数:
shared_ptr(constshared_ptr& r) ------ 接收左值。 - 移动构造函数:
shared_ptr(shared_ptr&& r) ------ 接收右值。 - 因为
std::move(sptr2) 的结果是一个右值,编译器会精确匹配到std::shared_ptr的移动构造函数。
- 拷贝构造函数:
-
内部资源交接阶段:右值如何变成"空壳"?
在移动构造函数
shared_ptr(shared_ptr&& r) 内部,发生了关键的"掠夺":- sptr3(新对象):直接复制了 r(即 sptr2)内部的指针地址和引用计数器指针。
- r(也就是 sptr2,源对象):由于它被标记为右值,移动构造函数认为它可以被修改。于是,sptr2 内部的指针被设置为 nullptr。
- 引用计数:因为是直接接管,没有产生新的"副本",所以引用计数保持不变。
完美转发
看一个参数传递过程中的身份降级问题:
cpp
void target(int& x) { /* 处理左值 */ }
void target(int&& x) { /* 处理右值 */ }
template<typename T>
void wrapper(T&& arg) {
target(arg); // 灾难现场:arg 有名字,所以它是左值!
}
int main() {
wrapper(10); // 10 本是右值,进入 wrapper 后 arg 变成了左值,
// 最终调用了 target(int&),移动语义失效了!
}
完美转发之所以存在,是为了解决一个问题:如何写一个中转函数(Wrapper),让它像透明人一样,不改变参数的任何属性?
实现完美转发的两大支柱:
-
万能引用 (Universal Reference)
在模板参数 T&& 中,它具备"变色龙"的特性:
- 传入左值:T&& 变为左值引用。
- 传入右值:T&& 变为右值引用。
-
引用折叠 (Reference Folding)
- 这是编译器的内部逻辑,用来处理"引用的引用"。你可以简单记为:只要有左值引用参与,结果就是左值引用;只有全是右值引用,结果才是右值引用。
-
秘密武器:
std::forward
std::forward<T>(arg) 是实现完美转发的最后一步。它的逻辑非常精妙:- 如果 T 被推导为左值引用(例如 int&),那么根据引用折叠,
std::forward把它转回 int&。 - 如果 T 被推导为原始类型(说明传入的是右值),
std::forward把它转回 int&&(即强制转换回右值)。
对比
std::move():std::move是暴力狂:管你是谁,通通变成右值。std::forward是精确仪器:你是左值就保持左值,你是右值就还原为右值。
- 如果 T 被推导为左值引用(例如 int&),那么根据引用折叠,
所以, 完美转发其实是以下三者的综合体:
- 万能引用 (T&&):负责接收任何类型的参数。 注意,万能引用一定指的是模板类型,因此一定涉及到模板函数。
- 引用折叠:负责在编译期计算出参数的真实身份。
std::forward:负责根据计算出的身份,进行最后的强制类型还原。
cpp
#include <iostream>
#include <utility> // 包含 std::move 和 std::forward
// 目标函数:左值引用版本
void target(int& x) {
std::cout << " [Target] 进入左值引用版本。值:" << x << std::endl;
}
// 目标函数:右值引用版本
void target(int&& x) {
std::cout << " [Target] 进入右值引用版本(触发移动语义逻辑)。值:" << x << std::endl;
}
// 完美转发包装器
template<typename T>
void perfectWrapper(T&& arg) { // 模板函数,注意,T&&是一个万能引用,而不是一个类型为T的右值引用
// 这里 arg 本身是有名字的左值
// std::forward<T>(arg) 会根据 T 的推导结果,决定将其转回左值还是右值
std::cout << " [Wrapper] 准备转发参数..." << std::endl;
target(std::forward<T>(arg));
}
int main() {
int a = 10;
target(a) ; // a是lvalue,调用 target(int& x)
target(10); // 10是右值,调用target(int&& x)
std::cout << "--- 测试 1:传入左值变量 a ---" << std::endl;
// 预期:perfectWrapper 接收左值,T 被推导为 int&,forward 后调用 target(int&)
perfectWrapper(a);
std::cout << "\n--- 测试 2:传入右值常量 20 ---" << std::endl;
// 预期:perfectWrapper 接收右值,T 被推导为 int,forward 后调用 target(int&&)
perfectWrapper(20);
std::cout << "\n--- 测试 3:使用 std::move 将 a 强转为右值 ---" << std::endl;
// 预期:std::move 让 a 表现得像个右值,最终进入 target(int&&)
perfectWrapper(std::move(a));
return 0;
}
对应输出如下所示:
text
[Target] 进入左值引用版本。值:10
[Target] 进入右值引用版本(触发移动语义逻辑)。值:10
--- 测试 1:传入左值变量 a ---
[Wrapper] 准备转发参数...
[Target] 进入左值引用版本。值:10
--- 测试 2:传入右值常量 20 ---
[Wrapper] 准备转发参数...
[Target] 进入右值引用版本(触发移动语义逻辑)。值:20
--- 测试 3:使用 std::move 将 a 强转为右值 ---
[Wrapper] 准备转发参数...
[Target] 进入右值引用版本(触发移动语义逻辑)。值:10
const重载
const重载是我们下文在研究ClickHouse的COW的列管理机制的重要实现手段。
比如,我们获取一个列,在这个列被自己独占的时候,这个列是可以被修改的,但是如果这个列是被共享的,那么就必须只读,这时候我们要修改这一列的数据,就必须先单独拷贝一份出来才能变成可写。这种类的只读、可写状态的切换,大量依赖const重载。
这背后其实隐藏着 C++ 极为核心的 const 成员函数重载 机制。
简单来说,ClickHouse 巧妙地利用了对象"身份"的差异,尤其是在 ClickHouse 的 COW 体系中,const 重载不仅是函数性质的区分,更是权限的硬性锁死。我们可以通过以下两个物理规则来定义对象"身份"带来的权力差异:
-
当对象身份为"常量" (const) 时:
-
成员只读:对象内部的所有成员变量自动变为 const 状态,严禁物理修改。
-
权限收缩:该对象只能调用自身的 const 成员函数。编译器会拦截任何试图调用"非 const"函数的行为,因为那意味着潜在的修改风险。
-
结果:它像是一份"只读存折",你只能看,不能取。
-
-
当对象身份为"非常量" (non-const) 时:
-
全权开放:对象成员处于可写状态。
-
权限扩展:它既能调用"非 const"成员函数执行写操作,也能兼容调用"const"成员函数进行读操作。
-
结果:它是一份"活期存折",拥有完整的支配权。
-
一句话总结: ClickHouse 正是利用这种"身份差异",让常量对象在物理层面就失去了"作恶"(篡改数据)的机会,从而确保了海量数据在多线程共享时的绝对安全。
下面这段代码展示const重载的基本运行机制:
cpp
class A
{
public:
int * data() { return p; }
const int * data() const { return p; }
private:
int * p = nullptr;
};
这两个函数名字都叫 data,参数列表也一样,区别只有:
cpp
data()
data() const
这就叫 const 成员函数重载。
它不是按"参数不同"重载,而是按调用点对象是不是 const来区分。
所以,如果我分别创建了两个A对象:
cpp
A a;
const A ca;
那么,他们对于const重载方法data()的调用结果如下所示:
cpp
a.data(); // 调用非 const 版本
ca.data(); // 调用 const 版本
这是因为,对于成员函数data(),表面上看,f() 没有参数,但是编译器其实对他调用的时候,有一个隐藏参数,就是这个成员函数所在的对象的this指针,即,调用其实更类似于下面的方式,携带了this指针:
cpp
data(&a);
我们需要知道,this 一定是一个常量指针(pointer itself is const),但是,根据它指向的对象不同,分成两种情况:
| this的真实类型 | 指针含义 | 对应的const重载的成员函数 |
|---|---|---|
| A* const this | 指向普通对象的const指针 | 非const成员函数 |
| const A* const this | 指向const对象的const指针 | const成员函数 |
在调用成员函数的时候,其实编译器会帮我们传入this指针,只是,这个this参数不需要我们手动写出来,编译器会自动帮我们传进去。因此:
- 在非 const 成员函数里,this 的类型相当于A * const this,这里的意思是,this是一个const指针,但是指向的是一个可以修改的普通对象;
- 在const 成员函数里,即如果函数声明是:void
f()const,那么,这个函数体里,this指向的是const对象,所以它相当于const A * const this,即this是一个const指针,指向一个const对象。
所以,在COW<Derived>中,就是使用const重载,根据this指针的类型不同,会调用到不同的getPtr()方法,而对应的getPtr()方法内部也会调用不同的const重载的derived()方法,从而,非常量的COW对象会返回MutablePtr(可写),而常量的COW对象会返回Ptr(只读) :
cpp
Derived * derived() // 返回Derived *
{
return static_cast<Derived *>(this); // this 类型是COW* const,即指向普通对象的const指针
}
const Derived * derived() const // 返回const Derived *,即一个指向常量对象Derived的指针
{
return static_cast<const Derived *>(this); // this类型是const COW* const,即指向const对象的const指针
}
MutablePtr getPtr()
{
return static_cast<MutablePtr>(derived());
// 非 const getPtr() 内部调用 非const derived()
// 得到 Derived *
// 再构造成 MutablePtr(可写)
}
Ptr getPtr() const
{
return static_cast<Ptr>(derived());
// const getPtr() 内部调用 const derived()
// 得到 const Derived *
// 再构造成 Ptr(只读)
}
所以,上面的derived()方法的const重载,和getPtr()方法的const重载,其核心区别不是返回值是否是const,其更本质的区别是:const 对象只能拿到只读句柄,非 const 对象才能拿到可写句柄。,即:
cpp
const COW<Derived> & -> 只能 getPtr() const -> 只能调用 derived() const -> 返回Ptr
COW<Derived> & -> 优先 getPtr() -> 只能调用 derived() -> 返回MutablePtr
所以,这不是随便写两个重载玩,而是在类型系统里建立一个权限规则:
- 你手里如果是 const 对象,就不可能拿到
MutablePtr - 你手里如果是 non-const 对象,才有资格拿到
MutablePtr
这是整个COW体系非常重要的不变式之一。
我们这里的问题是: 如果一个方法我只定义了一个const版本,那么非const实例调用这个方法会报错吗?相反,如果一个方法我只定义了一个非const版本,那么const实例调用这个方法会报错吗?
这是一个非常经典且关键的 C++ 权限控制问题。简单来说:第一种情况不会报错,但第二种情况一定会报错, 这是因为 C++ 在对象权限上遵循一个核心原则:权限可以被"收缩"(只读化),但不能被"扩张"(去只读化)。
- 一个非const实例调用一个只定义了const 版本的方法,完全没问题
- 物理逻辑:当你持有一个"非 const 实例"时,你拥有读写权。调用一个 const 方法意味着你仅仅行使了"读"的权利。
- 权限转换:C++ 允许将一个"可写对象"的指针或引用隐式转换为"只读"。这被视为是安全的。
cpp
class Column {
public:
void get() const { /* 只读操作 */ }
};
Column col; // 非 const 实例
col.get(); // 允许。拥有修改权的人当然有权选择只读。
-
const 实例调用了一个只定义了非const版本的方法,会直接编译报错。
- 物理逻辑:const 实例的核心契约是"物理不可变"。如果你调用一个非 const 方法,由于该方法在语法上潜伏着修改数据的风险,编译器必须为了安全而拦截。
- 权限转换:C++ 严禁将"只读权限"隐式扩张为"写权限"。
cppclass Column { public: void set() { /* 潜在修改操作 */ } }; const Column col; // const 实例 col.set(); // 报错!编译器:你承诺过不改,但我不能保证 set() 里面不改。
所以,我们遇到的const,修饰的是 类型(type),而类型可能是:
- 对象类型 (A)
- 指针类型 (A*)
- 引用类型 (A&)
- 成员函数 (void
f()const)
其实修饰的就是对象和指针,因此有了const对象和const指针,而由于指针可以指向对象,因此,出现了不同的排列组合,比如,const指针,指向非const对象的const指针,指向const对象的const指针,以及,指向const对象的非const指针,等等。。。
| 类型 | 指针是否可改 | 对象是否可改 | 中文解释 |
|---|---|---|---|
| A* | ✔ | ✔ | 普通指针:指针可以指向别的对象,也可以通过指针修改对象 |
| const A* | ✔ | ❌ | 指向const对象的指针:指针可以改变指向,但不能通过指针修改对象 |
| A* const | ❌ | ✔ | const指针:指针本身不能改变指向,但可以通过它修改对象 |
| const A* const | ❌ | ❌ | 常量指针指向只读对象:指针不能改变指向,也不能通过它修改对象 |
noexcept声明
在 C++ 高性能编程(如 ClickHouse 源码)中,noexcept 绝不仅仅是一个简单的"不抛异常"的标记,它是一份关乎性能生死的**"契约"**。
noexcept 是 C++11 引入的关键字,它向编译器和调用者传达了一个明确信号:这个函数保证绝对不会抛出任何异常。
这种承诺背后有着实实在在的物理收
- 如果你声明了 noexcept 但函数内部依然抛出了异常,程序不会尝试去寻找 catch 块进行昂贵的栈回溯,而是直接调用 std::terminate() 强制自杀。编译器因为确信没有异常发生,可以省去生成异常处理表(Exception Table)的开销,从而生成更精简、更高效的机器码。
noexcept实现性能优化的一个典型例子,是在std::vector的使用场景中。
cpp
#include <iostream>
#include <vector>
struct ColumnUnsafe {
ColumnUnsafe() = default;
ColumnUnsafe(const ColumnUnsafe&) { std::cout << "Copying Data (Slow!)\n"; }
// 没写 noexcept,std::vector 在扩容的时候不会使用移动语义
ColumnUnsafe(ColumnUnsafe&&) { std::cout << "Moving Pointer (Fast!)\n"; }
};
struct ColumnSafe {
ColumnSafe() = default;
ColumnSafe(const ColumnSafe&) { std::cout << "Copying Data (Slow!)\n"; }
// 写了 noexcept,std::vector在扩容的时候会放心使用移动语义
ColumnSafe(ColumnSafe&&) noexcept { std::cout << "Moving Pointer (Fast!)\n"; }
};
int main() {
std::vector<ColumnUnsafe> unsafeVec;
unsafeVec.reserve(1);
unsafeVec.emplace_back();
std::cout << "UnsafeVec Resizing: ";
unsafeVec.emplace_back(); // 扩容:触发拷贝!
std::vector<ColumnSafe> safeVec;
safeVec.reserve(1);
safeVec.emplace_back();
std::cout << "SafeVec Resizing: ";
safeVec.emplace_back(); // 扩容:触发移动!
}
输出结果为:
text
UnsafeVec Resizing: Copying Data (Slow!)
SafeVec Resizing: Moving Pointer (Fast!)
从运行结果看,
- UnsafeVec 在扩容时输出了 Copying Data。即便我们写了移动构造,它也失效了,没有被使用。
- SafeVec 在扩容时输出了 Moving Pointer,即使用了移动语义。
std::vector扩容时,看到了ColumnSafe的noexcept声明,因此选择使用移动语义来实现扩容。
要理解std::vector中noexcept的使用方式,我们先看看一个对象的基本结构,我们用物理模型:房产证与房子来进行解释,来看清一个管理大数据块的对象在内存中的真实样貌。以高性能引擎中的数据列(Column)为例:
-
对象实体(房产证):存放在栈或连续内存中,尺寸固定且很小(如 24 字节)。这 24 字节就是对象本身,而不是指向对象的指针。它记录了最核心的信息------指向堆内存的物理地址(指针)。
-
堆内存(房子):真正存储海量数据的地方,它独立于对象实体之外,由"房产证"上的指针全权代表。
当我们使用 std::vector<Column>来存放这些Column的时候,即我们将这些对象放入 std::vector,这个std::vector容器就成了一个专门整齐摆放"房产证"的抽屉。由于内存必须连续,这些 24 字节的"房产证"在抽屉里是一个挨一个紧密排列的。
当抽屉空间不够,需要放入新证时,std::vector 必须进行扩容。它会申请一个更大的新抽屉,并将旧抽屉里的房产证一张张"搬"到新位。 这就涉及到扩容时的抉择:拷贝还是移动?
- 拷贝语义:为了保险,系统在搬运时会在别处重新盖一栋一模一样的房子,再给新证记下新地址。这涉及海量数据的物理复制,代价极高。
- 移动语义:物理动作极其简单------新证直接抄走旧证上的"房子地址",随后旧证地址清零。房子(数据)纹丝不动,仅需几纳秒便完成了所有权的接力。
std::vector 认为在扩容过程中使用移动语义存在巨大的风险。一旦移动构造函数抛出异常,会导致不可挽回的数据破坏。因此,只有当有了noexcept担保,std::vector才会大胆使用移动语义。 可是问题是,移动只是抄个地址数值,为什么还需要 noexcept 担保?为什么还有风险?
想象搬运过程中,程序调用了该对象的移动构造函数。虽然主要任务是抄写指针,但如果该函数内部还执行了其他逻辑(如向内存追踪器注册新证)并突然抛出了异常,就会陷入极度尴尬的中间状态: 旧证为了准备交接,地址已经抹去了;而新证因为报错,还没来得及把地址写进去。 这种物理灾难会导致房子(数据)依然静静地待在堆内存里,但记录它位置的指针在交接瞬间丢失了。这块内存变成了谁也找不到的"孤儿",造成严重的内存泄漏。更糟的是,由于旧证已毁,系统甚至无法回滚到扩容前的状态。
所以,为了防止地址丢失的惨剧,std::vector 在扩容搬运前会进行严密的审计:
- 编译器首先检查对象的移动构造函数是否声明了 noexcept。如果有此担保,系统确信交接地址过程绝对安全,从而放心执行移动语义,实现 O(1) 级别的极致性能。
- 若无此担保,系统不敢冒"丢地址"的风险,为了绝对安全,它会强制选择深度拷贝。它宁可去物理克隆那份庞大的数据,也不敢在没有保障的情况下触碰指针。
在高性能编程中,漏掉一个 noexcept 声明,会导致原本只需"抄写地址"的轻量操作,在底层物理性地退化为极其沉重的全量数据克隆。
一个关于COW和IColumn的使用的例子
在ClickHouse官方代码中,给出了一个使用基于COW和IColumn的例子,在文件src/Common/examples/cow_columns.cpp中。
这个例子正好把COW/CRTP机制给跑通了,该代码通过ConcreteColumn在演示一个完整故事:
-
ColumnPtr(只读共享, immutable)可以被拷贝共享 -
当我们要修改一个Column的时候,必须调用mutate()
-
若共享(refcount>1)→ clone 出一份新对象(
COW) -
若独占(refcount==1)→ 直接返回可写句柄(不复制)
-
修改完成后,可把
MutablePtrmove 给Ptr,回到不可变共享态
cpp
#include <Common/COW.h>
#include <iostream>
#include <base/defines.h>
/**
* 1. 基础接口定义
* IColumn 继承自 COW<IColumn>,使其获得引用计数和写时复制的能力。
*/
class IColumn : public COW<IColumn>
{
private:
friend class COW<IColumn>;
// COW 机制要求必须实现 clone 函数,用于在发生写冲突时复制数据
virtual MutablePtr clone() const = 0;
public:
IColumn() = default;
IColumn(const IColumn &) = default;
virtual ~IColumn() = default;
// 业务接口:由具体的列类型(如 ColumnString)实现
virtual int get() const = 0;
virtual void set(int value) = 0;
};
// Ptr 是不可变智能指针 (immutable_ptr)
using ColumnPtr = IColumn::Ptr;
// MutablePtr 是可变智能指针 (mutable_ptr)
using MutableColumnPtr = IColumn::MutablePtr;
/**
* 2. 具体列类型实现
* COWHelper 自动处理了 clone() 和 create() 等样板代码。
*/
class ConcreteColumn : public COWHelper<IColumn, ConcreteColumn>
{
private:
friend class COWHelper<IColumn, ConcreteColumn>;
int data;
explicit ConcreteColumn(int data_) : data(data_) {} // 构造函数
ConcreteColumn(const ConcreteColumn &) = default; // 默认的拷贝构造函数
public:
int get() const override { return data; }
void set(int value) override { data = value; }
};
int main(int, char **)
{
// --- 初始化 ---
// 通过 create 静态方法创建第一个对象 A,引用计数 = 1
ColumnPtr x = ConcreteColumn::create(1);
// 共享所有权,x 和 y 指向同一个内存地址 A,引用计数 = 2
ColumnPtr y = x;
// --- COW 核心逻辑触发 ---
{
/**
* 调用 IColumn::mutate(std::move(ptr)) 尝试获取可写指针。
* * 核心逻辑:
* 1. 如果 use_count > 1:
* - 说明有其他人在读取此数据。
* - COW 机制会自动调用 clone() 产生一个全新的副本 B。
* - 返回副本 B 的可写指针。
* 2. 如果 use_count == 1:
* - 说明数据由当前指针独占。
* - 直接 assumeMutable() 转换指针类型,不发生任何拷贝,效率极高。
*/
MutableColumnPtr mut = IColumn::mutate(std::move(y));
// 此时 y 已失效(std::move),mut 指向新副本 B,修改 mut 不会影响 x
mut->set(2);
// 修改完成后,将可写指针转回不可变指针
y = std::move(mut);
}
// --- 独占状态下的修改 ---
{
// 此时 y 独占对象 B (use_count == 1)
MutableColumnPtr mut = IColumn::mutate(std::move(y));
// 关键点:由于只有一人在使用,此处 mutate 不会触发任何 clone 操作
mut->set(3);
y = std::move(mut);
}
return 0;
}
-
这个代码单独定义了
IColumn,但是实际上线上代码的IColumn是定义在IColumn.h中的,这里定义的IColumn是IColumn.h中的IColumn的简化版:
src/Common/examples/cow_columns.cppcppclass IColumn : public COW<IColumn> { private: friend class COW<IColumn>; // 父类COW<IColumn>被定义为friend,因此可以调用子类IColumn的私有方法clone() virtual MutablePtr clone() const = 0; // clone() 是 COW 内部钩子,私有 + friend 只让 COW<IColumn> 调用 ... };-
这里直接让
IColumn继承COW,而所有的最下层的IColumn实现都间接拥有了COW特性; -
这里可以看到,
IColumn是CRTP,它的父类是模板类,并且模板参数是自己:COW<IColumn>。这里的IColumn是在当前文件中自定义的 ,但是COW是COW.h的,即用的ClickHouse本身的COW. -
COW被定义成IColumn的friend,因此可以访问IColumn的virtual private方法virtualMutablePtrclone()。显然,clone()的具体实现定义在IColumn的子类ColumnConcrete中 -
IColumn的父类COW<IColumn>提供了COW::Ptr和COW::MutablePtr,以及静态方法IColumn::mutate(),相应代码定义在src/Common/COW.h中,我们下文会详细讲解的:cppclass COW : public boost::intrusive_ref_counter<Derived> { // 定义两个类模板 mutable_ptr 和 immutable_ptr protected: template <typename T> class mutable_ptr : public boost::intrusive_ptr<T> /// NOLINT { .... } public: using MutablePtr = mutable_ptr<Derived>; // 实际实现类Derived的mutable_ptr protected: // 子类可以使用immutable_ptr和mutable_ptr,比如,在COWHelper中可以使用 template <typename T> class immutable_ptr : public boost::intrusive_ptr<const T> /// NOLINT { ..... }; public: using Ptr = immutable_ptr<Derived>; // 实际实现类Derived的immutable_ptr // COW::mutate // 可以看到,这里的mutate只是浅修改,是直接调用shallowMutate() // 子类的IColumn::mutate()会为mutate"深度拷贝"出一个副本 static MutablePtr mutate(Ptr ptr) { return ptr->shallowMutate(); }
可以看到,
COW的静态方法COW::mutate()的参数是Ptr(immutable_ptr<Derived>),即静态方法COW::mutate()的参数是实际ConcreteColumn的修改,返回的是一个MutablePtr(mutable_ptr<Derived>)。下文会讲到,这里的COW::mutate()不是要修改对应的Ptr,而是把对应的Ptr基于COW的原则变成MutatalePtr,即变成一个可修改的封装。下文会详细讲解。 -
-
ConcreteColumn是模拟的具体的某种类型的列,和ColumnString、ColumnTuple一样,都直接继承COWHelper<Base, Derived>:
src/Common/examples/cow_columns.cppcppclass ConcreteColumn : public COWHelper<IColumn, ConcreteColumn>而
COWHelper<IColumn, ConcreteColumn>继承IColumn,所以ConcreteColumn间接是一个IColumn -
纯虚函数
clone():我们看到,
IColumn定义了IColumn::clone()的纯虚的实例函数,因此,它是希望clone()的具体行为由子类具体实现。在ClickHouse中,clone()的具体行为由COWHelper进行了实现,其实就是调用了Derived的拷贝构造函数。在我们这个例子中,ConcreteColumn的拷贝构造函数是private的,但是COWHelper是它的friend,因此由且只能由COWHelper::clone()去构造和调用:cpptypename `Base::MutablePtr` `clone()` const override { return typename `Base::MutablePtr`(new `Derived`(*derived())); // 使用Derived的拷贝构造函数构造Derived,并封装成MutablePtr }
另一个关于复合列的例子: 变色龙
在 ClickHouse 的 COW.h 设计中,chameleon_ptr(变色龙指针)是一个极具工程智慧的工具。它是对 immutable_ptr<T> 的再次封装,主要是为了解决复合对象(如 ColumnArray、ColumnTuple)内部子对象的读写权限管理问题。ClickHouse在cow_compositions.cpp中给了复合列的使用样例,其中就可以看到变色龙指针的使用方式。
chameleon_ptr(变色龙指针)存在的含义是: 基于MutablePtr/Ptr智能指针只能保证"外壳"的独占,而"内脏"的独占需要我们手动接力
变色龙指针chameleon_ptr
cpp
protected:
/// It works as immutable_ptr if it is const and as mutable_ptr if it is non const.
template <typename T>
class chameleon_ptr /// NOLINT
{
/**
* chameleon_ptr 本质上更像一个 "默认共享只读句柄" , 但它提供非 const 的 get()/operator->/operator*,把你带到可写世界 ,
* 换句话说:它把"是否可写"绑定到你拿到的是 const 还是非 const 的 chameleon_ptr。
*/
private:
immutable_ptr<T> value;
public:
template <typename... Args>
/**
* 把参数原样转发给value
* 这就是"完美转发"(forwarding constructor),
* 你给 chameleon_ptr 什么参数,它就原样转发给 value(内部 immutable_ptr)去构造
* @tparam Args
* @param args
*/
chameleon_ptr(Args &&... args) : value(std::forward<Args>(args)...) {} /// NOLINT
/**
* 完美转发
* @tparam U
* @param arg
*/
template <typename U>
chameleon_ptr(std::initializer_list<U> && arg) : value(std::forward<std::initializer_list<U>>(arg)) {}
// 如 const chameleon_ptr,就只能拿到只读子对象
// value 的类型是 immutable_ptr<T>,本质上是 intrusive_ptr<const T>,因此只能拿到 const T*。
const T * get() const { return value.get(); }
// 非 const 版本:返回 T*
// value 的类型是 immutable_ptr<T>, 这里调用的是 COW::assumeMutableRef,
// 因为 immutable_ptr 重载了 operator -> (immutable_ptr继承了boost::intrusive_ptr,默认就重载了operator -> ),因此,这里其实是调用
// 因此这里是 T -> assumeMutableRef(), 而在继承体系里面 T == Derived == IColumn / ColumnVector / ...
// {
// const T* p = value.operator->();
// p->assumeMutableRef();
// }
T * get() { return &value->assumeMutableRef(); }
// 这里重载了操作符->
const T * operator->() const { return get(); } // const版本
T * operator->() { return get(); } // 非const版本
// 重载了操作符*
const T & operator*() const { return *value; }
// 获取一个可读写的T,这时候,已经通过调用COW::assumeMutableRef拷贝了一份出来,因此是可读写
T & operator*() { return value->assumeMutableRef(); }
// 重载了操作符&
operator const immutable_ptr<T> & () const { return value; } /// NOLINT
operator immutable_ptr<T> & () { return value; } /// NOLINT
/// Get internal immutable ptr. Does not change internal use counter.
immutable_ptr<T> detach() && { return std::move(value); }
// 禁止隐式转换,必须显式转换,转换为bool的逻辑是: 是否为空指针
explicit operator bool() const { return value != nullptr; }
bool operator! () const { return value == nullptr; }
bool operator== (const chameleon_ptr & rhs) const { return value == rhs.value; }
bool operator!= (const chameleon_ptr & rhs) const { return value != rhs.value; }
};
public:
using WrappedPtr = chameleon_ptr<Derived>; // 这个WrappedPtr是public的
上文可以看到,COW 框架自带的 shallowMutate() 是"懒惰"的: 如果是一个复合列,那么当我们对这个父列执行 mutate() 时,框架只会检查父列的引用计数。如果需要克隆,它只会克隆父列这个"容器",这时候就有问题: 父列克隆出来的副本,内部的 WrappedPtr 依然指向原来那些共享的子列,所以,如果不手动干预,我们修改父列的成员时,其实是在修改原本应该被共享的数据,这会破坏整个 COW 的安全性。
所以,对于一个处于 Mutable 状态的对象,不仅它自己是独占的(use_count == 1),它身体里每一个零件(子列)也必须是独占的。
因此,我们必须手动编写一个 deepMutate(或者在 ClickHouse 中通常叫 mutate(),可以看IColumn::mutate()),在里面显式地对每一个 WrappedPtr 成员再调一遍 IColumn::mutate。这叫**"递归脱钩"(Recursive Detach)**,即通过递归的方式把内部子对象也进行脱钩处理,内部子对象全部变成了独占可写的状态。
COW中定义的chameleon_ptr的存在,就是为了解决嵌套列(Nested Columns)或复合数据结构的权限传递的连带关系。
cpp
using WrappedPtr = chameleon_ptr<Derived>
在 ClickHouse 源码中,它被 using WrappedPtr = chameleon_ptr<Derived>;。 这说明它是一个包装器。它把复杂的"检测父对象权限 -> 决定子对象是否提权(提权的意思是,只读共享变成独占可写)"的逻辑,封装成了最自然的 operator-> 调用。
我们从上面的代码可以看到,变色龙指针的内部只存了一个东西:immutable_ptr<T> value(本质是只读指针), 即,一个chameleon_ptr<Derived>是对一个immutable_ptr value的再次封装。
我们设想,假如T的类型是一个复合对象ColumnComposition,它内部持有一个子对象ConcreteColumn,那么, ClickHouse 为了节省内存,成员变量通常以只读形式(immutable_ptr)存储。但是,当我们通过 mutate() 获得了父对象的"写权限"(MutablePtr)时,如果我们想修改它内部的子对象,你必须也拥有子对象的"写权限"。 我们可以手动操作,这意味着每次改子对象都要手动写一堆转换逻辑。 可是,有了变色龙,它就可以自动感知**你(父对象)**当前的状态。如果父对象当前是只读的,变色龙就给我们返回只读指针;如果父对象已经是可写的,它就自动(递归)帮我们把子对象也变成可写的。
-
const重载的运算符get()
变色龙的变色功能,完全依靠的是运算符的const重载:
-
"只读"模式(当变色龙本身是 const 时)
cppconst T * get() const { return value.get(); } // 调用immutable_ptr::get()方法当我们通过一个 const
ColumnComposition访问成员时,编译器会调用这个 const 版本的get()。它老老实实返回内部immutable_ptr里的 const T*, 这时候只能读,不能改。这里直接返回value,因为变色龙内部封装的本身就是默认不可修改的immutable_ptr,所以直接返回。 -
"可写"模式(当变色龙本身是非 const 时)
cppT * get() { return &value->assumeMutableRef(); }当我们已经通过
mutate()拿到了父对象的MutablePtr(非 const 指针),此时我们访问成员,编译器会匹配到这个非 const 版本的get()。由于变色龙本身封装的默认是immutable_ptr,因此这里需要转换成可写版本的mutable_ptr,这里调用的是assumeMutableRef()方法:cpp// COW::assumeMutableRef Derived & assumeMutableRef() const { return const_cast<Derived &>(*derived()); }它不再返回 const T*,而是偷偷调用了
assumeMutableRef(),强行把内部的只读对象以可写引用的方式暴露出来。
-
-
操作符重载的->():
cpp// 这里重载了操作符-> const T * operator->() const { return get(); } // const版本 T * operator->() { return get(); } // 非const版本可以看到,这里的const operator->()直接调用const的get()方法,返回const T* , 而non-const的operator则是调用non-const的get()方法,返回non-const版本的T*
-
detach()方法detach是变色龙的关键操作,用来从变色龙对象中取出其封装的immutable_ptr。我们在deepMutate()中可以看到detach()的作用: 当我们需要对一个复合列进行提权的时候,也需要对子列进行提权,因此我们从变色龙对象中取出其封装的const的immutable_ptr,将其提权变成mutable_ptr,然后把这个提权以后的mutable_ptr放回宿主复合列中(当然,提权是自上而下的递归进行,此时宿主肯定已经事先完成了提权变成了non-const)
cppimmutable_ptr<T> detach() && { return std::move(value); }我们需要注意到两件事,
detach()的调用不仅要求宿主是非 const,还要求是右值环境(宿主是右值)。这是一个"双重门禁"::detach()方法是一个 non-const 方法,没有加const修饰符,这意味着宿主的chameleon_ptr<T>必须是 non-const 的。这个很好理解,detach()是一个破坏性操作,它把变色龙封装的immutable_ptr<T>value 从变色龙宿主中脱钩,这是对变色龙的破坏操作,因此必须要求 non-const 的变色龙。而 non-const 的变色龙又依赖 non-const 的宿主列比如ColumnComposition,而ColumnComposition又来自于MutablePtr<ColumnComposition>。- 如果宿主(res)是
Ptr<ColumnComposition>(只读),那么 res->wrapped 就是 const 对象,无法看到detach()。 - 如果宿主是
MutablePtr<ColumnComposition>(可写),res->wrapped 变回非 const 对象,detach()变得物理可见。
- 如果宿主(res)是
detach()方法是一个右值方法(detach()&&),这意味着它要求它的宿主目前已经是一个右值(即将销毁),这也很好理解:我们马上要对这个变色龙的内脏进行提取,提取以后这个变色龙就不应该在存在,是一个残缺的变色龙,因此应该是右值,防止被其他人继续引用。- 即便
detach()可见了,你也不能直接调。你必须写std::move(res->wrapped).detach()。 - 物理意义:这强迫开发者承认------"我不仅在修改这个外壳,我还要彻底掏空这个变色龙内部的所有权"。
- 即便
基于变色龙封装的复合列的处理的例子
cpp
#include <Common/COW.h>
#include <iostream>
#include <base/defines.h>
/**
* IColumn 定义了所有数据列的通用接口和权限转换协议。
*/
class IColumn : public COW<IColumn>
{
private:
friend class COW<IColumn>;
// 纯虚函数:强制子类实现具体的内存物理拷贝(深拷贝)
virtual MutablePtr clone() const = 0;
// 虚函数:递归提权的核心。默认只做浅层脱钩。
// 对于复合列,需要重写此方法以实现子列的递归脱钩。
virtual MutablePtr deepMutate() const { return shallowMutate(); }
public:
IColumn() = default;
virtual ~IColumn() = default;
virtual int get() const = 0;
virtual void set(int value) = 0;
// 静态入口:将只读 Ptr 转换为可写 MutablePtr 的唯一合法途径。
// 它通过调用虚函数 deepMutate() 触发多态行为。
static MutablePtr mutate(Ptr ptr) { return ptr->deepMutate(); }
};
using ColumnPtr = IColumn::Ptr;
using MutableColumnPtr = IColumn::MutablePtr;
/**
* [ 基础数据层 ]
* ConcreteColumn 代表最底层的原始数据列。
*/
class ConcreteColumn : public COWHelper<IColumn, ConcreteColumn>
{
private:
friend class COWHelper<IColumn, ConcreteColumn>;
int data;
// 构造函数私有化:强制通过 ConcreteColumn::create() 在堆上创建。
explicit ConcreteColumn(int data_) : data(data_) {}
ConcreteColumn(const ConcreteColumn &) = default;
public:
int get() const override { return data; }
void set(int value) override { data = value; }
};
/**
* [ 复合结构层 ]
* ColumnComposition 展示了嵌套列如何通过变色龙指针实现递归 COW。
*/
class ColumnComposition : public COWHelper<IColumn, ColumnComposition>
{
private:
friend class COWHelper<IColumn, ColumnComposition>;
// 变色龙指针:封装了子列。
// 物理上存储 immutable_ptr,但根据 ColumnComposition 的 const 状态动态暴露写权限。
ConcreteColumn::WrappedPtr wrapped;
// 初始化:通过工厂方法创建子列,并由变色龙"捕获"该物理地址。
explicit ColumnComposition(int data) : wrapped(ConcreteColumn::create(data)) {}
ColumnComposition(const ColumnComposition &) = default;
/**
* 实现"递归写时复制"的关键逻辑:
* 1. 脱钩外壳:通过 shallowMutate 确保当前 ColumnComposition 对象是独占的。
* 2. 剥离内芯:通过 detach() 弹出旧子列的物理地址。
* 3. 递归提权:调用 IColumn::mutate 处理子列,确保子列也完成物理独立。
* 4. 重新挂载:将新的可写子列塞回变色龙。
*/
IColumn::MutablePtr deepMutate() const override
{
// 第一步:外壳脱钩。res 指向一个新的(或唯一的)可写副本。
auto res = shallowMutate();
// 第二步:通过 std::move(res->wrapped).detach() 剥离旧子列。
// 第三步:调用静态方法进行递归提权。
// 第四步:赋值回 res->wrapped,重新建立包含关系。
res->wrapped = IColumn::mutate(std::move(res->wrapped).detach());
return res;
}
public:
// 权限穿透:当 ColumnComposition 是 const 时,wrapped-> 调用只读 get()。
// 当通过 MutablePtr 访问时,wrapped-> 自动触发 B 门提权逻辑,访问可写 set()。
int get() const override { return wrapped->get(); }
void set(int value) override { wrapped->set(value); }
};
int main(int, char **)
{
// 1. 创建初始对象:引用计数 = 1,此时 x 是 Mutable 状态的封装。
ColumnPtr x = ColumnComposition::create(1);
// 2. 共享状态:引用计数 = 2,x 和 y 物理上指向同一块地址,这就是典型的COW原理,只要不修改,那么就可以共享同一片内存,而不是过早地执行深拷贝操作
// 关于这里的拷贝构造函数的原理,我们在下文讲解boost::intrusive_ptr的时候做了特别详细的讲解和剖析
ColumnPtr y = x;
{
/**
* 3. 触发写时复制(COW):
* 由于 y 此时被共享(ref_count > 1),IColumn::mutate 会触发 deepMutate()。
* deepMutate 内部 new 了一个新的 ColumnComposition 副本。
* 此时 y 的物理地址发生改变,x 保持不变,实现了物理层面的"脱钩"。
*/
MutableColumnPtr mut = IColumn::mutate(std::move(y));
// 4. 修改独立副本:此时 mut 指向的物理空间是唯一的,修改不会影响 x。
mut->set(2);
// 5. 将修改后的所有权交还给 y。
y = std::move(mut);
}
return 0;
}
我们可以看到,
- 一个复合列会被MutablePtr/
Ptr封装为MutablePtr<ColumnComposition>/Ptr<ColumnComposition>,通过最外层的shallowMutate(),会返回一个MutablePtr<ColumnComposition> - 一个复合列ColumnComposition中封装了一个以子列为模板参数的变色龙成员(
ConcreteColumn::WrappedPtrwrapped),这里的ConcreteColumn::WrappedPtr就是chameleon_ptr<ConcreteColumn>, - 而一个
chameleon_ptr<ConcreteColumn>中封装了对应的子列的Ptr<ConcreteColumn>即immutable_ptr<ConcreteColumn> - ConcreteColumn则是存储具体数据的列
-
IColumn这里直接又定义了IColumn类,和IColumn.h中的IColumn相似。
-
static
MutablePtrmutate(Ptrptr)这是整个
COW机制的唯一入口。它接收一个只读指针(Ptr),返回一个可写指针(MutablePtr)。可以看到,它并不直接执行拷贝,而是通过调用ptr->deepMutate(),将"如何变身"(Ptr->MutablePtr)的决定权交给子类。可以看到,这里的
IColumn::mutate()是一个工具式的静态方法,这样用户就可以写IColumn::mutate(ptr),而不需要关心 ptr 具体是哪个子类,具体是哪个子类的deepMutate()在执行,由deepMutate()的多态机制最终决定。cppstatic MutablePtr mutate(Ptr ptr) { return ptr->deepMutate(); } -
virtual
MutablePtrdeepMutate()const这是递归脱钩的上层指挥官。这里同样定了成了纯虚函数,并且做了简单实现: 实现就是直接调用shallowMutate(),很显然,这个简单实现对复合类型是不适用的,复合类型ColumnComposition重写了deepMutate(),下文会讲解。
cppvirtual MutablePtr deepMutate() const { return shallowMutate(); }- 它首先会调用
shallowMutate()让最上层脱钩,这在普通列(如ColumnVector)中足够了,因为它们没有嵌套子列。 - 但是,对于
ColumnComposition这种复合列,它必须被重写。它负责按下我们之前讨论的"变色龙开关",递归地把子列也给"另存为"一份。 下文会详细讲解ColumnComposition的deepMutate()实现。
- 它首先会调用
-
virtual
MutablePtrclone()const这是纯粹的物理拷贝。这里也定义成了纯虚函数,意味着需要交给下层具体实现去完成具体的拷贝认为。
cppvirtual MutablePtr clone() const = 0; // clone被定义成虚函数,因为可能需要子类的具体clone实现我们看mutate()所表达出来的Copy on Write的基本原理,
- 如果引用计数为 1,那么就不需要调用clone()来产生新拷贝,而是直接将当前对象设置为"独立可写",
- 但是如果引用计数大于1, 那么就必须调用
clone()创建一个完全独立的、引用计数为 1 的新对象。
-
-
ColumnConcrete: 模拟复合列对象内部的列对象ColumnConcrete就是简单封装了一个int数据,定义了相应的构造函数。这里不做赘述。
-
ColumnComposition类:模拟复合列对象
-
构造方法
cppexplicit ColumnComposition(int data) : wrapped(ConcreteColumn::create(data)) {}-
第一阶段:工厂生产 ------ 产生一个处于"原生可写"状态的原始子列ConcreteColumn
cppConcreteColumn::create(data)。这里,就是创建一个
ConcreteColumn对象,并将 data 存入。create()静态方法返回一个MutablePtr(或者说是处于引用计数为 1 的原始指针),实际上是调用了定义在COWHelper中的COWHelper::create()静态方法。在这一瞬间,这个子列是物理独立且逻辑可写的。它是刚刚生产出来的"裸数据":cpptemplate <typename... Args> static MutablePtr create(Args &&... args) { return MutablePtr(new Derived(std::forward<Args>(args)...)); } -
第二阶段:将子列送入"变色龙"并打上只读标签
将
create()返回的结果传给 wrapped 的构造函数。这里触发了chameleon_ptr的完美转发构造函数:cpptemplate <typename... Args> chameleon_ptr(Args &&... args) : value(std::forward<Args>(args)...) {}关键转换:变色龙内部存储的物理实体是
immutable_ptr<T>value。当
create()产生的MutablePtr(可写)进入变色龙时,它会自动退化(Cast)为immutable_ptr(只读)。这就是 ClickHouse 安全性的体现------任何进入复合列的子列,默认都要先被"冰封"起来(变成immutable_ptr)。它不再是一个可以随便修改的裸指针,而被包装成了一个受权限体系管控的"受控对象"。
-
第三阶段:确立父子包含关系与初始化物理边界
代码执行:完成 wrapped 成员的初始化,结束构造。 此时,
ColumnComposition这个"外壳"被创建出来,它的成员 wrapped 成功捕获了子列的物理地址。此时,子列已经完全属于这个父列。由于构造函数返回的是一个普通对象(通常随后会被包装进Ptr或MutablePtr),这个子列的生命周期现在与父列绑定。我们从后面的main()方法可以看到,ColumnComposition的构造也是通过调用定义在COWHelper中的静态create()方法完成的:cppColumnComposition::create(0);
-
-
deepMutate()我们上面讲过,IColumn中将deepMutate()定义成了纯虚函数并且在 ClickHouse 的架构中,
deepMutate()是实现 "递归写时复制(RecursiveCOW)" 的核心物理链路。当一个复合列(如ColumnNullable或ColumnArray)需要被修改时,它不仅要确保自身是独占的,还要确保其内部持有的子列也能正确"脱钩"。cppIColumn::MutablePtr deepMutate() const override { auto res = shallowMutate(); // 生成一个只在表层完成提权的MutablePtr<ColumnComposition> res->wrapped = IColumn::mutate(std::move(res->wrapped).detach()); // 递归进行提权 return res; // 返回已经完成递归提权的MutablePtr<ColumnComposition> }可以看到:
-
它先对最外层的外壳进行物理脱钩
在ClickHouse中,脱钩的意思就是解除物理共享状态,从共享只读变成独立可写
shallowMutate()定义在COWHelper基类中,它执行的是 "浅层拷贝" 逻辑:引用计数检查:它首先检查当前复合列对象的引用计数。
判定分支:
- 如果引用计数 = 1:说明当前复合列是唯一的,shallowMutate 直接返回 this 的
MutablePtr。 - 如果引用计数 > 1:说明该列被共享。它会在堆(Heap)上 new 一个当前对象的副本(副本通过 default 拷贝构造函数产生)。
物理状态:此时,新产生的 res 拥有一个独立的物理内存地址,但它内部的 wrapped 成员依然指向那个被共享的旧子列,因此工作完成一半。
此时,宿主环境成功发生了切换:因为 res 是通过
shallowMutate()得到的MutablePtr(非 const 指针),所以 res->wrapped 这一成员也被编译器视为 非 const 状态。 - 如果引用计数 = 1:说明当前复合列是唯一的,shallowMutate 直接返回 this 的
-
然后,开始递归地对这个复合列的子列进行权限释放
这里的权限释放,指的是变色龙指针通过内部的
const_cast逻辑,强行撕掉子列身上的"只读标签",让我们可以在子列上执行修改操作。-
由于
res已经是MutablePtr<ColumnComposition>,因此此时调用运算符->,得到了非const版本的ColumnCompositioncppres->()...这里重载的运算符其实是来自
MutablePtr从父类boost::intrusive_ptr<T>继承而来的运算符operator ->():cpp// 在 boost::intrusive_ptr 内部(伪代码) template<class T> class intrusive_ptr { T * px; // 存储的原始物理地址 public: T * operator->() const { return px; // 返回原始指针 } // ... 其他重载 };这个重载的运算符返回了ColumnComposition * 。
如果res没有通过shallowMutate,是一个
Ptr<ColumnComposition>,那么res->()调用的就是boost::intrusive_ptr<T>的另外一个const版本的重载运算符->() const,因此返回的就是constColumnComposition* -
从non-const的ColumnComposition中取出封装的变色龙
cppres->wrapped -
基于非const的ColumnComposition *,通过右值运算符把它变成一个xvalue。变成xvalue的动机是: 我马上要从这个变色龙里面把变色龙封装的immutable_ptr取出来了,因此这个变色龙对象肯定已经没有用了,因此变成右值:
cppstd::move(res->wrapped) -
调用变色龙的detach方法,从变色龙中取出其封装的immutable_ptr:
cppstd::move(res->wrapped).detached -
对取出来的immutable_ptr执行权限释放,即通过内部的const_cast逻辑,强行撕掉子列身上的只读标签,让我们可以进行修改操作。这个"权限释放",是通过
IColumn::mutate()完成的:cppIColumn::mutate(std::move(res->wrapped).detach())注意,在cow_cimpositions.cpp中,IColumn是独立定义在这里的,而不是使用的IColumn.h中的IColumn。这里的
IColumn::mutate()是定义在cow_cimpositions.cpp中的:cppclass IColumn : public COW<IColumn> { public: static MutablePtr mutate(Ptr ptr) { return ptr->deepMutate(); } } }; -
将提权以后生成的MutatePtr,重新构造一个变色龙,并赋值给壳子已经变成
MutablePtr<ColumnComposition>的ColumnComposition:cppres->wrapped = IColumn::mutate(std::move(res->wrapped).detach());这里的隐式转换实际上是调用
chameleon_ptr的完美转发构造函数:template <typename... Args>chameleon_ptr(Args &&... args) : value(std::forward<Args>(args)...) {}, 由于IColumn::mutate()返回的mutable_ptr 可以安全地转化为immutable_ptr,编译器会利用这个chameleon_ptr的构造函数,临时创建一个"新的变色龙"。
-
-
这样,当上面的代码递归执行完成,我们就可以确定,最外层的壳子已经变成可写状态,同时它下层的wrapped也被替换成
-
column_compositions.cpp中定义的各个类,他们对应的封装关系以及关键方法之间的关系如下图所示:
text
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ 逻辑起点:deepMutate() const ┃
┃ ┃
┃ // shallowMutate 返回的是 MutablePtr<ColumnComposition> ┃
┃ auto res = shallowMutate(); ┃
┃ ┃
┃ // 这里的 res 是一个"写权限句柄" ┃
┃ // 它指向堆内存中一个新的(或唯一的)ColumnComposition 实例 ┃
┗━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
┃
┃ (通过 res-> 访问,成员 wrapped 变为 non-const 状态)
▼
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Ptr / MutablePtr <ColumnComposition> ┃ ◄── 1. 外部遥控器,对最外层复合列的COW封装
┗━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
┃ (通过 -> 寻址进入)
▼
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ColumnComposition 对象实例 (宿主内存块) ┃ ◄── 2. 宿主容器,复合列
┃ ┃
┃ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃
┃ ┃ chameleon_ptr<ConcreteColumn> wrapped (成员) ┃ ┃ ◄── 3. 变色龙核心,用来控制子列的变色
┃ ┃ ┃ ┃
┃ ┃ ┌──────────────────────────────────────────────────────┐ ┃ ┃
┃ ┃ │ immutable_ptr<ConcreteColumn> value (内核) │ ┃ ┃ ◄── 4. 内核存储,子列的封装,默认是immutable_ptr,如果要变成mutable,需要IColumn::mutate()
┃ ┃ │ [ 存储地址: 0x1234 ] ────────────────────┐ │ ┃ ┃
┃ ┃ └───────────────┬───────────────────────────│─────────┘ ┃ ┃
┃ ┃ │ │ ┃ ┃
┃ ┃ 【 A. 只读侧出口 】 【 B. 可写侧出口 】 ┃ ┃
┃ ┃ (宿主为 const) (宿主为 non-const) ┃ ┃
┃ ┃ │ │ ┃ ┃
┃ ┃ ┌───────────┴───────────┐ ┌───────────┴───────────┐ ┃ ┃
┃ ┃ │ get() const │ │ get() │ ┃ ┃ ◄── 5. 对称的 get(), const 和 non-const两种
┃ ┃ │ (直接返回 value) │ │ (执行 const_cast) │ ┃ ┃
┃ ┃ └───────────┬───────────┘ └───────────┬───────────┘ ┃ ┃
┃ ┃ │ │ ┃ ┃
┃ ┃ ┌───────────┴───────────┐ ┌───────────┴───────────┐ ┃ ┃
┃ ┃ │ operator-> const │ │ operator-> │ ┃ ┃ ◄── 6. 对称的 ->操作符,const和non-const两种
┃ ┃ │ (指向 get const) │ │ (指向 get) │ ┃ ┃
┃ ┃ └───────────┬───────────┘ └───────────┬───────────┘ ┃ ┃
┃ ┃ │ │ ┃ ┃
┃ ┃ ┌───────────────┴───────────────────────────┴──────────┐ ┃ ┃
┃ ┃ │ detach() 成员方法 │ ┃ ┃ ◄── 7. 从变色龙对象中奖wrapped剥离出来
┃ ┃ │ (逻辑:std::move(value) 并返回) │ ┃ ┃
┃ ┃ └───────────────────────┬──────────────────────────────┘ ┃ ┃
┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
│
│ (物理动作:由变色龙内部弹出地址)
▼
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ConcreteColumn ┃ ◄── 8. 数据层
┃ ( 存储 [ 10, 20, 30, ... ] ) ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
IColumn
在 ClickHouse 的核心架构中,IColumn 是数据的基本载体。由于 ClickHouse 采用了极速的 COW (Copy-on-Write) 机制,IColumn 的生命周期管理方法(如 mutate, clone, shallowMutate)设计得非常精妙且严谨。
以下是针对 IColumn 中与生命周期相关的核心方法的深度解析:
-
clone():物理层面的"克隆工厂"我们看到,
IColumn::clone()是一个纯虚函数,并且定义在 private 模块中。它的物理行为是,它执行的是深拷贝。当我们调用mutate()且发现对象被共享时,底层最终会通过这个方法在堆上 new 出一个新对象。- 定义成纯虚函数是因为可能上层在调用的时候,需要通过虚函数表映射到更加具体的实现类比如
COWHelper::clone()或者更底层的实现类的clone()方法。我们下文看到,COW::mutate()-> 的时候,就会调用clone()方法。 - 定义成private是因为ClickHouse不希望开发者直接是执行clone()操作,因为这样会脱离开COW的管理模式,因此,它定义了
COW<IColumn>的友元类,只有COW<IColumn>可以访问clone。我们下文会讲COW::shallowMutate(),它会调用clone()方法。
cpp/** * 只给 COW 机制用的、返回真实派生类型副本的虚函数,因此子类比如COWHelper是可以重载这个函数的,具体的实现也的确放在COWHelper中 * clone()定义成private的用意是: clone() 只能被 COW<IColumn> 调用, 任何外部代码都不允许直接 clone 一个 Column * 并且clone()方法是一个const方法,表示它不修改源对象 * @return */ [[nodiscard]] virtual MutablePtr clone() const = 0; - 定义成纯虚函数是因为可能上层在调用的时候,需要通过虚函数表映射到更加具体的实现类比如
-
mutate(): 逻辑层面的这里的
IColumn::mutate()是一个公共的静态方法,它负责将一个可能被多方共享的只读指针(Ptr)安全地转换为一个独占的可写指针(MutablePtr),内部涉及到深拷贝。cpppublic: [[nodiscard]] static MutablePtr mutate(Ptr ptr) { MutablePtr res = ptr->shallowMutate(); // 1. 尝试所有权脱钩(浅),如果共享了,则独立拷贝一份出来,如果没有共享,直接使用 ptr.reset(); // 2. 释放原只读指针的所有权 // 3. 递归处理"内脏"(深) res->forEachSubcolumn([](WrappedPtr & subcolumn) { subcolumn = IColumn::mutate(std::move(subcolumn).detach()); // 在subcolumn上继续调用IColumn::mutate(),往下递归 }); return res; }可以看到,这个深拷贝方法主要包含两步:
- Shallow 阶段:
shallowMutate()会首先对宿主Ptrptr进行提权操作,从const的Ptr ptr变成non-const的MutablePtr。称之为shallow,因为目前只是对宿主外壳进行了提权,但是宿主内部的子列还没有进行提权,这需要依赖下一步的递归操作shallowMutate()会检查引用计数。如果当前对象唯一,它只做一次指针强转(assumeMutable());如果被共享,则调用clone()。
- Deep 阶段:
- 这是处理复合列(如 Nullable 或 Array)的关键,如果没有复合列,那么这一步就什么都不做。
- 由于浅拷贝只会复制外层容器,内部嵌套的列可能依然被共享,因此需要递归提权。通过
forEachSubcolumn()递归调用IColumn::mutate(),我们能确保整个"列树"的每一个节点都实现了物理上的独占。 - 我们在上文讲解
我们看到,COW中也有对应的public的
COW::mutate()静态方法,它也是public的,因此是提供给外部调用者使用的,但是,它的职责范围和实现深度与IColumn::mutate()方法完全不同,简单来说:COW::mutate()只管**"皮"(当前这一层对象),而IColumn::mutate()还要管"肉"**(递归处理所有子列)。-
COW中的 mutate() (成员方法)只能做到浅层脱钩:在基类
COW<Derived>中,mutate()是一个普通的静态成员方法。它的逻辑非常简单,直接调用了我们之前讨论过的COW::shallowMutate(),进而进一步调用Derived(IColumn)的clone() 方法进行复制, 它的职责仅处理当前这一个对象的引用计数。显然,
COW::mutate()无法处理ColumnNullable 这种复合列,因为,如果只调用COW::mutate(),那么虽然得到了一个新的"外壳",但外壳里面的"数据列"和"空值标记列"依然和老对象共享着。cpppublic: // COW::mutate // 可以看到,这里的mutate只是浅修改,是直接调用shallowMutate() // 子类的IColumn::mutate()会为mutate"深度拷贝"出一个副本 static MutablePtr mutate(Ptr ptr) { return ptr->shallowMutate(); } protected: // 返回一个 mutable_ptr<Derived>,这个方法是一个protect方法,意味着只有子类能调用,比如COWHelper // 这里的shallowMutate()不是进行修改的含义,而是准备进行修改,所以拷贝一份出来"供修改" // 区别于子类的shallowMutate MutablePtr shallowMutate() const { // 这个use_count定义在boost::intrusive_ref_counter中 if (this->use_count() > 1) return derived()->clone(); // CRTP的特性:基类根本不需要在意派生类是否有clone()方法,派生类自己负责实现clone()方法,如果没实现但是又调用了shallowMutate(),编译期间报错 else return assumeMutable(); } -
IColumn中的 mutate() (静态方法):深度递归这才是我们在业务代码中真正调用的
IColumn::mutate(Ptrptr)。它也是一个public的静态方法,从上面的代码可以看到,它内部手动实现了递归脱钩。
- Shallow 阶段:
COW
COW的基本功能
COW实现的是 显式 Copy-On-Write(写时复制)对象管理。它的基本设计目标是:
- 多个对象共享一个只读对象的时候,不需要进行对象的深拷贝,直接共享一份拷贝就行
- 当某个对象需要修改时
- 如果对象被共享,先克隆出来一份,然后修改
- 如果没有共享,不需要克隆,直接修改
cpp
template <typename Derived> // 在IColumn的继承关系中,Derived就是IColumn,而不是最底层的Column比如ColumnString等
class COW : public boost::intrusive_ref_counter<Derived>
{
private:
/**
* 这也是一个const重载(而不是函数名重载)
* static_cast<Derived*>(this) 这种"指针向下转型", 不会、也不可能调用 Derived 的构造函数,它只是在改 "你怎么看这个地址"
* @return
*/
Derived * derived() { return static_cast<Derived *>(this); }
const Derived * derived() const { return static_cast<const Derived *>(this); }
我们看到,COW类继承了boost::intrusive_ref_counter<Derived>,下文我们在详细讲到boost::intrusive_ref_counter和boost::intrusive_ptr的时候会知道,
- 继承了
boost::intrusive_ref_counter<Derived>,说明COW是一个被引用计数机制所管理的对象,即被管理者, - 而继承了
boost::intrusive_ptr的比如immutable_ptr和mutable_ptr是COW的管理者,管理者利用侵入式的引用计数来实时检测和维护逻辑上独占或者逻辑上共享的两种不同场景。
所以,COW的存在是为了应对对象非常大,因此复制代价很高,但大多数时候只是读取的情况,将对象的真正clone()延迟到真正不得不拷贝一份出来的时候。
ClickHouse的Column很多时候就是这种情况,比如ColumnVector,ColumnString,ColumnArray等,一列数据就又上百万行。
在列结构中,直接继承COW的是IColumn,使用的是典型的CRTP模型:
cpp
/// Declares interface to store columns in memory.
// 为IColumn赋予对应的COW功能
class IColumn : public COW<IColumn>
{
private:
friend class COW<IColumn>; // 父类COW可以访问子类IColumn
所以,可以看到,这里,COW 的 Derived 是 IColumn,而不是 IColumn 的更下层实现比如 ColumnVector。所以,COW 层其实是不知道最底层的 IColumn 实现的。但是,下层的比如 COWHelper<Base, Derived> 中的 Derived 就是具体的底层数据类型比如 ColumnVector,这也是下文我们会讲到 COWHelper 的存在原因:COWHelper 作为桥接层,它知道最具体的 Column 类型,因此只有 COWHelper 才有能力提供比如 clone()/create() 这种操作具体数据类型的能力。
cpp
boost::intrusive_ref_counter // 引用计数层,这是实现COW功能的基础,通过引用计数知道当前Column被共享了多少份,而mutate的时候必须独占
▲
│
COW<IColumn> // Derived == IColumn
▲
│
IColumn // 接口层
▲
│
COWHelper<IColumn, ColumnVector>. // Base=IColumn , Derived=ColumnVector
▲
│
ColumnVector. // 底层Column
我们看到,COW通过immutatble_ptr<T>和mutable_ptr<T>两个类模板分别定义了两种列管理的语义: 共享只读和独占可写。我们下文会详细讲解 继承了boost::intrusive_ptr的immutable_ptr和mutable_ptr 是怎么管理 继承了boost::intrusive_ref_counter的COW 的,但是在此以前,我们先看看COW是怎么使用immutatble_ptr<T>和mutable_ptr<T>两个类模板的:
cpp
public:
using MutablePtr = mutable_ptr<Derived>; // 对于IColumn: public COW<IColumn>, Derived=IColumn
using Ptr = immutable_ptr<Derived>; // 对于IColumn: public COW<IColumn>, Derived=IColumn
/**
* 这也是一个const重载(而不是函数名重载)
* static_cast<Derived*>(this) 这种"指针向下转型", 不会、也不可能调用 Derived 的构造函数,它只是在改 "你怎么看这个地址"
* @return
*/
Derived * derived() { return static_cast<Derived *>(this); }
const Derived * derived() const { return static_cast<const Derived *>(this); }
// 静态方法,构造一个MutablePtr
template <typename... Args>
static MutablePtr create(Args &&... args) { return MutablePtr(new Derived(std::forward<Args>(args)...)); }
template <typename T>
static MutablePtr create(std::initializer_list<T> && arg) { return create(std::forward<std::initializer_list<T>>(arg)); }
Ptr getPtr() const { return static_cast<Ptr>(derived()); } // 这里调用 derived const(){},返回 const Derived *
MutablePtr getPtr() { return static_cast<MutablePtr>(derived()); } // 这里调用 derived const(){},返回 Derived *
-
COW提供了静态的
create()方法,用来构造对应的MutablePtr。很显然,刚构造的对象肯定是应该独占可写,而不是共享只读的,因此返回的是MutablePtr:cpptemplate <typename... Args> // 完美转发参数 static MutablePtr create(Args &&... args) { return MutablePtr( // 构造一个 mutable_ptr<Derived> new Derived( // new Derived(...) std::forward<Args>(args)... // 把参数转发给 Derived 构造函数。Derived在COW中是IColumn ) ); }可以看到,
COW::create()接受的是Derived的构造函数参数,把构造函数参数直接转发给Derived进行构造(这里的Derived是COW<Derived>),构造出来一个Derived对象,然后把这个对象封装成一个MutablePtr,形成一个独占可写的对象管理语义。我们从ClickHouse的代码可以看到,只有
IColumn直接继承了COW<Derived>,因此Derived=IColumn。由于IColumn只是一个接口,不能实例化,所以,COW::create()其实只是机制级别的create(),除非有直接继承了COW<Derived>的其他可以实例化的类,比如,如果有任何其他对象继承COW<Derived>,比如classColumnTuple: publicCOW<ColumnTuple>, 那么Derived=ColumnTuple。所以,
COW::create()的设计动机,就是禁止用户直接new对象(IColumn的实现类比如ColumnVector),而是强制用户必须以下面的方式创建对象,因为只有以以下方式创建的对象,创建出来的对象才会强制被mutable_ptr管理,从而一旦创建就无法绕过COW规则:cppColumnAnything::MutablePtr x = ColumnAnything::create(...) // 这里的ColumnAnything是直接继承了COW<ColumnAnything>的对象我们在上文给出的
cow_columns.cpp例子中可以看到ColumnConcrete.create()方法,在cow_compositions.cpp中也可以看到ColumnCompositions.create()方法的调用。我们下面会看到,
COWHelper中也有create()方法,这个create()方法才是实际可以调用的方法:,因为COWHelper方法中的Derived才是实实在在的底层IColumn的实现类:cpptemplate <typename... Args> static MutablePtr create(Args &&... args) { return MutablePtr(new Derived(std::forward<Args>(args)...)); }它与COW中的
create()的关键区别是Derived类型不同,我们下文会讲到,COWHelper作为一个粘合剂,它的模板参数Derived已经是底层实际的列类型了。 -
我们看到,COW还提供了
getPtr()方法,这是两个const重载方法,我们在下文会详细讲解const重载。总之,简单来讲,通过getPtr()的const重载,和derived()方法的const重载,COW精准实现了下面的语义:const COW只能返回一个Ptr(只读,共享),非const的COW可以返回一个MutablePtr(独占,可写)cpp/** * 这也是一个const重载(而不是函数名重载) * static_cast<Derived*>(this) 这种"指针向下转型", 不会、也不可能调用 Derived 的构造函数,它只是在改 "你怎么看这个地址" * @return */ Derived * derived() { return static_cast<Derived *>(this); } const Derived * derived() const { return static_cast<const Derived *>(this); } Ptr getPtr() const { return static_cast<Ptr>(derived()); } // 这里调用 derived const(){},返回 const Derived * MutablePtr getPtr() { return static_cast<MutablePtr>(derived()); } // 这里调用 derived const(){},返回 Derived *
然后,我们看一下COW的三个关键成员方法, mutate(), shallowMutate()和assumeMutate(),他们的实现基于一个基本设计动机: 在 ClickHouse 的列式存储引擎中,数据通常是以巨大的内存块(Column)形式存在的,因此,为了在多线程查询和复杂计算中既保证性能(减少拷贝)又保证安全(防止误写破坏他人数据),这套 COW 框架设计了三个层层递进的核心方法:mutate()、shallowMutate() 和 assumeMutable()。
我们可以用一个"图书馆借书"的例子来串联他们三者之间的协调关系:
-
mutate()(借书申请):我们跟管理员说:"我要在这本书上画重点(修改)"
这是COW提供的静态工具方法,因此是专门给外部调用者使用的public的工具方法,用来获取一个对应的
immutable_ptr<Derived>的Mutable版本。注意,同下面的
shallowMutate()一样,这里的mutate()并不是直接修改数据,而是根据当前的共享副本Ptr ptr返回一个可安全修改(独立副本)的数据引用MutablePtr:cpppublic: using Ptr = immutable_ptr<Derived>; // 对于IColumn: public COW<IColumn>, Derived=IColumn // COW::mutate // 可以看到,这里的mutate只是浅修改,是直接调用shallowMutate() // 子类的IColumn::mutate()会为mutate"深度拷贝"出一个副本 static MutablePtr mutate(Ptr ptr) { return ptr->shallowMutate(); // 实际上调用的是IColumn::shallowMutate,即父类的COW::shallowMutate()方法 }注意这里的运算符重载。这里的
Ptr ptr是immutable_ptr<Derived>,这里其实是调用immutable_ptr继承的boost::intrusive_ptr的operator->(),operator->()返回一个const IColumn*,所以这里实际上是调用Derived的shallowMutate()方法,如果Derived继承COW<Derived>,那么这里就是调用COW::shallowMutate()。我们在看了
IColumn::mutate()才会明白这个COW::shallowMutate()中shallow的含义: 即这个COW::shallowMutate()只处理最外层的壳子的写权限问题,如果是复合列,并不进行递归处理。所以,COW::mutate()只是处理了表层的mutate,无法递归处理复合列的mutate()操作,复合列的mutate()操作需要依赖IColumn::mutate()。我们上文已经讲过。 -
shallowMutate()(管理员检查):
shallowMutate()是定义在COW 类中的protected的成员方法,因此不是对外提供功能的,而是给上面的静态的mutate()方法调用的。它的含义是,管理员看了一眼这本书的登记表:
- 如果发现这本书还有别人在借(
use_count > 1),管理员就去复印机那里克隆(clone)一本新的给你。 - 如果发现这本书全校只有你在借(
use_count == 1),管理员觉得没必要浪费纸,直接进入下一步。
所以,在静态共计方法
COW::mutate()中调用的是COW::shallowMutate(),shallowMutate()根本不是虚函数,所以根本不会调用到COWHelper::shallowMutate()里面去,但是内部调用到的具体执行clone操作的clone()方法是定义在IColumn中的纯虚函数,因为具体的clone()操作显然依赖具体的IColumn实现去完成,不是上层关心的事。这是COW::shallowMutate()方法:cppprotected: // 返回一个 mutable_ptr<Derived>,这个方法是一个protect方法,意味着只有子类能调用,比如COWHelper // 这里的shallowMutate()不是进行修改的含义,而是准备进行修改,所以拷贝一份出来"供修改" // 区别于子类的shallowMutate MutablePtr shallowMutate() const { // 这个use_count定义在boost::intrusive_ref_counter中 if (this->use_count() > 1) return derived()->clone(); // CRTP的特性:基类根本不需要在意派生类是否有clone()方法,派生类自己负责实现clone()方法,如果没实现但是又调用了shallowMutate(),编译期间报错 else return assumeMutable(); // 目前独占,那么允许直接mutate }注意,这里的
shallowMutate()并不是真的做修改,而是返回了一个封装了一个独立可写的对象句柄MutablePtr,这样,调用者拿着这个独立可写的MutablePtr就可以毫无顾忌地对列进行修改,即,它代表的是修改的允许,而不是进行了修改。我们在COWHelper中也看到了shallowMutate()方法,这里的
COWHelper::shallowMutate()是给具体列类型自己的代码用的,不是给上面的IColumn::Ptr这种基类指针调用的。我们在上文中的第二个具体例子中,这是
COWHelper::shallowMutate()方法:cpptemplate <typename Base, typename Derived> class COWHelper : public Base { protected: // COWHelper中的shallowMutate()会调用父类COW的COW::shallowMutate() // 这里的Derived是 ConcreteColumn, // static_cast<Derived *>(Base::shallowMutate().get()) 是典型的CRTP风格(编译期多态),调用者肯定是Derived类型 // Base::shallowMutate() 返回的是 Base::MutablePtr,但我们需要的是 Derived::MutablePtr。所以 COWHelper 必须 把"基类指针包装"转换成"具体类指针包装",于是就多了一层封装 MutablePtr shallowMutate() const { return MutablePtr(static_cast<Derived *>(Base::shallowMutate().get())); } };注意它的作用不是改变 copy-on-write 逻辑,而是做一层类型收窄:
Base::shallowMutate()返回的是Base::MutablePtr,但在具体类里,比如ColumnVector<T>,我们往往想拿到的是ColumnVector<T>::MutablePtr,所以COWHelper::shallowMutate()只是把基类返回的可变指针,转成具体派生类的可变指针:比如某个具体列类继承了
COWHelper<IColumn, ConcreteColumn>,那它自己的代码里可以直接写:cppauto res = shallowMutate();这时调用的就是
COWHelper::shallowMutate()比如,下文会讲到复合类型的
mutate()的时候,cow_compositions.cpp的示例就是这样:cppIColumn::MutablePtr deepMutate() const override { std::cerr << "Mutating\n"; auto res = shallowMutate(); // shallowMutate定义在COWHelper中 res->wrapped = IColumn::mutate(std::move(res->wrapped).detach()); return res; }这里
ColumnComposition继承自COWHelper<IColumn, ColumnComposition>,所以shallowMutate()返回的是ColumnComposition::MutablePtr,不是IColumn::MutablePtr。返回收窄的类型,是为了调用者在调用了
shallowMutate()以后就可以直接使用收窄的类型比如ColumnComposition 的字段和方法,而不是只有父类IColumn的字段和方法。我们对比一下ColumnComposition::MutablePtr和IColumn::MutablePtr的定义:-
对
ColumnComposition:COWHelper<IColumn, ColumnComposition>来说:cppColumnComposition::MutablePtr = IColumn::template mutable_ptr<ColumnComposition> -
而
IColumn自己继承的是COW<IColumn>,所以:cppIColumn::MutablePtr = COW<IColumn>::mutable_ptr<IColumn>
所以这两个分别是:
mutable_ptr<ColumnComposition>和mutable_ptr<IColumn>,mutable_ptr<ColumnComposition>就可以通过operator ->()直接调用ColumnComposition的方法。 - 如果发现这本书还有别人在借(
-
assumeMutable()(盖章授权):当我们看到
use_count = 1, 那么管理员在书的封面上盖一个"允许涂改"的章(const_cast),把当前书直接递给我,无需进行额外复制。cpppublic: // COW::assumeMutable MutablePtr assumeMutable() const { /** * * 所以,这里调用的为 非const版本的getPtr(),返回一个 MutablePtr (mutable_ptr<Derived>) */ return const_cast<COW*>(this)->getPtr(); // 返回一个 MutablePtr (mutable_ptr<Derived>) }在上面的代码中,
this在assumeMutable() const里类型是:const COW*, 所以const_cast<COW*>(this)把它从const COW*变成:COW*(去掉 const), 然后调用const重载的non-const getPtr(),从而返回一个MutablePtr:同理,
COW:assumeMutableRef()是把*derived()所返回的const Derived &转成Derived &:cpp// COW::assumeMutableRef Derived & assumeMutableRef() const { return const_cast<Derived &>(*derived()); }所以, 所以两者本质区别只是返回形式不同:
assumeMutable()返回 可变智能指针,assumeMutableRef()返回 可变对象引用。我们这里其实看到一个"合法但是不安全"的后门: 在
COW.h 中,assumeMutable()是public的,因此不仅仅对外的静态工具方法COW::mutate()可以调用,其他调用者也可以直接调用,而不检查use_count。这样难道没有危险吗?其实,从设计哲学上讲,ClickHouse 的开发者遵循的是 "信任程序员,但要求程序员负责" 的准则:-
命名暗示(Naming Convention):
在 C++ 的命名习惯中,assume(假设)这个词本身就是一个巨大的红色警告。它暗示调用者:"库不保证安全性,你必须自己确保前提条件(Precondition)成立"。这和
std::get_if或static_cast的逻辑是一样的。 -
代码审查与惯例:
在 ClickHouse 的代码库中,普通开发者几乎不会直接调用
assumeMutable()。标准做法是调用IColumn::mutate()或shallowMutate()。直接调用assumeMutable()通常只出现在极端性能敏感的底层逻辑中,且调用者百分之百确定此时没有并发共享。 -
物理隔离:
assumeMutable()返回的是MutablePtr。即便你通过它强行拿到了修改权,如果你在use_count> 1 的情况下修改了数据,你会直接破坏掉其他共享者的只读语义,导致不可预知的后果(逻辑错误或崩溃)。这在 ClickHouse 的测试框架下极易暴露。
-
COW和IColumn: IColumn是如何基于CRTP获得了COW(Copy on Write)的功能的
IColumn通过CRTP的方式继承了COW<IColumn>,所以,IColumn和COW的协作关系是: COW 基类定义了通用的"所有权协议",而 IColumn 通过把自己的类型传给基类,实现了将COW协议绑定到IColumn,即实现了IColumn的Copy-On-Write特性。
其中,关键的继承链条如下所示:
cpp
// 第一步:定义 COW 模板基类(Copy On Write协议层)
template <typename Derived>
class COW : public boost::intrusive_ref_counter<Derived>
{
// 这里定义了 Ptr, MutablePtr 以及 shallowMutate()
// 注意:它知道自己的子类是 Derived
};
// 第二步:IColumn 继承 COW,并将自己作为模板参数传回给基类COW(绑定层)
class IColumn : public COW<IColumn>
{
// 此时,IColumn 自动获得了 COW<IColumn> 里的所有成员
};
CRTP 的精妙之处在于,基类 COW 可以在编译期知道子类 IColumn 的存在,并利用这一点实现高效类型安全的转换。
我们看一下,COW的存在到底具体为IColumn注入和增加了什么:
-
COW模板为IColumn自动生成了配套的指针类型:cpptemplate <typename Derived> class COW { public: using Ptr = intrusive_ptr<const Derived>; // 只读指针 using MutablePtr = intrusive_ptr<Derived>; // 可写指针 };当
IColumn继承了COW<IColumn>后,它内部自动就有了IColumn::Ptr和IColumn::MutablePtr这两个类型。 -
注入了shallowMutate()的逻辑
这是关联的核心。
COW基类实现了一个通用的逻辑:"如果只有我一个人用,就直接给我权限;否则,找子类去克隆一个。"。IColumn本身是不知道shallowMutate()的存在的,IColumn只自己定义了具体的虚函数clone()方法实现来支持shallowMutate()cpptemplate <typename Derived> class COW { public: MutablePtr shallowMutate() const { // 1. 利用 CRTP 的技巧,将 const 基类指针转为真实的子类指针 const Derived * self = static_cast<const Derived *>(this); // 2. 检查来自 boost::intrusive_ref_counter 的引用计数 if (self->use_count() > 1) return self->clone(); // 3. 关键:这里调用了 IColumn 里的虚函数 clone() else return self->assumeMutable(); // 4. 零成本强转 } };我们上面讲过IColumn的private 方法
IColumn::clone(),这个方法是一个虚函数,就是为了在父类的shallowMutate()中调用clone()的时候,能够正确分发调用到具体的下层实现类IColumn或者更下层COWHelper的clone()方法,这就是多态调用。
immutable_ptr和mutable_ptr的实现原理
总而言之,Copy-on-Write (COW) 是一种延迟执行(Lazy Evaluation)的资源管理策略。它的核心逻辑可以高度概括为:"只要大家都不改,我们就共用一份;谁要改,谁就自己去复印一份。"
以下是更完备的基于Copy on Write协议的三个阶段的拆解:
- 初始阶段:读共享 (Read-Sharing)当多个调用者(线程、进程或函数)请求同一份资源时,系统并不会给每个人都分配一个独立的副本。做法:所有调用者都指向同一个内存地址(同一物理页或同一个对象)。状态:此时所有引用都是**只读(Read-Only)**的。好处:极大地节省了内存空间,并省去了昂贵的内存拷贝时间(时间复杂度为 O ( 1 ) O(1) O(1))。
- 触发阶段:写拦截 (Write-Intercept)当其中一个调用者试图对数据进行"修改"操作时,系统(通常是操作系统内核或语言运行时)会拦截这个写指令。检查引用计数:系统查看当前资源的引用计数:
- 如果是 1:说明没有别人在看这份数据,你是唯一拥有者,直接原地修改(In-place update),不产生拷贝
- 如果大于 1:说明有别人也在用这份数据,为了不影响他人,必须执行"拷贝"。
- 执行阶段:拷贝并重定向 (Copy & Redirect)这是
CoW真正发生的地方。申请空间:在内存中申请一块新区域。物理拷贝:将原始数据完整地复制到新区域。重定向:将发出写请求的那个调用者的指针指向这个新副本,而其他调用者依然指向原来的旧数据。修改:在独立出来的副本上完成写操作。
在这三个阶段中,如果说 IColumn 是房子的蓝图,COW 是房产管理的法律,那么 Ptr 和 MutablePtr 就是用户手中持有的房产证
他们的各自职责如下所示:
| 组件 | 角色 | 核心职责 |
|---|---|---|
| IColumn | 资源实体 (The Asset) | 定义数据的物理结构、提供 clone 的物理实现。它只负责"怎么盖房子",不关心谁来住。 |
| COW | 协议框架 (The Protocol) | 定义 mutate 逻辑、维护引用计数、决策何时调用 clone。它是指导"何时盖新房"的规则。 |
| Ptr / MutablePtr | 执行句柄 (The Handler) | 负责权限的物理隔离。通过显式的类型转换(mutate)将调用者的意图告知协议框架,是协议落地的入口。 |
所以,上文讲了IColumn和COW以后,我们下面开始看一下作为整个最底层真正完成物理隔离的工具,Ptr/MutablePtr的工作原理。
我们会看到,immutable_ptr和mutable_ptr都继承了boost::intrusive_ptr<T>,正是因为这样的继承关系,使得immutable_ptr和mutable_ptr有了引用计数的相关功能,从而实现了类似std::unique_ptr/std::shared_ptr的生命周期管理,至于怎么实现的,我们下文会详细讲解boost::intrusive_ptr和boost::intrusive_ref_counter的实现引用计数的工作原理`。
并且在讲解完原理以后会以 COW<Derived>: public boost::intrusive_ref_counter和mutable_ptr<T>: boost::intrusive_ptr为例子讲解immutable_ptr和mutable_ptr是怎么实现对对象的生命周期管理的`。这里不做赘述。
immutable_ptr和mutable_ptr的实现原理
所以,为了实现COW的基本语义,ClickHouse强制区分了读状态下的共享语义和写状态下的独占语义。ClickHouse 的设计是,定义了两个包装类,一个用来表达共享只读状态,一个用来表达独占可写的状态:
cpp
Ptr = immutable_ptr // 可以共享,只读
MutablePtr = mutable_ptr. // 独占,可写
我们先看一下用来表达独占可写状态的语义的类mutable_ptr:
cpp
template <typename T>
class mutable_ptr : public boost::intrusive_ptr<T> // 底层就是 intrusive_ptr<T>,持有的是"可写 T"
{
private:
using Base = boost::intrusive_ptr<T>;
template <typename> friend class COW; // 允许 COW 访问 private 构造函数
template <typename, typename> friend class COWHelper; // 允许 COWHelper 访问 private 构造函数
explicit mutable_ptr(T * ptr) : Base(ptr) {} // 只允许框架内部从裸指针构造
// 用户不能随便从外面 mutable_ptr<T>(new T)
// 必须通过COW/COWHelper中的create()/mutate() 这类受控入口拿到
public:
mutable_ptr(const mutable_ptr &) = delete; // 禁止拷贝
// 因为 mutable_ptr 的语义是"独占可写"
// 一旦能拷贝,就可能出现两个 writer 指向同一对象
// 这会破坏 COW 的核心不变式
mutable_ptr(mutable_ptr &&) = default; // 允许移动
mutable_ptr & operator=(mutable_ptr &&) = default;
// "独占所有权"可以转移,但不能复制
template <typename U>
mutable_ptr(mutable_ptr<U> && other) : Base(std::move(other)) {}
// 允许从兼容类型的 mutable_ptr 移动构造
// 典型场景:mutable_ptr<Derived> -> mutable_ptr<Base>
mutable_ptr() = default; // 默认空指针
mutable_ptr(std::nullptr_t) {} // 允许写 mutable_ptr p = nullptr;
};
这里, mutable_ptr的语义是: 我现在拿到的是一个可写对象T,原则上它应该只有我一个持有者。即,它要表达的是: 可写,独占,可以转移,但是不可以共享。这里的可以转移,但是不可以共享的意思是:对这个对象T的写权限可以从我转移到你,但是,不可能你我共同拥有写权限。
总之,mutable_ptr的核心语义是: mutable_ptr 是一种利用"侵入式引用计数"来实时监测和维护"逻辑独占性"的特殊句柄。
- 它继承
intrusive_ptr:是为了利用已有的计数基础设施,实现读写状态的无缝切换和高效的内存追踪。 - 它表达独占语义:是为了在类型系统层面强制约束写操作的安全,确保在"写"这一动作发生时,不会对其他并发的"读"造成干扰。
这里的一个重要问题是,既然要独占,为什么不用std标准库中的std::unique_ptr,而要用带引用计数的 intrusive_ptr?
原因在于 "身份的统一性" 与 "零成本的权力降级":
-
对象模型的统一:
ClickHouse 的列对象(
IColumn)本质上是共享的。如果使用std::unique_ptr,当你修改完想把对象从独占状态变回共享状态时,我们必须销毁unique_ptr并重新构造一个shared_ptr,因为只有shared_ptr才能表达共享状态。而继承自intrusive_ptr意味着:无论外界如何看待这个对象(是独占(ref_count= 1)还是共享(ref_count> 1)),对象内部的那个计数器永远是同一个。 -
零成本的"降级"(Mutation to Sharing):
当你完成修改,要把
mutable_ptr<T>转回immutable_ptr<T>时,由于两者底层都是intrusive_ptr,这仅仅是一个简单的指针赋值。不需要重新分配控制块,不需要复杂的转换逻辑。 -
侵入式的性能优势:
std::unique_ptr虽然轻量,但它无法感知外部是否还有人在引用这个对象(它太孤独了)。而mutable_ptr继承自intrusive_ptr,使得它可以随时通过use_count()询问对象:"现在真的只有我一个人在用你吗?"。如果发现计数确实为 1,它就可以放心大胆地原地修改,而不需要真的去执行 Copy。
我们看一下mutable_ptr的具体实现细节:
-
将从
T*去构造一个mutable_ptr的构造函数设置成private,只有friend可以通过一个T* 构造一个mutable_ptr,外部根本无法直接构造mutable_ptr:cppprivate: template <typename> friend class COW; // 允许 COW 访问 private 构造函数 template <typename, typename> friend class COWHelper; // 允许 COWHelper 访问 private 构造函数 explicit mutable_ptr(T * ptr) : Base(ptr) {} // 只允许框架内部从裸指针构造 // 用户不能随便从外面 mutable_ptr<T>(new T) // 必须通过COW/COWHelper中的create()/mutate() 这类受控入口拿到下面讲到
immutable_ptr的时候可以看到类似的设计,构造函数的作用域是private。同时,我们看到构造函数被声明为
explicit。下文可以看到,无论是mutable_ptr,还是immutable_ptr,构造函数都被声明为explicit:cppprivate: explicit mutable_ptr(T * ptr) : Base(ptr) {}它的核心作用是:禁止将原始指针(Raw Pointer)隐式地"升级"为智能指针。因为,如果不声明为
explicit,那么我们可以写出这样的代码:cppvoid process(MutablePtr col); IColumn * raw_ptr = new ColumnUInt8(); process(raw_ptr); // 隐式转换!如果不加 explicit,编译器会自动帮我们把 raw_ptr 包装成 MutablePtr这里,就发生了隐式转换:如果没有explicit关键字来禁止"隐式转换",那么在调用process(
raw_ptr);的时候,编译器会自动帮你把raw_ptr包装成智能指针MutablePtr。这里的"包装",指的就是编译器偷偷调用了构造函数,通过参数raw_ptr构造了一个智能指针对象MutablePtr。下文会详细讲解explicit关键字。这里使用explicit关键字的动机是:
-
为了达到不可共享 的语义,
mutable_ptr禁用了自己的拷贝构造函数:cppmutable_ptr(const mutable_ptr &) = delete; // 禁止拷贝如果没有禁用拷贝构造函数,那么外部用户完全可以通过拷贝构造函数构造另外一个
mutable_ptr b, 和mutable_ptr a指向同一个内部对象(一个IColumn):cppmutable_ptr<Column> a = Column::create(...); mutable_ptr<Column> b = a; // 假设这行允许这时候,就变成:
texta ----\ ----> 同一个 Column b ----/其中,a和b都是
mutable_ptr,都指向了同一个T。 -
定义了默认的移动构造函数和移动赋值运算符,从而实现独占对象的所有权转移:
cppmutable_ptr(mutable_ptr &&) = default; // 允许移动 mutable_ptr & operator=(mutable_ptr &&) = default; // "独占所有权"可以转移,但不能复制所以,通过下面的语义,对Foo的独占所有权从a移动到了b:
cppmutable_ptr<Foo> a = ...; mutable_ptr<Foo> b(std::move(a)); -
允许进行兼容类型移动构造
cpptemplate <typename U> mutable_ptr(mutable_ptr<U> && other) : Base(std::move(other)) {}例如:
cppmutable_ptr<Derived> d = ...; mutable_ptr<Base> b(std::move(d));这是因为,
Derived* ->Base*的转换是合法转换,因此,移动以后:cppb -----> Derived object d -----> nullptr
与mutable_ptr相对应,COW<Drived>中还定义了对应共享只读语义的immutable_ptr<T>, 它的含义是: 这是一个共享只读对象,我可以把它拷来拷去,但谁都不能通过它改对象。我们再来看一下immutable_ptr的具体实现:
cpp
template <typename T>
class immutable_ptr : public boost::intrusive_ptr<const T> // 注意:是 const T
// 也就是"只读共享指针"
{
private:
using Base = boost::intrusive_ptr<const T>; // 基类别名
template <typename> friend class COW; // 允许 COW 访问 private 构造函数
template <typename, typename> friend class COWHelper; // 允许 COWHelper 访问 private 构造函数
explicit immutable_ptr(const T * ptr) : Base(ptr) {} // 只允许框架内部从裸指针构造
// 用户外部不能直接用裸指针塞进来
public:
immutable_ptr(const immutable_ptr &) = default; // 拷贝构造函数,允许拷贝
immutable_ptr & operator=(const immutable_ptr &) = default;
// 因为 immutable_ptr 代表"共享只读"
// 共享没问题,复制也没问题
template <typename U>
immutable_ptr(const immutable_ptr<U> & other) : Base(other) {}
// 允许兼容类型之间拷贝
// 典型场景:immutable_ptr<Derived> -> immutable_ptr<Base>
immutable_ptr(immutable_ptr &&) = default; // 允许移动
immutable_ptr & operator=(immutable_ptr &&) = default;
template <typename U>
immutable_ptr(immutable_ptr<U> && other) : Base(std::move(other)) {}
// 允许兼容类型之间移动
template <typename U>
immutable_ptr(mutable_ptr<U> && other) : Base(std::move(other)) {}
// 允许从 mutable_ptr 移动构造
// 这是"可写独占 -> 只读共享"的关键转换
template <typename U>
immutable_ptr(const mutable_ptr<U> &) = delete; // 禁止从 mutable_ptr 拷贝构造
// 只能 move,不能 copy
// 否则 mutable_ptr 还活着,同时又多了 immutable_ptr
// 会破坏"mutable_ptr 独占"的语义
immutable_ptr() = default; // 默认空指针
immutable_ptr(std::nullptr_t) {} // 允许 nullptr 初始化
};
我们看一下它的具体实现:
-
必须注意,它的模板参数故意是
const T,这意味着,通过->()重载返回的都是const T,因此,下面的代码会编译报错:cppimmutable_ptr<Column> p; p->non_const_method(); // 编译错误这是因为,在调用
p->non_const_method()的时候,编译器实际上会将它理解成两步:-
第一步,调用
boost::intrusive_ptr<Column>重载的操作符operator ->():cppp.operator->()->non_const_method();而由于模板参数是
const T,所以,假如我们构造的是一个immutable_ptr<ColumnString>,那么传递给intrusive_ptr的T的类型就是const ColumnString,p.operator->()返回的是const ColumnString*,这是一个指向const对象的指针,即对象Column不可以被修改,所以,这个表达式实际上变成了:cpp(const Column *)->non_const_method(); -
对const Column* 调成员函数
non_const_method(), 这其实就变成了:cppconst Column* ptr; ptr->non_const_method();这显然是非法调用,我们在讲const重载的时候讲过,如果
non_const_method()为 非const成员函数,那么它的隐含参数this的类型是Column * const,但是,此时,由于ptr的类型是const Column*,因此传入的参数是const Column *: 参数不匹配。 也就是说,"只读"不是靠约定,而是直接靠类型系统强制。
所以,这就是immutable_ptr<T>相对于mutable_ptr<T>具有immutable属性的最根本原因,通过const类型的模板参数注入, immutable_ptr所管理的底层对象T(运行的时候是实际的column实现,比如Column)具有了const属性的原因。
-
-
构造函数(即从一个裸指针T构造一个
mutable_ptr对象,不包括拷贝和移动构造)是private,因此只能从它的friend中去构造,从外部根本无法构造出来一个immutable_ptr<T>:cppprivate: using Base = boost::intrusive_ptr<const T>; // 基类别名 template <typename> friend class COW; // 允许 COW 访问 private 构造函数 template <typename, typename> friend class COWHelper; // 允许 COWHelper 访问 private 构造函数 explicit immutable_ptr(const T * ptr) : Base(ptr) {} // 只允许框架内部从裸指针构造 // 用户外部不能直接用裸指针塞进来 -
拷贝构造函数
-
可以看到,它有标准的拷贝构造函数,意思是允许一个
immutable_ptr<T>拷贝构造另一个immutable_ptr<T>,底层行为(比如对T的处理行为)由boost::intrusive_ptr的拷贝语义决定,一般就是共享同一个对象(只对T*指针进行拷贝,即浅拷贝),并增加引用计数:cppimmutable_ptr(const immutable_ptr &) = default;cppimmutable_ptr<Foo> a = ...; immutable_ptr<Foo> b(a); // 调用拷贝构造函数达到的效果就是a和b都指向一个相同的const Foo,引用计数+1, 两者都只能读,不能写。这也是COW的机制的基本原理,不到最后修改的万不得已,拷贝都只是拷贝引用,而不进行深拷贝。
-
-
同样的,它也有标准的重写的赋值运算符,同拷贝构造函数一样,也是默认行为。我们下文在讲解intrusive_ptr的时候会讲到,拷贝构造函数调用的时候会增加引用计数,而不进行深拷贝:
cppimmutable_ptr & operator=(const immutable_ptr &) = default; // 默认的拷贝构造函数immutable_ptr<Foo> a = ...; immutable_ptr<Foo> b; b = a; // 调用拷贝赋值构造函数 -
它还定义了兼容类型的拷贝构造函数
cpptemplate <typename U> immutable_ptr(const immutable_ptr<U> & other) : Base(other) {}这个构造函数允许不同模板参数之间进行拷贝,只要底层类型可转换:
cppstruct Base {}; struct Derived : Base {}; immutable_ptr<Derived> d = ...; immutable_ptr<Base> b(d); // 合法这里合法,是因为底层是
boost::intrusive_ptr<const Derived>到boost::intrusive_ptr<const Base>,只要 constDerived* 能转换成 constBase*,就可以。支持多态向上转型。这和普通智能指针很像,例如
shared_ptr<Derived>可以转成shared_ptr<Base>。注意,反过来一般不行:
cppimmutable_ptr<Base> b = ...; immutable_ptr<Derived> d(b); // 通常不行因为 const
Base* 不能自动转成 constDerived*。 -
它还已定义了移动构造函数和移动赋值运算符
cppimmutable_ptr(immutable_ptr &&) = default; // 移动构造函数 immutable_ptr & operator=(immutable_ptr &&) = default; // 移动赋值运算符因此,下面的移动过程是合法的,结果通常是,b 接管 a 的内部指针,a 变成空,不增加引用计数,或者至少比拷贝更高效:
cppimmutable_ptr<Foo> a = ...; immutable_ptr<Foo> b(std::move(a));所以,
immutable_ptr(共享)也是允许移动的,但是mutable_ptr(独占)是绝对不允许拷贝的。 -
它还允许从
mutable_ptr<U>移动构造,这是最关键的构造函数之一,它的语义是,允许把一个可写独占指针,转换成只读共享指针 ,但只能通过move,不能通过copy:cpptemplate <typename U> immutable_ptr(mutable_ptr<U> && other) : Base(std::move(other)) {}例子:
cppmutable_ptr<Foo> m = ...; immutable_ptr<Foo> i(std::move(m));原来对象由 m 独占,现在把它"交出去",变成只读共享形式, m 被搬空,不能再继续写, 得到 i,以后可以拿去共享传播, 这其实是COW里面及其常见的状态转换:
- 创建/修改阶段:独占可写
- 发布/共享阶段:只读共享
为什么从mutable_ptr到immutable_ptr的过程只能是move,不可以是copy?
因为如果允许 copy:
cppmutable_ptr<Foo> m = ...; immutable_ptr<Foo> i(m); // 假设允许那就会出现:m 还活着,还能写,i 也活着,表示这个对象已经作为"只读共享对象"暴露出去了,这会直接破坏整个模型:一边有人以为对象是
immutable的,另一边却还有个mutable_ptr能修改它,所以这里必须 move,不能 copy。我们在上文讲解
column_compositions.cpp中,可以看到这个从mutable_ptr到immutable_ptr的转换的移动构造的使用:cpp// 对象刚创建的时候都是MutablePtr,这里是偷偷调用了这个从MutablePtr到ImmutablePtr的移动构造方法 ColumnPtr x = ColumnComposition::create(1);
拷贝 vs. 移动,构造 vs. 赋值
我们上文中看到了immutable_ptr以及mutable_ptr中复杂的拷贝构造,移动构造,拷贝赋值,移动赋值,通过这些函数的定义,形成了immutable_ptr以及mutable_ptr的一些基本功能。
我们因此把拷贝和移动,构造和赋值进行了总结和区分,如下所示。
| 动作类型 | 语法形式 | 对象状态 | 物理本质 (内存动作) | 对 ClickHouse 引用计数的影响 |
|---|---|---|---|---|
| 拷贝构造 | T a(b); | 初始化 (从无到有) | 在内存开辟新空间,将 b 的数据完整复制到 a。 | 原子计数 +1 |
| 移动构造 | T a(std::move(b)); |
初始化 (从无到有) | a 直接接管 b 的指针地址,同时将 b 的指针置空。 | 计数不变 (仅所有权转移) |
| 拷贝赋值 | a = b; | 已存在 (覆盖更新) | a 先释放原有资源,再复制 b 的数据到当前内存。 | 旧地址 -1,新地址 +1 |
| 移动赋值 | a = std::move(b); |
已存在 (覆盖更新) | a 先释放原有资源,直接接管 b 的指针地址。 | 旧地址 -1,b 的地址不动 |
-
构造 (Constructor) vs 赋值 (Assignment)
- 构造 :对象生命的起点 。内存中原本没有这个对象。
- 特征 :伴随变量定义(如
ColumnPtrx = ...)。
- 特征 :伴随变量定义(如
- 赋值 :对象已经初始化 。内存中已存在该对象,现在要改变它的内容。
- 特征:对已有变量操作(如 res->wrapped = ...)。
- 构造 :对象生命的起点 。内存中原本没有这个对象。
-
拷贝 (Copy) vs 移动 (Move)
- 拷贝 :原对象资源被保留 。为了两个对象都能用,必须产生副本。
- 特征 :操作数是普通变量(左值),代价较高(原子操作
add_ref)。
- 特征 :操作数是普通变量(左值),代价较高(原子操作
- 移动 :原对象资源被转移 。原对象放弃所有权,通常变为空。
- 特征 :操作数是临时变量或使用了
std::move,代价极低。
- 特征 :操作数是临时变量或使用了
- 拷贝 :原对象资源被保留 。为了两个对象都能用,必须产生副本。
explicit 关键字
上文看到immutable_ptr和mutable_ptr的构造函数都声明为了explicit关键字,其实是禁止编译器隐式(implicit)地调用构造函数。
在 C++ 中,explicit 关键字就像一个"禁止自动翻译"的标识。
为了理解这句话,我们需要对比一下隐式转换和显式构造的区别。
-
如果没有 explicit(隐式转换)
当构造函数只有一个参数(或者除了第一个参数外都有默认值)且没有 explicit 修饰时,编译器会认为:"只要你需要这个类的对象,而你手头刚好有那个参数类型的变量,我就帮你自动变一个出来。"
在你的例子中,
boost::intrusive_ptr<T>的构造函数长这样:cppintrusive_ptr(T * p, bool add_ref = true); // 隐式构造函数这就产生了一种"魔术":
cppvoid doSomething(MyPtr<Obj> ptr) { /* ... */ } // 你可以这么写: doSomething(new Obj(42));发生了什么? 编译器发现
doSomething()方法 需要一个MyPtr<Obj>,但你传了一个 Obj*。因为它看到构造函数不带explicit,所以它在后台偷偷写了代码,调用构造函数帮我把 Obj* 包装成了一个临时的MyPtr对象。这就是所谓的自动转换。 -
如果加上 explicit(显式构造)
如果 Boost 开发者把构造函数改写成:
cppexplicit intrusive_ptr(T * p, bool add_ref = true);那么编译器就会变得**"死板"**:它不再允许自动包装。
如果你再写
MyPtr<Obj>p = new Obj(42);,编译器会报错:Error: cannot convert 'Obj*' to 'MyPtr<Obj>' in initialization.此时就只能明确地调用构造函数:
cppMyPtr<Obj> p(new Obj(42)); // 正确:直接初始化 // 或者 MyPtr<Obj> p = MyPtr<Obj>(new Obj(42)); // 正确:显式类型转换总之,在 C++ 里,explicit 就像是合同里的"必须本人签字",而没有 explicit 则允许"他人代签"。Boost 的
intrusive_ptr选择允许"代签",是为了让你写代码时手感更接近原生指针。
COWHelper
按照我们的正常理解,似乎有了COW,就不需要COWHelper的存在了。这在某种简单的、没有继承关系的情况下是可以的,比如:
cpp
class ConcreteColumn : public COW<ConcreteColumn>
{
friend class COW<ConcreteColumn>;
private:
ConcreteColumn(...);
ConcreteColumn * clone() const;
};
这里,ConcreteColumn就是最底层的某种数据类型的Column实现,比如ColumnString,ColumnTuple等,这时候,继承关系很简单,COW<ConcreteColumn> 通过 CRTP 知道"最终类型就是 ConcreteColumn",没有其他可能。
cpp
boost::intrusive_ref_counter<Derived> (最底层基类:提供引用计数器)
│
└── COW<Derived> (框架基类:提供 Ptr/MutablePtr 语义、mutate/shallowMutate 等逻辑)
│
└── IColumn (业务接口类:所有列对象的抽象基类)
│
└── COWHelper<IColumn, ConcreteColumn> (桥接辅助类:实现 clone 等具体逻辑)
│
└── ConcreteColumn (具体实现类:如 ColumnVector, ColumnString 等)
从上面可以看到,实际情况下,继承COW<Derived>的是一个抽象接口层而不是某一个数据类型,因为一列的具体实现太多了,ColumnString,ColumnTuple,ColumnLowCardinality等,因此,需要抽象成接口IColumn,让IColumn继承COW<Column>的特性,从而,具体的Column类型Column*就自动有了COW的特性。
这时候,如果直接让IColumn继承COW<IColumn>,即如下所示的时候,问题就来了:
cpp
class IColumn : public COW<IColumn>
{
public:
virtual MutablePtr clone() const = 0;
};
class ColumnVector : public IColumn
{
};
IColumn 继承的是COW<IColumn>,所以在 IColumn 这个体系里,默认得到的类型都是IColumn::Ptr,IColumn::MutablePtr,也就是"指向 IColumn 的指针语义",但真实对象其实是ColumnConcrete。
但是,我们希望的是 :
ColumnVector::create(...) 能直接创建ColumnVectorclone()真正复制出一个ColumnVector- 在
ColumnVector作用域里能方便地拿到ColumnVector::Ptr/ColumnVector::MutablePtr
所以,单靠IColumn:COW<IColumn>,这些都不够顺手,甚至不够完整。
于是,我们需要一个链接接口层IColumn和实现层(比如ColumnVector)的连接器,于是就有了COWHelper类:
IColumn是抽象接口层,负责定义COW语义接口;ConcreteColumn才是真正对象;COWHelper<IColumn, ConcreteColumn>的作用,就是把IColumn 的"抽象COW接口" 和ConcreteColumn的具体 clone / create / pointer type 功能连接起来:
cpp
┌────引用计数层─────────────────────────────────┐
│ boost::intrusive_ref_counter<IColumn> │
│ 提供 intrusive 引用计数 │
└───────────────────────────────────────────────┘
▲
│ inherits
┌──COW<IColumn>:COW机制层────────────────────────┐
│ - immutable_ptr<T> / mutable_ptr<T> │
│ - Ptr / MutablePtr │
│ - mutate() / shallowMutate() │ //
│ - assumeMutable() │
└───────────────────────────────────────────────┘
▲
│ inherits
┌─── IColumn抽象接口层────────────────────────────┐
│ - 定义列接口 │
│ - virtual clone() const = 0 │ // IColumn接口定义的clone()方法,由实现层根据数据类型具体实现
│ - 承载 COW<IColumn> 提供的 COW 语义 │
└───────────────────────────────────────────────┘
▲
│ inherits
┌───桥接层:COWHelper<IColumn, ColumnVector>─ ───-------─┐
│ uses Base = IColumn │
│ uses Derived = ColumnConcrete │ // 桥接层既知道接口IColumn,也知道当前的具体实现ColumnConcrete
│ │
│ 负责把 COW 机制绑定到具体类型 │
│ - static create() → new ColumnVector(...) │ // 桥接层知道当前数据的具体类型Derived,因此可以实现
│ - Base::MutablePtr clone() → new ColumnVector(*this) │ // IColumn定义的virtual方法可以在这一层实现,因为这一层已经知道了具体类型ColumnConcrete
| - static MutablePtr mutate(Ptr ptr) |
│ - Ptr / MutablePtr → ColumnVector 版本 │
└───────────────────────────────────────────────--------┘
▲
│ inherits
┌─ColumnConcrete具体实现层────-----───────────────┐.
│ - 实际列数据存储 │
│ - 列算法实现 │
│ - 真实对象类型 │
└───────────────────────────────────────────────┘
所以,综合来讲:
COW<IColumn>只知道接口是IColumn,COWHelper<IColumn, ColumnVector>负责告诉系统"实际干活的是ColumnVector"。
-
定义了
derived()方法这里的
derived()其实是COWHelper里一个非常典型的CRTP小工具方法,它的作用很简单: 把当前这个COWHelper<Base, Derived>对象,重新"看成"真实的派生类Derived对象。cppclass COWHelper : public Base { private: // 典型的CRTP用法,Derived调用derived()就可以获得对应的子类指针 Derived * derived() { return static_cast<Derived *>(this); } const Derived * derived() const { return static_cast<const Derived *>(this); }-
this指针的立即e
在这里,this的含义很明确: 在 C++ 里,任何成员函数里面的 this,都表示"当前对象自己的地址",所以,这里的this指的是当前的COWHelper对象的地址。由于COWHelper不会被拿来单独实例化,它一定是作为某一个真实实例类型的基类存在,比如:
cppclass ColumnVector final : public COWHelper<IColumn, ColumnVector>所以,当一个
ColumnVector对象存在时,它的内存里一定包含一个COWHelper<IColumn, ColumnVector>基类子对象。因此,这里的this虽然静态类型是COWHelper<IColumn, ColumnVector>*,但它实际上指向的,是一个完整的ColumnVector对象中的基类部分,所以,这种向下的静态转换在 C++ 中是允许的:static_cast<Derived *>(this)。 -
const重载的理解
同时,我们可以看到这里也有const重载: 因为成员函数调用时,当前对象可能是非 const 对象,也可能是 const 对象,所以这里必须提供两套版本
- 当调用者是非 const 对象时,编译器会选这个版本:
Derived*derived(),返回的是:Derived*,这样后面就可以继续调用派生类的非 const 方法。 - 而当调用者是 const 对象时,编译器会选这个版本: const
Derived*derived()const,返回的是 constDerived*,这样就不会破坏只读语义。
- 当调用者是非 const 对象时,编译器会选这个版本:
-
derived()的使用那么,既然
derived()方法可以把COWHelper背后所管理的真实的比如ColumnVector对象返回出来,那么derived()方法是给谁用的呢? 很显然,比如,我们需要进行对象的拷贝的时候, 比如:
cpptypename Base::MutablePtr clone() const override { return typename Base::MutablePtr(new Derived(*derived())); }这里的
derived(),拿到的就是当前对象对应的真实Derived引用。 于是,newDerived(derived()) 才能调用真实列类型的拷贝构造函数,比如 newColumnVector(*this)。后面会将COWHelper::clone()方法。
-
-
我们看到,
COWHelper中和COW一样,都定义了静态的create()方法,并且是const重载方法,和COW中一模一样:cppusing Ptr = typename Base::template immutable_ptr<Derived>; // immutable_ptr定义在COW中 using MutablePtr = typename Base::template mutable_ptr<Derived>; // mutable_ptr定义在COW中 // 直接给ColumnVector, ColumnString使用的静态方法,可以参考 cow_columns.cpp // 不是 virtual,也不需要对象实例;只是名字查找时,派生类作用域也能找到基类的静态成员 template <typename... Args> static MutablePtr create(Args &&... args) { return MutablePtr(new Derived(std::forward<Args>(args)...)); } template <typename T> static MutablePtr create(std::initializer_list<T> && arg) { return MutablePtr(new Derived(std::forward<std::initializer_list<T>>(arg))); }但是,由于COWHelper中的Derived是最真实的数据类型(比如
ColumnVector,ColumnString),并且,在C++中,静态成员方法是可以继承的,所以,当用户基于一个实际列ColumnVector调用create()方法的时候,真实调用链是:cppauto col = ColumnVector::create(...); // 真实调用链 ColumnVector ↓ COWHelper<IColumn, ColumnVector> ↓ IColumn ↓ COW<IColumn>最终,找到了
COWHelper::create()方法。所以,我们回过头看看
COW::create()方法和COWHelper::create()方法,区别如下:COW::create()COWHelper::create()Derived的运行时类型 IColumnColumnVector实例化对象 IColumnColumnVector是否能用 ❌ 抽象类 ✅ 作用 提供机制 提供具体实现 -
定义了
clone()方法cpp/** * COWHelper::clone(),实际运行时继承了Base中的虚函数virtual IColumn::clone() = 0 方法 * 写时拷贝, 由于Base是一个模板类,因此需要添加typename声明 * 这里clone()是在Base(IColumn)里面定义的virtual函数,具体的clone() 实现这里放在COWHelper里面,因为COWHelper是从接口类(IColumn)到具体实现类(ColumnVector) * 的桥接类,所以,这里就对具体类进行拷贝构造 * 在 * @return */ typename Base::MutablePtr clone() const override { return typename Base::MutablePtr(new Derived(*derived())); }这里的
COWHelper::clone()不是一个普通的"帮助函数",而是真正去实现Base(IColumn) 里面那个虚函数clone()const:cppclass IColumn : public COW<IColumn> { private: friend class COW<IColumn>; // 父类COW可以访问子类IColumn [[nodiscard]] virtual MutablePtr clone() const = 0; }注意,基类
IColumn里面这个IColumn::clone()是 private 的,但这不妨碍COWHelper去重写它。这里最容易绕的点是: private 限制的是"谁能调用它",不是"子类能不能 override 它" 。 也就是说,在 C++ 里:基类的虚函数就算是private virtual,派生类仍然可以提供同签名的override,只是外部代码不能通过基类接口直接去调用这个 private 函数,即,派生类"能不能 override"这件事,看的是虚函数签名是否匹配;不看你有没有权限去调用它 。这是 C++ 里一个很经典的写法:
- 基类把某个虚函数藏起来,不让外部直接碰
- 但仍然允许派生类去实现它
- 最后由基类内部某个受控入口去触发虚调用
具体到ClickHouse中,其调用过程是:
cppCOW<IColumn>::shallowMutate() ↓ derived()->clone() # COW是IColumn的friend,因此,COW的成员函数shallowMutate()中可以访问clone()方法,并且访问的时候遵循虚拟派发规则 ↓ virtual dispatch # 基于虚函数表进行虚派发 ↓ COWHelper<IColumn, ColumnVector>::clone() # 派发到真正的实现类所以虽然
clone()在IColumn里是 private,但COW<IColumn>是它的 friend,因此这一句是合法的:derived()->clone();而一旦这句合法触发了虚调用,运行时就会分派到真实派生类,也就是COWHelper<..., Derived>::clone()。在
IColumn这一层,clone()是 private virtualMutablePtr clone() const = 0。到了
COWHelper<IColumn, ColumnVector>这一层,才第一次知道"真实对象到底是谁",也就是Derived==ColumnVector所以,这里clone()做的事情其实很直接:
derived()把 this 从COWHelper* 静态下转成 constDerived*- *
derived()得到当前真实对象 - new
Derived(*derived()) 调用真实列类型的拷贝构造函数,实现了对象的考比尔,比如ColumnVector(constColumnVector&) - 最后,把这个新对象包装成
Base::MutablePtr,然后返回包装好的Base::MutablePtr
-
定义了
shallowMutate()方法上面在讲到
COW的时候,已经详细讲解过COW::shallowMutate()方法以及COWHelper::shallowMutate()方法的关联和差别,这里不再赘述。cppprotected: // COWHelper中的shallowMutate()会调用父类COW的COW::shallowMutate() // 这里的Derived是 ConcreteColumn, // static_cast<Derived *>(Base::shallowMutate().get()) 是典型的CRTP风格(编译期多态),调用者肯定是Derived类型 // Base::shallowMutate() 返回的是 Base::MutablePtr,但我们需要的是 Derived::MutablePtr。所以 COWHelper 必须 把"基类指针包装"转换成"具体类指针包装",于是就多了一层封装 MutablePtr shallowMutate() const { return MutablePtr(static_cast<Derived *>(Base::shallowMutate().get())); }
关键生命周期函数一览
text
【物理层级】 【关键生命周期方法声明】 【生命周期职责】
|
[ 0. 引用计数底座 ] private: atomic_ref_count; 【计数物理位】生命周期的物理依据。
(intrusive_ptr_base) 埋在对象头部的原子引用计数器。
|
▲ 继承 (Inherit)
|
[ 1. 框架协议层 ] public: static MutablePtr create(...); 【底层工厂】静态工具。基础内存分配。
(COW<T>) public: static MutablePtr mutate(Ptr ptr); 【单层脱钩】静态工具。仅处理当前层,不递归。
protected: MutablePtr shallowMutate(); 【决策中心】非静态。判定原地提权(变成Mutable)或克隆。
public: MutablePtr assumeMutable() const; 【原地提权】非静态。强制 const_cast。
|
▲ 继承 (Inherit)
|
[ 2. 业务接口层 ] public: static MutablePtr mutate(Ptr ptr); 【深拷贝总指挥】静态工具。处理递归脱钩。
(IColumn) public: virtual Ptr clone() const = 0; 【克隆接口】虚函数。定义克隆契约。IColumn不做实现,交给COWHelper具体实现,因为只有COWHelper才有具体列类型 Derived
|
▲ 继承 (Inherit)
|
[ 3. 模板助手层 ] public: static MutablePtr create(...); 【业务工厂】静态工具。生成子类对象。
(COWHelper) protected: using COW<T>::shallowMutate(); 【协议透传】CRTP 注入。暴露基类协议。
public: Ptr clone() const override; 【自动实现】利用模板自动完成Derived的 clone()
| (即:return new Derived(*this))
▲ 继承 (Inherit)
|
[ 4. 具体实现层 ] private: ConcreteColumn(const ConcreteColumn &); 【物理执行】成员属性。字节搬运工。
(ConcreteColumn) 物理复刻的真正完成者,但是是private,只能有friend COWHelper去调用,防止生命周期管理协议被破坏
生命周期管理: boost::intrusive_ptr还是std::shared_ptr
boost::intrusive_ptr 和 boost::intrusive_ref_counter的配合和工作原理
我们从COW<IColumn> 和 immutatable_ptr类中看到了boost::intrusive_ptr 和 boost::intrusive_ref_counter的相关实现,它可以抽象成下面最简的例子:
cpp
#include <boost/smart_ptr/intrusive_ptr.hpp>
#include <boost/smart_ptr/intrusive_ref_counter.hpp>
#include <iostream>
// 1) 被管理对象:refcount "侵入"到对象内部
struct Obj : public boost::intrusive_ref_counter<Obj>
{
int v;
// explicit构造函数,防止从整数到对象的隐式转换
explicit Obj(int x) : v(x)
{
std::cout << "Obj constructed, v=" << v << "\n";
}
~Obj()
{
std::cout << "Obj destructed, v=" << v << "\n";
}
};
// 2) 指针句柄:继承 intrusive_ptr,演示"另一个类继承 intrusive_ptr"
template <typename T>
struct MyPtr : public boost::intrusive_ptr<T>
{
using Base = boost::intrusive_ptr<T>;
// 继承 intrusive_ptr 的构造函数(C++11+)
using Base::Base;
// 只是为了演示:提供一个名字不同的函数,告诉你它确实是"我们自定义的指针类型"
void debug(const char* name) const
{
std::cout << name
<< " raw=" << this->get() // get()方法来自 boost::intrusive_ptr,获取T的地址
<< " use_count=" << (this->get() ? this->get()->use_count() : 0)
<< "\n";
}
};
int main()
{
std::cout << "=== create p1 (MyPtr<Obj>) ===\n";
MyPtr<Obj> p1(new Obj(42)); // new Obj,p1 接管;内部会 add_ref
p1.debug("p1");
std::cout << "\n=== copy p1 -> p2 ===\n";
MyPtr<Obj> p2 = p1; // 拷贝,内部会 add_ref
p1.debug("p1");
p2.debug("p2");
std::cout << "\n=== assign p1 -> p3 ===\n";
MyPtr<Obj> p3;
p3 = p1; // 赋值,内部会 add_ref
p1.debug("p1");
p2.debug("p2");
p3.debug("p3");
std::cout << "\n=== leave inner scope by resetting p2 ===\n";
p2.reset(); // release 一次
p1.debug("p1");
p2.debug("p2");
p3.debug("p3");
std::cout << "\n=== reset p1 ===\n";
p1.reset(); // release 一次
p1.debug("p1");
p3.debug("p3");
std::cout << "\n=== reset p3 (should delete Obj) ===\n";
p3.reset(); // 最后一次 release,refcount->0,触发 delete
p3.debug("p3");
std::cout << "\n=== end ===\n";
return 0;
}
当我们使用 struct Obj : public boost::intrusive_ref_counter<Obj>的时候,实际上发生的事是:
-
在 Obj 对象内放了一个 refcount 成员,这个refcount成员变量是被嵌入到Obj对象中的
-
这里的refcount的嵌入就是通过普通的继承来完成的,因为,C++ 对象模型的铁律: 派生类对象的内存里,一定包含一个完整的"基类子对象", 即,Obj的内存布局一定类似于:
text| intrusive_ref_counter<B> 子对象 | # 基类子对象 | B 自己的成员 | # 子对象自己而
intrusive_ref_counter<B>里有一个成员:textstd::atomic<long> refcount;所以 refcount 就物理地存在于 B 对象内部。这个refcount的嵌入与CRTP没有任何关系。
在ClickHouse的源码中,
COW<IColumn>的声明是这样的:cpptemplate <typename Derived> class COW : public boost::intrusive_ref_counter<Derived> { ... }可以看到,这里boost::intrusive_ref_counter的模板参数是Derived,但是子类是COW。实际运行的时候,
Derived=IColumn,所以COW其实是COW<IColumn>,可以看到,子类和模板参数不是完全相同的一个类,却是一个有继承关系的类:IColumn继承COW<IColumn>。这里可以怎么理解?-
首先,我们认为这种情况属于CRTP吗?根据CRTP的严格定义,这里不属于CRTP, 因为CRTP要求子类和基类(类模板)的模板参数类型就应该相同,但是,在这个例子中,子类是
COW<IColumn>,类模板的模板参数(运行时)是IColumn,并不完全相同:
The Curiously Recurring Template Pattern is an idiom in which a class X derives from a class template Y, taking a template parameter Z, where Y is instantiated with Z = X. For example,cpptemplate<class Z> class Y {}; class X : public Y<X> {};
-
-
-
boost::intrusive_ref_counter<T>提供(或注入)了下面两个自由函数(free functions),对 Obj* 生效:这两个自由函数定义在了intrusive_ref_counter.hpp中:
cpptemplate< typename DerivedT, typename CounterPolicyT > inline void intrusive_ptr_add_ref(const intrusive_ref_counter< DerivedT, CounterPolicyT >* p) BOOST_SP_NOEXCEPT { CounterPolicyT::increment(p->m_ref_counter); } template< typename DerivedT, typename CounterPolicyT > inline void intrusive_ptr_release(const intrusive_ref_counter< DerivedT, CounterPolicyT >* p) BOOST_SP_NOEXCEPT { if (CounterPolicyT::decrement(p->m_ref_counter) == 0) delete static_cast< const DerivedT* >(p); }但是,这两个自由函数的注入和refcount的注入不同。因为继承带来的注入只包括成员变量,成员函数或者基类子对象,不包含自由函数 。这两个自由函数的注入依赖的是模板里定义的 friend 自由函数 +
ADL(实参依赖查找)。这两个函数后面会被
boost::intrusive_ptr查找到并调用。下文会详细讲解这两个自由函数是定义在哪里,以及如何被boost::intrusive_ptr查找到的。 -
把删除语义绑定到 Obj
这里的绑定,实际上绑定到了
boost::intrusive_ref_counter<T>的T上,运行的时候,就是绑定到T的实际运行时类型上。因此,我们在实例化struct Obj的时候,根据struct Obj : public boost::intrusive_ref_counter<Obj>,删除语义就绑定了Obj对象上。 -
在这里,由于MyPtr继承了父类的构造函数,而
boost::intrusive_ptr<T>存在类似通过T* 进行构造的构造函数,因此下面的调用是合法的:cpp// 简化版的 boost 源码逻辑 template<class T> class intrusive_ptr { public: intrusive_ptr(T * p, bool add_ref = true) : px(p) { if(px != 0 && add_ref) intrusive_ptr_add_ref(px); } };由于继承了
boost::intrusive_ptr<T>的构造函数,因此上面的main()方法中的代码就可以理解了: 先构造了一个new Obj(42)对象,然后通过显式调用MyPtr继承来的构造函数,基于参数Obj* 构造了一个MyPtr对象。cppusing Base = boost::intrusive_ptr<T>; using Base::Base; // 继承 intrusive_ptr 的构造函数(C++11+) MyPtr<Obj> p1(new Obj(42)); // new Obj,p1 接管;内部会 add_ref
所以, 为了让boost::intrusive_ref_counter和boost::intrusive_ptr实现对一个类B的生命周期管理,其"最小成立关系"应该如下所示:
cpp
// 在B中注入refcount
struct B : boost::intrusive_ref_counter<B>
{
int data;
};
// 指针句柄(可以是任何类,完全不需要继承 B)
struct C : boost::intrusive_ptr<B>
{
using boost::intrusive_ptr<B>::intrusive_ptr;
};
它满足了如下约定:
- B 是 被管理对象
intrusive_ref_counter<B>把 refcount 注入进 B 对象intrusive_ptr<B>(或其子类 C)只要拿着B*就能工作- 管理对象C 和 被管理对象B 完全不需要继承关系
我们看到,使用boost::intrusive_ptr的时候,子类©可以和基类模板的模板参数B不相同甚至没有任何继承关系,但是继承了boost::intrusive_ref_counter的子类,和boost::intrusive_ref_counter自己的模板参数(B),必须完全相同或者有继承关系,否则(比如: struct A : boost::intrusive_ref_counter<B>),refcount会注入到类A中(因为A是子类),但intrusive_ptr<B> 管理的是 B 对象(struct C : boost::intrusive_ptr<B>),refcount 和被 delete() 的对象不是同一个内存实体,语义已彻底错位。
既然是这样,那为什么template <typename Derived> class COW : public boost::intrusive_ref_counter<Derived> 是没问题的呢?这是因为,最终被管理的对象类型仍然是 Derived(运行时为 IColumn),而 COW<Derived>(运行时为 COW<IColumn>) 只是一个"中间基类子对象",refcount 依然物理地嵌入在 Derived(运行时为 IColumn) 的对象内存中(上文讲过,IColumn 是 COW<IColumn> 的子类),并且 intrusive_ptr_add_ref(Derived*) 的语义完全正确。
下图展示了管理者 immutable_ptr<T> / mutable_ptr<T> 和被管理者 T(ColumnConcrete->COWHelper->IColumn->COW) 之间基于 boost::intrusive_ptr 进行声明周期管理的概念图:
text
【 管理者 (Pointer) 】 【 被管理者 (Column) 】
(Logic / Controller) (Memory / Resource)
immutable_ptr<Column> ConcreteColumn 实例对象
┃ ┃
〔继承自 intrusive_ptr〕 〔继承自 COW / Counter〕
▼ ▼
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ boost::intrusive_ptr ┃ ┃ intrusive_ref_counter ┃
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
┃ ┃ ┃ ┃
┃ T * px; ━━━━━━指向被管理者━━━━━━━━━━╋━━━━━━━▶┃ mutable counter_type m_ref_counter;┃
┃ (存储物理地址) ┃ ┃ (物理内存中的原子计数器,来自基类子对象) ┃
┃ ┃ ┃ ┃
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ ┃ ▲ ┃
┃ ┃ ┃ ┃ ┃
┃ ◆ 拷贝构造函数: ┃ ┃ ┃ ┃
┃ intrusive_ptr(intrusive_ptr const& r)┃ ┃ ┃ ┃
┃ : px(r.px) { ┃ ┃ ┃ ┃
┃ if(px != 0) ┃ ┃ ┃ ┃
┃ intrusive_ptr_add_ref(px); ━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━┛ ┃
┃ } ┃ ┃ ( 物理动作:原子自增 +1 ) ┃
┃ ┃ ┃ ┃
┃ ◆ 析构函数: ┃ ┃ ┃
┃ ~intrusive_ptr() { ┃ ┃ ┃ ┃
┃ if(px != 0) ┃ ┃ ┃ ┃
┃ intrusive_ptr_release(px); ━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━┛ ┃
┃ } ┃ ┃ ( 物理动作:原子自减 -1 ) ┃
┃ ┃ ┃ ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
┃ ┃
┌─────────────────────────┸──────────────────────────────┸─┐
│ 【 自由函数桥接层 (ADL 触发) 】 |
| 定义在 intrusive_ptr.hpp中 │
│ void intrusive_ptr_add_ref(p) { p->m_ref_counter++; } │
│ void intrusive_ptr_release(p) { if (--p->m_ref_counter == 0) delete p; }
└──────────────────────────────────────────────────────────┘
📦 基类子对象(
BaseClass Subobject)定义
在 C++ 中,基类子对象 指的是:派生类对象内部,用来表示其某个基类的那一部分内存区域。
它不是独立分配的对象,也不是指针或引用,而是派生类对象内存布局中的一个组成部分。
核心特性
- 每个派生类对象都包含其所有非虚基类的基类子对象
- 基类子对象在派生类对象中拥有完整且独立的存储区域
- 无论继承层级有多深,间接基类同样以基类子对象的形式嵌入在最终对象中
内存模型示意
Derived对象内存┌────────────────────────┐
│
Base基类子对象 ││ ├─
Base::member1││ └─
Base::member2││
Derived自身成员 ││ └─
Derived::member│└────────────────────────┘
向上转型的依据
cpp
Derivedd;
Base* bp = &d; // bp 指向 d 对象内部的Base基类子对象指针向上转型之所以安全,是因为
Derived对象内部包含一个表示其基类Base的基类子对象 ;
Base* 实际指向的是该基类子对象在Derived对象内存中的起始位置。与侵入式设计的关系
- 侵入式引用计数(如
intrusive_ref_counter<T>)
正是通过将计数器refcount作为基类子对象嵌入到被管理对象 T(IColumn) 内部来工作- 只要某个类(比如
COW<IColumn>)是最终对象(IColumn)的(直接或间接)基类,这个基类的成员就可通过该最终对象去访问判定准则
若某段数据需要被 T* 访问,那么该数据必须位于 T 的对象内存中------
也即,必须属于 T 的某个基类子对象或自身成员。
一句话总结
基类子对象不是"附加的对象",而是派生类对象内存布局中不可分割的一部分;
C++ 的继承、向上转型以及侵入式生命周期管理,都建立在这一对象模型之上。
我们可以看到:
text
=== create p1 (MyPtr<Obj>) ===
Obj constructed, v=42
p1 raw=0x57cf0c334d30 use_count=1 // p1 创建后 use_count=1
=== copy p1 -> p2 ===
p1 raw=0x57cf0c334d30 use_count=2 // 拷贝到 p2 后, p1和p2的 use_count=2
p2 raw=0x57cf0c334d30 use_count=2
=== assign p1 -> p3 ===
p1 raw=0x57cf0c334d30 use_count=3 // 再赋值到 p3 后, p1,p2,p3的 use_count=3
p2 raw=0x57cf0c334d30 use_count=3
p3 raw=0x57cf0c334d30 use_count=3
=== leave inner scope by resetting p2 ===
p1 raw=0x57cf0c334d30 use_count=2 // p2.reset() 后 use_count=2
p2 raw=0 use_count=0
p3 raw=0x57cf0c334d30 use_count=2
=== reset p1 ===
p1 raw=0 use_count=0. // p1.reset() 后 use_count=1
p3 raw=0x57cf0c334d30 use_count=1
=== reset p3 (should delete Obj) ===
Obj destructed, v=42 // p3.reset() 后触发析构(打印 Obj destructed...)
p3 raw=0 use_count=0
=== end ===
所以,上面的例子其实是用Obj对象来模拟IColumn, 而用MyPtr来模拟immutable_ptr<IColumn>.
intrusive_ptr和shared_ptr/unique_ptr的区别
这里,我们会详细介绍shared_ptr/unique_ptr的底层区别,准确理解移动语义的真实含义,然后再对比intrusive_ptr的底层实现,来看看为什么ClickHouse选择使用intrusive_ptr来表达独占和共享语义并实现延迟拷贝的功能的。
shared_ptr和unique_ptr的内存布局以及对应区别
-
std::unique_ptr(8 字节)语义: 独占所有权。
布局: 只有一个8字节指针,没有所谓的控制块,极致轻量。独占特性是由
unique_ptr自己的定义来完成的,比如,它没有对应的拷贝构造函数,只有移动构造函数,因此只能完成移动语义,不可以拷贝。栈 (Stack) 堆 (Heap) +----------------+ 红线 +-----------------------+ | unique_ptr<T> | -------> | 业务对象 T | +----------------+ | [ 实际数据... ] | +-----------------------+ -
std::shared_ptr的物理布局一个
std::shared_ptr在 64 位系统下占 16 字节,由两个指针组成。Ptr1 (Data Pointer): 8 字节,直接指向堆上的业务对象(如 Column)。Ptr2 (Control Block Pointer): 8 字节,指向一个独立的内存块。这个控制块才是存放"原子引用计数"的地方。std::shared_ptr(16 字节)语义: 共享所有权。
布局: 两个指针,额外多一次堆内存分配(控制块)。
栈 (Stack) 堆 (Heap) +----------------+ 红线 +-----------------------+ | shared_ptr<T> | -------> | 业务对象 T | | [ Data Ptr ] | | [ 实际数据... ] | | | 蓝线 +-----------------------+ | [ Control Ptr ]| ----+ +----------------+ | +-----------------------+ +--> | 控制块 (Control Block) | | [ 原子引用计数: 2 ] | | [ 弱引用计数: 0 ] | +-----------------------+-
"拷贝"过程中的原子操作
当你执行
Ptry = x;(拷贝)时:复制:将 x 的两个指针值复制给 y。
原子增加:
CPU必须根据Ptr2 找到控制块,对其中的 Shared Count 执行一次原子自增指令。为什么要原子? 因为可能有另一个线程也在拷贝同一个 x,如果没有
CPU级别的原子锁,两个线程同时加 1 可能会导致计数只增加了 1(竞态条件),从而导致对象被提前销毁。在shared_ptr的场景下,我们执行拷贝shared_ptr y = x,就是执行shared_ptr对象的拷贝语义,而不是shared_ptr所指向的业务对象的拷贝语义。而unique_ptr则完全禁用了拷贝语义,比如,它的拷贝构造函数是private的。
-
"移动"是如何实现优化的?
当我们执行
Ptry =std::move(x);(移动)时:-
接管:y 直接拿走了 x 的两个指针值。
-
清空:x 的两个指针被设为 nullptr。
-
结果:引用计数根本没动。
因为资源只是从 x 挪到了 y,总的拥有者数量没变,所以不需要访问控制块,也就规避了昂贵的原子指令开销。
在unique_ptr的场景下,我们执行unique_ptr y =
std::move(x),就是执行unique_ptr对象的移动语义,而不是unique_ptr所封装的业务对象的移动语义。当然,shared_ptr也可以执行移动语义。 -
所以,
unique_ptr和shared_ptr的关键差别是:-
独占 vs 共享:
unique_ptr不准拷贝。我们要传给别人,必须使用移动语义std::move。移动时,只是把上面的那根"红线(指向业务对象)"地址复制给新指针,旧的蓝色线指针抹黑(设为 nullptr),代价极低。shared_ptr允许拷贝。但拷贝时,我们必须顺着下面的"蓝色线(指向控制块的指针)"找到控制块,执行一次原子操作(Atomic Increment)把计数从 2 变成 3。在高并发下,这根指向控制块的"蓝色线"的访问会变慢。
-
内存开销:
unique_ptr就像一个普通的 T* 指针,没有任何额外浪费。shared_ptr无论你怎么用,它永远要在栈上占 16 字节,并在堆上额外开辟一块"控制块"内存。
-
移动优化的真相:
- 当你对
shared_ptr使用std::move时,你实际上是拒绝了顺着"蓝色线"去改计数器。你直接把两个指针的值(地址)像接力棒一样传给了下一个指针。这样就规避了原子计数操作,让shared_ptr的传递性能瞬间提升到和unique_ptr一个量级。
- 当你对
几个必须理解准确的歧义
-
我们说
std::unique_ptr不准拷贝,指的是unique_ptr本身不允许拷贝,而不是unique_ptr背后的业务对象不允许拷贝,即,这意味着你不能写 ptr2 = ptr1。编译器会报错,因为它要保证**"所有权"**是唯一的。如果你想把这个"管理权"给别人,你必须用std::move,这就像是把钥匙直接递给别人,你自己手里就没钥匙了。 -
业务对象是否允许拷贝,取决于业务对象自己的定义。比如,如果业务对象自己定义了拷贝构造函数,那么当然可以写Column
new_col= *ptr1;。但这属于业务层面的深拷贝,和智能指针的管理逻辑无关。 -
同理,
std::shared_ptr允许拷贝:指的是管理句柄(钥匙)可以复印很多把。你可以复印一把给函数 A,复印一把给线程 B。大家手里的钥匙都指向同一个控制块。 -
std::move的语义,并不是真的进行移动,
-
-
ClickHouse 的
intrusive_ptr(8 字节)语义: 共享所有权(但计数器在对象体内)。
布局: 只有一个指针,不需要额外的控制块。
栈 (Stack) 堆 (Heap) +----------------+ +-----------------------+ | intrusive_ptr | -------> | 业务对象 T (含计数器) | +----------------+ | [ 原子引用计数: 2 ] | <--- 计数器在这里! | [ 实际数据... ] | +-----------------------+
下面的例子通过对比,具体展示了对象的拷贝构造、移动构造,以及基于拷贝构造、移动构造、std::move移动语义实现的unique_ptr和shared_ptr的运作方式:
cpp
#include <iostream>
#include <memory> // std::unique_ptr, std::shared_ptr
#include <vector>
#include <utility> // std::move
class LargeResource {
public:
size_t size;
int* data;
explicit LargeResource(size_t s) : size(s), data(new int[s]) {
std::cout << "[构造] 创建了大小为 " << size << " 的资源\n";
}
// 1. 拷贝构造函数 (Copy Semantics) - 深拷贝
LargeResource(const LargeResource& other) : size(other.size), data(new int[other.size]) {
std::copy(other.data, other.data + size, data);
std::cout << "[拷贝] 复制了一份全新的资源数据\n";
}
// 2. 移动构造函数 (Move Semantics) - 资源窃取
LargeResource(LargeResource&& other) noexcept : size(other.size), data(other.data) {
other.data = nullptr; // 关键:将原对象置空,防止析构时释放内存
other.size = 0;
std::cout << "[移动] 直接拿走了原对象的内存地址 (零拷贝)\n";
}
~LargeResource() {
if (data) {
std::cout << "[析构] 释放了内存\n";
delete[] data;
} else {
std::cout << "[析构] 资源已被移动,无需释放\n";
}
}
};
// 演示函数
void useShared(std::shared_ptr<LargeResource> p) {
std::cout << " -> 进入函数,当前引用计数: " << p.use_count() << "\n";
}
int main() {
// --- PART 1: 拷贝与移动 ---
std::cout << "--- 1. 拷贝 vs 移动 ---\n";
LargeResource res1(100);
LargeResource res2 = res1; // 触发拷贝构造
LargeResource res3 = std::move(res1); // 触发移动构造,res1 失去资源
// --- PART 2: unique_ptr (独占所有权) ---
std::cout << "\n--- 2. std::unique_ptr ---\n";
auto u_ptr1 = std::make_unique<LargeResource>(200);
// auto u_ptr2 = u_ptr1; // 编译错误!unique_ptr 禁止拷贝
std::unique_ptr<LargeResource> u_ptr2 = std::move(u_ptr1); // 必须移动
if (!u_ptr1) std::cout << "u_ptr1 已空,所有权移交给 u_ptr2\n";
// --- PART 3: shared_ptr (共享所有权与移动优化) ---
std::cout << "\n--- 3. std::shared_ptr ---\n";
auto s_ptr1 = std::make_shared<LargeResource>(300);
std::cout << "s_ptr1 创建后引用计数: " << s_ptr1.use_count() << "\n";
std::cout << "执行拷贝传参:\n";
useShared(s_ptr1); // 传入副本,计数+1,函数结束计数-1
std::cout << "回到 main,引用计数: " << s_ptr1.use_count() << "\n";
std::cout << "执行移动传参:\n";
useShared(std::move(s_ptr1)); // 移动传参,计数不增加!s_ptr1 变为空
if (!s_ptr1) std::cout << "s_ptr1 已空,资源现在由函数参数管理(并在函数结束时释放)\n";
std::cout << "\n--- 程序结束 ---\n";
return 0;
}
对应的输出如下所示:
text
--- 1. 拷贝 vs 移动 ---
[构造] 创建了大小为 100 的资源
[拷贝] 复制了一份全新的资源数据
[移动] 直接拿走了原对象的内存地址 (零拷贝)
--- 2. std::unique_ptr ---
[构造] 创建了大小为 200 的资源
u_ptr1 已空,所有权移交给 u_ptr2
--- 3. std::shared_ptr ---
[构造] 创建了大小为 300 的资源
s_ptr1 创建后引用计数: 1
执行拷贝传参:
-> 进入函数,当前引用计数: 2
回到 main,引用计数: 1
执行移动传参:
-> 进入函数,当前引用计数: 1
[析构] 释放了内存
s_ptr1 已空,资源现在由函数参数管理(并在函数结束时释放)
--- 程序结束 ---
[析构] 释放了内存
[析构] 释放了内存
[析构] 释放了内存
[析构] 资源已被移动,无需释放
注意,在调用useShared()的时候,我们尝试了拷贝传参和移动传参两种方式,参数类型是std::shared_ptr,即,这里的拷贝和移动是针对的std::shared_ptr,而不是std::shared_ptr背后的义务对象 LargeResource,上文已经讲过这个必须搞清楚的地方。
intrusive_ptr的对应优势
在这里,我们对比了intrusive_ptr的一些优势:
| 项目 | std::shared_ptr |
intrusive_ptr |
|---|---|---|
| 引用计数位置 | 控制块(control block) | 对象内部 |
| 是否侵入对象 | ❌ 不需要修改对象 | ✅ 对象必须包含引用计数 |
| 内存结构 | shared_ptr → control block → object |
intrusive_ptr → object(ref_count) |
| 内存分配 | 可能需要额外分配 control block(make_shared 可合并) |
只分配对象本身 |
| 性能 | 多一次间接访问,可能多一次内存分配 | 访问更直接,内存更紧凑 |
| 标准支持 | C++ 标准库 | Boost |
-
内存布局差异
std::shared_ptr的使用方式如下:auto p = std::make_shared<A>();它对应的内存布局上文已经展示了。
如果不用
make_shared,那么需要两次内存分配,才能完成一个shared_ptr对象的构建malloc object malloc control block当然,使用make_shared ,可以一次完成分配:
[ control block + object ]而无论怎样,intrusive_ptr的内存分配都更简单,因为它的引用计数是嵌入(intrusive)在对象内部:
比如:
cppclass A { public: std::atomic<int> ref_count; };内存结构如下所示:
cppintrusive_ptr │ ▼ object ├─ ref_count └─ data -
生命周期管理方式的差异
shared_ptr在 控制块里维护计数。构造的时候,控制块中ref_count++,而析构的时候:
ref_count-- if (ref_count == 0) delete object所以,控制块负责, 引用计数的变化,以及当引用计数清零以后的对象删除。
而对于intrusive_ptr,则是让被管理对象自己维护引用计数。
Boost
intrusive_ptr依赖两个函数:void intrusive_ptr_add_ref(T* p); void intrusive_ptr_release(T* p);示例:
class A { public: std::atomic<int> ref_count{0}; }; void intrusive_ptr_add_ref(A* p) { ++p->ref_count; } void intrusive_ptr_release(A* p) { if (--p->ref_count == 0) delete p; }intrusive_ptr只是调用这些函数。 -
内存布局与管理机制带来的差异
虽然
shared_ptr和intrusive_ptr都能实现引用计数,但由于计数器"放哪儿"的不同,导致了它们在 ClickHouse 这种高性能工程中表现出完全不同的特质:-
空间的极致利用与指针的"重量"
在 64 位系统下,
std::shared_ptr本身占据 16 字节(包含一个数据指针和一个控制块指针)。这意味着在处理包含成千上万个列片段(Column Parts)的 Block 时,仅仅是指针本身的存储就会带来一倍的内存冗余。在 ClickHouse 的 Block 结构中,数据被切分成多个列(Column),每一列可能由多个 Column Part(片段)组成。如果你有 10,000 个片段,你就需要 10,000 个智能指针来管理它们的生命周期。使用intrusive_ptr:你需要 10 , 000 × 8 字节 = 80 , 000 字节 10,000 \times 8 \text{ 字节} = 80,000 \text{ 字节} 10,000×8 字节=80,000 字节。使用std::shared_ptr:你需要 10 , 000 × 16 字节 = 160 , 000 字节 10,000 \times 16 \text{ 字节} = 160,000 \text{ 字节} 10,000×16 字节=160,000 字节。这里的指针是存储在类似std::vector<Ptr>这样的容器里的。当你遍历或处理这些片段时,这些指针本身就是占用的内存。相比之下,
intrusive_ptr物理上就是一个 8 字节的原始指针。由于计数器已经"侵入"到了对象内部,指针不再需要额外携带"看守人"的地址。这种极致的轻量化,使得 ClickHouse 可以在内存中维护极其复杂的对象拓扑,而不用担心指针本身撑爆缓存。 -
性能的"零跳转"与缓存友好性
这是两者在运行效率上的分水岭。
使用
shared_ptr时,如果你想修改计数或访问数据,CPU往往需要经历"两次跳转":先去控制块看一眼计数,再去对象看一眼数据。在海量数据处理时,这种微小的跳转会频繁导致CPU缓存失效(Cache Miss)。而
intrusive_ptr将计数器和数据放在了一起。当你为了修改计数而访问对象头部时,CPU的预取机制会顺便把对象的数据也加载进缓存。这种"顺手牵羊"的局部性,是 ClickHouse 能够跑出极致吞吐量的物理基础。 -
"自治"与"托管"的逻辑差异
-
shared_ptr像是"第三方托管":对象的生死由外部的控制块说了算。如果对象想在逻辑中意识到"我现在是不是唯一的持有者",它必须通过复杂的enable_shared_from_this机制回头去找那个控制块。 -
intrusive_ptr则是"对象自治":对象自己带着计数器。在COW框架中,shallowMutate 只需要简单地低头看一眼自己肚子里的数字,就能瞬间决定是原地修改还是克隆。
-
这种"自治"属性,完美契合了
mutable_ptr和immutable_ptr的设计初衷:让对象自己掌控所有权状态,而不是寄希望于外部的管理员。 -
-
独占性的表达方式
std::unique_ptr只能表达 "物理上的唯一"(内存地址只有我知道),当我们想把独占变成共享的时候,则变得异常困难,下文会举例子说明。而基于
intrusive_ptr的mutable_ptr表达的是 "逻辑上的唯一": 它允许对象在"被多人共享(只读)"和"被一人独占(读写)"之间反复横跳,而不需要更换管理工具。当我们完成修改,要把mutable_ptr<T>转回immutable_ptr<T>时,由于两者底层都是intrusive_ptr,这仅仅是一个简单的指针赋值。不需要重新分配控制块,不需要复杂的转换逻辑。假设我们有一份列数据(Column),它最初是被多个查询并发共享的(只读),现在我们需要对它进行一次去重操作(修改),然后再重新发布给所有人共享,我们看看基于
std::unique_ptr/std::shared_ptr是如何实现转换,以及ClickHouse基于intrusive_ptr是如何实现转换的:-
使用
std::unique_ptr+std::shared_ptr的困境在这种模式下,我们需要不断地**"换手册"**(销毁一种指针,创建另一种指针),而且必须处理两种完全不兼容的内存管理结构。
-
初始状态:数据由
std::shared_ptr<Column>管理,被 10 个线程共享读取。 -
准备修改:为了保证安全,我们必须把数据"独占"。由于
shared_ptr无法直接变成unique_ptr,我们只能:- 克隆一份数据:auto
new_data=std::make_unique<Column>(*old_shared_ptr); - 代价:此时你创建了一个全新的对象,分配了新的内存,但旧的
shared_ptr还在内存中,且它们的控制块完全不互通。
- 克隆一份数据:auto
-
完成修改:去重结束后,我想把
unique_ptr再变回共享状态:- 你需要执行:
std::shared_ptr<Column>shared_res=std::move(new_data); - 隐形开销:虽然这次移动是合法的,但底层发生了一次新的堆内存分配,用来创建
shared_ptr专属的控制块(Control Block)。
- 你需要执行:
结论:每切换一次身份,就要重新折腾一次外部的"看守人"结构,不仅慢,而且代码逻辑支离破碎。
-
-
使用 ClickHouse 的
COW模式(intrusive_ptr)在这种模式下,对象始终是同一个,只是我们手里拿的**"准入证"**变了。
-
初始状态:
大家手里拿的是
immutable_ptr(本质是intrusive_ptr<const T>)。 -
执行修改 (mutate):
cpp// 逻辑:mutate 发现 use_count == 1,直接原地升级 MutablePtr m_ptr = Column::mutate(std::move(const_ptr));发生了什么? 只是把
const T*强转成了T*并包进了MutablePtr。没有内存分配,没有控制块创建。 -
完成修改(变回共享):
// 修改完,直接移动赋值回去 const_ptr = std::move(m_ptr);发生了什么? 仅仅是一个 8 字节的指针赋值。
关键点:由于计数器就在对象身体里,无论你叫它
MutablePtr还是immutable_ptr,当你把指针传给别人时,它依然去改对象身体里那个现成的数字。总之,这两种方案的核心差异对比是:
-
std 的方案(物理结构不统一):
身份切换 = 销毁/新建一套外部管理块。
text共享期 [shared_ptr] -> [Control Block A] -> [Data] | 转换期 (必须拷贝或重建控制块) | 独占期 [unique_ptr] -----------------------> [Data (可能是新地址)] | 转换期 (必须新建控制块 B) | 共享期 [shared_ptr] -> [Control Block B] -> [Data] -
ClickHouse 的方案(对象自治,逻辑统一):
身份切换 = 仅仅是改了一下看待同一块内存的"眼光"。
text共享期 [immutable_ptr] ---+ | 独占期 [MutablePtr] ---+---> [ Data + 内核计数器 ] | 共享期 [immutable_ptr] ---+
-
-
immutable_ptr和mutable_ptr是怎么管理Column的生命周期的
既然我们了解了 boost::intrusive_ptr 和 boost::intrusive_ref_counter 对对象的生命周期进行管理的基本原理,那么,具体到 ClickHouse 的代码中,实现了 boost::intrusive_ptr 的 mutable_ptr 以及 immutable_ptr 是怎么对实现了 boost::intrusive_ref_counter 的 COW<Derived> 进行生命周期管理的呢 (immutable_ptr 和 mutable_ptr 的管理原理一样,只不过一个是独占,一个是共享,这里不做区分)?
我们先看一下整个引用计数的管理架构,我们从中可以看到实现了 boost::intrusive_ptr<T> 的 mutable_ptr<T> 以及 immutable_ptr<T> 的管理者角色,以及实现了 boost::intrusive_ref_counter 的 COW<Derived> 的 T(ColumnConcrete->COWHelper<Derived, Base>->IColumn->COW<T>) 这一被管理者角色之间的交互方式和工作原理:
text
【 A. 生命周期启动/流转 】 【 B. 生命周期结束 】
ColumnPtr y = x; (拷贝) (指针离开作用域/重置)
┃ ┃
▼ ▼
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ boost::intrusive_ptr ┃ ┃ boost::intrusive_ptr ┃
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
┃ 构造函数: ┃ ┃ 析构函数: ┃
┃ if(px) ┃ ┃ if(px) ┃
┃ intrusive_ptr_add_ref(px); ┃ ┃ intrusive_ptr_release(px); ┃
┗━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┛
┃ ┃
┃ (编译器通过 ADL 寻找匹配的自由函数) ┃
▼ ▼
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ boost::intrusive_ref_counter.hpp (自由函数层) ┃
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
┃ ┃
┃ void add_ref(p) { void release(p) { ┃
┃ p->m_ref_counter.inc(); if (--p->m_ref_counter == 0) ┃
┃ } delete static_cast<Derived*>(p); ┃
┃ } ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
┃
▼
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ClickHouse Column 物理内存 (内核计数层) ┃
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
┃ ┃
┃ struct Column : public COW<Column> { ┃
┃ // 物理地址 0x7f00... ┃
┃ mutable std::atomic<uint32_t> m_ref_counter; ◄───【 原子指令操作区 】┃
┃ } ┃
┃ ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
所以,我们从生命周期的三个方面,启动,流转和销毁,来看看整个管理和被管理的细节。
-
生命周期的启动:
创建 Column 实例生命周期的起点通常始于具体的列对象(如 ColumnConcrete)的创建。按照继承链条,关系如下:ColumnConcrete → \rightarrow → COWHelper<IColumn, ColumnConcrete> → \rightarrow → IColumn → \rightarrow → COW当调用 ColumnConcrete::create(...) 时,实际执行的是 COWHelper 中预定义的静态工厂方法:C++// 定义在 COWHelper 中的创建方法
cpptemplate <typename... Args> static MutablePtr create(Args &&... args) { return MutablePtr(new Derived(std::forward<Args>(args)...)); } template <typename T> static MutablePtr create(std::initializer_list<T> && arg) { return MutablePtr(new Derived(std::forward<std::initializer_list<T>>(arg))); }这里隐藏了几个关键的物理动作:
- 物理基因的注入:由于 ColumnConcrete 最终继承自
boost::intrusive_ref_counter,new 出来的ColumnConcrete对象在内存布局中天然包含了一个m_ref_counter成员变量。这个变量被标记为 mutable,允许在只读状态下修改生命周期。 - 权限的初步封装:create 方法返回的是
MutablePtr。它的构造函数是私有的,但因为 COW 是其友元,所以能顺利调用:
cpp// 逻辑跳转:MutablePtr(T * ptr) -> Base(ptr) -> boost::intrusive_ptr<T>(ptr) private: explicit mutable_ptr(T * ptr) : Base(ptr) {}计数的第一次握手: 一旦 new 出来的裸指针被喂给
boost::intrusive_ptr,其构造函数会立即通过 ADL(参数依赖查找) 机制寻找并触发全局自由函数intrusive_ptr_add_ref:cpp// 位于 boost 源码中,通过 p 指针找到对象内的 m_ref_counter 并执行原子自增 template< typename DerivedT, typename CounterPolicyT > inline void intrusive_ptr_add_ref(const intrusive_ref_counter< DerivedT, CounterPolicyT >* p) { CounterPolicyT::increment(p->m_ref_counter); }至此,对象内部的 refcount 从 0 变成 1,生命周期正式开启。
- 物理基因的注入:由于 ColumnConcrete 最终继承自
-
生命周期中的流转:所有权的接力与共享
当你在代码里写下
ColumnPtr y = x; 或者使用std::move()时,底层管理引擎会根据我们对资源的态度,做出完全不同的物理响应。-
拷贝流转(共享):权力登记
当我们拷贝一个指针时(比如
IColumn::Ptr y = x;),会触发immutable_ptr的拷贝构造函数,进而触发基类intrusive_ptr的拷贝构造函数:cppintrusive_ptr(intrusive_ptr const & r) : px(r.px) { if(px != 0) intrusive_ptr_add_ref(px); // 重点:在这里通过自由函数给对象续命 }物理动作:新的指针 y 拿到了 x 的物理地址,并立刻大喊一声:"我也要管这块内存!",这时候,基类
intrusive_ptr的拷贝构造函数通过调用全局自由函数intrusive_ptr_add_ref()触发m_ref_counter原子加 1。这时候,这块数据现在有了两个"监护人",引用计数变成
2。任何一个监护人离开,数据都不会消失:
intrusive_ptr_add_ref(ptr), 于是对象内部 `refcount 变成 1,引用计数加1:cpptemplate< typename DerivedT, typename CounterPolicyT > inline void intrusive_ptr_add_ref(const intrusive_ref_counter< DerivedT, CounterPolicyT >* p) BOOST_SP_NOEXCEPT { CounterPolicyT::increment(p->m_ref_counter); } -
移动流转(接力):所有权平移
相比于拷贝构造函数,如果我们使用
std::move来进行移动而不是拷贝时(比如在mutate()过程中),情况大不相同,移动操作仅仅会带来8 字节指针地址的赋值,原指针会被直接清零(nullptr)。cppIColumn::Ptr y = std::move(x);这时候,完全不触发
intrusive_ptr_add_ref。。这其实就是为什么std::move()语义非常高效的原因,它不是通过触发一次自增和一次自减来实现所有权的转移,而是完全不会触发计数器的原子操作。 这就像是接力比赛。权力直接从一个人手里传到了另一个人手里,但"监护人"的总数没变,所以不需要去动昂贵的原子计数器。这是 ClickHouse 性能起飞的关键。
-
-
生命周期的销毁:最后的物理清算
当有成员离开作用域的时候,就需要触发计数器的自减。而当所有的指针都离开了作用域,或者被重新赋值,最后一个人就要负责"关灯走人"。
每当一个
mutable_ptr或immutable_ptr结束生命时,它的基类析构函数就会启动:~intrusive_ptr() { if( px != 0 ) intrusive_ptr_release( px ); }这里是整个生命周期管理最惊险的一幕。信号传导到自由函数
intrusive_ptr_release()中,自由函数会检查是否已经是最后一个离开的,如果是,则需要进行关灯操作。这时,Column 对象的析构函数被物理触发,它所占用的堆内存被归还给操作系统。如果是复合列,它还会连锁带动它内部所有子列指针的析构。cpptemplate< typename DerivedT, typename CounterPolicyT > inline void intrusive_ptr_release(const intrusive_ref_counter< DerivedT, CounterPolicyT >* p) BOOST_SP_NOEXCEPT { if (CounterPolicyT::decrement(p->m_ref_counter) == 0) // 计数器自减,并且检查是否已经全部走完,如果是,则需要关灯, delete static_cast< const DerivedT* >(p); }
friend和friend自由函数以及查找规则
我在阅读ClickHouse代码的时候,发现其实我一直对friend都没有准确理解。
很多时候,我们都是带着模糊理解去读代码,比如,friend,我们似乎一直都明白它的含义: 当我们把一个class B定义为另一个class A的friend,那么就意味着Class B有了访问Class A的私有成员的权限。但是,对于Class B有了访问Class A的私有成员的权限, 其实这里有两种理解:
-
实例层面有了权限: Class B的实例对象有了访问Class A的私有成员的权限
-
代码层面有了权限: Class B的成员函数有了访问Class A的私有成员的权限
正确答案是,friend是赋予了Class B这个Class的成员函数对Class A的私有成员的访问权限,所以,我们以上文中friend class
COW<IColumn>;被定义成了IColumn的friend这个例子,来理解friend的正确含义:cpp// 为IColumn赋予对应的COW功能 class IColumn : public COW<IColumn> { private: friend class COW<IColumn>; // 父类COW可以访问子类IColumn [[nodiscard]] virtual MutablePtr clone() const = 0;所以,这里的正确含义是:
COW<IColumn>这个类被IColumn授权了- 因此,写在
COW<IColumn>类定义里面的成员函数代码,可以访问IColumn的private/protected; - 这是一条编译期规则 ,如果编译器在编译期间看到
COW<IColumn>的代码中有对IColumn的private/protected方法的访问,那么就会确认COW<IColumn>这个类是否被定义为IColumn的friend; - 它跟"有没有创建一个
COW<IColumn>对象"没有本质关系。
所以,凡是
COW<IColumn>::xxx(...) 这些成员函数,它们的函数体里,可以合法写出对IColumn私有成员的访问,比如:class COW : public boost::intrusive_ref_counter<Derived> { ...... MutablePtr shallowMutate() const { // 这个use_count定义在boost::intrusive_ref_counter中 if (this->use_count() > 1) return derived()->clone(); // COW作为IColumn的friend,可以方位IColumn的private函数clone() else return assumeMutable(); }也就是说,不是说"某个
COW<IColumn>对象很特殊,所以它能调";而是说:只要这段代码属于COW<IColumn>的成员函数体,编译器就允许它访问IColumn的私有成员。。所以,我们可以把friend理解成"权限属于代码作用域,不属于对象实例"。
上面在讲intrusive_ptr_add_ref()和intrusive_ptr_release()的时候讲到了他们是friend自由函数,我们需要具体了解一下:
cpp
#include <iostream>
namespace N {
struct B {
private:
int b;
public:
// 构造函数,用来设置 b
explicit B(int value) : b(value) {}
friend void f(B* obj) {
// f是B的friend,因此可以访问B的私有成员b
std::cout << "call f, b = " << obj->b << '\n';
}
};
} // namespace N
int main() {
N::B b(1);
f(&b); // will output: `call f`
// N::f(&b); // will build failed with msg: 'f' is not a member of 'N'
}
上面的例子中,friend void f(B*) { ... }不是一个类成员函数,而是一个自由函数,并且,它被声明为B的friend,因此可以方位B的私有成员b。
根据C++标准:在类定义中定义的 friend 函数,实际上被定义在"最近的外层命名空间作用域"中 ,所以,上面的例子中, f(B*) 被定义在 namespace N 里,而不是struct B中。
这里,main()方法中能够成功找到f()的原因,就是ADL(Argument-Dependent Lookup):
当编译器看到一个 未限定名函数调用f(expr),它会做两件事:
所以,我们可以看一下intrusive_ptr_add_ref()的查找方式。
类boost::intrusive_ref_counter<T>通常在类模板内部定义(或声明)与 T* 匹配的 friend 自由函数,使其能够访问对象内部的计数并在归零时删除对象。概念上等价于:
cpp
namespace boost {
template <class T>
class intrusive_ref_counter {
// 内部保存 refcount(伪代码)
// mutable std::atomic<long> refcount;
// 关键:friend 自由函数(概念模型)
friend void intrusive_ptr_add_ref(T* p) noexcept {
// ++p->refcount;
}
friend void intrusive_ptr_release(T* p) noexcept {
// if (--p->refcount == 0) delete p;
// 实际实现通常会 delete static_cast<T*>(p)
}
};
} // namespace boost
在上面的代码中, friend void intrusive_ptr_add_ref(T* p)不是一个属于intrusive_ref_counter的成员函数,而是一个自由函数,并且,它被声明为intrusive_ref_counter 类的friend,因此可以访问intrusive_ref_counter的私有成员,即可以访问refcount。
根据C++标准:在类定义中定义的 friend 函数,实际上被定义在"最近的外层命名空间作用域"中 ,所以,上面的例子中, friend void intrusive_ptr_add_ref(T* p) 被定义在 namespace boost 里,而不是intrusive_ref_counter中。
那么, 引用计数的管理者,即调用点boost::intrusive_ptr是怎么找到函数intrusive_ptr_add_ref()呢?我们看一下编译器是怎么查找未限定名调用的。
在这里,未限定名调用(unqualified call)指的是"函数名没有写作用域限定符",它既可以调用自由函数,也可以调用成员函数,取决于语法形式和查找规则。最简单的不是调用自由函数的未限定名调用,而是我们最常见的一个类的成员函数之间的相互调用,这其实也是一种未限定名调用(unqualified call)。
当编译器遇到未限定名调用:intrusive_ptr_add_ref§的时候,它会进行两阶段查找:
-
普通未限定名查找(ordinary lookup)先进行,即查找从调用点所在的作用域开始,逐层向外:
- 当前块作用域
- 类作用域(成员函数所在类)
- 外层命名空间作用域
- 全局命名空间
在 Boost 的实现中,调用点(boost::intrusive_ptr中调用intrusive_ptr_add_ref()的位置)位于namespace boost 内部(intrusive_ptr自身就在 boost 里),因此普通查找会首先在 boost 命名空间内查找intrusive_ptr_add_ref。
比如,下面的例子:
cpp#include <iostream> namespace N { struct B {}; // 这个函数在 namespace N 里 void f(B*) { std::cout << "ordinary lookup found N::f\n"; } void test() { // 调用点也在 namespace N 里 B b; f(&b); // 普通未限定名查找:在 N 里直接找到 N::f } } int main() { N::test(); } -
如果普通查找未找到或候选不够,再触发
ADL(argument-dependent lookup)编译器会根据实参类型 T* 计算"关联实体"(associated
namespaces/classes),并将这些命名空间中的同名函数加入候选集合。对于 p 类型为 T*,与 T 相关的命名空间(例如 T 所在命名空间)会被纳入
ADL查找范围, 与 T 相关的类/基类也会影响候选集合,因此,即使intrusive_ptr_add_ref不是通过普通查找可见,只要其定义对 T* 通过ADL可达,调用也能成功。cpp#include <iostream> namespace N { struct B { // friend 自由函数定义:不做普通声明引入 friend void f(B*) { std::cout << "ADL found friend f\n"; } }; } int main() { N::B b; f(&b); // 普通查找找不到 f;ADL 因参数是 N::B* 而找到 N 里的 friend f }
在 Boost intrusive 的实现中,intrusive_ptr_add_ref() 的调用主要通过普通未限定名查找完成,因为由 intrusive_ref_counter<T> 生成的 intrusive_ptr_add_ref(T*) 定义在namespace boost, 而调用点boost::intrusive_ptr也位于 namespace boost,因此可以基于ordinary lookup直接完成。ADL 并非主要路径,而是作为扩展与兜底机制,使得用户能够在对象类型的命名空间中自定义引用计数操作。
特殊构造函数的禁用和默认生成
在 C++ 术语中,默认构造函数(Default Constructor)确实有着严格的定义,它并不泛指"编译器默认生成的那个函数",而是指一种特定的调用能力。
默认构造函数的定义是:可以不带任何实参(Arguments)就进行调用的构造函数。
它包含两种情况:
-
完全没有参数:
cppColumn() { ... } -
参数全部都有默认值:
cppColumn(int x = 0, double y = 0.0) { ... }
(虽然它有参数,但你可以直接用 Column c; 来调用它,所以它也是默认构造函数。)
必须明白,如果一个类同时拥有 Column() 和 Column(int x = 0),编译器会因为不知道该调用哪一个而报错(歧义性)。
"默认生成的" vs "默认构造函数"
这是最容易搞混的地方。我们要区分"函数类型"和"函数来源":
- 默认构造函数(函数类型):指"不需要传参就能用的构造函数"。
- 默认生成的默认构造函数(函数来源):指"如果你什么都不写,编译器偷偷帮你写的那个不带参数的函数"。
关于是否帮助生成默认的构造函数,编译器的原则是:
- 一旦我们写了任何一种构造函数(哪怕是带参数的 Column(int x),即,哪怕是非默认的构造函数,拷贝构造或者移动构造),编译器就会觉得:"哦,这个类怎么构造你已经有主见了,那我就不乱掺和了。" 于是,它就不会再自动帮你生成那个"不带参数"的函数了。
这意味着,如果我们自己写了非默认的构造函数,也没有通过= default指定编译器帮我们生成默认的构造函数,那么默认构造函数就是缺失的。知识后如果调用默认构造函数,就会报错。
cpp
#include <iostream>
#include <vector>
class SmartColumn {
public:
SmartColumn() { std::cout << "1. 调用了默认构造函数\n"; }
// 显式允许:移动构造
SmartColumn(SmartColumn&&) noexcept {
std::cout << "2. 调用了移动构造\n";
}
// ---------------------------------------------------------
// 情况 A:如果不写下面这两行,由于上面写了移动构造,
// 编译器会自动"注销"拷贝函数。下面的SmartColumn v2 = v1 会报错。
// 情况 B:显式找回默认拷贝逻辑
SmartColumn(const SmartColumn&) = default;
// 情况 C:显式禁用赋值操作,防止误操作
SmartColumn& operator=(const SmartColumn&) = delete;
// ---------------------------------------------------------
};
int main() {
std::cout << "--- 实验开始 ---\n";
SmartColumn v1; // 正常:调用默认构造
// 尝试拷贝构造
// 如果上面没有 = default,这里编译会直接报错:
// "use of deleted function 'SmartColumn::SmartColumn(const SmartColumn&)'"
SmartColumn v2 = v1;
std::cout << "成功克隆了房产证(拷贝)\n";
// 尝试赋值操作
// 这里一定会报错,因为上面写了 = delete
// SmartColumn v3;
// v3 = v1;
return 0;
}
除了default,还有另外一个函数标志符delete,表示显式剥夺管理权。
这是最强硬的指令。它告诉编译器:"这个函数不许存在,谁要是敢调用它,直接报错,别想背着我偷偷生成。"
比如, mutable_ptr禁用了拷贝构造函数,具体原理我们上文讲过。
cpp
/// Copy: not possible.
mutable_ptr(const mutable_ptr &) = delete; // 不允许有拷贝构造函数
immutable_ptr禁用了从mutable_ptr到immutable_ptr的拷贝构造函数,避免一个mutable_ptr被拷贝成为immutable_ptr,但是却允许mutable_ptr被移动车呢各位immutable_ptr:
cpp
/// Move from mutable ptr: ok.
template <typename U>
immutable_ptr(mutable_ptr<U> && other) : Base(std::move(other)) {} /// NOLINT
/// Copy from mutable ptr: not possible.
template <typename U>
immutable_ptr(const mutable_ptr<U> &) = delete; // 不允许有从mutable_ptr到immutable_ptr的拷贝构造函数ctor
概念对照
| 名词 | 它解决什么 | 在 ClickHouse 中的定位 |
|---|---|---|
RAII |
资源什么时候释放 | shared_ptr / intrusive_ptr 管 Column 生命周期 |
RTTI |
运行时这是什么类型 | Debug 下的 assert_cast / typeid |
CRTP |
编译期知道最终类型 | Helper / COW / 去 virtual |
| Mixin | 复用"能力实现" | *Helper 系列 |
COW |
什么时候复制 | mutate() + clone() |
SIMD |
批量处理元素 | filter / hash / memcpy 路径 |
相关引用
boost::intrusive_ref_count
value_category
declval
move_constructor
noexcept
explicit
forward