C++ 初始化真疯狂 (2017)
C++ 初始化真疯狂
2017年1月20日,关于 c++
C++ 小测验时间:在下面程序的 main
函数的最后一行,a.a
和 b.b
的值是什么?
#include <iostream>
struct foo {
foo() = default;
int a;
};
struct bar {
bar();
int b;
};
bar::bar() = default;
int main() {
foo a{};
bar b{};
std::cout << a.a << ' ' << b.b;
}
答案是 a.a
是 0
,而 b.b
是不确定的,因此读取它是未定义行为。为什么?因为 C++ 中的初始化真的太疯狂了。
默认初始化、值初始化和零初始化
在我们深入研究导致这种情况的细节之前,我将介绍默认初始化、值初始化和零初始化的概念。如果您已经熟悉这些概念,请随意跳过本节。
T global; //零初始化,然后是默认初始化
void foo() {
T i; //默认初始化
T j{}; //值初始化 (C++11)
T k = T(); //值初始化
T l = T{}; //值初始化 (C++11)
T m(); //函数声明
new T; //默认初始化
new T(); //值初始化
new T{}; //值初始化 (C++11)
}
struct A { T t; A() : t() {} }; //t 是值初始化的
struct B { T t; B() : t{} {} }; //t 是值初始化的 (C++11)
struct C { T t; C() {} }; //t 是默认初始化的
这些不同初始化形式的规则相当复杂,因此我将给出 C++11 规则的简化概述(C++14 甚至更改了其中一些规则,因此这些值初始化形式可以变成聚合初始化)。如果您想了解这些形式的所有细节,请查看相关的 cppreference.com 文章123,或者参阅本文底部的标准引文。
- 默认初始化(default-initialization) – 如果
T
是一个类,则调用默认构造函数;如果它是一个数组,则每个元素都会被默认初始化;否则,不执行任何初始化,从而导致不确定的值。 - 值初始化(value-initialization) – 如果
T
是一个类,则该对象被默认初始化(如果在T
的默认构造函数不是用户提供/删除的情况下,会先进行零初始化);如果它是一个数组,则每个元素都会被值初始化;否则,该对象会被零初始化。 - 零初始化(zero-initialization) – 在任何其他初始化之前应用于
static
和线程局部变量。如果T
是标量(算术、指针、枚举),则它从0
初始化;如果它是类类型,则所有基类和数据成员都会被零初始化;如果它是一个数组,则每个元素都会被零初始化。
以 int
作为 T
的简单例子为例,global
和所有值初始化的变量都将具有值 0
,而所有其他变量将具有不确定的值。读取这些不确定的值会导致未定义行为。
回到我们最初的例子
现在我们有了必要的知识来理解我最初的例子中发生了什么。本质上,foo
和 bar
的行为因 =default
在其构造函数上的不同位置而改变。同样,如果您需要,相关的标准段落位于页面底部,但要点如下:
由于 foo
的构造函数在其第一次声明时被默认化,因此从技术上讲,它不是 用户提供的 —— 我稍后会解释这个术语的含义,现在只需接受这个标准术语。相反,bar
的构造函数仅在其定义处被默认化,因此它是 用户提供的。换句话说,如果您不希望您的构造函数由用户提供,请务必在声明时编写 =default
,而不是像在其他地方那样定义它。当您考虑一下时,这个规则是有道理的:如果没有访问构造函数的定义,一个翻译单元就无法知道它将是一个简单的编译器生成的构造函数,还是将向月球发送电报以检索一些数据并阻塞直到获得响应。
默认构造函数由用户提供对类类型有一些影响。例如,如果 const 限定的对象缺少用户提供的构造函数,则不能对其进行默认初始化,这种想法是如果对象应该只设置一次,那么最好使用一些合理的东西进行初始化:
const int my_int; //格式错误,没有用户提供的构造函数
const std::string my_string; //格式良好,具有用户提供的构造函数
const foo my_foo; //格式错误,没有用户提供的构造函数
const bar my_bar; //格式良好,具有用户提供的构造函数
此外,为了成为 trivial(因此是 POD) 或 aggregate,一个类必须没有用户提供的构造函数。如果您不知道这些术语,请不要担心,只要知道您的构造函数是否由用户提供会修改您可以对该类做什么以及它的行为方式的一些限制就足够了。
但是,对于我们的第一个示例,我们感兴趣的是用户提供的构造函数如何与初始化规则交互。该语言要求 a
和 b
都是值初始化的,但只有 a
额外进行零初始化。对 a
进行零初始化会给 a.a
赋值 0
,而 b.b
根本没有初始化,如果我们尝试读取它,会产生未定义的行为。这是一个非常微妙的区别,它无意中将我们的程序从安全执行变为召唤鼻恶魔/吃掉你的猫/订购比萨饼/你最喜欢的未定义行为的比喻。
幸运的是,有一个简单的解决方案。冒着重复之前多次给出的建议的风险,初始化您的变量。
认真对待。
去做吧。
初始化你的变量。
如果 foo
和 bar
的设计者认为它们应该是默认可构造的,他们应该用一些有意义的值初始化它们的内容。如果他们认为它们 不应该 是默认可构造的,他们应该删除构造函数以避免问题。
struct foo {
foo() : a{0} {} //显式初始化为 0
int a;
};
struct bar {
bar() = delete; //删除构造函数
//在此处插入非默认构造函数,该构造函数执行一些有意义的操作
int b;
};
将这种关于初始化的思维方式内化是编写不令人惊讶的代码的关键。如果您已经对您的代码进行了性能分析,并且发现不必要的初始化导致了瓶颈,那么当然,可以对其进行优化,但是您最好确定额外的性能值得可能的头痛和花费的资金来保证代码的安全。
如果您仍然不相信 C++ 初始化规则非常复杂,请花点时间思考一下您可以想到的所有初始化形式。我的答案在行后面。
完成了吗?你提出了多少?在研读标准时,我数出了 十八 种不同的初始化形式4。以下是它们以及一个简短的示例/描述:
- 默认初始化 (default):
int i;
- 值初始化 (value):
int i{};
- 零初始化 (zero):
static int i;
- 常量初始化 (constant):
static int i = some_constexpr_function();
- 静态初始化 (static): 零初始化或常量初始化
- 动态初始化 (dynamic): 不是静态初始化
- 无序初始化 (unordered): 未显式专门化的类模板静态数据成员的动态初始化
- 有序初始化 (ordered): 具有静态存储持续时间的其他非局部变量的动态初始化
- 非平凡初始化 (non-trivial): 当一个类或聚合由一个非平凡构造函数初始化时
- 直接初始化 (direct):
int i{42}; int j(42);
- 复制初始化 (copy):
int i = 42;
- 复制列表初始化 (copy-list):
int i = {42};
- 直接列表初始化 (direct-list):
int i{42};
- 列表初始化 (list): 复制列表初始化或直接列表初始化
- 聚合初始化 (aggregate):
int is[3] = {0,1,2};
- 引用初始化 (reference):
const int& i = 42; auto&& j = 42;
- 隐式初始化 (implicit): 默认或值初始化
- 显式初始化 (explicit): 直接初始化、复制初始化或列表初始化
不要试图记住所有这些规则;其中蕴含着疯狂。只要小心,并记住 C++ 的初始化规则会在你最不期望的时候扑向你。显式初始化你的变量,如果你曾经陷入认为 C++ 是一种理智的语言的陷阱,请记住这一点:
在 C++ 中,您可以通过更改告诉编译器生成它可能无论如何都会为您生成的东西的点来为您的程序提供未定义的行为。
附录:标准引文
所有引文均来自 N4140(本质上是 C++14)。
显式默认函数和隐式声明函数统称为默认函数,并且实现应为它们提供隐式定义 (12.1 12.4, 12.8),这可能意味着将它们定义为已删除。如果一个函数是用户声明的并且在其第一次声明时未显式默认化或删除,则该函数是由用户提供的。 用户提供的显式默认函数(即,在第一次声明后显式默认)在其显式默认的点定义;如果这样的函数被隐式定义为已删除,则程序格式错误。 要 零初始化 类型
T
的对象或引用,意味着:
- 如果
T
是标量类型 (3.9),则对象被初始化为通过将整数文字 0(零)转换为 T 获得的值- 如果
T
是(可能是 cv 限定的)非联合类类型,则每个非静态数据成员和每个基类子对象都将被零初始化,并且填充被初始化为零位;- 如果
T
是(可能是 cv 限定的)联合类型,则对象的第一个非静态命名数据成员被零初始化,并且填充被初始化为零位;- 如果
T
是数组类型,则每个元素都被零初始化;- 如果
T
是引用类型,则不执行初始化。
要 默认初始化 类型
T
的对象,意味着:
- 如果
T
是(可能是 cv 限定的)类类型(第 9 条),则调用T
的默认构造函数 (12.1)(如果T
没有默认构造函数或者重载解析 (13.3) 导致歧义或者初始化上下文中已删除或无法访问的函数,则初始化格式错误);- 如果
T
是数组类型,则每个元素都被默认初始化;- 否则,不执行初始化。如果程序要求默认初始化 const 限定类型
T
的对象,则T
应该是具有用户提供的默认构造函数的类类型。
要 值初始化 类型
T
的对象,意味着:
- 如果
T
是(可能是 cv 限定的)类类型(第 9 条),它没有默认构造函数 (12.1) 或具有用户提供或删除的默认构造函数,则该对象被默认初始化;- 如果
T
是没有用户提供或删除的默认构造函数的(可能是 cv 限定的)类类型,则该对象被零初始化,并检查默认初始化的语义约束,并且如果T
具有非平凡的默认构造函数,则该对象被默认初始化;- 如果
T
是数组类型,则每个元素都被值初始化;- 否则,该对象被零初始化。
具有静态存储持续时间 (3.7.1) 或线程存储持续时间 (3.7.2) 的变量应在任何其他初始化发生之前进行零初始化 (8.5)。[…]
- cppreference value-initialization ↩
- cppreference default-initialization ↩
- cppreference zero-initialization ↩
- 可以随意争论说,其中一些是初始化形式的不同风格,或者初始化的属性而不是单独的概念,我真的不在乎,只要说有很多就足够了。 ↩
c++ 我反馈。请在 Twitter 上告诉我您对这篇文章的看法 @TartanLlama 或在下面发表评论! </> 在 Github © 2017 Sy Brand Carte Noir 主题作者 Jacob Tomlinson