C++ 中的变量初始化

本文简单介绍现代 C++ 中的变量初始化。

初始化类型

在 C++ 之中,当一个变量被声明或被 new 表达式创建时,就会发生初始化。 总的来说,初始化可以分为以下几个大类:

  1. 默认初始化(Default initialization):若变量被初始化时没有任何初始化器,那么发生默认初始化,例如int x;或者new float
  2. 值初始化(Value initialization):若初始化器只有一对括号,那么发生值初始化,例如T x()
  3. 复制初始化(Copy initialization):若初始化器含有等号且不含有任何花括号,那么发生复制初始化,例如std::string a = "abc";
  4. 列表初始化(List initialization):若初始化器含有一对花括号,那么发生列表初始化,例如std::vector <int> v{1,2,3}或者std::vector <int> v = {1,2,3}
  5. 直接初始化(Direct initialization):若初始化器含有一对括号,且括号内含有参数,那么发生直接初始化,例如T x(1)

还有一些针对特殊类型和特殊生命周期变量的初始化,比如聚合初始化、引用初始化和常量初始化。

默认初始化

默认初始化的初始化器最为简单,语义也比较单纯。 当一个变量被默认初始化时,取决于变量的类型的类别,可能发生以下行为: 若该变量的类型是类类别(即类、结构体和联合体),那么按参数列表为空进行重载决议,选择一个默认构造函数并执行;若不能找到这样的的构造函数,那么程序是非良构的。 否则,若该变量的类型是数组类别,那么对每个元素都进行默认初始化。 若以上两种情况都不符合,那么不发生初始化。 这种情况的典型例子是未初始化的变量可能具有不确定的值。 在 C++26 中,对这种情况专门进行了规定,称其具有错误的值(Erroneous value)。 可想而知,对引用类型,若不发生任何初始化,那么程序是非良构的(即发生编译错误)。

特别地,对具有继承关系的类,若基类的构造函数没有在子类的构造函数中指明,那么基类发生默认初始化。 具有静态和线程存储期的对象在进行默认初始化时不会变为未初始化,我们之后会讨论这一点。

值初始化

接着介绍一下值初始化。 值初始化和默认初始化比较类似,区别在于是否发生零初始化。 对类类型,发生如同上文的默认初始化。 但是,若对默认构造函数的重载决议选择了编译器生成的版本,那么先进行零初始化,其中所有成员以及填充位均通过零初始化置为零,再发生默认初始化。 对数组,其中每个成员均发生值初始化。 对其他类型,则直接发生零初始化。

对标量类型,零初始化相当于将其从字面量0转型。 因此int a = int();得到的变量的值为零(我们很快就能见到为什么不能写为int a();),即使主机可能不使用补码表示1,从而使得零的二进制表示下可能不是每一位都是零。 而对浮点数,若其遵守 IEEE 754 标准,那么得到的值是正零而非负零;若其不遵守该标准,那么也可能不是每一位都是零。

对于值初始化,有几点需要注意的。 首先,值初始化当且仅当初始化器只有一对括号时才触发。 实际上,若初始化器只具有一对花括号,那么会通过列表初始化选择值初始化,我们在介绍列表初始化时再进行说明。 其次,值初始化非常容易被视为无参数的函数声明,例如以下代码

std::string object();

会被视为无参数函数object的声明。 这是“最恼人解析”(The Most Vexing Parse)的一个特例。

直接初始化

正如其名,直接初始化可视为直接调用构造函数的初始化。

若被初始化的类型是数组类型,那么在 C++20 之前程序是非良构的; 在 C++20 之后,这被视为一种特殊的聚合初始化,我们之后会介绍这种初始化。

若被初始化的类型是类类型,那么则针对括号中的参数进行重载解析,并选择指定的构造函数进行初始化。 若初始化的表达式是纯右值,那么可能发生复制消除,在 C++17 后这种复制消去是强制的。 若这个类还是聚合体,且没有找到合适的构造函数,那么在 C++20 之后,则会发生特殊的聚合初始化。

这种初始化和值初始化一样,也可能被编译器视为函数声明。

复制初始化

形如

T a1 = b;
T a2 = T{...};
T a3 = std::move(b);

等的初始化方式称为复制初始化。 这种初始化传统上需要先构造临时变量,然后进行复制,因此得名复制初始化。 然而,在现代 C++ 中,这种初始化和直接初始化几无区别。

和直接初始化一样,编译器会以右侧的参数进行重载决议并选择对应的构造函数。 相较于这种初始化,复制初始化在进行重载决议时不会考虑被标记为explicit的构造函数和转型函数。 譬如以下代码:

T b;                    // 默认初始化。
T a1 = b;               // 调用 T(const T&) 重载。
T a2 = T{...};          // 先进行列表初始化,再进行复制初始化。
                        // 由于 T{...} 是纯右值,可能发生复制消去。
T a3 = std::move(b);    // 调用 T(T &&) 重载。

复制初始化也发生在函数返回时、抛出异常时和按值捕获异常时。 在 C++17 中,标准进行了一系列重写来消去这些复制。 在这种情况下,被复制初始化的变量可能会进行原址构造,而不会进行复制。

列表初始化

列表初始化是 C++11 引入的新初始化方式,也叫做统一初始化(Uniform initialization)或万能初始化(Universal initialization),因为其能将上面的初始化统一起来。 这种初始化的特点是具有花括号,例如。

// 这也叫做复制列表初始化
T a1 = {...};
// 这也叫做直接列表初始化
T a2{...};

这种初始化比较复杂,按以下顺序进行解析:

  1. 对于聚合类型,有:
    1. 若列表中具有指定初始化器(比如{.x = ..., .y = ...}),那么被初始化类型必须是聚合类型,且发生聚合初始化。这一条是 C++20 加入的。
    2. 若列表中不具有指定初始化器,但是被初始化类型是聚合类型,并且初始化器中只有一个表达式,那么根据初始化器类型选择复制初始化或直接初始化。特别地,对字符串类型,则会进行聚合初始化,如同正常的字符串字面量一样。
    3. 若初始化器中有多个表达式,那么进行聚合初始化。
  2. 若初始化器中为空,且被初始化类型具有默认构造函数,那么进行值初始化。
  3. 若被初始化类型是std::initializer_list,那么该类型会由一个数组支撑,且数组中的每个元素进行复制初始化。
  4. 若被初始化类型是类类型,那么考虑其构造函数,有:
    1. 首先,若构造函数中含有只具有initializer_list参数(或者第一个参数为initializer_list,且其他参数均具有默认值)的重载,那么将初始化器视为单个initializer_list并进行重载决议。
    2. 否则,将初始化器中的每个表达式视为参数并进行重载决议。此时不允许参数进行窄化转换。此外,若该列表初始化是复制列表初始化且选中了标有explicit的重载函数,那么程序是非良构的。
  5. 若被初始化类型不是类类型,那么选择进行复制初始化或者直接初始化,不允许窄化转换。
  6. 若初始化器为空,进行值初始化。

这里省略了一些不太重要或者过于繁琐的内容,比如枚举类型和引用类型的初始化。

这里有几点需要注意。 首先,列表初始化中的重要一点是不允许窄化转换,这是列表初始化比传统初始化更“安全”的原因之一。 其次,由于列表初始化不使用括号,因此完全避免了解析时和函数声明的二义性,规避了“最恼人解析”问题。

由于列表初始化优先使用initializer_list参数重载的构造函数,因此以下代码是不同的:

// 构造具有 100 个 100 的 vector
std::vector <int> a1(100, 100);
// 构造具有两个 100 的 vector
std::vector <int> a2{100, 100};

最后是复制列表初始化和复制初始化的区别。 在复制列表初始化中,若选中了explicit的重载函数,那么程序是非良构的,会发生编译错误; 但是在复制初始化中,explicit的构造函数不会被重载决议考虑,因此考虑以下程序:

struct A {
    explicit A(int) {};
    A(size_t) {};
};

int main() {
    A a1 = 1;
    A a2 = {2};
}

a1能被A(size_t)构造函数构造,但是a2的初始化会发生编译错误。

聚合初始化

聚合初始化(Aggregate initialization)是一种特别的列表初始化,这种初始化只能应用于聚合类型上。 简单理解,聚合类型要么是数组,要么类似 C 语言中的结构体,不允许具有任何用户定义的构造函数,且不能具有虚函数、虚继承、非公有继承和非公有成员等。

聚合体的一大特点是可以将其中的所有成员按统一的顺序排序。 排序之后,则可以按上述顺序通过初始化器自动进行初始化,这种初始化就称为聚合初始化。 确定成员变量顺序的过程比较复杂,此处不再赘述。

初始化时机

最后我们介绍一下初始化发生的时机,这需要和对象的生命周期一起考虑。

对象生命周期

C++ 的对象生命周期开始于:

  1. 该对象所需的大小合适且对其适当的内存已被分配;并且
  2. 该对象的初始化完成,即使是默认初始化。

对联合体的成员变量,即使初始化完成,该对象依然可以位于生命周期之外。

而结束于

  1. 该对象被析构,即调用类类型的析构函数或者其他类型的伪析构函数2或者
  2. 该对象的内存被解分配或者被其他对象重新使用。

如果一个类类型是可平凡析构的(trivially-destructible),那么编译器可能决定不调用析构函数而直接重用内存。

对位于生命周期之外的对象进行任何操作均是未定义行为,除非是在该对象正在被构造或者析构时。 正在被构造和析构的对象仍不处于其生命周期中,因此在构造和析构函数中的操作依然受到限制,尤其是对复杂继承关系的虚函数调用。

这个生命周期是 C++ 抽象机中的概念,而非实际存在的概念。 实践中,编译器除非被特别要求,否则一般不会检查对象的生命周期。 这是因为不满足生命周期的操作是未定义行为,编译器可能进行任何操作而不违反标准。 因此,考虑以下代码:

struct A {
    int a{1};
    A() {std::cout << "Ctor A called.\n";}
    ~A() {std::cout << "Dtor A called.\n";}
    void some_member_function() {
        std::cout << a << "\n";
    }
};

struct B {
    float b{2};
    B() {std::cout << "Ctor B called.\n";}
    ~B() {std::cout << "Dtor B called.\n";}
};

int main() {
    std::byte bytes[1024];
    
    A * a = new (bytes) A;  // a 的生命周期开始。
    B * b = new (bytes) B;  // a 的内存被重新使用,因此其生命周期结束
                            // 尽管没有调用析构函数。
    // 未定义行为
    a->some_member_function();
}

初始化与存储期

显然,对象的生命周期一定被包含于对象的内存的存在时间,即存储期(Storage duration)中。 现代 C++ 的变量具有四种存储期:

  1. 静态(static)存储期:由staticextern标记的变量,或者命名空间域中的变量,且不具有线程存储期。这类变量在程序启动时分配存储,在其结束时解分配。
  2. 线程(thread-local)存储期:由thread_local标记的变量。这类变量在线程启动时分配存储,在其结束时解分配。每个线程具有自己的对象。
  3. 自动(automatic)存储期:在代码块中,且不具有其他存储期的变量;或者函数中的实参。这类变量在代码块开始时分配存储,在其结束解分配。
  4. 动态(dynamic)存储期:
    • 使用new分配的变量,在被delete时解分配;
    • 或者隐式创建的变量,这种变量的存储期依赖于其他变量;
    • 或者被抛出的异常,这种变量的存储期是未指定的。

自动存储期变量的初始化都是在定义时进行的;而使用new创建的动态存储期变量的初始化是在分配存储后进行的。 特别地,对使用new创建的动态存储期变量,若构造函数抛出了异常,那么会查找解分配函数(operator delete)。 若不能找到唯一的解分配函数,那么内存不会被解分配,尽管异常会被传播。

静态和线程存储期的变量值得单独讨论。

局部静态存储期变量

我们先讨论局部的静态和线程存储期的变量。 这类变量的初始化分为两种情况:

  1. 一般情况下,这些变量的初始化发生在控制流第一次到达该语句处。
  2. 如果此前的初始化发生了异常,那么下一次到达该语句处时会重新尝试初始化。
  3. 发生零初始化或者常量初始化,此时初始化可能被提前至控制流第一次到达前、程序或线程启动之后。

静态储存期的对象在发生默认初始化时,永远不会变为未初始化变量,而是会进行零初始化。

常量初始化是针对静态和线程存储期变量的特别初始化方式。 若初始化表达式是编译器能够确定的常量,那么发生常量初始化。 常量初始化的效果和其他初始化没有区别,只是编译器须保证常量初始化在其他静态或线程存储期变量之前完成。 C++20 后为了扩大编译期计算优化的可能性,标准中对常量初始化进行了比较复杂的重写,此处不再赘述。

全局静态存储期变量

非局部的静态存储期变量需按顺序进行两种初始化:静态初始化和动态初始化。

静态初始化(Static initialization)是非常特别的一种初始化,相当于将初始化提前至编译期执行。 静态初始化只允许进行常量初始化和零初始化两种初始化,如果不发生常量初始化,那么就必须进行零初始化。 对常量初始化,一般的实现是在编译器进行计算并提前保存在生成的二进制文件中; 若编译器不能完成编译期计算,那么必须保证其初始化在其他初始化开始之前完成。

动态初始化(Dynamic initialization)则可分为三类:

  1. 无序动态初始化:未被显式特化的模板类的静态或线程成员变量发生无序动态初始化,这些静态变量的初始化是不定序(indeterminately sequenced),线程变量的初始化则是无序(unsequenced)的。
  2. 偏序动态初始化:未被显式或隐式特化的模板类的内联变量进行偏序动态初始化。若某个偏序初始化变量甲在所有翻译单元中均先于其他偏序或有序变量乙定义,那么甲的初始化先序于(be sequenced before)或先发生于(happens-before)乙的初始化。
  3. 有序动态初始化:其他类型的初始化,在同一翻译单元中,先出现的变量的初始化永远先于后出现的变量的初始化。然而,在不同的翻译单元中,静态变量是不定序的、线程变量是无序的。

特别注意最后一点,在不同的翻译单元中,静态变量的初始化是不定序的,因此若静态变量之间存在依赖,若其初始化以不定序的方式发生,那么可能存在被依赖的类后于依赖的类初始化的情况,从而发生错误。 这被称为静态初始化顺序问题(static initialization order fiasco)。 对于标准的 C++ 抽象机尚且如此,对于实际运行的程序,由于共享库和动态链接的问题,静态变量的生命周期更是难以捉摸,因此最好要避免全局变量,或者使用使用时构造(Construct on First Use)或者巧妙计数器(Nifty Counter)等习语,或者使用 C++20 引入的模块。

对动态初始化,有两类特殊的情况。 编译器可能决定将动态初始化提前至编译期完成,如同静态初始化一样,从而优化性能。 这种初始化称为提前动态初始化(Early dynamic initialization)。 编译器也可能决定将动态初始化延后至主函数或线程函数启动之后而对象被访问(ODR-use)之前,这种初始化称为延后动态初始化(Deferred dynamic initialization)。 这两种顺序的变化也可能导致其他问题。

例如,编译器执行提前动态初始化时,只需要检查两个条件:

  1. 对没有进行优化的动态初始化版本,不改变命名空间域中在该初始化之前声明的任何变量的值;
  2. 进行优化的静态初始化版本,以及在所有未被要求进行静态初始化的变量均被动态初始化时进行的动态初始化,两种初始化对该变量产生相同的值。

考虑以下代码

inline double fd() { return 1.0; }
extern double d1;   // 这一句是声明而非定义,因此不影响初始化顺序。

double d2 = d1;
double d1 = fd();

我们首先考虑完全动态初始化的情况。 此时,先发生d2d1的静态零初始化,再发生d2的动态复制初始化,最后发生d1的动态复制初始化,结果为

d2 = 0.0, d1 = 1.0;

然后我们考虑提前动态初始化。 由于两个变量的动态初始化均不改变在此前声明的变量的值,因此均可能进行提前动态初始化。 若d1进行静态初始化,而d2进行动态初始化,那么根据以上推理,d1会被静态初始化至和两个变量均进行动态初始化相同的值,即d1 = 1.0。 这样,当d2进行动态复制初始化时,得到的值为d2 = 1.0。 若d2进行静态初始化,那么无论d1是提前还是正常动态初始化,d2的值都是0.0,因为静态初始化的值必须和完全进行动态初始化一致。 这样,在完全符合标准的情况下,d2的值可能是1.00.0两种之一,取决于编译器如何进行提前动态初始化。

显式生命期管理

最后我们来讨论一下 C++20 引入的显式/隐式生命期问题。

由于上文介绍的关于对象生命期的关系,在 C++ 标准库的各类容器中,如果发生内容的复制或移动操作,除了申请内存之外,还需要通过调用 placement new 来在新申请的内存上构造对象。 这一操作必须执行,尽管这个对象可能是平凡可复制的(Trivally copyable),甚至就是标量类型。 C++ 标准库提供了一系列未初始化内存管理函数来简化这一操作,包括:

  • uninitialized_copyuninitialized_copy_n
  • uninitialized_filluninitialized_fill_n
  • (C++17)uninitialized_moveuninitialized_move_n
  • (C++17)uninitialized_default_constructuninitialized_default_construct_n
  • (C++17)uninitialized_value_constructuninitialized_value_construct_n

这些操作可以直接对未初始化的内存进行操作,免去了手动管理对象生命期的问题。 这些函数实际上一般就是对 placement new 调用的封装。 C++17 还引入了destory系列函数,相当于对析构函数的封装,可以直接摧毁对象而不释放其内存。

对一些类型,调用这些 placement new 会带来性能损失,因为直接复制其对象表示的内存就能达成对象的复制。 因此,C++ 标准中引入了隐式生命期(Implicit-lifetime)对象的概念。 一个类类型是隐式生命期的,若其满足:

  • 是没有自定义的析构函数的聚合体,或者
  • 至少有一个平凡的合格构造函数和一个平凡的、非删除的析构函数。

隐式生命期类对象和平凡可复制对象在相当大程度上是重合的,例外在于,一个没有任何平凡构造函数的类可以是平凡可复制的,但不可能具有隐式生命期。 这种类型其实比较常见,例如标记为final的多态类型。 这种类型不可能是隐式生命期的,因为多态类型不能具有平凡的构造函数,但是可能是平凡可复制的,从而允许编译器将复制优化为memcpy调用3。 (非final的多态类型不可能被优化为memcpy调用,这是因为可能发生对象切片。)

隐式生命期的对象可以被直接内存操作“隐式地”创建,而不必调用 placement new。 这种内存操作包括std::memcpystd::memmovestd::bit_cast。 这意味着如果容器中的对象均是具有隐式生命期且平凡可复制的对象,那么可以直接调用memcpy来进行复制,而不必使用uninitialized_copy了。 实际上,在提出隐式生命期对象之前 C++ 标准库就已经在对平凡可复制的对象使用memcpy进行优化了,尽管这会导致对象生命期问题和未定义行为,参考 Clang 对这一问题的讨论。

C++23 还引入了新的std::start_lifetime_as系列库函数,允许直接在已申请的内存上隐式开始对象生命周期。 这一优化特别适用于网络数据接收等情景下:

struct Data{ int d; };
static_assert(std::is_trivially_copyable_v<Data>);
static_assert(std::is_implicit_lifetime_v<Data>);

// 标准 C++ 写法。
Data* recv_0() {
    void * rcvd_bytes = recv();
    // 注意:不能使用 placement new,否则发生覆盖。
    auto ptr = new Data();
    std::memcpy(ptr, rcvd_bytes, sizeof(Data));
    return ptr;
}

// 按标准为未定义行为,实际上大部分编译器能产生正确的结果。
// C++20 后为良构的。
Data* recv_1() {
    void * rcvd_bytes = recv();
    auto ptr = static_cast<Data *>(std::malloc(sizeof(Data)));
    std::memcpy(ptr, rcvd_bytes, sizeof(Data));
    return ptr;
}

// C++23 写法。
// 节省一次复制。
Data* recv_2() {
    void * rcvd_bytes = recv();
    return std::start_lifetime_as<Data>(rcvd_bytes);
}

指针清洗

作为 C++17 引入的底层内存管理的最后一块拼图,我们最后讲解一下std::launder的使用。 这个函数定义为

template< class T >
constexpr T* launder( T* p ) noexcept;

标准给出的解释是:

  1. 内存某处有一类型为T(忽略constvolatile限定)且仍在生命期内的对象x,该对象的地址为A
  2. 指针p指向地址A
  3. 所有经过返回的指针访问的内存,均可通过p访问;

那么该函数返回一个指向x的指针。 考虑到该函数对于x生命期的限定,可以猜想这个函数和编译器中对对象的生命期优化有关。 实际上,这个函数确实一般和 placement new 一同使用。 我们马上介绍使用它的两个典型情景。


考虑以下代码:

struct X { const int x; };
union Y { X x; float f; };
void fn_1() {
    X x1{1};
    // 良构:发生存储重用。
    auto p = new (&x1) X{2};
    std::cout << p->x ;
}
void fn_2() {
    Y y11;
    // 未定义行为。
    auto p = new (&y1.x) X{2};
    std::cout << p->x ;
}

关于存储重用,可参考此文。 总之,对于fn_2,编译器可能认为const int x不会被修改而进行常量折叠,从而导致错误的优化。 尽管P1971R0禁止了这种优化,但是一类特别的常量仍然容易受到影响,即类中的虚函数表指针:

struct Base
{
    virtual int transmogrify();
};
struct Derived : Base
{
    int transmogrify() override
    {
        new(this) Base;
        return 2;
    }
};
int Base::transmogrify()
{
    new(this) Derived;
    return 1;
}

static_assert(sizeof(Derived) == sizeof(Base));

int main()
{
    Base base;
    int n = base.transmogrify();
    // 未定义行为
    // int l = base.transmogrify();
    int m = std::launder(&base)->transmogrify(); // OK
}

编译器可能认为所有通过base调用的transmogrify()均使用基类中定义的版本,而不从虚函数表中进行查找。 这一优化叫做去虚拟化(Devirtualization),而std::launder可以避免这种优化,因此这个函数也叫去虚拟化围栏(Devirtualization fence)。


第二种情况发生在与reinterpret_cast同时使用时。 考虑以下代码:

 
int main()
{
    struct Y { int z; };
    alignas(Y) std::byte s[sizeof(Y)];
    Y* q = new(&s) Y{2};
    // 未定义行为:std::byte 类型指针不能访问 Y 类型指针值。
    const int f = reinterpret_cast<Y*>(&s)->z;
    const int g = q->z; // OK
    const int h = std::launder(reinterpret_cast<Y*>(&s))->z; // OK
}

关于这个 UB 的细节,可以查找指针互相转换性(pointer-interconvertible)相关的资料。 指针互相转换性的规定允许编译器使用严格类型别名之外的别名分析来进行优化,并且认为s指针不可能访问该地址处的Y变量而不发生未定义行为,因此优化掉所有访问。 std::launder可以避免这种优化。

目前,大部分编译器并不进行这种优化,因此这个std::launder的用处不大。 若提案P3006R1被接受,那么这个std::launder()就不再需要了。

需要注意的是std::launder并不影响严格别名规则。 在上面的要求中我们可以看到p指向的对象x必须具有T类型,因此通过std::launder访问其他类型的指针仍是未定义行为。 总之,std::launder的适用范围为:对象生命周期已正确开始,但指针因_优化假设_而可能指向旧对象时,发生的未定义行为可由std::launder消除。

  1. 这一点已经成为历史,因为 C++20 标准后所有整数必须为补码表示。 

  2. 为减少模板特化数量,类似int这种标量类型在被别名时具有自动生成的析构函数,称为伪析构函数。这些函数是否有实际作用一直具有争议,直到P0593R6被纳入标准,才决定使其具有终止生命周期的语义。 

  3. 标准提案 P3279R0 澄清了这些问题。 

更新时间: