[译] GotW #1 Solution: Variable Initialization – or Is It?

原文在这里 GotW #1 Solution: Variable Initialization – or Is It?

第一个问题是用来强调理解你写的代码的含义的重要性. 下面是几行简单的代码 – 大部分都与其他的有一些区别, 即使只是语法略有变化.

JG 问题

1. 下面的代码有什么不同?

1
2
3
4
5
6
7
8
9
10
11
12
13
widget w;                   // (a)

widget w();                 // (b)
widget w{};                 // (c)

widget w(x);                // (d)
widget w{x};                // (e)

widget w = x;               // (f)
widget w = {x};             // (g)

auto w = x;                 // (h)
auto w = widget{x};         // (i)

Guru 问题

2. 下面的每一行代码做了什么?

1
2
3
vector<int> v1( 10, 20 );   // (a)

vector<int> v2{ 10, 20 };   // (b)

3. 除了上面的情况以外, 使用 { } 初始化对象还有什么其他的好处?

4. 什么时候使用 ( ) 以及 { } 来初始化对象? 为什么?

解决方案

这几个问题展示了几件事:

  • 默认初始化, 显式初始化, 拷贝初始化, 以及初始化列表之间的区别.
  • 初始化时 ( ){ } 之间的差异.
  • 在现代 C++ 中要避免的, 看起来像初始化, 但完全和初始化无关的东西

不过, 最重要的是: 如果你坚持问题 #4 中的两个简单的指导的话, 就可以忽略这些情况, 规则相当的简单, 而且默认情况下可以获得高效的性能.

1. 下面的代码有什么不同?

让我们一条一条的来看.

情况 (a) 是默认初始化

1
widget w;                   // (a)

这一行代码声明了一个 widget 类型的变量 w, 假定 widget 是一个 class 类型, 它会使用默认构造函数 widget::widget() 来初始化.

情况 (b) 是一个 “恼人” 的无关转移, 是历史遗留问题

1
widget w();                 // (b)

这是 C++ 的一个陷阱: 第一眼看上去, 它看上去像另一个调用默认构造函数 widget::widget() 的变量声明. 事实上, 感谢语法二义性, 这是一个函数声明. 一个名为 w, 无参, 返回值是 widget 类型的函数声明. (如果你无法一眼看出的话, 考虑下上面的代码其实与 int f() 没有任何的差别, 而这个函数声明是显而易见的对吧.)

避免你认为 “吖, 但是那些 () 是多于的, 这是程序员自己的错误, 因为他们没有直接写 widget w;”, 注意, 这个的问题也会出现在你以为你正在使用临时对象初始化变量的时候:

1
2
3
// same problem (gadget and doodad are types)
//
widget w( gadget(), doodad() );  // pitfall: not a variable declaration

Scott Meyers 很多年前就称这个是 “C++ 中最恼人的语法解析”, 因为标准中解决语法二义性是这么说的: “如果能够被解析为函数声明, 那么它就是一个函数声明.”

好消息是, 这个问题将要成为历史, 在新的代码中你将不会遇到它, 因为 C++11 移除了这个陷阱. 注意 C++11 并没有修改语义 – C++11 的向后兼容 C++98 做的非常好, 包括这个语法二义性, 仍然是它原有的含义. 但是, C++11 通过提供新的语法取代情况 (b), 因此我们再也不会掉到这个陷阱中了.

情况 (c) 干净明确

1
widget w{};                 // (c)

在这儿我们有了第一个使用 { } 而不使用 () 的理由: 对于任意的 class 类型 widget, 情况 (c) 做的事情像 (a) 一样的明确 – 无二义性, 干净, 明确.

“哈哈, 但是等等, 它可没有那么简单!” 有些人可能会反对. “如果 widget 有一个接受 std::initializer_list 的构造函数那会怎么样呢? 他们都是被优先选择的, 所以, 如果 widget 有这么一个构造函数, 这个写法不会调用它吗?”

答案是不会, 这个真的像你看到的一样简单, 因为标准中明确了, 如果可以的话, 空的 { } 列表意味着调用默认构造函数. 不过, 能意识到 initializer_list 是好的, 让我们在后面再讨论它.

情况 (d) 和 情况 (e) 是直接初始化

1
2
widget w(x);                // (d)
widget w{x};                // (e)

假设 x 不是一个类型名, 这两个都是直接初始化. 因为变量 w 是由 x 直接调用 widget::widget(x) 初始化的. 如果 x 也是 widget 类型, 它会调用拷贝构造函数. 否则的话, 调用一个转换构造函数.

但是, 注意 {x}, 它会创建一个 initializer_list. 如果 widget 有接受 initializer_list 的构造函数的话, 这个构造函数是被优先选择的; 否则, 如果 widget 有接受任意 x 的类型的构造函数的话(包括类型转换), 这个构造函数将会被调用.

情况 (e) 有两个优于 (d) 的地方: 一, 与 (c) 一样, (e) 是明确, 无二义性的. 如果 x 是一个类型名, 那么 (d) 就是一个函数声明, 即使在作用域内有一个名为 x 的变量 (看下文), 而 (e) 不可能是一个函数声明.

第二: (e) 更安全, 因为它不允许有损转换, 一些内建类型是允许的. 考虑下面的代码:

1
2
int i1( 12.345 );           // ok: loss .345, we didn't like it anyway
int i2{ 12.345 };           // error: would be lossy implicit narrowing

情况 (f) 与 (g) 是拷贝初始化和拷贝类表初始化

这是最后两个 non-auto 的情况:

1
widget w = x;               // (f)

这就是所谓的拷贝初始化. 从概念上来讲, 变量 w 是由 widget 的转移构造函数或者拷贝构造函数初始化的. 有可能是在调用一个隐式转换函数之后(显式转换不会调用).

通常的错误: 这个绝对是初始化; 绝不是赋值, 所以绝对不会调用 T::operator=(). 是的, 我知道这有一个=赋值符号, 但是不要让这个符号影响到你 – 这仅仅是从 C 继承而来的符号, 不是赋值操作符.

下面是语义:

  • 如果 x 的类型是 widget, (f) 与 (d) widget w(x); 的含义完全一致, 除非明确的构造函数不能使用. 他保证了只有一个构造函数被调用.
  • 如果 x 是其他类型, 从概念上来讲编译器首先将 x 隐式转换为 widget 类型的临时对象, 然后对临时右值使用转移构造函数, 如果没有好的转移构造函数, 那么会使用拷贝构造函数 – “低效的转移”作为备选. 假设存在可用的隐式转换, (f) 就与 widget w(widget(x)) 一致了.

注意, 上面说了几次从概念上来讲. 这是因为通常编译器都会做优化, 优化掉临时变量, 如果存在隐式转换, 从 (f) 转换为 (d), 那么就优化掉了额外的转移操作. 但是, 尽管编译器这么做了, widget 的拷贝构造函数也必须是可访问的, 即使没有调用 – 拷贝构造函数的副作用可能发生也可能不发生, 就这些.

现在来注意一下增加的符号 =:

1
widget w = {x};             // (g)

这个是所谓的拷贝列表初始化. 它与 widget w{x}; 的含义一致 除非显示的构造函数无法使用. 它保证了只有咦个的构造函数被调用.

情况 (h) 和 (i) 也是拷贝初始化, 但是更简单

1
2
auto w = x;                 // (h)
auto w = widget{x};         // (i)

语义与 (f) 和 (g) 一样, 但是学习起来更简单, 因为使用了 auto 保证了右侧表达式类型推导的准确性. 需要注意的是 (i) 在隐式转换与显式转换的情况下都能正常工作.

(h) 与 (d) 的含义一致, type_of_x w(x);. 只有一个拷贝构造函数被调用. 它可以保证类型发生变化时, 程序还总是正确的: 因为 (h) 没有明确特性的类型, 它有两个有效的保证, 因为这不会存在类型的转换, 以及更好的维护性, 因为当程序中 x 的类型变更时, ‘w’ 会自动变更类型与 x 保持一致.

当你想要明确类型或者需要显式的类型转换时, (i) 是最风格一致的, 而且一旦使用了 { } 就可以避免有损的类型转换了. 大部分的编译器实现, 只会有一个构造函数调用 – 和我们看到的 (f) 与 (g) 相似, 概念上来讲会有两个构造函数调用, 一个转换构造函数或者拷贝构造函数用来创建临时的 widget{x} 然后紧接着一个转移构造函数来构造 w, 但是编译器会把后一个优化掉.

通常情况下, 我推荐你尝试这两种用法, 并倾向于使用它来伴随你舒服的成长. 现在几乎所有我写的局部变量声明都采用这种方式. (我知道会有一些人怀疑这种方式 – 更多的关于关键字 auto 的问题在其他的 GotW 中讨论.)

2. 下面的每一行代码做了什么?

在第二个问题的代码中, 我们创建了一个 vector<int> 并将参数 1020 传给它的构造函数 – 第一种情况是 (10, 20), 第二种情况是 {10, 20}.

两个都会调用构造函数, 但是会调用哪个呢? 嗯, vector<int> 有很多接受两个参数的构造函数, 但是只有下面两个能够正确的接受参数 1020. 为了简单, 忽略掉默认的分配器参数, 两个构造函数应该是下面的样子:

1
2
3
vector( size_t n, const int& value );    // A: n copies of value

vector( initializer_list<int> values );  // B: copy of values

有两个简单的规则可以帮助我们确定哪个函数将被调用:

  • 在表达式的上下文中使用 { } 你会得到一个 initializer_list.
  • 接受 initializer_list 参数的构造函数会优于其他构造函数被选择, 同时会隐藏其他构造函数, 否则可能是可行的.

有了这两条, 答案就很简单了:

1
2
3
4
5
vector<int> v1( 10, 20 );    // (a) calls A: 10 copies of the value 20
assert( v1.size() == 10 );

vector<int> v2{ 10, 20 };    // (b) calls B: the values 10 and 20
assert( v2.size() == 2 );

3. 除了上面的情况以外, 使用 { } 初始化对象还有什么其他的好处?

首先, 这叫做统一初始化, 因为它统一 – 所有的类型, 包括 structs, 数组, 标准库容器, 而且也不存在恼人的语法解析问题.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct mystruct { int x, y; };

// C++98 
rectangle       w( origin(), extents() );       // oops, vexing parse 
complex<double> c( 2.71828, 3.14159 );
mystruct        m = { 1, 2 };
int             a[] = { 1, 2, 3, 4 };
vector<int>     v;                              // urk, need more code
for( int i = 1; i <= 4; ++i ) v.push_back(i);   //   to initialize this

// C++11 (note: "=" is optional)
rectangle       w   = { origin(), extents() };
complex<double> c   = { 2.71828, 3.14159 };
mystruct        m   = { 1, 2 };
int             a[] = { 1, 2, 3, 4 };
vector<int>     v   = { 1, 2, 3, 4 };

注意, 这不仅仅是个审美的问题. 考虑编写通用的能够初始化任何类型的代码… 我们正在做的, 让我们使用完美转发做为例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T, typename ...Args>
void forwarder( Args&&... args ) {
    // ...
    T local = { std::forward<Args>(args)... };
    // ...
}

forwarder<int>            ( 42 );                  // ok
forwarder<rectangle>      ( origin(), extents() ); // ok
forwarder<complex<double>>( 2.71828, 3.14159 );    // ok
forwarder<mystruct>       ( 1, 2 );                // ok because of {}
forwarder<int[]>          ( 1, 2, 3, 4 );          // ok because of {}
forwarder<vector<int>>    ( 1, 2, 3, 4 );          // ok because of {}

如果 forwarder 内部使用了 ( ) 做初始化符号的话, 那么最后三条是不合法的.

新的符号 { } 在任何地方都能完美的工作, 包括类成员的初始化:

1
widget::widget( /*...*/ ) : mem1{init1}, mem2{init2, init3} { /*...*/ }

另外, 它还能简单清晰的表达传递函数参数, 返回值, 而不需要一个具名的临时对象:

1
2
3
4
5
6
7
8
9
10
void draw_rect( rectangle );

draw_rect( rectangle(origin, selection) );         // C++98
draw_rect({ origin, selection });                  // C++11

rectangle compute_rect() {
   // ...
   if(cpp98) return rectangle(origin, selection);  // C++98
   else      return {origin, selection};           // C++11
}

4. 什么时候使用 ( ) 以及 { } 来初始化对象? 为什么?

下面是简单的指导:

准则: 尽量在初始化时使用 { }, 比如 vector<int> v = { 1, 2, 3, 4 }; 或者 auto v = vector<int>{ 1, 2, 3, 4 };, 因为它有更好的一致性, 更正确, 可以避免去了解过去的陷阱. 在单参数的情况下你可能会仅仅看到一个赋值符号 =, 就像 int i = 42; 一样, 这种情况下, 省略括号是好的…

上面的准则可以覆盖绝大部分情况, 但是也有一个例外:

… 在极端情况下, 比如 vector<int> v(10,20); 或者 auto v = vector<int>(10,20); 时, 使用 ( ) 明确的调用会被 initializer_list 构造函数隐藏的构造函数.

但是, 这个仅仅是在极端情况下, 因为默认构造函数以及拷贝构造函数已经很特别, 并且能与括号 { } 很好的工作, 现在一个好的类的设计会避免用到 () 来提供用户定义的构造函数, 因为下面的设计准则:

准则: 当你设计一个类时, 避免提供一个与 initializer_list 构造函数会产生二义性的构造函数, 这样用户就不需要使用 () 来访问会被隐藏掉的构造函数.

Comments