C++新标准之后对初始化方式有了很多的变动,现在的初始化方式主要可以分为五种来讨论,分别是list initialization、aggregate initialization、zero initialization、default initialization、value initialization。本文根据标准以及cppreference上的相关资料论述了这五种初始化方式,并讨论了POD、成员初始化列表、new关键字等方面的问题。
1 | // Value initialization |
博文中总结了C++11标准下的一些初始化的简要规则,但在C++14/17/20标准中,这些规则又出现许多变动。
direct initialization和copy initialization
直接初始化包括
1 | // 列表初始化 |
复制初始化包括
1 | T object = 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
类型的函数。如果将构造函数看成一类特殊的函数的话,看起来也不别扭的。例如在下面的语句中,TimeKeeper
和 Timer
都是用户自定义的类型
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 的 list initialization,如果提供了相应构造函数,还可以花括号列表作为一个 std::initializer_list
对象传给对应函数。
当一个类定义了从 std::initializer_list
的构造函数后,对于使用 {}
语法将调用该构造函数。例如 std::vector<int>(10)
创建10个元素并对每个元素进行 zero initialization,std::vector<int>{10}
创建一个值是10的元素。
下面的几种场景下会导致 list initialization,这里根据上面的直接初始化/复制初始化分为两种。
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)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++模板编程》。
- 从浮点数到整数
从浮点到整数会导致向0舍入。此外,值也有可能溢出成未确定的值。 - 从高精度到低精度
包括从long double
、double
和float
三种类型之间从高到低的转换,除非是不溢出的 constexpr。从高精度到低精度可能导致舍入和溢出为Inf。
【C++23】一些新规则,不多说了。 - 从整数到浮点,除非是能够精确存储的 constexpr
从相同字长的整数到浮点虽然不会溢出,但会被舍入。 - 从整数或无作用域枚举类型到不能表示原类型所有值的整数类型,除非是能够精确存储的 constexpr
例如从-1到(unsigned char)(-1)
。
下面举了一些不能编译的例子
1 | int f() { |
下面是能编译的例子
1 | // 因为1和-2是常数,并且能被精确存到 float 中 |
在 Effective Modern C++ 中举了下面的例子
1 | class Widget { |
使用 Widget w{10, 5.0}
初始化会调用带 std::initializer_list<long double>
的构造函数,因为优先级高。但是考虑将构造函数改为 std::initializer_list<bool>
,那么 Widget w{10, 5.0}
就会因为 narrow conversion 导致无法编译,而不是 fallback 到 第二个构造函数上。
1 | error: narrowing conversion of '10' from 'int' to 'bool' [-Wnarrowing] |
list initialization主要规则
list initialization 的规则如下,从上往下开始匹配
如果初始化器列表有一个元素
- 如果T是聚合类型,初始化器列表包含T的相同/导出类型的单个元素,则使用该元素进行复制/直接初始化
- 如果T是
char []
,初始化器列表为一个literal string,那么使用这个string来初始化 - 对于其他情况,遵循下面的规则
如果初始化器列表是空的
- 如果T是个aggregate聚合类型,那么进行aggregate initialization
- 否则,如果T是class、有默认构造函数,那么进行value initialization
这里注意先后顺序,在C++14前,顺序是相反的。
考虑是否能构造
std::initializer_list
- 如果T是
std::initializer_list
的特化,则从花括号初始化器列表依赖语境直接初始化或复制初始化T。
- 如果T是
考虑T是否存在接受
std::initializer_list
的构造函数- 首先检查所有接受
std::initializer_list
作为唯一参数,或作为第一个参数但剩余参数都具有默认值的构造函数。进行重载决议。 - 如果无匹配,则T的所有构造函数参与针对由花括号初始化器列表的元素所组成的实参集的重载决议,注意narrow conversion是不被允许的。若此阶段产生explicit 构造函数为复制列表初始化的最佳匹配,则编译失败。
- 首先检查所有接受
如果 T 是一个枚举类型
如果 T 不是类类型
如果 T 是一个引用类型
在最后,如果 T 使用了空的
{}
,则使用值初始化
以下规则来自 cppreference 的 notes 部分
- 花括号初始化器列表不是一个表达式,所以没有类型,因此模板类型推导不能直接推导出来。
auto
关键字会将所有的花括号初始化器列表推导为std::initializer_list
。
std::initializer_list
除了在上面的 list initialization 使用,std::initializer_list
还可以被绑定到 auto
,从而有下面的用法
1 | for (int x : {-1, -2, -3}) // auto 的规则令此带范围 for 工作 |
aggregate initialization
aggregate initialization 是 list initialization的特例。聚合初始化指的是可以使用一个花括号初始化器(braced-init-list)来初始化聚合对象,类似于C的初始化风格。
1 | std::pair<int, int> p = {42, 24}; |
在C++20标准草案中出现了指代初始化器,这里暂时不讨论。
aggregate(聚合)类型
聚合类型包括
- 数组
- 特定的类
- 没有
private
或protected
非静态成员 - 没有用户提供的构造函数
注意从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规则
对于每个直接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如果对应的clause是个表达式,可以进行隐式转换(参见list-initialization)
在C++11标准开始,narrowing conversion的窄化隐式转换不被允许如果对应的clause是一个嵌套的braced-init-list(这就不算是表达式了),使用list-initialization进行初始化这个成员或者public基类
注意public基类同样是在C++17标准开始的未知长度的数组的长度等于初始化时的braced-init-list的长度
static成员和无名位域在聚合初始化中被跳过
如果braced-init-list的长度超过了要初始化的成员和基类数,是ill-formed,并产生编译错误
长度不足的情况
1.(C++11前)剩下来的采用value-initialization
2.(C++14开始)对于剩下来的member,如果该member的class提供default-initializer,采用default-initializer,否则采用空列表初始化(和list-initialization一样)- 特别地,如果里面有引用,则是ill-formed
花括号消除
zero initialization
zero initialization 的场景是和 value 以及 default 有重叠的。这里注意,zero initialization 如果发生,则发生在其他初始化前。
具有 static
和 thread_local
存储期的具名变量,包括全局变量、函数中 static 变量等,应当执行 zero initialization。
常量应当遵循常量初始化。
zero initialization 的形式是:
1 | static T object ; (1) |
需要特别说明,C++ 中的全局变量也属于静态存储期的具名变量。下面的代码中,x
是一个未初始化的全局变量,它将被存储在 bss(Block Started by Symbol)段中,根据 CSAPP,bss 段会在运行开始前被初始化为 0。注意,C 和 C++ 在这里的行为不一样:C 会作为一个 COMMON,C++ 会走 zero initialization 放到 bss 上。
1 | int x; |
zero initialization 的场景是:
- For every named variable with static or thread-local(since C++11) storage duration that is not subject to constant initialization, before any other initialization.
- 作为对 non-class 类型的 value-initialization sequence 序列的一部分。
- 或者 for members of value-initialized class types that have no constructors, including value initialization of elements of aggregates for which no initializers are provided.
- When an array of any character type is initialized with a string literal that is too short, the remainder of the array is zero-initialized.
对于 2 和 3 补充一点。标准指定如果 class 有一个 user-provided 或者 defaulted 的 default constructor 的话,那么 zero-initialization 就不会执行,无论这个 constructor 是否实际被重载决议选择。但实际上所有的已知编译器都会在重载决议选择 non-deleted defaulted default constructor 时加上 zero-initialization。
zero initialization 的效果是:
- 当 T 是一个 scalar type 标量类型,用 0 值来初始化。
- 如果 T 是一个非 union 的 class,所有的基类和非静态成员被 zero initialized,所有 padding 被初始化为 zero bits。
- 如果 T 是一个 union,第一个非静态成员用 0 值初始化,所有 padding 被初始化为 0。
- 如果 T 是一个数组,数组中的每个元素被 zero initialize。
- 如果 T 是一个引用,不做任何事情。
关于 static and thread-local 变量的初始化在 default 和 zero initialization 中都提到了。其中没有冲突,因为之前说过,zero initialization 如果发生,则发生在其他初始化前。这里 cppreference 上也补充了 Notes。
If the definition of a non-class non-local variable has no initializer, then default initialization does nothing, leaving the result of the earlier zero-initialization unmodified.
Trivial default constructor
The default constructor for class T is trivial (i.e. performs no action) if all of the following is true:
- The constructor is not user-provided (i.e., is implicitly-defined or defaulted on its first declaration).
- T has no virtual member functions.
- T has no virtual base classes.
- T has no non-static members with default initializers. (since C++11)
- Every direct base of T has a trivial default constructor.
- Every non-static member of class type (or array thereof) has a trivial default constructor.
value initialization
下面的几种场景下会导致value initialization
1 | // 使用小括号 initializer 创建一个匿名临时对象 |
value initialization 的效果是:
- 在下列情况下执行 default initialization
当一个 class 没有 default constructor,或 delete 了默认构造函数,或者具有 user-provided 构造函数。 - 如果一个 class 有一个 default constructor,并且它既不是 user-provided 的,也没有被 delete
则首先 zero-initialized。然后检查 default-initialization 语义,如果 T 有一个 non-trivial default constructor,则会执行 default-initialized。 - 如果有 non-trivial 的默认构造函数,调用该默认构造函数进行 default initialization
这段可以和 zero initialization 参照,如果默认构造函数是 trivial 的,则意味着编译器并不需要做任何事,这等价于只 zero initialization。 - 数组中的每一个元素被 value initialization
- 否则对对象进行 zero initialization
- 引用不能被 value initialization
注1:这里的 user-provided 指的是用户定义的且没有显式 =default
的构造函数
注2:由于 T object();
声明了一个函数,所以在 C++11 允许使用 T object{}
前,应当使用 T object = T()
。这种方式会导致复制,但常常被编译器优化掉。
default initialization
default initialization 发生在当一个变量未使用 initializer(the initial value of a variable) 构造。
特别地,从 C++11 标准以后空的圆括号不算做未使用 initializer,也就是 T x
和 T x()
是不一样的。
default initialization 的形式是:
1 | // 声明 auto、static、thread_local 变量时(还有两种是register和extern)不带任何initializer(比如小括号initializer) |
default initialization 的场景是:
- when a variable with automatic, static, or thread-local storage duration is declared with no initializer
- when an object with dynamic storage duration is created by a new-expression with no initializer
- when a base class or a non-static data member is not mentioned in a constructor initializer list and that constructor is called
default initialization 效果是:
- 对于类类型,构造函数根据重载决议在默认构造函数中选择一个为新对象提供初始值。
这里没有提默认构造函数是合成的,还是用户提供的。
注意,在 C++11 前的标准中,POD 的初始化是特殊的。例如new A
和new A()
是不一样的,new A
并不会对成员进行初始化。 - 对于数组类型,数组中的每一个元素被 default initialization。
- 对于其他情况,包括基础类型,编译器不做任何事情。例如此时的自动变量被初始化到不确定的值。使用这个值会导致 UB,除非一些情况。
例如标准不要求对 auto 变量定义附带进行 zero initialization,我们需要显式 memset
一下。
1 | // 下面的都不会执行 zero initialization |
而下面的代码则会蕴含执行一次 zero initialization 的语义
1 | void func(){ |
但是根据 zero initialization 的定义,具有 static
和 thread_local
存储期的具名变量,包括全局变量、函数中 static
变量,应当执行 zero initialization,常量型应当执行 constant initialization。所以这里的 default initialization 实际上什么都没做。详见 zero initialization 章节。
constant initialization
下面的几种场景下会导致constant initialization
constant initialization会替代zero initialization(C++14标准前不是替代而是接着)初始化static和thread-local对象。发生在所有初始化前
1 | static T & ref = constexpr; (1) |
初始化方式与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++ 带来人气的同时却仍然是一个巨大的包袱,有很多库都对对象的 POD 性质有有要求。当然 C++ 一贯喜欢在标准中加一堆东西,去兼容很古早的特性。但自己标准里面又是一堆 until C++xx,甚至打洞的行为。比如 guaranteed copy elision 对 as-if rule 都是一个破坏。
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 | class X { |
但是注意,下面这种写法是错误的
1 | class X { |
这实际上不是调用了构造函数,而是创建了一个X
的右值对象。
new
new函数与new关键字
我们对new
的使用经常以new
关键词,即new X
和new X()
的形式出现。但是在标准库中还存在着new
函数,具体如下
1 | void *operator new(size_t); |
这里的**::operator new
等是一系列标准库函数而不是运算符**,它们和 std::malloc
等一样,用来分配未初始化的内存空间,之后可以在这段内存空间上使用 placement new 构造对象。所以 new 关键字相当于 operator new 加上 placement new。
而常用的 new
关键字实际上也都是在底层先调用 operator new
申请内存,然后在调用构造函数创建对象的。
特别地,这种先分配内存再构造对象的思想也被用到了 STL 的 allocator 模块中,例如某些 allocator 实现会定义 allocate()
和 deallocate()
用来管理内存空间,而构造、析构则使用 ::construct()
和 ::destroy()
。
在Effective C++ 要求运算符new
和delete
需要成对使用,原因是new []
时会额外保存对象的维度,例如它会在申请的内存的头部之前维护一个size_t
表示后面有n
个对象(Linux以及Redis的SDS的典型构造),这样可以知道在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,我们只要规定好调用者还是被调用者释放即可,然后malloc
和free
伺候。我们知道相比于C,C++的ABI是混乱的,因此我们一旦实现了自定义的new,那么一个自定义的delete是必不可少的了。
但当我们的代码向外面提供接口时,事情变得复杂起来。一方面new是C++的关键词,而new函数也是new机制的关键部分,重载operator new
是传染性的。
一个解决的方案是在类中重载operator new
函数,即static void *Cls::operator new(size_t size);
,注意这里的声明是静态的,因为此时对象还没有创建,没有context。
C++对象构造顺序
- Base 优先于 Derived
这个很容易理解,类似于云计算架构,先有个基座,然后向上有存储,然后向上有数据库啥的。 - 类成员的构造优先于类自己的构造函数
这也是很好理解的,一个个细胞构建好,才能构建人体嘛。不然你怎么分配空间? - 先构造的后析构
这个也是可以理解的,后构造的可能对先构造的有依赖关系。 - 初始化列表中的初始化顺序,是按照在类声明中定义的顺序
所以是先初始化基类,在根据类声明中的顺序初始化,再执行构造函数体