C++初始化方式

C++新标准之后对初始化方式有了很多的变动,现在的初始化方式主要可以分为五种来讨论,分别是list initializationaggregate initializationzero initializationdefault initializationvalue initialization。本文根据标准以及cppreference上的相关资料论述了这五种初始化方式,并讨论了POD、成员初始化列表、new关键字等方面的问题。

下面是个总的例子

1
2
3
4
5
6
7
8
9
10
11
12
// Value initialization
std::string s{};
// Direct initialization
std::string s("hello");
// Copy initialization
std::string s = "hello";
// List initialization
std::string s{'a', 'b', 'c'};
// Aggregate initialization
char a[3] = {'a', 'b'};
// Reference initialization
char& c = a[0];

博文中总结了C++11标准下的一些初始化的简要规则,但在C++14/17/20标准中,这些规则又出现许多变动。

direct initialization和copy initialization

直接初始化包括

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 列表初始化
T object ( arg );
// This code can't be shown, or it will cause error
T object { arg };
// This code can't be shown, or it will cause error
// 初始化纯右值
T ( other );
// This code can't be shown, or it will cause error
// 使用static_cast初始化纯右值临时变量
static_cast< T >( other )
// 使用非空的new
// This code can't be shown, or it will cause error
// 使用initializer list
// This code can't be shown, or it will cause error
// 在lambda表达式中复制捕捉
// This code can't be shown, or it will cause error

复制初始化包括

1
2
3
4
5
6
7
8
T object = other;
// C++11后归入列表初始化
T object = {other} ;
f(other)
return other;
throw object;
catch (T object)
T array[N] = {other};

复制初始化调用复制构造函数,注意到复制初始化不是赋值,例如std::string s = "hello";是(先cast再)复制初始化,std::string s; s = "hello";后一句是赋值。虽然通过copy elision技术编译器可以省略复制初始化时创建临时对象的开销,但是复制初始化和直接初始化具有显著的不同。例如当复制构造函数或移动构造函数都delete时,无法进行复制初始化,典型的例子是atomic类型(不过VS2015可以编译)

1
std::atmoic<int> = 10;

list initialization

花括号初始化器

在C++11标准中,花括号初始化器的功能被增强了。
注意到在C++11前,初始化和函数声明需要区分,例如std::vector<int> X()既可以被看做一个变量定义,也可以被看做函数声明,这被称为Most vexing parse
Most vexing parse会造成二义性的问题,一个int f(x),既可以被看做使用变量type_or_var来构造的对象f,又可以看做一个接受type_or_var类型的函数,这从某些方面也是不别扭的,如果将构造函数看成一类特殊的函数的话。例如在下面的语句中,TimeKeeperTimer都是用户自定义的类型,

1
TimeKeeper time_keeper(Timer());

那么time_keeper既可以按照我们希望的那样被看做一个变量定义,也可能被看做一个函数声明,这个函数返回一个TimerKeeper,接受一个参数,一个匿名的返回Timer的函数指针。对于这种二义性,C++标准指出按照第二种方式来解释。
为了解决这个问题,在C++11标准前可以通过加上一组括号来强制按照第一种方式解释TimeKeeper time_keeper( (Timer()) ),但是对于具有0个参数的constructor,我们不能写成A (())的形式,StackOverflow详细论述了这一点。我们要不写成复制初始化的形式A a = A(),要不就需要写成一种很丑的办法。例如对于内置变量写成A a((0)),对于非POD写成A a利用其默认初始化特性。在我的一篇提问中详细地讨论了这个问题
在C++11后,可以通过称为uniform initialization syntax的方法,使用花括号初始化的形式std::vector<int> X{},通过list initialization的决议,最终完成value initialization。

列表初始化

list initialization是使用braced-init-list(花括号初始化器)的初始化语句。
尽管aggregate initialization被视为一种初始化聚合体的list initialization,但aggregate initialization有着自己的特点,例如aggregate initialization直接不允许有用户定义的构造函数。但是对于非aggregate的list initialization,如果提供了相应构造函数,还可以花括号列表作为一个std::initializer_list对象传给对应函数。
当一个类定义了从std::initializer_list的构造函数后,对于使用{}语法将调用该构造函数,例如std::vector<int>(10)创建10个元素并对每个元素进行zero initialization,std::vector<int>{10}创建一个值是10的元素。
下面的几种场景下会导致list initialization,这里根据上面的直接初始化/复制初始化分为两种。

  1. direct-list-initialization

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 使用花括号初始化器(空或嵌套的)初始化具名对象
    T object { arg1, arg2, ... }; (1)
    // 初始化匿名对象
    T { arg1, arg2, ... }; (2)
    // 在动态存储上初始化对象
    new T { arg1, arg2, ... } (3)
    // 不使用等号`=`初始化对象的非静态成员
    Class { T member { arg1, arg2, ... }; }; (4)
    // 在成员初始化列表中使用花括号初始化器
    Class::Class() : member{arg1, arg2, ...} {... (5)
  2. copy-list-initialization

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 比1多个等号
    T object = {arg1, arg2, ...}; (6)
    // 函数参数
    function( { arg1, arg2, ... } ) ; (7)
    // 作为函数返回值
    return { arg1, arg2, ... } ; (8)
    // 作为`operator []`的参数
    object[ { arg1, arg2, ... } ] ; (9)
    // 赋值
    object = { arg1, arg2, ... } ; (10)
    // 强转
    U( { arg1, arg2, ... } ) (11)
    // 相对于4使用等号
    Class { T member = { arg1, arg2, ... }; }; (12)

narrowing conversion

C++11开始,list initialization不再允许narrowing conversion。narrowing conversion是下面的隐式转换。关于隐式转换的详细规则,可以参见我的文章《C++模板编程》

  1. 从浮点数到整数
    从浮点到整数会导致向0舍入。此外,值也有可能溢出成未确定的值。
  2. 从高精度到低精度
    包括从long doubledoublefloat三种类型之间从高到低的转换,除非是不溢出的constexpr。从高精度到低精度可能导致舍入和溢出(为Inf)。
  3. 从整数到浮点,除非是能够精确存储的constexpr
    从相同字长的整数到浮点虽然不会溢出,但会被舍入
  4. 从整数或无作用域枚举类型到不能表示原类型所有值的整数类型,除非是能够精确存储的constexpr
    例如从-1到(unsigned char)(-1)

下面是一个简单的例子,下面的代码都是不能编译的

1
2
3
vector<int> a{1.0, 2};
vector<int> b{1.0, 2.0f, 3};
vector<float> c{float(1), 2.0f};

在Effective Modern C++中举了下面的例子

1
2
3
4
5
6
7
8
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<long double> il);
operator float() const; // convert
// to float
};

这段代码在使用braced initialization时,使用Widget w{10, 5.0}初始化会调用带std::initializer_list<long double>的构造函数,因为优先级高。但是考虑将构造函数改为std::initializer_list<bool>,那么Widget w{10, 5.0}就会导致narrow conversion导致无法编译。

list initialization主要规则

list initialization会按照从上到下的顺序执行。

  1. 如果初始化器列表有一个元素

    • 如果T是聚合类型,初始化器列表包含T的相同/导出类型的单个元素,则使用该元素进行复制/直接初始化
    • 如果T是char [],初始化器列表为一个literal string,那么使用这个string来初始化
    • 对于其他情况,遵循下面的规则
  2. 如果初始化器列表是空的

    • 如果T是个aggregate聚合类型,那么进行aggregate initialization
    • 否则,如果T是class、有默认构造函数,那么进行value initialization

      这里注意先后顺序,在C++14前,顺序是相反的。

  3. 考虑是否能构造std::initializer_list

    • 如果T是std::initializer_list的特化,则从花括号初始化器列表依赖语境直接初始化或复制初始化T。
  4. 考虑T是否存在接受std::initializer_list的构造函数

    • 首先检查所有接受std::initializer_list作为唯一参数,或作为第一个参数但剩余参数都具有默认值的构造函数。进行重载决议。
    • 如果无匹配,则T的所有构造函数参与针对由花括号初始化器列表的元素所组成的实参集的重载决议,注意narrow conversion是不被允许的。若此阶段产生explicit 构造函数为复制列表初始化的最佳匹配,则编译失败。
  5. 如果T是一个枚举类型

  6. 如果T不是类类型

  7. 如果T是一个应用类型

  8. 在最后,如果T使用了空的{},则使用值初始化

以下规则来自cppreference的notes部分

  1. 花括号初始化器列表不是一个表达式,所以没有类型,因此模板类型推导不能直接推导出来。auto关键字会将所有的花括号初始化器列表推导为std::initializer_list

std::initializer_list

除了在上面的list initialization使用,std::initializer_list还可以被绑定到auto,从而有下面的用法

1
2
3
4
5
for (int x : {-1, -2, -3}) // auto 的规则令此带范围 for 工作
std::cout << x << ' ';
std::cout << '\n';
auto al = {10, 11, 12}; // auto 的特殊规则
std::cout << "The list bound to auto has size() = " << al.size() << '\n';

aggregate initialization

aggregate initialization是list initialization的特例。聚合初始化指的是可以使用一个花括号初始化器(braced-init-list)来初始化聚合对象,类似于C的初始化风格。

1
std::pair<int, int> p = {42, 24};

在C++20标准草案中出现了指代初始化器,这里暂时不讨论。

aggregate(聚合)类型

聚合类型包括

  1. 数组
  2. 特定的类
    • 没有privateprotected非静态成员
    • 没有用户提供的构造函数
      注意从C++11标准开始,用户可以提供=delete或者=default型的构造函数。在C++17中又限定了聚合类型也不能有inherited或者explicit的构造函数。
      在C++11前转换构造函数必须是单参数非explicit的,但C++11后只要是非explicit的都是转换构造函数。
    • 没有virtual、private或protected的基类
      注意这个性质是从C++17开始的,在前面的标准中,聚合初始化必须没有基类。我觉得这个规定应该早一点出来,毕竟从C++11开始
    • 没有虚成员函数

可以看出POD一定是聚合类型。
注意聚合类或者数组可以包含非聚合的public基类和成员。

aggregate initialization规则

  1. 对于每个直接public基类、数组或者非静态成员(静态成员和未命名的位域被跳过),按照声明顺序或者下标顺序,使用initializer list中对应的initializer clause进行copy-initialization
    注意直接public基类的初始化支持在C++17标准起开始支持,参照下面的这个形式

    1
    2
    3
    4
    // aggregate in C++17
    struct derived : base1, base2 { int d; };
    derived d1{ {1, 2}, { }, 4}; // d1.b1 = 1, d1.b2 = 2, d1.b3 = 42, d1.d = 4
    derived d2{ { }, { }, 4}; // d2.b1 = 0, d2.b2 = 42, d2.b3 = 42, d2.d = 4
  2. 如果对应的clause是个表达式,可以进行隐式转换(参见list-initialization)
    在C++11标准开始,narrowing conversion的窄化隐式转换不被允许

  3. 如果对应的clause是一个嵌套的braced-init-list(这就不算是表达式了),使用list-initialization进行初始化这个成员或者public基类
    注意public基类同样是在C++17标准开始的
  4. 未知长度的数组的长度等于初始化时的braced-init-list的长度
  5. static成员和无名位于在聚合初始化中被跳过
  6. 如果braced-init-list的长度超过了要初始化的成员和基类数,是ill-formed,并产生编译错误
  7. 长度不足的情况
    1.(C++11前)剩下来的采用value-initialization
    2.(C++14)对于剩下来的member,如果该member的class提供default-initializer,采用default-initializer,否则采用空列表初始化(和list-initialization一样)
    1. 特别地,如果里面有引用,则是ill-formed

花括号消除

value initialization

下面的几种场景下会导致value initialization

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 使用小括号initializer创建一个匿名临时对象
T(); (1)
// 使用小括号initializer在动态存储上创建一个匿名临时对象
new T (); (2)
// 使用成员初始化列表(member initializer)初始化对象的非静态成员
Class::Class(...) : member() { ... } (3)
// 使用小括号initializer创建一个具名对象
T object {}; (4) (since C++11)
// 同1
T{}; (5) (since C++11)
// 同2
new T {}; (6) (since C++11)
// 同3
Class::Class(...) : member{} { ... } (7) (since C++11)

value initialization的效果是:

  1. 当一个class没有默认构造函数(或delete了默认构造函数)、具有 user-provided构造函数,default initialization这个对象
  2. 当一个class具有为delete的默认构造函数,并且没有 user-provided构造函数,首先zero initialization。如果有non-trivial的默认构造函数,再调用该默认构造函数进行default initialization。这段可以和zero initialization参照,如果默认构造函数是trivial的,则意味着编译器并不需要做任何事,这等价于只zero initialization。
  3. 数组中的每一个元素被值初始化
  4. 否则对对象进行zero initialization
  5. 引用不能被值初始化

注1:这里的 user-provided指的是用户定义的且没有显式=default的构造函数
注2:由于T object();声明了一个函数,所以在C++11允许使用T object{}前,应当使用T object = T()。这种方式会导致复制,但常常被编译器优化掉

zero initialization

zero initialization(如果发生)发生在其他初始化前,下面的几种场景下会导致zero initialization

1
2
3
4
5
6
7
8
9
10
11
// 具有static和thread\_local存储期的具名变量,包括全局变量、函数中static变量等,应当执行zero initialization
// 注意常量变量应当遵循常量初始化
static T object ; (1)
// 作为下两种value initialization**一部分**,注意不包括default initialization
// 1. 非class类型(例如primitive type)
// 2. 无构造函数的对象的成员
T () ; (2) (since C++11)
T t = {} ; (2) (since C++11)
T {} ; (2) (since C++11)
// 字符数组初始化时未初始化部分被zero initialization
char array [ n ] = ""; (3)

需要特别说明,C++中的全局变量也属于静态存储期的具名变量,下面的代码中,x是一个未初始化的全局变量,它将被存储在bss(Block Started by Symbol)段中,根据CSAPP,bss段会在运行开始前被初始化为0。

1
int x;

zero initialization效果是:

  1. 当T是一个scalar type标量类型,用0值来初始化
  2. 如果T是一个非联合(union)的class,所有的基类和非静态成员被zero initialized,所有padding被初始化为zero bits,如果有构造函数,构造函数会被忽略
  3. 如果T是一个union,第一个非静态成员用0值初始化,所有padding被初始化为0
  4. 如果T是一个数组,数组中的每个元素被zero initialize
  5. 如果T是一个引用,不做任何事情

default initialization

default initialization发生在当一个变量未使用initializer(the initial value of a variable)构造,特别地,从C++11标准以后空的圆括号不算做未使用initializer。详情可以参照下面的初始化语句
下面的几种场景下会导致default initialization

1
2
3
4
5
6
// 声明auto、static、thread_local变量时(还有两种是register和extern)不带任何initializer(比如小括号initializer)
T object ; (1)
// 在动态存储上创建对象且不带任何initializer时,特别地,C++11标准后使用空圆括号(类似`std::vector<int> a()`)不算做默认初始化
new T ; (2)
new T () ; (2) (**until c++03**)
// 当基类和非静态成员不在某个构造函数的construct initializer list(类似`:a(_a), b(_b), c(_c)`)中时

default initialization效果是:

  1. 对于类类型,构造函数根据重载决议在默认构造函数中选择一个为新对象提供初始值。
    注意,在C++11前的标准中中,POD的初始化是特殊的,例如new Anew A()是不一样的,new A并不会对成员进行初始化。
  2. 对于数组类型,数组中的每一个元素被默认初始化。
  3. 对于其他情况(包括基础类型),编译器不做任何事情。例如此时的自动变量被初始化到不确定的值。使用这个值会导致UB,除非一些情况。

例如标准不要求在堆和栈中的变量定义附带进行zero initialization,我们需要显式memset一下。

1
2
3
4
5
void func(){
int x;
int x[100];
int * px = new int[100];
}

而下面的代码则会蕴含执行一次zero initialization的语义

1
2
3
4
5
void func(){
int x {};
int x[100]();
int * px = new int[100]();
}

但是根据zero initialization的定义,具有static和thread_local存储期的具名变量,包括全局变量、函数中static变量,应当执行zero initialization,常量型应当执行constant initialization,详见zero initialization章节。

constant initialization

下面的几种场景下会导致constant initialization
constant initialization会替代zero initialization(C++14标准前不是替代而是接着)初始化static和thread-local对象。发生在所有初始化前

1
2
static T & ref = constexpr;      (1)    
static T object = constexpr; (2)

初始化方式与POD

Plain Old Data是在C++03标准的一个概念,表示能和C兼容的对象内存布局,并且能够静态初始化。POD常包括标量类型和POD类类型。
C++11通过划分了trivial和standard layout,精确化了POD的概念,这有点类似于C++将C风格的类型转换分为了reinterpret_cast等四个转换函数。在精化之后,POD类型即是trivial的,也是standard layout的。
虽然C++11修订了POD相关内容,但是POD这一“兼容C”的特性在为C++带来人气的同时却仍然是一个巨大的包袱(虽然其他的例如C linkage也没好到那里去),有很多库都对对象的POD性质有有要求。

trivial

trivial类型首先是trivially copyable的,也就是说它能通过memcopy进行拷贝。通过简单的思考可以得知为了能够达到这样的效果,它必须具有trivial的复制、移动构造函数和操作符和析构函数。以复制构造函数为例,一个trivial的复制构造函数必须不是用户提供的,并且它所属的类没有任何虚函数(包括虚析构函数)和虚基类,并且每个数据成员都是trivial的。此外trivial的默认构造函数内部不能初始化非静态数据成员。
可以发现trivial主要是对为了兼容C式的初始化、拷贝和析构行为来规定的。

standard layout

成员初始化列表

根据Inside the C++ object model:
在构造函数体中的“初始化”实际上是对默认初始化后的该成员的进行赋值,因此浪费了一次初始化的开销
对于reference、const、有一组参数的base class构造函数、有一组参数的member class构造函数这四种情况,必须使用初始化列表进行初始化。
一般地,初始化列表中的初始化顺序是按照成员在类中的声明顺序而不是在列表中的顺序,构造函数体内代码会在初始化列表“执行”完毕后开始工作。但在初始化列表中使用成员函数仍然是不好的。

委托构造函数

在成员初始化列表中还可以出现委托构造函数(在新标准中),如

1
2
3
4
5
6
7
8
class X {
int a;
X(int x) { }
// 1
X() : X{42} { }
// 2
X() : X(42) { }
};

但是注意,下面这种写法是错误的

1
2
3
4
5
6
7
class X {
int a;
X(int x) { }
X() {
X(42);
}
};

这实际上不是调用了构造函数,而是创建了一个X的右值对象。

new

new函数与new关键词

我们对new的使用经常以new关键词,即new Xnew X()的形式出现。但是在标准库中还存在着new函数,具体如下

1
2
3
4
5
6
7
void *operator new(size_t);
void *operator delete(void *);

void *operator new[](size_t);
void *operator delete[](void *);

// 此外还有两种noexcept的版本

这里的::operator new等是一系列标准库函数而不是运算符,它们和std::malloc等一样,用来分配未初始化的内存空间,之后可以在这段内存空间上使用placement new构造对象。而我们常用的new关键字实际上也都是在底层先调用operator new申请内存,然后在调用构造函数创建对象的。特别地,这种先分配内存再构造对象的思想也被用到了STL的allocator模块中,例如某些allocator实现会定义allocate()deallocate()用来管理内存空间,而构造、析构则使用::construct()::destroy()
在Effective C++ 要求运算符newdelete需要成对使用,原因是new []时会额外保存对象的维度,例如它会在申请的内存的头部之前维护一个size_t表示后面有n个对象,这样可以知道在delete []时需要调用几次析构函数,显然我们的operator new系列也是要成对使用的。当然硬要说例外也有,如果说我们初始化了一个trivial类型的数组如new int[20],我们实际上是可以直接通过delete而不是delete []进行删除的,原因是delete最终还是会调用::operator delete释放分配的内存块,而trivial类型没有自定义的析构函数。同样的,我们可以通过观测STL的allocator模块来了解这个过程,以PJ Plauger的实现为例_Destroy_range(_FwdIt _First, _FwdIt _Last)函数会先判断is_trivially_destructible<_Iter_value_t<_FwdIt>>()是否成立,在成立的情况下就不会逐个元素地调用_Destroy进行删除了。

重载new函数

在Effective C++中对这种用法进行了详尽的讨论。重载new函数能够实现“HOOK”内存分配的功能,这是具有诱惑性的。例如我们可以写出这样的new。当然一般这样写的人更有可能执着于美丽,去定义一个new的宏。

1
new (__FILE__, __LINE__, __FUNCTION__)Cls;

对于C,我们只要规定好调用者还是被调用者释放即可,然后mallocfree伺候。我们知道相比于C,C++的ABI是混乱的,因此我们一旦实现了自定义的new,那么一个自定义的delete是必不可少的了。
但当我们的代码向外面提供接口时,事情变得复杂起来。一方面new是C++的关键词,而new函数也是new机制的关键部分,重载operator new是传染性的。
一个解决的方案是在类中重载operator new函数,即static void *Cls::operator new(size_t size);,注意这里的声明是静态的,因为此时对象还没有创建,没有context。