C++
的初始化方式之繁多,估计是所有编程语言之最。 我们先通过一个列表来感受一下:
- 默认初始化
- 值初始化
- 直接初始化
- 拷贝初始化
- 零初始化
- 聚合初始化
- 引用初始化
- 常量初始化
- 数组初始化
- 列表初始化
以至于仅仅是初始化,都被专门开发成了一门课程。
但这些看似繁杂的初始化方式,背后有没有一些简单的线索可循?
我们先来看看最为简单的 直接初始化 。
我们首先定义一个类 Foo
:
下面列表中所包含的构造表达式均为 直接初始化 :
简单说,当初始化参数非空时(至少有一个参数),如果你
- 使用 圆括号 初始化(构造)一个对象,或者
- 用 圆括号 或 花括号 来初始化一个 non-class 类型的数据时(基本类型,指针,枚举等,因而只可能是单参)时,
这就是直接初始化。
这种初始化方式,对于 non-class 类型被称作 直接初始化 很容易理解。而对于 class 类型, 直接初始化 的含义也很明确,就是直接匹配对应的构造函数。 伴随着匹配的过程:
- 参数允许窄向转换 ( narrowing );
- 允许隐式转换;
比如:
除此之外,还有几种表达式也属于 直接初始化 :
- static_cast<T>(value) ;
- 使用 圆括号 的类成员初始化列表;
- lambda 的捕获初始化列表
不难看出,除了 lambda 的场景,以及用 花括号 初始化 non-class 类型之外, 直接初始化 正是石器时代 ( C++ 11 之前) 的经典初始化方式。
到了摩登时代 ( 自 C++ 11 起), 引入了被称作 universal 的统一初始化方式:列表初始化 。 之所以被称作 universal ,是因为之前花括号只被用来初始化聚合和数组,现在可以用来初始化一切: 基本类型,枚举,指针,引用,类。
由于列表为空有非常特殊而明确的定义,我们在这里仅仅考虑列表非空的场景。
我们先看看如下表达式:
以及如下表达式:
这两组表达式都被称为 列表初始化 。唯一的差别是,后者使用了等号,看起来像赋值一样。前者被称为 列表直接初始化 ,后者则叫做 列表拷贝初始化 。
虽然后者名字里有 拷贝 二字,并不代表其背后真的会进行拷贝操作。仅仅是因为历史的原因,以及为了给出两个名字以区分两种方式。
但事实上,对于 class 的场景,两者都是直接匹配并调用类的构造函数,并无根本差异。
其中一点细微的差别是:如果匹配到的构造函数,或者类型转换的 operator T
被声明为 explicit
,一旦你使用等号,则必须明确的进行指明:
对于类来说,而列表初始化(使用 花括号 ),相对于直接初始化(使用 圆括号 ),其差异主要体现在两个方面:
- 如果类存在一个单一参数是
std::initializer_list<T>
,或者第一个参数是std::initializer_list<T>
,但后续参数都有默认值, 使用 花括号 构造,总是会优先匹配初始化列表版本的构造函数。- 花括号 不允许窄向转换。
值初始化 ,简单来说,就是用户不给出任何参数,直接用 圆括号 或者 花括号 进行的初始化:
注意,这里面没有 Bar bar()
的初始化形式。由于这样的形式与函数声明无法区分,因而被明确定义为这是一个名为 bar
,返回值类型为 Bar
的函数声明。
而在石器时代,为了能够进行 值初始化 ,只能使用 Bar bar = Bar();
的形式。而这种形式在当时的语意为:等号右侧实例化了一个临时变量,通过拷贝构造构造了等号左侧的 bar
,但当时编译器基本上都会将这个不必要的拷贝给优化掉。到了 C++ 17
,这类表达式的拷贝语意被终结。更详细的细节请参照 value-object
。
值初始化 的最大好处是,无论你是一个对象,还是一个基本类型或指针,你总是可以得到初始化(这也是为何被称作值初始化):
- 如果一个类有 自定义默认构造函数 ,则其直接被调用;
- 如果一个类没有 自定义默认构造 ,但有一个系统自动生成的默认构造函数(或用户明确声明为
default
的默认构造函数),则系统会先将其对象内存完全清零(包括 padding ) ,随后,如果这个类的任何非静态成员有 非平凡默认构造 的话,在调用这些默认构造;- 对于基本类型和指针,直接清零。
相对于程序员会直接给出 ()
或者 {}
的 值初始化 ,虽然都是无参数初始化, 默认初始化 什么括号也不给:
如果一个类有非平凡的默认构造函数,则会直接调用。否则什么都不做,让那么没有非平凡构造的成员的内存状态留在它们被分配时内存(无论是在堆中还是栈中)的状态。 比如:
或许有人会倡导不要使用 默认初始化 ,而是统统使用 值初始化 。这在很多情况下都是正确的,但却并非全无代价。对于可平凡构造的对象而言, 值初始化会导致整个对象清零,如果对象较大,而随后的过程,你肯定会对对象的内容一一赋值(做真正的初始化),那么清零的过程其实是一种不必要的浪费。这对于关注性能的项目,可能是一个 concern 。
拷贝初始化非常简单:
请注意,拷贝初始化并不意味着必然发生拷贝,随着历史的车轮滚滚向前,曾经以为属于拷贝语义的表达式,如今早已面目全非。
零初始化,并非 C++ 的某种语法形式,而是伴随着其它语法形式的行为定义。比如:
这样的数据定义,最终必然会被放入 bss 数据段,从而在程序加载时,被 loader 全部清零。
再比如:
这事实上是 值初始化 的范畴,只不过其结果是清零。
Important
- 无参数初始化有两种形式: 值初始化 (带有
()
或{}
)和 默认初始化 (无()
或{}
)。前者会保证进行初始化(调用默认构造,或清零,或混合);后者只会调用默认构造(如果是平凡的,则什么都不做)。 - 有参数初始化,可以通过
()
或者{}
的方式进行,两者的差异在于后者更优先匹配初始化列表,以及窄向转换的约束。 - 在不使用
()
或者{}
的场景下,使用=
进行的初始化,属于 拷贝初始化 。如果被初始化对象是一个 class 类型, copy构造 或 move构造 会被调用;在使用()
或{}
的场景下,在 C++ 17 之后,除了explicit
的约束之外,和 直接初始化 没有任何语义上的差异。