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