Blog Website

Learn modern C++ techniques in ClickHouse

0. 前言

从 C++11 开始的现代 C++ 是 C++ 编程语言的一次重大变革,在维持稳定性的情况下,从易用性、安全性、效率等各个方面增加了许多现代编程语言的特性。自 C++11 问世以来,受到了 C++ 开发者的广泛青睐,大量 C++ 项目都转向现代 C++ 或使用现代 C++ 进行开发,其中就包括 ClickHouse

ClickHouse 是一款优秀的开源 OLAP 数据库,其出现可以算得上是划时代的产品,工程实现亦可以称为业内标杆,精巧的向量化引擎设计与实现受到业内的广泛青睐和借鉴,引领了实时 OLAP 领域的一波浪潮。

本文会以 ClickHouse 代码为例,谈一谈其中使用到的一些常见现代 C++ 特性。然而,从 C++11 开始的现代 C++ 与之前版本的 C++ 相比,几乎可以算是一门新的语言,其包含太多新的语言特性和功能,因此本文不会详细罗列介绍现代 C++ 特性。

1. 从类型说起

C++ 是一个静态强类型语言,现代 C++ 的演进过程中显著增强和扩展了对类型处理的能力。

1.1 值类别

为了解决中间结果带来的额外拷贝,C++11 引入了移动语义,同时对值类别重新进行了定义,值类别指的表达式结果的类别,与变量或类型的类别是两个不同的概念。过去通俗地说,如果一个变量或表达式能够取地址,则为左值;如果不能取地址,则为右值。

先看一个简单的例子:

1
2
3
4
5
6
class Field{
public:
Field(const Field & rhs);
Field(Field && rhs);
...
};

Field 是 ClickHouse 类型系统中的一个重要数据结构,能够用于表示任意类型的单一值在内存中的存储,其既有参数为左值引用类型的拷贝构造函数,也有参数类型为右值引用的移动构造函数。下面的 bc 对象会分别调用哪个构造函数呢?

1
2
3
Field && a = 10;
Field b(a);
Field c(10);

尽管 a 定义为了右值引用类型,但作为传入构造函数的表达式是一个左值(在介绍 decltype 时能够进一步理解),因此会调用第一个拷贝构造函数(也可以通过上面说的能否取地址来进行判断),而显然后一个会调用移动构造函数。需要注意的是,左值引用只能绑定到左值表达式,常量左值引用既可以绑定到左值表达式,也可以绑定到右值表达式,但对于右值引用,无论是常量还是非常量,均只能绑定到右值表达式。

为了使 a 绑定到移动构造函数,可以通过 Field d(static_cast<int&&>(a)) 实现,而这正是移动语义 std::move 所做的工作,其会强制将一个表达式转换为右值引用类型。std::move(a) 在 C++11 中被称为将亡值,将亡值和纯右值统称为右值。

1.2 移动语义

引入右值引用之后,即可以很方便地表达移动语义。拷贝语义与移动语义都是将原值赋予目的值,但拷贝语义不会修改原值的内容,而移动语义可能会。引用移动语义之后,移动构造变得更加简单,当传入的参数为临时值或者是 std::move 后的对象,则自动触发移动构造。

在 ClickHouse 中,移动语义非常普遍,并且在某些场景下,需要禁止拷贝语义,只支持移动语义。在 ClickHouse 中,Column 是数据在内存中的列存表示,是 ClickHouse 向量化执行的基础;例如,ColumnVector 用于存储数值类型的列,ColumnString 用于存储字符串类型的列,ColumnMap 用于存储 Map 类型的列等。IColumn 接口类继承自 COW 以实现 Column 对象的 Copy-On-Write。

COW 中,分别实现了 mutable_ptrimmutable_ptr,前者可以指向一个 mutable 的对象,后者可以指向 immutable 的对象,其中,mutable_ptr 是不可以共享的,而 imutable_ptr 是可以共享的,如果想共享一个 mutable_ptr 指针指向的对象,则需要将 mutable_ptr 移动赋值给 immutable_ptr。这两个指针的实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
template <typename T>
class mutable_ptr : public boost::intrusive_ptr<T> /// NOLINT
{
private:
...
public:
/// Copy: not possible.
mutable_ptr(const mutable_ptr &) = delete;

/// Move: ok.
mutable_ptr(mutable_ptr &&) = default; /// NOLINT
mutable_ptr & operator=(mutable_ptr &&) = default; /// NOLINT

/// Initializing from temporary of compatible type.
template <typename U>
mutable_ptr(mutable_ptr<U> && other) : Base(std::move(other)) {} /// NOLINT
...
};

template <typename T>
class immutable_ptr : public boost::intrusive_ptr<const T> /// NOLINT
{
private:
...
public:
/// Copy from immutable ptr: ok.
immutable_ptr(const immutable_ptr &) = default;
immutable_ptr & operator=(const immutable_ptr &) = default;

template <typename U>
immutable_ptr(const immutable_ptr<U> & other) : Base(other) {} /// NOLINT

/// Move: ok.
immutable_ptr(immutable_ptr &&) = default; /// NOLINT
immutable_ptr & operator=(immutable_ptr &&) = default; /// NOLINT

/// Initializing from temporary of compatible type.
template <typename U>
immutable_ptr(immutable_ptr<U> && other) : Base(std::move(other)) {} /// NOLINT

/// 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 中,不能支持共享,因为如果两个 mutable_ptr 指向同一个对象,那么在修改对象时,则不是线程安全的,为了避免这种情况发生,则需要禁止拷贝语义,只支持移动语义,从而保证了不会有两个不同的 mutable_ptr 指向同一个对象。而在 immutable_ptr 中,拷贝和移动都是支持的,从而支持只读共享,同时,也可以将一个 mutable_ptr 移动赋值给 immutable_ptr 以支持共享,但不能是拷贝语义,否则同样会影响线程安全。

那么,Copy-On-Write 是怎么实现的呢?当需要修改一个 immutable_ptr 指针指向的对象时,则可以通过 COW 类提供的 clonemutate 方法拷贝一个新的对象,然后再进行修改。

此外,针对移动语义,需要注意的是,对于从函数返回的局部变量,切勿通过 return std::move(tmp_variable) 来尝试将“复制”转为“移动”,而忽略了 C++ 一直存在的返回值优化(RVO):当需要从函数返回的局部对象类型与函数返回值类型相同,并且返回的是局部对象本身时,能够省略局部对象的复制或移动,即使编译器未执行复制省略,也需要将返回对象作为右值处理。因此,如果添加了 std::move,反而可能会使得 RVO 无法实施,带来不必要的临时对象构造、析构开销。

1.3 转发引用

转发引用(forwarding reference,也称为万能引用)的引入是为了解决模板函数重载带来的代码膨胀问题和可扩展性问题,如果没有转发引用,那么许多情况下我们需要针对左值引用和右值引用分别实现不同的函数。

例如,现在我们有一个继承自 std::string 的类 A,为了能够使得在传入右值的时候进行 std::string 的移动构造,传入 左值的时候进行拷贝构造,那么其可能需要有如下不同形参的构造函数:

1
2
3
4
5
6
class A : public std::string
{
public:
A(std::string && s): std::string(std::move(s)) {}
A(const std::string & s): std::string(s) {}
};

在这儿,如果使用转发引用作为形参,那么只需要实现如下一个构造函数即可:

1
2
3
4
5
6
class A : public std::string
{
public:
template<typename T>
A(T && s): std::string(std::forward<T>(s)) {}
};

转发引用形如前面提到的右值引用,但其既可以绑定到右值,也可以绑定到左值,能够绑定到 const 对象,也能绑定到非 const 对象。而在使用转发引用时,则必然离不开“转发”:std::forward,其能够使目标函数与转发函数接收到相同的实参,即在参数传递过程中保持值类别的不变,左值依旧为左值,右值依旧为右值。

由于函数形参均表现为左值语义,因此 std::forward 会在 A 的构造函数传入实参为右值的情况下,将其强制转换为右值,从而使得能够调用 std::string 的移动构造函数。如果要更进一步理解转发引用的工作原理,则需要了解 C++11 的引用折叠(reference collapsing)机制,本文不再进行阐述。当然,在上述例子中,为了对模板类型进行约束,可以通过 concept 来实现,之后的构造函数变为如下形式:

1
2
3
4
5
6
7
class A : public std::string
{
public:
template<typename T>
requires std::convertible_to<T, std::string>
A(T && s): std::string(std::forward<T>(s)) {}
};

这儿用到了 std::convertiable_to 这一 concept,表示类型 T 需要能够隐式转换为 std::string,否则模板会替换失败,在下文中会进一步对 concept 进行介绍。

可以看到,使用转发引用形参,能够减少不必要的代码编写。特别是,当形参越多时,如果采用重载的方式,由于每一个形参都有左值和右值,那么重载函数的数量将会成指数增长。在 C++11 引入变参模板之后,函数可能有无穷多个形参,在这种情况下,只能使用转发引用作为形参,例如 std::make_sharedstd::make_unique 的实现,其声明如下所示:

1
2
3
4
5
template<typename T, typename... Args>
shared_ptr<T> make_shared<Args &&... args);

template<typename T, typename... Args>
unique_ptr<T> make_unique<Args &&... args);

如何区分右值引用和转发引用?

  • 如果 T && 中的 T 涉及类型推导,则为转发引用。

例如出现在函数模板中,此时 param 为转发引用:

1
2
template<typename T>
void f (T && param);

例如,auto 声明中:

1
auto && var2 = var1; // var2 为转发引用

或者出现在函数参数类型推导中:

1
void f(auto && param) {...} // param 为转发引用,从 C++20 开始支持

上述声明等价于:

1
2
template <typename T>
void f(T && param) {...}
  • 反之,如果 T && 中的 T 为具体类型,则为右值引用,例如:
    1
    int && a = 10;
    需要注意的是,在下面这一个例子中,param 是右值引用,而不是转发引用,这是因为在这儿 f 并不涉及型别推导,实例 A 的具体型别确定了 f 的型别,而在 g 中,Args 独立于 T,因此 args 为转发引用。在标准库中类似的例子可参考 std::vectorpush_backemplace_back
    1
    2
    3
    4
    5
    6
    7
    8
    template<typename T>
    class A
    {
    public:
    void f(T && param);
    template<typename... Args>
    void g(Args... && args);
    }

网上一些文章在介绍左值、右值的过程中,错误混淆了右值引用和转发引用,例如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

template<typename T>
void print(T & t){
std::cout << "左值" << std::endl;
}

template<typename T>
void print(T && t){
std::cout << "右值" << std::endl;
}

template<typename T>
void testForward(T && v){
print(v);
print(std::forward<T>(v));
print(std::move(v));
}

int main(int argc, char * argv[])
{
testForward(1);
int x = 1;
testFoward(x);
}

在上述例子中,尽管当传入参数为左值时,根据模板的重载决议,会调用第一个左值引用版本的 print 函数,但第二个 print 函数中的形参并非为右值引用,而是转发引用,因此实际上左值引用版本的实现完全是多余的。

转发引用在 ClickHouse 中的随处可见,例如前面提到的 Field 类的构造函数之一:

1
2
3
4
5
6
class Field
{
public:
template <typename T>
Field(T && rhs, enable_if_not_field_or_bool_or_stringlike_t<T> = nullptr);
};

FieldRef 继承自 Field,其中一个构造函数如下,通过 std::forward 保证传递过程中参数值语义的不变。

1
2
3
4
5
6
struct FieldRef : public Field
{
/// Create as explicit field without block.
template <typename T>
FieldRef(T && value) : Field(std::forward<T>(value)) {} /// NOLINT
};

例如,类成员函数的形参,在这儿,三个成员函数的形参均为转发引用,而不是右值引用:

1
2
3
4
5
6
7
class ZooKeeperRetriesControl
{
public:
void retryLoop(auto && f) {...}
void retryLoop(auto && f, auto && iteration_cleanup) {...}
bool callAndCatchAll(auto && f) {...}
};

1.4 自动类型推导

1.4.1 auto 类型推导

auto 类型推导、基于范围的 for 循环和 lambda 表达式据称是 C++11 中最受用户欢迎的三个特性

有了 auto 类型推导,很多情况下不需要再写出繁琐的类型名称,auto 类型推导的规则与模板类型推导规则基本相同(除大括号初始化的情况外)。需要注意的是,auto 表现为值语义,例如:

1
2
const int & a = 10;
auto b = a;

此时,b 的类型为 int,丢失了引用语义和 cv 属性,如果需要保留引用语义,则需要声明为 auto & b = a,此时 b 的类型为 const int &。这与模板的类型推导是完全一致的,例如:

1
2
3
4
5
6
7
8
template<typename T>
void f(T param);
template<typename T>
void g(T & param);

const int & a = 10;
f(a); // 此时 T 和 param 的类型均为 int
g(a); // 此时 T 的类型为 const int, param 的类型为 const int &

而在转发引用的场景下,类型推导规则同样与模板推导规则一样:

1
2
3
4
5
6
7
8
int n = 10;
const int const_n = n;
const int & const_ref = n;

auto && x1 = n; // x1 的类型为 int &
auto && x2 = const_n; // x2 的类型为 const int &
auto && x3 = const_ref; // x3 的类型为 const int &
auto && x4 = 10; // x4 的类型为 int &&

C++11 支持了函数返回值类型的尾序指定语法,如下所示,decltype 能够获取到参数表达式的具体类型,在下文中会进行介绍,通过这一方式,能够实现返回值类型的自动推导。

1
2
template<typename T>
auto f(T && a) -> decltype(std::forward<T>(a)) { return std::forward<T>(a);}

C++14 进一步增强了返回值类型推导的功能,上述函数可直接写为:

1
2
template<typename T>
decltype(auto) f(T && a) { return std::forward<T>(a); }

同时,C++14 也支持了 lambda 表达式参数的 auto 推导,例如:

1
auto f = [](auto param) { ... };

但普通函数的参数自动推导则直到 C++20 才支持,从而完成了 lambda 表达式参数和普通函数参数 auto 类型推导的统一。在 C++ 20 中,我们可以写出类似下面这样的函数:

1
decltype(auto) g(auto && a) { return std::forward<decltype(a)>(a);}

实际上,其等价于:

1
2
template<typename T>
T && g(T && a) { return std::forward<T>(a); }

上面的例子只是为了介绍功能,并无实际意义。事实上,auto 的使用并不是越多越好,过度使用会对代码的可读性造成极大的影响。在使用 auto 类型推导时,我们可尽可能手动指定 cv 属性或通过 * 表明指针类别,即使类型推导能够推导出来,从而能够提高代码的可读性。

例如,在下面的例子中,函数返回值类型是 const 引用,那么在调用时仅使用 auto & 也能够推导出正确的类型,但是与 const auto & 相比,显然代码可读性更差,如果不去看函数的实现,那么就没法得知该返回值为常量引用。

1
2
3
4
5
6
7
8
9
10
class Context
{
public:
...
const Settings & getSettingsRef() const { return settings; }
...
}

// auto & settings = context->getSettingsRef(); 此时 settings 的类型仍然为 const Settings &
const auto & settings = context->getSettingsRef();

而对于指针的情况同样如此:

1
const auto * array_type = typeid_cast<const DataTypeArray *>(type_ptr.get());

会比

1
auto array_type = typeid_cast<const DataTypeArray *>(type_ptr.get());

具有更好的代码可读性,尽管二者是等价的。

1.4.2 decltypedeclvaldecay_t

从 C++11 起,引入了 decltype 用于获取表达式的类型,从而打破了从值获得类型的枷锁,在元编程中,decltype 具有重要的应用。

需要注意的是,decltype 有两个版本:不带括号的版本和带括号的版本。不带括号的版本用于获取标志符的类型,即标志符定义时的类型,是最符合我们常规认识的,例如:

1
2
3
4
5
6
7
8
9
10
11
int n;
int * p = &n;
const int * cp = &n;
int & lrn = n;
int && rfn = 0;

using T1 = decltype(n); // int
using T2 = decltype(p); // int *
using T3 = decltype(cp); // const int *
using T4 = decltype(lrn); // int &
using T5 = decltype(rfn); // int &&

而带括号的版本返回的是表达式的值类别,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int n;
int * p = &n;
const int * cp = &n;
int & lrn = n;
int && rfn = 0;

// 左值(带一个引用)
using T1 = decltype((n)); // int &
using T2 = decltype((p)); // int * &
using T3 = decltype((cp)); // const int * &
using T4 = decltype((lrn)); // int &
using T5 = decltype((rfn)); // int &
using T6 = decltype((++n); // int &
// 纯右值(不带引用)
using T7 = decltype((n++)); // int
using T8 = decltype((10)); // int
// 将亡值(带两个引用)
using T9 = decltype((std::move(n)); // int &&
using T10 = decltype((static_cast<int &&>(n))); // int &&

需要注意的是,对于 T5,尽管 rfn 定义为了右值引用类型,但其作为表达式整体则表现为左值,同时,++n 作为表达式来说其结果为左值,而 n++ 作为表达式其结果为右值。

此外,C++14 引入了 decltype(auto) 的类型推导方式。这是因为 auto 本身表现为值语义,丢失了引用性和 const 属性,若主动指明 const 或引用,则又导致结果始终为 const 或只能表现为引用语义,因此不够通用,特别是在范型编程场景下用。而 decltype 既能获取标志符定义时的完整类型,也能获取整体作为表达式时候的值类别,因此总是能够准确获取到等号右边表达式的类型。

例如:

1
2
3
4
int n = 10;
decltype(auto) n1 = n; // n1 的类型为 int
decltype(auto) n2 = (n); // n2 的类型为 int &
decltype(auto) n3 = 1 + 2; // n3 的类型为 int

在这儿,auto 的作用主要是作为占位符,类型推导按照 decltype 的规则来,比较常见的用法是用于函数的返回值类型推导,例如:

1
2
3
4
5
template<typename Container>
decltype(auto) elemAt(Container && c, size_t i)
{
return std::forward<Container>(c)[i];
}

在这儿,结合转发引用,只要类型 Container 具有 operator [],无论是左值右值,是否为 const,总能正常执行并将结果以正确的类型返回。

通过 decltype,我们可以在非求值上下文中某个类的某个函数的返回值类型,例如:

1
2
template<typename F, typename... Args>
using ReturnType = decltype(F{}.func(Args{}...));

在这儿,由于无法对类型直接调用,因此需要对 FArgs 均进行实例化,得到一个合法的函数调用语句,并通过 decltype 获取返回值类型,这儿的实例化并不会在真正在内存中构造出对象,仅在编译期非求值上下文中,用于构造合法的语句。当 FArgs 都有默认构造函数时,一切都正常,然而,如果没有,或者构造函数不可用,那么上述方法便不可行,因为 F{}Args{} 是无效的。

为了解决上述问题,需要引入 C++11 标准库中的 std::declval 模板函数,其能够不受上述约束而构造出对象,只需要将 F{} 改成 std::declval<F>() 即可,因此上述实现需改为:

1
2
template<typename F, typename... Args>
using ReturnType = decltype(std::declval<F>().func(std::declval<Args>()...));

事实上,C++标准库中 std::declval 会返回一个模板参数的转发引用,并且只能用于诸如 decltypesizeof 之类的非求值上下文中,其只有一个函数声明,而没有定义:

1
2
template<typename T>
T&& declval();

这是因为,在非求值上下文中,我们并不需要真正构造对象,因此只需要返回一个引用即可,同时返回转发引用保留了左值和右值引用的属性。另外,由于没有函数定义,能够防止被用于求值环境中,因为一旦使用,就会在编译时出现模板函数未定义的链接错误。

例如,下面是 ClickHouse 中一个 concept 的定义

1
2
3
4
5
template <typename HashTable, typename KeyHolder>
concept HasPrefetchMemberFunc = requires
{
{std::declval<HashTable>().prefetch(std::declval<KeyHolder>())};
};

该 concept 表示类型 HashTable 需要具有一个以 KeyHolder 为参数的方法 prefetch,由于并不知道 HashTableKeyHolder 是否具有默认构造函数,因此这儿需要通过 std::declval 来构造对象,实现编译期的求值。

最后,介绍一个用于类型退化的模板类 std::decay,其具有一个成员类型 type,从 C++14 起,std::decay_t<T> 就等价于 std::decay<T>::type。考虑如下一个场景:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
void f(T && param)
{
if constexpr (std::is_same_V<T, int>)
{
// do something
}
else
{
// do otherthing
}
}

std::is_same_v 是从 C++11(准确说是 C++17)开始引入的判断两个类型是否相同的模板变量,if constexpr 会在编译时对表达式进行评估,生成对应分支的代码,这个函数的意思是,希望当 T 的类型为 int 和不为 int 时,分别生成不同逻辑的代码。然而,在这儿,由于 param 是一个转发引用,其总是一个引用,并且还可能有 const 修饰符,因此,上述逻辑无法实现这一功能,这时候就需要引入 std::decay_t,因为 std::decay_t<T> 得到的类型是 T 除去 const 修饰符、除去引用后的类型:

1
2
3
using T1 = std::decay_t<int>; // int
using T2 = std::decay_t<int&>; // int
using T3 = std::decay_t<const int &&>; //

因此,上述模板函数的正确实现为:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
void f(T && param)
{
if constexpr (std::is_same_V<std::decay_t<T>, int>)
{
// do something
}
else
{
// do otherthing
}
}

但需要注意的是,对于数组,得到的是数组元素的指针类型:

1
using T4 = std::decay_t<int[4]>; // int *

而对于函数,得到的是函数指针类型:

1
using T5 = std::decay_t<int(int)>; // int(*)(int)

在 ClickHouse 的 UDF 实现中,decltypedecay_t 结合被大量使用,用于从值推导类型。下面的例子是函数 repeat 的执行逻辑代码片段,该函数接收一个 String 列和一个整数列,返回字符串重复整数次后的字符串。该 if 条件处理传入的整数列是一个常量的情况,但由于整型常量可能是不同的类型,UInt8UInt32 等都有可能,因此需要先得到具体的类型,再从 Column 中获取值。函数的参数里面有 DataType 参数 type,需要得到 type 的类型,然后其 FieldType 就是 Column 中存储的数据的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
if (const ColumnConst * col_num_const = checkAndGetColumn<ColumnConst>(col_num.get()))
{
auto col_res = ColumnString::create();
castType(arguments[1].type.get(), [&](const auto & type)
{
using DataType = std::decay_t<decltype(type)>;
using T = typename DataType::FieldType;
T times = col_num_const->getValue<T>();
RepeatImpl::vectorStrConstRepeat(col->getChars(), col->getOffsets(), col_res->getChars(), col_res->getOffsets(), times);
return true;
});
return col_res;
}

decltype(type) 返回的是一个完整的类型,可能包含引用等,再加上 decay_t 得到退化后的类型,最终实现从值到类型的推导。

1.4.3 CTAD

C++17 进一步支持了类模板参数推导,当实例化一个模板类时,编译器能够根据参数自动推导模板类型,一个最直接的例子是 std::pair 的声明更方便了,也不再需要使用 std::make_pair 了:

1
2
std::pair<int, int> p1(1, 2); // C++17 之前
std::pair p2(1, 2); // C++17

CTAD 能够使得代码更加简洁统一,例如,过去在使用同步原语时:

1
2
3
4
5
std::mutex mutex;
std::lock_guard<std::mutex> lock(mutex);
...
std::shared_mutex shared_mutex;
std::shared_lock<std::shared_mutex> shared_lock(shared_mutex);

而现在,只需要:

1
2
3
4
5
std::mutex mutex;
std::lock_guard lock(mutex);
...
std::shared_mutex shared_mutex;
std::shared_lock shared_lock(shared_mutex);

2. 资源管理

不同于自动回收垃圾的托管语言,C++ 需要用户手动管理对象生命周期,以防止内存泄漏。

2.1 RAII

RAII 来自 C++98,并不是现代 C++ 才提出的内容,但其是 C++ 资源管理的核心,全称是:Resource Acquisition Is Initialization, 资源获取即初始化。RAII 的本质思想是每个资源都应该有一个所有者,由作用域对象表示:在构造函数中获取资源,在析构函数中释放资源。RAII 几乎遍布所有的 C++ 库,资源不仅仅指的是内存,也包括文件句柄、socket 连接、锁等。

RAII 可以总结为:

  • 将每一个资源都封装到类中:在构造函数中获取资源并建立类不变量,如果不能完成则抛出异常;在析构函数中释放所有资源并且不能抛出异常(析构函数抛出异常会导致资源泄漏)
  • 始终通过 RAII-类的实例来使用资源:该对象具有自动存储周期或临时生命周期(栈上分配的对象),或者生命周期与一个具有自动存储周期或临时生命周期的对象绑定(在堆上分配的栈上对象的成员,由栈上对象的构造析构负责分配释放)。

例如,现代 C++ 中的互斥锁包装器 std::lock_guard 就是一个典型的 RAII 类,其能够在作用域生命周期内持有一个互斥锁:在创建对象时获取锁,在析构时释放锁。在这儿,资源就是互斥锁。
在 ClickHouse 这样的数据库系统中,在许多地方都涉及到资源的互斥访问,因此大量使用了互斥锁,使用过程中通常都要结合 std::lock_guard,使用方式基本都是如下所示:

1
2
3
4
5
6
7
void ClassName::funcName(...)
{
std::lock_guard lock(mutex); // 获取 mutex(对象中的成员)
/// 访问互斥资源
...
/// 当离开作用域时,std::lock_guard 析构,隐式释放锁
}

例如,通过互斥锁实现多线程对对象状态的互斥访问和修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
inline void markInvalid()
{
std::lock_guard lock(mutex);
valid = false;
}
inline bool isValid()
{
std::lock_guard lock(mutex);
return valid;
}
inline bool isEnable()
{
std::lock_guard lock(mutex);
return is_enable;
}
inline void disable()
{
std::lock_guard lock(mutex);
is_enable = false;
}
inline void enable()
{
std::lock_guard lock(mutex);
is_enable = true;
}

此外,在 RAII 中,还值得一提的是类成员的构造顺序和析构顺序:类成员总是按照定义的顺序来构造的,而不是按照初始化列表的顺序,而析构的顺序则反过来。某些情况下,当两个类有循环依赖时,需要注意类成员定义的顺序,否则可能导致析构时的数据竞争

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class FileLogDirectoryWatcher
{
public:
...
FileLogDirectoryWatcher(const std::string & path_, StorageFileLog & storage_, ContextPtr context_);
~FileLogDirectoryWatcher() = default;
private:
friend class DirectoryWatcherBase;
...
/// Note, in order to avoid data race found by fuzzer, put events before dw,
/// such that when this class destruction, dw will be destructed before events.
/// The data race is because dw create a separate thread to monitor file events
/// and put into events, then if we destruct events first, the monitor thread still
/// running, it may access events during events destruction, leads to data race.
/// And we should put other members before dw as well, because all of them can be
/// accessed in thread created by dw.
Events events;
...
std::unique_ptr<DirectoryWatcherBase> dw;
};

在类 FileLogDirectoryWatcher 中,其成员 dw 中有一个线程会持续访问 events 成员,直到析构 dw 时停止运行。因此,在该类的定义中,我们必须先声明 events,再声明 dw,保证析构时能够先析构 dw,再析构 events,若顺序反过来,那么将会出现数据竞争:析构线程想要析构 events 对象,而 dw 中的线程还在访问 events 对象。

RAII 并不能够完全解决垃圾回收的需求,而 C++11 引入了资源管理指针,二者结合,消除了垃圾回收的需求。

2.2 智能指针

C++11 引入了智能指针 shared_ptrunique_ptr,分别用于共享所有权和独占所有权。shared_ptr 通过引用计数实现资源的共享:指向同一对象所有指针共享一个计数器,当最后一个指向对象的指针被销毁(计数器变为 0)之后,对象也会随之被销毁。为了解决循环引用可能导致的内存泄漏问题,可以使用 weak_ptr ,其不会增加引入计数。unique_ptr 拥有所指对象的独占权,并在自身销毁时将其所指向的对象销毁。

在多线程程序中使用 shared_ptr 时,由于涉及引用计数器变化时的同步操作(锁),会增加创建和回收的开销,需谨慎使用,或采用传引用的方式进行调用。

此外,无论是 shared_ptr 还是 unique_ptr,最主要的两种构造方式(以 shared_ptr 为例)是:make_shared 构造和通过 new 构造:

1
2
auto p0 = std::shared_ptr<T>(new T());
auto p1 = std::make_shared<T>();

这两种构造方式会带来不同的内存布局:make_shared 能够使得引用计数块和对象在内存上连续,只需要进行一次内存分配;而后者需要先通过 new 分配对象内存,再将对象指针传给 shared_ptr 的构造函数,需要再次额外分配引用计数块,导致内存可能是不连续的。相比 new 的方式,make_shared 能够带来更高的运行效率。并且,通过 new 的方式还可能导致内存泄漏,而 shared_ptr 则是异常安全的。因此,在非特殊情况下,应尽可能使用 make_shared 而不是通过 new 的方式来创建智能指针。

2.3 enable_shared_from_this

在异步编程场景中,一个常见的场景是,需要在类中将该类的对象注册到某个回调类或函数中,此时,不能简单传递 this 指针,因为很可能回调时该对象已经不存在导致空指针访问。若通过智能指针管理对象,也不能直接将 this 指针构造成 shared_ptr ,因为无法通过裸指针获得引用计数块信息,这样构造会造成对象内存的多次释放。

通过裸指针(this 指针)构造智能指针的关键在于得到引用计数信息,为了解决这一问题,标准库提供了 enable_shared_from_this 这一模板基类,通过 CRTP(会在下文介绍) 在父类中存储子类的指针信息和引用计数信息,同时提供了 shared_from_thisweak_from_this 接口以获取智能指针。在进行智能指针构造时,通过编译时多态判断被构造的类是否派生自 enable_shared_from_this,如果是,则将相关信息存储到基类中以供后续使用,否则什么也不用做,体现出了 C++ “零成本抽象” 的思想。

在 ClickHouse 中,几乎没有通过裸指针创建对象,将 RAII 与智能指针相结合,较好解决了 C++ 中的资源回收需求。此外,许多类都继承自 enable_shared_from_this 用于在类中获取智能指针,例如,IDataType 基类getPtr 方法从类中返回一个智能指针。

1
2
3
4
5
6
7
class IDataType : private boost::noncopyable, public std::enable_shared_from_this<IDataType>
{
public:
IDataType() = default;
virtual ~IDataType();

DataTypePtr getPtr() const { return shared_from_this(); }

在异步回调函数中使用 shared_from_this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
xstd::vector<String> AsyncBlockIDsCache::getChildren()
{
auto zookeeper = storage.getZooKeeper();

auto watch_callback = [last_time = this->last_updatetime.load()
, my_update_min_interval = this->update_min_interval
, my_task = task->shared_from_this()](const Coordination::WatchResponse &)
{
auto now = std::chrono::steady_clock::now();
if (now - last_time < my_update_min_interval)
{
std::chrono::milliseconds sleep_time = std::chrono::duration_cast<std::chrono::milliseconds>(my_update_min_interval - (now - last_time));
my_task->scheduleAfter(sleep_time.count());
}
else
my_task->schedule();
};
std::vector<String> children;
Coordination::Stat stat;
zookeeper->tryGetChildrenWatch(path, children, &stat, watch_callback);
return children;
}

3. 模板与范型编程

范型编程是 C++ 语言中的重要组成部分,其具有非常高的灵活性,使得我们能够以一种独立于特定类型的方式开发代码,具有更细粒度的静态类型检查,生成的代码通常具有更高的效率。模板是范型编程的基础,C++ 标准库中的各种容器、算法,几乎都离不开模板。现代 C++ 进一步扩展了范型编程的能力,例如变参模板支持、lambda 表达式、concept 引入等;同时,范型编程的产物模板元编程也在现代 C++ 中获得了巨大的成功。

模板与范型编程在 ClickHouse 中具有非常广泛的应用,是 ClickHouse 实现高效代码的关键技术,通过泛型编程实现编译期多态,将部分控制逻辑和计算逻辑从运行时转移到编译时,从而生成性能更优、向量化更好的代码。

3.1 lambda 表达式

在介绍 lambda 表达式之前,先介绍一下函数对象:一个对象只要能够像函数一样进行调用,即为函数对象,同时,函数对象中可以携带状态。函数对象可以作为参数传递给其他函数,例如标准库 <algorithm> 中的算法都需要提供一个函数对象,另一个比较常见的场景是回调函数。

过去,在 C++ 是通过重载操作符 () 来实现函数对象的,而状态则能够在类中存储,例如:

1
2
3
4
5
6
7
template <typename T>
struct add
{
T operator() (T x, T y) { return x + y; }
}

int sum = add{}(1, 2);

此外,标准库提供了高阶函数以支持参数绑定:

1
2
3
using namespace std::placeholders;

auto add10 = std::bind(plus<int>{}, 10, _1);

在这儿,bind 接收一个函数对象进行参数绑定,绑定的参数为 10,而 _1 为来自 std::placeholders 名字空间的占位符,表示在调用时才对该参数进行绑定,最终生成一个只接受一个参数的函数对象 add10,可以直接通过 add10(1) 进行调用。

C++ 11 引入了 lambda 表达式,进一步简化了函数对象的定义,lambda 表达式的背后是编译器会自动生成一个匿名类型并实例化对象。

1
[捕获](形参列表) -> 后置返回类型(可省略){ 函数体 }

从 C++14 起,lambda 表达式支持形参的 auto 类型推导(上文已有提到),因此,上文提到的函数对象实现可以简化为:

1
2
auto add = [](auto x, auto y) { return x + y; };
auto add10 = [add](auto y) { return add(10, y); };

此外,值得一提的是 C++11 引入的函数适配器 std::function,其作为一个函数模板,能够存储任何可调用的对象(普通函数、lambda 表达式、bind 表达式以及指向成员函数的指针等)。因此,无论函数对象的类型如何,都可以被对应原型的 std::function 存储,拥有统一的类型,因此可以在运行时绑定,实现语义运行时多态,例如 std::function<int(int, int)> 类型可以绑定到任何返回值为 int 类型,参数为两个 int 类型的函数对象。

在 ClickHouse 中,lambda 表达式出现较多的地方是作为高阶函数的参数以及回调函数,同时,通过 std::function 声明回调函数的类型。例如:

1
using MergeTreeReadTaskCallback = std::function<std::optional<ParallelReadResponse>(ParallelReadRequest)>;

通过该方式定义了一个函数对象类型,之后,可以像使用其他类型一样使用该函数对象类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Context.cpp
void Context::setReadTaskCallback(ReadTaskCallback && callback)
{
next_task_callback = callback;
}
// TCPHandler.cpp
query_context->setMergeTreeReadTaskCallback([this](ParallelReadRequest request) -> std::optional<ParallelReadResponse>
{
Stopwatch watch;
CurrentMetrics::Increment callback_metric_increment(CurrentMetrics::MergeTreeReadTaskRequestsSent);
std::lock_guard lock(task_callback_mutex);

if (state.cancellation_status == CancellationStatus::FULLY_CANCELLED)
return std::nullopt;

sendMergeTreeReadTaskRequestAssumeLocked(std::move(request));
ProfileEvents::increment(ProfileEvents::MergeTreeReadTaskRequestsSent);
auto res = receivePartitionMergeTreeReadTaskResponseAssumeLocked();
ProfileEvents::increment(ProfileEvents::MergeTreeReadTaskRequestsSentElapsedMicroseconds, watch.elapsedMicroseconds());
return res;
});

在这儿,Context 中声明了一个 MergeTreeReadTaskCallback 类型的对象,其能够通过 setMergeTreeReadTaskCallback 方法进行设置,在调用该方法时,可以直接通过一个 lambda 表达式来创建函数对象,只需要保证函数签名与 MergeTreeReadTaskCallback 中的函数签名一致即可。基于函数适配器和 lambda 表达式,使得函数对象的使用和创建更加方便和灵活。

此外,lambda 表达式显著增加了范型编程的吸引力,最常见的用途之一就是作为 STL 算法的参数。例如,在 std::sort自定义比较函数

1
2
3
4
 std::sort(nodes.begin(), nodes.end(), [](const auto & lhs, const auto & rhs) 
{
return std::tie(lhs.info.level, lhs.info.mutation) > std::tie(rhs.info.level, rhs.info.mutation);
});

或者,作为范型编程中的模板参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template <typename F>
static bool castType(const IDataType * type, F && f)
{
return castTypeToEither<
DataTypeUInt8,
DataTypeUInt16,
DataTypeUInt32,
DataTypeUInt64,
DataTypeUInt128,
DataTypeUInt256,
DataTypeInt8,
DataTypeInt16,
...
DataTypeInterval>(type, std::forward<F>(f));
}

bool valid = castType(arguments[0].get(), [&](const auto & type)
{
...
});
if (!valid)
{
...
}

上述代码是 ClickHouse 中 UDF 实现时经常用到的一个方法,在运行时通过函数参数的父类指针动态确定其派生类的具体类型,之后,将类型作为 lambda 函数的参数,在 lambda 表达式中实现具体的执行逻辑。在 castType 这一模板函数中,lambda 表达式作为了模板函数的参数。

3.2 SFINAE

在谈论模板相关的特性之前,先简单介绍一下 SFINAE,虽然其并不是现代 C++ 才有的东西。SFINAE 的全称是 “Substitution Failure Is Not An Error”,是用于模板函数重载决议的一条规则,即替换失败不是错误,替换指的是模板形参的替换。

在 C++ 中,函数重载决议过程由多个阶段组成:首先是名称查找,找出同名的函数声明与函数模板,作为候选集;之后,对于候选集中的模板函数,需要进行实例化,即进行模板参数的推导和参数替换,如果替换失败,那么则将对应的模板函数从候选集中删除,但不会产生错误,即 SFINEA;最后,是重载决议的阶段,该阶段会从候选集中找出所有可行函数,并从中挑选出最佳可以函数函数,如果没有找到任何可行函数,此时则会出现编译错误。

最佳可行函数的挑选主要按照下面一些规则进行:

  1. 函数形参与实参类型最匹配,转换最少的为最佳可行函数
  2. 非模板函数优于模板函数
  3. 对于多个模板实例,最具体的模板实例最佳
  4. 若模板函数具有约束(concept),则选择约束最强的

基于 SFINAE 特性,我们可以控制编译期的决策过程,选择预期的版本,同时能够实现编译时的分支判断能力,过去在元编程中被大量使用,主要依赖于 std::enable_if。在 C++17 引入 if constexpr 以及 C++20 引入 concept 约束之后,类似的技巧会逐步被更加先进的元编程技术所替代。

3.3 变参模板

C++11 引入了变参模板,通过变参模板,能够实例化包含任意长度参数列表的类模板和函数模板,并且能够以类型安全的方式给函数传递任意多个参数。变参模板最常见的例子就是 std::make_sharedstd::make_unique 的实现,其基本形式为:

1
2
3
4
5
template<typename Tp, typename ...Args>
shared_ptr<Tp> make_shared(Args&& ...args)
{
...
}

在上面的 lambda 表达式处提到的 castType 的实现中实际上就用到了 castTypeToEither 这一模板函数

1
2
3
4
5
template <typename... Ts, typename T, typename F>
static bool castTypeToEither(const T * type, F && f)
{
return ((typeid_cast<const Ts *>(type) && f(*typeid_cast<const Ts *>(type))) || ...);
}

在这个模板函数中,我们可以在 Ts 中指定任意个类型,若参数 type 通过 typeid_cast 能够转换到其中任意一个类型,则将转换得到的类型作为参数传递给函数 f(lambda 表达式)进行处理。通过该模板,能够在运行时动态从基类指针得到具体的派生类类型,如果 Ts 中的任意类型都不是 type 所指向的派生类类型,那么则会返回 false

在现代 C++ 代码中,变参模板已经被大量使用,然而其缺点是容易导致代码膨胀,N 个参数意味着模板的 N 次实例化。

3.4 Type Traits 与元编程

随着 C++ 的不断发展,编译期求值的重要性不断提高。元编程旨在将计算从运行期转移到编译期,广泛用于在编译期进行数值和类型的计算。最开始,元编程只是模板发展历史中偶然发现的产物,由于没有标准的支持,通过模板实现元编程用起来也很蹩脚,好在现代 C++ 标准的演进过程中不断引入了大量语法糖,使得元编程得以被简化。

我们可以从函数的角度来理解元编程,在函数中,会有参数和返回值,而对于模板元编程,如果以函数的角度来看(元函数),那么参数则为尖括号包裹的模板参数,模板参数通常为类型或常量,返回值则为模板成员,成员可能为数值(通常用 value 表示),或者类型(用 type 表示)。

3.4.1 类型计算

在类型计算中,运用最广泛的就是类型萃取(Type Traints),通过类型萃取得到类型的组合信息,例如萃取函数类型或者参数、返回值类型信息;对类型退化得到原始类型(前面提到的 decay_t);进行类型与值,类型与类型之间的映射和变换等。类型萃取的主要目的是获取类型相关的特性(属性),根据这些信息我们能够提供更具针对性的实现,从而编译器能够在多个选择中决策出最优的实现。此外,类型萃取也可以在编译时对类型进行变换,给定任意类型 T,可以给这一类型添加 const 修饰、添加引用或指针等,无任何运行时开销。

标准库 <type_traints> 中的类型特征谓词通常是以 is_ 为前缀,例如:

1
2
3
4
5
6
7
8
// 是否为整型
std::is_integral<T>::value; // std::is_integral<int>::value == true;
// 是否为浮点型
std::is_floating_point<T>::value;
// 是否为数值类型,即整型或浮点
std::is_arithmetic<T>::value;
// T 和 U 是否为相同类型
std::is_same<T, U>::value; // std::is_same<int, int>::value == true;

C++14 引入变量模板(variable template)特性之后,C++17 标准库中为 type traints 预定义了一系列变量模板,从而可以用更简洁的方式来表达上述类型特征:用 _v 来代替 ::value 的访问方式。例如:

1
template<typename T, typename U> constexpr bool is_same_v = is_same<T, U>::value;

变量模板与类型别名(using)较为相似,不同的是变量模板可以接收模板参数,同时支持特化。

除类型谓词外,标准库中也有用于类型变换的 type traints,即基于已有类型进行修改,得到新的类型,输出类型可以通过 type 类型成员进行访问,例如:

1
2
3
4
5
using T1 = std::remove_const<const int>::type; // int
using T2 = std::add_const<int>::type; // const int
using T3 = std::remove_reference<int &>::type; // int
using T4 = std::add_lvalue_reference<int>::type; // int &
using T5 = std::add_rvalue_reference<int>::type; // int &&

从 C++14起,标准库中添加了一序列类型别名,因此可以通过 _t 的方式来访问 ::type,例如:

1
2
template<typename T>
using add_lvalue_reference_t = typename add_lvalue_reference<T>::type;

此外,标准库中也提供了一些常见辅助类,方便实现其他 type traints,其中,integral_constant 能够将类型与值进行一一对应:

1
2
3
using Two = std::integral_constant<int, 2>;
static_assert(Two::value == 2);
static_assert(std::is_same_v<Two::type, int>)

标准库中也提供了一些相应的类型别名:

1
2
3
4
template<bool v>
using bool_constant = integral_constant<bool, v>;
using true_type = integral_constant<bool, true>;
using false_type = integral_constant<bool, false>;

下面,我们以一个 ClickHouse 中判断一个类型是否为 shared_ptr 的例子来看如何实现 type traints。

首先,定义一个基本模板类,使其返回 false,因此可以直接继承上文提到的 std::false_type,其拥有一个静态成员常量 value 且始终为 false,并且是一个空类,使用继承的方式既能达到空基类优化的效果,并且能够得到约定的返回结果 value

1
2
3
4
template <typename T>
struct is_shared_ptr : std::false_type
{
};

之后,定义一个针对 std::shared_ptr<T> 特化的版本,继承自 std::true_type,因此当输入是 shared_ptr 类型时能够返回 true

1
2
3
4
template <typename T>
struct is_shared_ptr<std::shared_ptr<T>> : std::true_type
{
};

最后,再定义一个变量模板 is_shared_ptr_v 从而能够更方便的访问 value

1
2
template <typename T>
inline constexpr bool is_shared_ptr_v = is_shared_ptr<T>::value;

3.4.2 数值计算

在模板元编程中,通过模板特化的方式,来进行数值计算,一个非常常见的例子是计算斐波那契数列:

1
2
3
4
5
6
template<size_t N>
constexpr size_t Fibonacci = Fibonacci<N-1> + Fibonacci<N-2>;
template<> constexpr size_t Fibonacci<0> = 1;
template<> constexpr size_t Fibonacci<1> = 1;

static_assert(Fibonacci<10> == 89);

由于在编译时不允许修改输入的非类型参数,无法简单通过 for 循环对迭代变量进行修改和求值。因此,元编程通过递归的方式解决这类迭代问题,从而只需要在输入参数的基础上进行计算而无需修改原值,递归的边界通过特化进行表达,在上述例子中,当输入为 0 或 1 时,停止递归,直接返回边界结果。

上述例子体现出了模板元编程的基本思想:使用递归代替迭代,使用特化代替分支。这一例子只是为了说明元编程可以做什么,但并不是表达数值算法的好方法,比如通过编译期求值函数(constexpr 函数)可以大大简化元编程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
constexpr size_t fibonacci(size_t n)
{
size_t f0 = 1;
size_t f1 = 1;
size_t fn;
for(size_t i = 2; i <= n; ++i)
{
fn = f1 + f0;
f0 = f1;
f1 = fn;
}
return fn;
}

static_assert(fibonacci(10) == 89);

因此,函数依然是用于求值的最佳方式,即使是在编译期,而模板元编程最好只用于计算新的类型和控制结构。

constexpr 修饰表示函数可能在编译期求值,只要参数合适,那么编译器将会尝试求值,而 C++20 进一步引入了 consteval,使用 consteval 修饰时要求函数必须能够被编译时求值,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
consteval ssize_t formatStringCountArgsNum(const char * const str, size_t len)
{
/// It does not count named args, but we don't use them
size_t cnt = 0;
size_t i = 0;
while (i + 1 < len)
{
if (str[i] == '{' && str[i + 1] == '}')
{
i += 2;
cnt += 1;
}
else if (str[i] == '{')
{
/// Ignore checks for complex formatting like "{:.3f}"
return -1;
}
else
{
i += 1;
}
}
return cnt;
}

在 ClickHouse 的日志输出字符串中,使用 {} 作为参数的占位符,formatStringCountArgsNum 为计算字符串中参数个数的函数,使用 consteval 修饰意味着该函数必须在编译时被求值。

此外,constexprconsteval 修饰的函数意味着函数是内联的

3.4.3 再谈 constexpr

constexpr 的引入是为了实现编译时计算的类型安全,同时直接支持元编程,而非模板元编程。

通过 constexpr 定义的常量,通常可以代替使用宏定义的常量,既能够保证类型安全,且无运行时开销。constexpr 定义的变量要求表达式能够在编译时求值,并且拥有 const 属性。而前面已经提到过的 constexpr 函数更好地支持了编译期计算,相比于基于模板的编译期计算,可读性更高,使编译期编程更接近于“普通编程”。

除定义 constexpr 变量和 constexpr 函数之外, C++17 引入了 if constexpr 语句,与普通的 if 语句相比,其会在编译时对布尔常量进行评估,生成对应分支的代码。引入 if constexpr 之后,能够比较清晰地处理编译时的分支选择问题, 进一步简化了模板编程。过去,则需要通过模板特化或 enable_if 等方式来实现,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
void f(T arg)
{
// 默认实现
}
template<> void f<T1>(T1 arg)
{
// 针对 T1 类型的特定实现
}
template<> void f<T2>(T2 arg)
{
// 针对 T2 类型的特定实现
}

而有了 if constexpr 之后,我们能够用更简洁的方式实现上述代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T>
void f(T arg)
{
if constexpr(std::is_same_v<T, T1>)
{
// 针对 T1 类型的特定实现
}
else if constexpr(std::is_same_v<T, T2>)
{
针对类型 T2 的特定实现
}
else
{
// 默认实现
}
}

if constexpr 控制结构在 ClickHouse 的常量函数实现中具有非常多的应用,在进行计算时,通常需要对不同的数据类型进行分派,提供不同的计算逻辑。例如,abs 函数的计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename A>
static inline NO_SANITIZE_UNDEFINED ResultType apply(A a)
{
if constexpr (is_decimal<A>)
return a < A(0) ? A(-a) : a;
else if constexpr (is_big_int_v<A> && is_signed_v<A>)
return (a < 0) ? -a : a;
else if constexpr (is_integer<A> && is_signed_v<A>)
return a < 0 ? static_cast<ResultType>(~a) + 1 : static_cast<ResultType>(a);
else if constexpr (is_integer<A> && is_unsigned_v<A>)
return static_cast<ResultType>(a);
else if constexpr (std::is_floating_point_v<A>)
return static_cast<ResultType>(std::abs(a));
}

在上述函数中,控制结构中的表达式均为编译期常量布尔表达式,编译时会根据不同的模板类型生成不同的代码,函数中实际不会包含分支跳转语句,并且函数是 inline 的,不会出现函数调用,因此最终生成的代码对向量化更加友好。

3.4.4 constinit

C++20 进一步引入了 constinit 变量,其同样要求在编译时能够对表达式求值,但同时保留了可变的属性。

constinit 的引入是为了解决 C++ 各个编译单元全局变量运行时初始化顺序不确定的问题,若这些全局变量存在依赖,那么初始化结果就是未定义的。过去,该问题常见的解决方案是使用局部静态变量代替全局变量,将依赖转变为对函数的依赖,通过控制函数的调用顺序确保局部静态变量的初始化顺序。通过 constinit 解决了变量运行时初始化顺序不确定的问题,其要求全局声明周期的变量在编译时进行初始化,既能节省运行时初始化的开销,又避免了依赖问题。

3.4.5 std::enable_if

在 C++ 早期元编程历史中,对 SFINAE 有非常高的依赖,从 C++11 起,标准库进一步提供了 std::enable_if,其主要用于 SFINAE 场景中,通过对模板函数、模板类中的模板类型进行谓词判断,从而使程序能够选择合适的模板函数重载版本或模板类特化版本。

std::enable_if 接受两个模板参数,第一个参数为布尔条件,第二个参数为类型,当条件为真时,成员 type 为第二个模板参数,当条件为假时,不存在 type 成员:

1
2
3
4
5
6
7
8
template<bool, typename = void>
struct enable_if { };

template<typename T>
struct enable_if<true, T> { using type = T; };

template<bool B, typename T = void >
using enable_if_t = typename enable_if<B,T>::type; // C++14

在没有 if constexpr 和 concept 之前,std::enable_if 在元编程中被大量使用,例如,这是过去 ClickHouse 中的某函数的声明和实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <class T, typename std::enable_if<std::is_same_v<T, int8_t>
|| std::is_same_v<T, int16_t>
|| std::is_same_v<T, int32_t>, T>::type * = nullptr>
static ReturnType apply(T x)
{
//...
}

template <typename T, typename std::enable_if<!std::is_same_v<T, int8_t>
&& !std::is_same_v<T, int16_t>
&& !std::is_same_v<T, int32_t>
&& !std::is_same_v<T, int64_t>, T>::type * = nullptr>
static ReturnType apply(T x)
{
//...
}

通过将 std::enable_if 与 type traits 结合,能够对模板类型进行限制,根据模板类型选择合适的实现。上述例子中,根据 enable_if,我们无需查看函数实现,就能够从函数声明中直接看出两个模板函数的类型作用范围。

然而,enable_if 的问题在于与模板声明混杂在一起,降低了代码的可读性,还会导致难以分辨出完整的模板参数,例如下面的例子:

1
2
3
4
5
6
7
template <typename ReturnType = void, typename CheckForNull, typename DeserializeNested, typename std::enable_if_t<std::is_same_v<ReturnType, bool>, ReturnType>* = nullptr>
static ReturnType safeDeserialize(
IColumn & column, const ISerialization &,
CheckForNull && check_for_null, DeserializeNested && deserialize_nested)
{
//...
}

对于上述 apply 模板函数,通过 if constexpr 能够更清晰的进行表达:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
tempalte<typename T>
static ReturnType apply(T x)
{
if constexpr (std::is_same_v<T, int8_t> ||
std::is_same_v<T, int16_t> ||
std::is_same_v<T, int32_t>)
{
//...
}
else
{
//...
}
}

而在 C++20 引入 concept 之后,我们可以使用更简洁、可读性更高的方式来代替 enable_if:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <class T, T * = nullptr>
requires std::same_as<T, int8_t> || std::same_as<T, int16_t> || std::same_as<T, int32_t>
static ReturnType apply(T x)
{
//...
}

template <class T, T * = nullptr>
requires(!std::same_as<T, int8_t> && !std::same_as<T, int16_t> && !std::same_as<T, int32_t>)
static ReturnType apply(T x)
{
//...
}

3.5 concept 约束

长期以来,C++ 的模板参数没有任何约束,仅仅在实例化时才能够发现类型上的错误,这导致一方面,编译错误信息非常难以看懂,另一方面,在阅读模板元编程的代码时,面对大量的模板参数,在不深入实现的情况下,难以理解具体是干什么的。语言上的缺陷导致后续产生了 std::enable_if 等变通方法,但其仍然不能真正解决问题,直到 concept 的出现。concept 的引入大大降低了元编程的难度,同时简化了范型编程。

在上文中,已经多次提到 concept,concept 是一个对类型进行约束的编译期谓词(也可以简单理解成是一个布尔表达式常量),给定一个类型,判断其是否满足语法和语义要求。从 C++ 很早期的发展开始,就已经不断有关于概念的讨论,但由于各种原因,直到 C++20 才被正式标准化。

3.5.1 concept 定义

concept 主要可以通过两种方式进行定义。第一种方式是使用 type traits 来进行定义,此时,概念被定义为一个约束表达式(布尔表达式常量)。例如,下面的代码定义了名为 floating_point 的 concept,在判断类型是否满足 concept 约束时,编译器会对 concept 定义的约束表达式进行求值,因此可以通过静态断言检测类型是否满足要求。

1
2
3
4
template<typename T>
concept floating_point = is_floating_point_v<T>;

static_assert(floating_point<float>);

concept 也支持通过逻辑操作符(逻辑与和逻辑或)进行组合以定义更复杂的概念:

1
2
3
4
5
6
7
template<typename T>
concept int_or_float = is_same_v<int, T> || is_same_v<float, T>;
template<typename T>
concept large_int = is_integral_v<T> && (sizeof(T) >= 4);

static_assert(int_or_float<float>); //第一个表达式为假,但第二个为真,整体为真
static_assert(large_int<long>);

但逻辑操作符在约束表达式中的语义相对布尔运算有细微区别,主要体现在变参模板构成的约束表达式,其既不是约束合取,也不是约束析取:

1
2
template<typename ...Ts>
concept has_integral_type = (is_integral_v<typename Ts::type> || ...);

上述 concept 不是析取表达式,因此没有短路操作,其首先检查整个表达式是否合法,这要求每一个模板参数都有类型成员 type,否则整个表达式为假。若要表达至少一个模板参数存在类型成员 type 并且为整型,可以通过添加一层间接层解决:

1
2
3
4
template<typename T>
concept has_nested_integral_type = is_integral_v<typename T::type>;
template<typename ...Ts>
concept has_integral_type = (has_nested_integral_type<Ts> || ...);

下面的 concept 定义是一个支持短路的析取表达式,表示要求存在一个模板参数拥有整型类型成员 type

1
2
template<typename T, typename U>
concept has_integral_type = is_integral_v<typename T::type> || is_integral_v<typename U::type>;

若要检查逻辑表达式的合法性,即是否都包含 type 成员,则需要写成如下形式,其要求两个模板参数都拥有 type 成员,且至少其中一个为整型。

1
2
template<typename T, typename U>
concept has_integral_type = bool(is_integral_v<typename T::type> || is_integral_v<typename U::type>);

同理,对于逻辑否定的情况,c1 要求类型 T 存在类型成员 type 且必须为整型,而 c2 要求 T 不存在关联类型或关联类型不为整型。

1
2
3
4
template<typename T>
concept c1 = is_integral_v<typename T::type>;
template<typename T>
concept c2 = (!is_integral_v<typenamt T::type>);

除使用 type traits 外,另一种定义 concept 的语法是使用 requires 表达式,用于表达模板参数及其对象的特征要求:成员函数、自由函数和关联类型等。requires 表达式的基本语法为:

1
2
3
4
requires (形参列表,可选)
{
// 一系列表达式要求
}

requires 表达式的结果为布尔类型,为编译期谓词,当进行求值时,会按表达式体中声明的先后顺序依次检查表达式的合法性,只有所有要求都合法,才返回真,否则为假。

requires 表达式提供四种形式的要求

  • 简单要求:任意表达式,不以 requires 开头,表示表达式需要是合法的(仅检查合法性,不会进行评估),例如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    template<typename T>
    concept addable = requires (T a, T b)
    {
    a + b; // 要求 a + b 是一个合法的表达式
    };

    template<typename T>
    concept is_a_animal = requires (T a)
    {
    a::name; // 具有静态成员 name
    a.age; // 具有成员变量 age
    a.bark(); // 具有 bark 方法
    }
  • 类型要求:以 typename 开头,后跟一个类型名,要求类型是合法的,例如:
    1
    2
    3
    4
    5
    6
    template<typename T>
    concept c = requires
    {
    typename T::type; // 存在类型成员 type
    typename std::vector<T>; // 能够实例化 vector
    }
  • 复合要求:复合要求可以进一步指定表达式的返回类型,是否会抛出异常等,基本语法为:
    1
    {表达式} noexcepct(可选) -> 返回类型要求(可选)
    例如:
    1
    2
    3
    4
    5
    6
    7
    template<typename T>
    concept c = requires(T x)
    {
    {*x} -> std::convertible_to<typename T::inner>; //

    {x + 1} -> std::same_as<int>;
    };
    在上述例子中,std::same_asstd::convertible_to 是标准库中定义的两个 concept,concept 会将表达式类型补充到 concept 的第一个参数中,因此省略了第一个参数。
    1
    2
    3
    4
    template<typename T, typename U>
    concept same_as = ...;
    template<typename T, typename U>
    concept convertible_to = ...;
    上文提到的 HasPrefetchMemberFunc 就是一个最简单的复合要求,其和简单要求几乎没有区别,只是使用大括号将表达式括起来了:
    1
    2
    3
    4
    5
    template <typename HashTable, typename KeyHolder>
    concept HasPrefetchMemberFunc = requires
    {
    {std::declval<HashTable>().prefetch(std::declval<KeyHolder>())};
    };
  • 嵌套要求:其支持在表达式体中通过 requires + 约束表达式来表达额外的约束,例如:
    1
    2
    3
    4
    5
    6
    7
    template<typename T>
    concept c = requires
    {
    requires sizeof(T) > sizeof(T*); // T 的大小大于 T* 的大小
    { T.func() } noexcept -> std::same_as<int>; // 具有 func() 方法,不会抛出异常,返回类型为 int
    typename T::type; // 具有 type 成员类型
    }
    例如,ClickHouse 中定义了名为 OptionalAgument 的 concept,其要求变参模板中的模板参数个数只能为 0 或 1:
    1
    2
    3
    4
    5
    template <typename... T>
    concept OptionalArgument = requires(T &&...)
    {
    requires(sizeof...(T) == 0 || sizeof...(T) == 1);
    };

另外,需要注意的是简单要求和嵌套要求中对布尔表达式的约束是不一样的,前者只检查表达式的合法性,而后者需要在合法性基础上求值,例如:

1
2
3
4
5
6
template<typename T>
concept c = requires
{
sizeof(T) <= sizeof(int); // 始终为真
requires sizeof(T) <= sizeof(int); // 要求 T 的大小小于等于 int 的大小
}

3.5.2 concept 使用

通过使用 requires 子句,能够为模板类或模板函数的模板参数添加约束,例如,下面的代码是 ClickHouse 中用于计算将输入值舍去到 2 的幂次的模板函数,其针对不同的类型提供了不同的实现,使用 requires 子句进行约束,在没有 concept 之前,只能使用晦涩难懂的 enable_if 来实现这一功能(或者使用 C++17 引入的 if constexpr)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
template <typename T>
requires std::is_integral_v<T> && (sizeof(T) == sizeof(UInt64))
inline T roundDownToPowerOfTwo(T x)
{
return x <= 0 ? 0 : (T(1) << (63 - __builtin_clzll(x)));
}

template <typename T>
requires std::is_same_v<T, Float32>
inline T roundDownToPowerOfTwo(T x)
{
return bit_cast<T>(bit_cast<UInt32>(x) & ~((1ULL << 23) - 1));
}

template <typename T>
requires std::is_same_v<T, Float64>
inline T roundDownToPowerOfTwo(T x)
{
return bit_cast<T>(bit_cast<UInt64>(x) & ~((1ULL << 52) - 1));
}

template <typename T>
requires is_big_int_v<T>
inline T roundDownToPowerOfTwo(T)
{
throw Exception(ErrorCodes::NOT_IMPLEMENTED, "roundToExp2() for big integers is not implemented");
}

在进行模板函数的重载决议时,会根据 concept 的偏序规则 选择满足条件的约束最强的函数。

除了 requires 子句之外,一些简单情况下,也可以使用更简洁的语法来引入约束,例如:

1
2
template<std::integral T, std::integral U>
void f(T, U);

typename 关键字替换成了 std::integral 这一 concept,对多个模板参数添加 concept 约束,等价于一个约束合取表达式,即上述例子等价于:

1
2
3
template<typename T, typename U>
requires (std::integral<T> && std::integral<U>)
void f(T, U)

此外,概念约束也可以用于 auto 参数推导中:

1
2
void f(std::integral auto a, std::integral auto b); // 普通函数 auto 类型约束
auto f = [] (std::integral auto lhs, std::integral auto rhs) { return lhs + rhs; }; // lambda 约束

不过这样的使用方式可能会使 C++ 代码看起来越来越魔幻。

在 ClickHouse 的代码中,基本上过去所有使用 std::enable_if 来实现 SFINAE 的地方都已经替换成了更加简洁、代码更加清晰的 concepts 约束

3.6 CRTP 与编译期多态

在介绍 std::enable_shared_from_this 时,我们已经提到过 CRTP,其全称是奇异递归模版模式(Curiously Recurring Template Pattern)。CRTP 是 C++ 模板编程中非常常用的一种方法,其将派生类作为基类的模板参数,从而让基类可以使用派生类提供的方法。CRTP 并不是现代 C++ 才有的东西,但其在 ClickHouse 中具有非常广泛的应用,因此在此进行简要介绍。

CRTP 主要有两方面的用途:

  • 代码复用:子类派生自模板基类,可以复用基类的方法;
  • 编译期多态:由于基类是一个模板类,派生类是基类的模板参数,因此基类可以调用派生类中的方法,从而实现静态多态,与运行时多态相比,没有虚函数调用开销。

CRTP 的基本形式为:

1
2
3
4
5
6
7
8
9
template<typename T>
class Base
{
...
};
class Derived : public Base<Derived>
{
...
};

例如,前面提到的 std::enable_shared_from_this 在 ClickHouse 中被大量应用,都依赖于 CRTP。IStorage 是 ClickHouse 中用于描述一个表(table)的接口类 ,其继承自三个基类,并且都使用了 CRTP;此外,用于描述一个数据类型的 IDataType,用于描述一个数据库的 IDatabase,用于描述一个聚合函数实现的 IAggregateFunction 等都通过 CRTP 继承自 std::enable_shared_from_this,而用于描述一个列的 IColumn 通过 CRTP 继承自 COW 基类用于实现 Copy-On-Write。

1
2
3
4
5
6
7
8
9
10
class IStorage : public std::enable_shared_from_this<IStorage>, public TypePromotion<IStorage>, public IHints<1, IStorage>
{
...
};

class IDataType : private boost::noncopyable, public std::enable_shared_from_this<IDataType> { ... };
class IDatabase : public std::enable_shared_from_this<IDatabase> { ... };
class IAggregateFunction : public std::enable_shared_from_this<IAggregateFunction>, public IResolvedFunction { ... };
class RWLockImpl : public std::enable_shared_from_this<RWLockImpl> { ... };
class IColumn : public COW<IColumn> { ... };

CRTP 实现静态多态的关键在于基类能够在编译期获得派生类的类型,因此能够在编译时对派生类的函数进行派发,与基于虚函数的动态多态相比,一方面,省去了间接访址的开销;另一方面,更容易实现函数的内联,从而消除函数调用,利于实现向量化执行。

基于上述思想,在 ClickHouse 中,一些通用算子例如一元运算、二元运算的实现,利用模板(结合元编程) + 编译期多态技术,既实现了代码复用,又充分保证了算子的向量化执行。这儿说的通用算子指的是 ClickHouse 中的普通函数,其对于一行输入产生一行对应的输出。

以一元运算为例,一元运算包括取绝对值(abs)、取反(negate)、位移等,这些不同的一元运算都是对于一列输入的每一行数据产生一个输出,最主要的区别仅在于对每一行数据的计算逻辑上。因此,为了实现代码复用,我们可以把一元运算抽象成一个统一的类,再通过派发的方式分别实现不同的一元运算。若基于动态分派的方式实现,那么实现逻辑可能如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class FunctionUnaryArithmetic
{
public:
ColumnPtr executeImpl(Argument & arguments)
{
ColumnPtr res;
...
size_t col_size = arguments[0].column->size();
// 对输入列的每一行数据分别进行计算产生一个输出,然而由于虚函数调用,for 循环计算无法向量化
for(size_t i = 0; i < col_size; ++i)
{
res[i] = apply(arguments[0].column[i]);
}
...
return res;
}
// 子类继承并实现 apply 函数,其中实现具体计算逻辑
virtual ResultType apply(T a) = 0;
...
};
class AbsFunction: public FunctionUnaryArithmetic
{
public:
ResultType apply(T a) override { ... }
};

从上面的代码中可以看到,基于动态分派的方式,尽管实现了代码复用,但是由于出现虚函数调用,导致核心计算逻辑无法实现向量化。因此,需要转向静态分派的方式来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template<typename Op>
class FunctionUnaryArithmetic
{
public:
ColumnPtr executeImpl(Argument & arguments)
{
ColumnPtr res;
...
size_t col_size = arguments[0].column->size();
// 对输入列的每一行数据分别进行计算产生一个输出,算子具体实现逻辑类作为模板参数,
// 可以在编译时对算子实现方法进行分派,消除虚函数调用
for(size_t i = 0; i < col_size; ++i)
{
res[i] = Op::apply(arguments[0].column[i]);
}
...
return res;
}
...
};
class AbsImpl
{
public:
static inline ResultType apply(T a) { ... }
};

在基于静态分派的实现中,将 FunctionUnaryArithmetic 抽象成了一个模板类,模板参数是每一种一元运算的具体实现,因此,能够在编译时实现对一元运算的静态分派,消除了虚函数调用,并且 apply 函数能够被内联,从而实现核心计算逻辑的向量化执行。ClickHouse 中的一元运算二元运算等常量函数正是基于上述静态分派的方式来实现的,其中也大量依赖了 type traits、if constexpr 等元编程手段来实现控制逻辑。

基于静态分派的方式,实现了代码复用,保证了性能,同时代码也具有良好的可扩展性,例如,当我们要基于上述一元运算框架实现 sign 这一函数时,主要逻辑只需要实现 apply 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template <typename A>
struct SignImpl
{
using ResultType = Int8;
static const constexpr bool allow_fixed_string = false;

static inline NO_SANITIZE_UNDEFINED ResultType apply(A a)
{
if constexpr (IsDecimalNumber<A> || std::is_floating_point_v<A>)
return a < A(0) ? -1 : a == A(0) ? 0 : 1;
else if constexpr (is_signed_v<A>)
return a < 0 ? -1 : a == 0 ? 0 : 1;
else if constexpr (is_unsigned_v<A>)
return a == 0 ? 0 : 1;
}

#if USE_EMBEDDED_COMPILER
static constexpr bool compilable = false;
#endif
};

4. 其他

4.1 variantoptional 以及 any

在 C 语言中,可以用 union 来实现不同类型在内存中的“分时共享”,然而,其面临的问题是,没有编译期和运行期的检查确保这个地址仅用作其真实指代的类型,需要由用户自己确保 union 在成员使用上的一致。

C++17 引入了 std::variantstd::optionalstd::any 消除了 union 存在的问题:

  • std::variant<T, U>:持有 T 或 U(变参模板);
  • std::optional<T>:持有 T 或什么都不持有;
  • std::any:持有任意类型;

然而,上述三个特性当前存在的问题在于访问接口的不统一:

1
2
3
4
5
6
7
std::optional<int> var1 = 7;
std::variant<int,string> var2 = 7;
std::any var3 = 7;

auto x1 = *var1 ; // 对 optional 解引用
auto x2 = std::get<int>(var2); // 像访问 tuple 一样访问 variant
auto x3 = std::any_cast<int>(var3); // 转换 any

在不久的将来,有望在 C++ 中通过函数式编程风格的模式匹配更加优雅地解决 union 的问题。

4.2 “飞船运算符”

C++20 引入了三路比较操作符 <=>(由于很像星球大战中的一款飞船,因此也叫飞船运算符), 用于简化自定义比较运算符的实现。例如,过去在实现比较运算符时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct RowNumber
{
UInt64 block = 0;
UInt64 row = 0;
bool operator < (const RowNumber & other) const
{
return block < other.block
|| (block == other.block && row < other.row);
}

bool operator == (const RowNumber & other) const
{
return block == other.block && row == other.row;
}

bool operator <= (const RowNumber & other) const
{
return *this < other || *this == other;
}
};

我们需要分别实现每一个需要的比较符号,而引入三路比较符之后,只需要一行简单的代码即可:

1
2
3
4
5
6
7
struct RowNumber
{
UInt64 block = 0;
UInt64 row = 0;

auto operator <=>(const RowNumber &) const = default;
};

编译器能够帮我们实现 6 个正确的比较操作符。此外,C++20 还提出了强有序、弱有序等概念,三路比较符默认返回类型为强有序。

在使用三路比较符时,需要注意的是,这 6 个运算符实际上分为了两类:

  • equality operators (==, !=)
  • ordering operators (<=>, <, >, <=, >=)

因此,在实现 <=> 时,编译器实际上只会为我们实现 4 个 排序运算符,若需要等值运算符,我们还需要实现一个 == 运算符。只有 <=>default 实现时,会同时生成 default ==,这也是为什么上面的例子不需要实现 == 的原因。

5. 总结

本文主要介绍了部分从 C++11 开始的常见现代 C++ 语言特性及其在 ClickHouse 中的应用。然而,由于现代 C++ 特性浩如烟海,因此还有大量已经在 ClickHouse 中广泛使用但本文没有介绍的现代 C++ 特性,例如并发(内存模型、线程和锁、std::future 等)、文件系统(std::filesystem)、异常(noexcept)和范围(ranges 库)等,除此之后,一些非常重要,但当前还没有在 ClickHouse 中使用到的现代 C++ 特性(据我所知),例如模块、协程等,本文也未涉足。