每周技巧 #146:默认初始化与值初始化

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #146: Default vs Value Initialization

原文最初作为 TotW #146 发布于 2018 年 4 月 19 日。

作者:Dominic Hamon

更新于 2020 年 4 月 6 日。

快捷链接:abseil.io/tips/146

“成功之路永远在施工。” —— Lily Tomlin

TL;DR

为了安全性和可读性,在标量对象被显式设置为某个值之前,你应该假设它们没有初始化为合理值。使用初始化器可以确保标量值被初始化为安全值。

引言

对象创建时,可能已初始化,也可能未初始化。读取未初始化对象并不安全,但理解对象何时未初始化并不简单。

首先要理解正在构造的类型是标量、聚合,还是其他类型。标量类型可以理解为简单类型:整数或浮点算术对象、指针、枚举、成员指针、nullptr_t聚合类型是数组,或者是没有虚成员、没有非公开字段或基类、也没有构造函数声明的类。

影响实例是否已初始化为可安全读取值的另一个因素,是它是否有显式初始化器。也就是说,语句中的对象名后面是否跟着 (){}= {}

由于这些规则并不直观,确保对象已初始化时最容易记住的规则是:提供初始化器。这称为值初始化,不同于默认初始化;对标量和聚合类型,后者是编译器在没有初始化器时会执行的事情。

用户提供的构造函数

如果一个类型定义了用户定义构造函数,它就不是聚合类型,并且初始化会简单得多:值初始化和默认初始化都会调用构造函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct Foo {
  Foo() : v() {}

  int v;
  std::string s;
};

int main() {
  Foo default_foo;
  Foo value_foo = {};
  ...
}

= {} 会触发 value_foo 的值初始化,从而调用 Foo 的默认构造函数。之后,v 可以安全读取,因为构造函数的初始化列表对它进行了值初始化。事实上,由于 v 不是类类型,这是值初始化的一种特殊情况,称为零初始化,因此 value_foo.v 的值为 0

类似地,虽然 default_foo 是默认初始化,但它调用同一个构造函数,所以 default_foo.v 也会零初始化,并且可安全读取。

注意,Foo::s 有用户提供的构造函数,因此无论哪种情况都会值初始化,并且可安全读取。

用户声明与用户提供的构造函数

用户可以声明构造函数,同时要求编译器通过 =default 提供定义。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct Foo {
  Foo() = default; // “用户声明”,不是“用户提供”。

  int v;
};

int main() {
  Foo default_foo;
  Foo value_foo = {};
}

在这种情况下,Foo 定义了一个用户声明但不是用户提供的构造函数。虽然这个类型不会是聚合,但成员会像聚合一样初始化。这意味着 default_foo.v 将未初始化,而 value_foo.v 将被零初始化。注意,“用户声明”只适用于在默认构造函数的声明点被默认化(= default)的默认构造函数。类外默认化的实现Foo::Foo() = default)被视为用户提供,并且行为等同于 Foo::Foo() {} 的定义。

用户提供构造函数中的未初始化成员

1
2
3
4
5
6
7
8
9
struct Foo {
  Foo() {}

  int v;
};

int main() {
  Foo foo = {};
}

在这种情况下,虽然 Foo 有用户提供的构造函数,但它没有初始化 v。此时 v 再次被默认初始化,这意味着它的值不确定,读取它不安全。

显式值初始化

一般来说,为了读者,最好把初始化器替换为对具体值的显式初始化,即使这个值是 0。这称为直接初始化,是值初始化的一种更具体形式。

1
2
3
4
5
struct Foo {
  Foo() : v(0) {}

  int v;
};

默认成员初始化

比为类定义构造函数更简单、同时仍能避免默认初始化和值初始化陷阱的方案,是尽可能在类成员声明处初始化成员:

1
2
3
struct Foo {
  int v = 0;
};

这能确保无论 Foo 实例如何构造,v 都会初始化为确定值。

默认成员初始化也起到文档作用,尤其是对于布尔值或非零初始值,它说明了这个成员的安全初始值是什么。

专业提示:标量零初始化

标量值在初始化后可安全读取的完整规则如下:

  • 类型后面跟着显式的 (){}= {} 初始化器。
  • 正在构造的该类型实例是一个带有上述初始化器的数组元素。例如,new int[10]()
  • 正在构造的该类型实例是一个禁用默认构造函数的类成员,并且外层对象实例被值初始化。
  • 正在构造的该类型实例是静态或线程局部对象。
  • 正在构造的该类型实例是一个聚合类型类的成员,并且该类有初始化器。

数组类型

数组声明很容易忘记添加显式初始化器,但这会导致特别麻烦的初始化问题。

1
2
3
4
5
int main() {
  int foo[3];
  int bar[3] = {};
  ...
}

foo 的每个元素都是默认初始化,而 bar 的每个元素都会零初始化。

关于默认化默认构造函数声明的插曲

小测验:这些风格不同的声明会影响代码行为吗?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
struct Foo {
  Foo() = default;

  int v;
};

struct Bar {
  Bar();

  int v;
};

Bar::Bar() = default;

int main() {
  Foo f = {};
  Bar b = {};
  ...
}

许多开发者会合理地假设,这也许会影响代码生成质量,但除此之外只是风格偏好。你可能已经猜到了,既然我这么问,事实并非如此。

原因可以追溯到前面关于用户声明与用户提供的构造函数的章节。由于 Foo 的构造函数在声明处默认化,它不是用户提供的(但它用户声明的)。这意味着虽然 Foo 不是聚合类型,f.v 仍会零初始化。然而,Bar 有一个用户提供的构造函数,尽管它是由编译器作为默认化构造函数创建的。由于这个构造函数没有显式初始化 Bar::vb.v 将被默认初始化,读取它不安全。

建议

  • 显式写出标量类型要初始化成的值,而不是依赖零初始化。
  • 在显式初始化或赋值之前,把所有标量类型实例都视为具有不确定值。
  • 如果某个成员有合理默认值,并且类有多个构造函数,请使用默认成员初始化器,确保它不会未初始化。注意,构造函数中的成员初始化器会覆盖默认值

延伸阅读

每周技巧 #144:关联容器中的异构查找

上一节

每周技巧 #147:负责任地使用穷尽式 `switch` 语句

下一节