0%

零开销继承:空基类优化(EBCO)

The Empty Base Class Optimization (EBCO)

C++ 没有真正零大小的类,因为在数组等场景需要用地址区分元素。一般编译器上空类的大小是 1 个字节。不过,当基类为空时,EBCO 会使得基类在子类中不占空间。但是 EBCO 的适用有条件。下面的场景是从实践中观察出来的,可能并没有被标准定义,但它演示了 EBCO 失效:

  1. 定义“空基类组”的概念:如果空类型 B 继承了空类型 A,那么 B 和 A 可以存在于同一个空基类组(看成并查集会比较好理解)。和其他空类型不相关的类型可以认为自为一组。
  2. 某类型 C 继承了一个大小为 N 的空基类组,那么它至少具有 N 个字节。如果这个类型 C 不是空类型,则还有对齐要求。
  3. 继承多个空基类组,对子类对象所占用空间的要求等效于继承于最大的那个空基类组。
struct E1 {};
struct E2 : E1 {};
struct E20 {};
struct E21 : E20 {};
struct E22 : E20 {};
struct E23 : E20 {};
struct E24 : E20 {};

struct B : E1, E2, E20, E21, E22 {
    // int a;
};

int main() {
    std::cout << "sizeof(d): " << sizeof(B) << '\n';
    // 输出为 3
    // 如果 B 中还有个整形元素 a,输出为 4
    // 如果 B 中还有个整形元素 a,并再继承空类 E23 和 E24,输出为 8(5对齐到4的倍数)
}

空成员优化

如果类中有一个空类的对象,那么它将无法适用 EBCO。将其改造为继承可以节约空间。

类型擦除

主要讲的是类型擦除,例子是 std::function 类模板。特殊情况:std::function<void(void)>std::function<void()> 是同一个类型。

实现思路

  1. std::function 原始模板捕获了函数类型。
template<typename Signature>
class FunctionPtr;
  1. std::function 的偏特化能够获得函数的返回值和函数参数包的类型。
template<typename R, typename... Args>
class FunctionPtr<R(Args...)> { ... }

分类

现在值元编程已经能够很好地被 constexpr 函数取代。

类型元编程例子:std::chrono 中的时间单位用分数表示,两个时间单位的加和的单位可以在运行时确定——尽管分母很可能会变化。

混合元编程例子:

  1. 时间的相加。时间单位可以最终确定,但是由于给单位施加的量在运行时确定,所以需要运行时计算。同时除了量之外,其他的表达式都可以在编译期得到简化。
  2. 强制循环展开。比如:
template <typename T, std::size_t N>
struct DotProductT {
    static inline T result(T *a, T *b) {
        return *a * *b + DotProductT<T, N-1>::result(a+1, b+1);
    }
}
template <typename T>
struct DotProductT<T, 0> { // 偏特化
    static inline T result(T *, T *) {
        return T{};
    }
}

本章介绍了构建类型列表的技巧。既然类型列表可以使用这类技巧,值列表当然也可以在编译期完成类似的计算。

类型列表一开始是用可变长参数列表定义的。

template <typename... Elements>
class Typelist {};

很容易使用偏特化手段为 Typelist 定义 Front trait,以此获得列表的第一个元素。当然这要求类型列表非空,否则不能完成实例化。

类似地,可以定义下面一系列操作:

类型计算可以在编译期间完成,取值的部分仍然是运行时操作。在 libc++ 中被实现成多继承+反序构造,在 libstdc++ 中被实现成双继承+正序构造。

Base

将元素包裹在 Base 中再继承可以利用到 EBCO(可优化时继承元素,不可优化时将元素作为成员)。通过给 Base 类模板添加下标作为参数,可以区分类型相同的元素。

get 函数

按下标获取:无论是 parameter pack 继承还是递归继承,由于有编号的存在,每个元素被存储位置的对应类型都是可以区分的。将 *this 转换成基类的引用再调用对应的 get() 方法(这个是 Baseget() 方法,不是元组的),或者直接用基类名作为限定名调用对应方法就能实现 get

按类型获取:逐个比较类型,直到类型相符。然后得到对应位置的下标,调用“按下标获取”的 get 重载版本。如果被指定的类型出现了多次,则应该造成编译失败。

reverse(反转元组)

尽管可以像上一章实现 Typelist 一样频繁拼接,但 Typelist 的拼接是在编译期间完成的,而元组的拼接是运行时操作,会造成多次数据拷贝(而且越靠近中间的数据拷贝次数越多)。

本章想要实现的类模板相当于 std::variant。 可以用递归 union 模板,也可以用字符数组存储,本章选用的是后者。

使用字符数组存储未知元素,需要注意:

  1. 使用 alignas(Types...) 属性标识字符数组的对齐大小,否则强制类型转换后对齐大小和目的类型可能不相符。

  2. 使用 返回 后的指针。std::launder 可以提示编译器某个地址中的值发生了改变,甚至类型都可能变化了,以避免编译器分析变量的生命周期并进行激进的优化。

    https://stackoverflow.com/a/39382728/

[basic.life]/8 tells us that, if you allocate a new object in the storage of the old one, you cannot access the new object through pointers to the old. launder allows us to side-step that.

数据结构

书中的实现是让 Variant 继承了 VariantStorage 模板和 VariantChoice 模板。

表达式模板解决了什么问题

表达式模板主要用在大数组的计算上。以相加为例,应该支持的操作有:同长度的数组相加、数组和标量的相加。有时候表达式比较复杂,会涉及多个操作,这种场景下,手工编写代码效率高,但是需要自己控制循环;使用模板编写代码则需要想办法把操作一次性完成,避免多次内存分配和内存访问。

标准库中的 std::valarray 就是想要解决这一类问题,但是其实现因为历史原因可能算不上高效。

例子:

Array<double> x(1000), y(1000);
...
x = 1.2*x + x*y;

如何设计上面的代码?

Shallow Instantiation

作用:早点暴露参数校验错误,避免在一堆模板实例化失败的信息中找错误。

可以用 static_assert 或者定义无用类:

这样在实例化 shell 函数的时候还会实例化其中的 ShallowChecks 类,从而确保参数能够解引用。

Archetypes

作用:测试文档的描述是否精确定义了模板算法对参数的要求。

对于变量而言,其括号表达式的值类别是左值引用。但由于 decltype 有特殊效果,直接对变量 x 使用 decltype(x) 并不遵循这一点,为此可以按照下面说的使用 decltype((x))

With the keyword decltype (introduced in C++11), it is possible to check the value category of any C++ expression. For any expression x, decltype((x)) (note the double parentheses) yields:

  • type if x is a prvalue
  • type& if x is an lvalue
  • type&& if x is an xvalue

The double parentheses in decltype((x)) are needed to avoid producing the declared type of a named entity in case where the expression x does indeed name an entity (in other cases, the paren-theses have no effect). For example, if the expression x simply names a variable v, the construct without parentheses becomes decltype(v), which produces the type of the variable v rather than a type reflecting the value category of the expression x referring to that variable.

例子:

#include <iostream>

template<typename T>
struct value_category {
    static constexpr auto value = "prvalue";
};

template<typename T>
struct value_category<T&> {
    static constexpr auto value = "lvalue";
};

template<typename T>
struct value_category<T&&> {
    static constexpr auto value = "xvalue";
};

// Get value category of an expression
#define VALUE_CATEGORY(expr) value_category<decltype((expr))>::value
#define PRINT(expr) printf("(%s) : %s\n", #expr, VALUE_CATEGORY(expr))

int main() {
    PRINT(4);   // prvalue
    int x;
    int &y = x;
    int &&z = (int &&)x;
    PRINT(x);   // lvalue
    PRINT(y);   // lvalue
    PRINT(z);   // lvalue
    PRINT(x+1); // prvalue
}

这一章介绍的大都是用来查询类型属性的 type traits。还有一些其他的工具。

按数据概念分类

下面是基本(primary)类型,范围都是互斥的,而且任何一个类型必然使得下面中的一个且仅有一个评估为真:

(C++14 之前没有 is_null_pointer,所以那个时候 nullptr 的类型 std:: nullptr_t 是个特例。)