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};

简单说,当初始化参数非空时(至少有一个参数),如果你

  1. 使用 圆括号 初始化(构造)一个对象,或者

  2. 圆括号花括号 来初始化一个 non-class 类型的数据时(基本类型,指针,枚举等,因而只可能是单参)时,

这就是直接初始化。

这种初始化方式,对于 non-class 类型被称作 直接初始化 很容易理解。而对于 class 类型, 直接初始化 的含义也很明确,就是直接匹配对应的构造函数。 伴随着匹配的过程:

  1. 参数允许窄向转换 ( narrowing );

  2. 允许隐式转换;

比如:

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

除此之外,还有几种表达式也属于 直接初始化

  1. static_cast<T>(value) ;

  2. 使用 圆括号 的类成员初始化列表;

  3. 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

对于类来说,而列表初始化(使用 花括号 ),相对于直接初始化(使用 圆括号 ),其差异主要体现在两个方面:

  1. 如果类存在一个单一参数是 std::initializer_list<T> ,或者第一个参数是 std::initializer_list<T> ,但后续参数都有默认值, 使用 花括号 构造,总是会优先匹配初始化列表版本的构造函数。

  2. 花括号 不允许窄向转换。

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 ,这类表达式的拷贝语意被终结。更详细的细节请参照 值与对象

值初始化 的最大好处是,无论你是一个对象,还是一个基本类型或指针,你总是可以得到初始化(这也是为何被称作值初始化):

  1. 如果一个类有 自定义默认构造函数 ,则其直接被调用;

  2. 如果一个类没有 自定义默认构造 ,但有一个系统自动生成的默认构造函数(或用户明确声明为 default 的默认构造函数),则系统会先将其对象内存完全清零(包括 padding ) ,随后,如果这个类的任何非静态成员有 非平凡默认构造 的话,在调用这些默认构造;

  3. 对于基本类型和指针,直接清零。

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 的约束之外,和 直接初始化 没有任何语义上的差异。