7.在创建对象时区分()与{}

前言

 
C++11中提供了许多初始化方法,以至于多的有点混乱。一般来说,初始化可以由小括号、等号、大括号完成:

1
2
3
int x(0);
int y = 0;
int z{0};

也可以等号与大括号并用:
1
int z = {0};

在本节接下来的内容中我们将不叙述这种并用的初始化方法,因为C++往往习惯于将其与仅使用大括号的情形等价。


Braced initialization

 

使用等号进行初始化经常会给C++新手带来困扰,因为这看起来像是一个赋值操作,但实际情况并非如此。对于内置类型而言可能没啥关系,但对于用户自定义类型而言,区分初始化与赋值是十分必要的,因为二者调用了完全不同的函数:

1
2
3
Widget w1; // call default constructor
Widget w2 = w1; // not an assignment; calls copy ctor
w1 = w2; // an assignment; calls copy operator=

C++ 98中尽管初始化语法不少,但仍然存在一些无法初始化的情况,例如没法在初始化时指定容器内部的元素值等等。C++11为了解决上述问题,引入了”uniform initialization”的概念:一个初始化表达式应该可以用于任何地方并且可以表达一切。由于该语法基于大括号(brace),作者也将其称为”Braced initialization”,其使用实例如下:
1
std::vector<int> v{ 1, 3, 5 }; // v's initial content is 1, 3, 5

大括号亦可用于指定非静态数据成员的默认初始值,C++11引入的这项新功能与operator=初始化语法共存,但小括号则不行:
1
2
3
4
5
6
7
class Widget {

private:
int x{ 0 }; // fine, x's default value is 0
int y = 0; // also fine
int z(0); // error!
};

另一方面,无法被拷贝的对象只能够使用大括号或小括号初始化,无法通过operator=初始化:
1
2
3
std::atomic<int> ai1{0};
std::atomic<int> ai2(0); // fine
std::atomic<int> ai3 = 0; // error!

将”Braced initialization”称为”uniform initialization”是十分自然的,因为只有brace可以在任何C++初始化表达式中正常使用。


Braced initialization特性

禁止narrow conversion

Braced initialization的特性之一在于其禁止在初始化过程中对内置类型作隐式narrow conversion,如果变量不能保证准确表达初始化器内部值,那么该代码将无法完成编译:

1
2
3
double x, y, z;

int sum1{ x + y + z }; // error! sum of doubles may not be expressible as int

使用圆括号和operator=进行初始化则不会检查是否触发narrow conversion,因为这可能会对历史遗留代码造成破坏:
1
2
int sum2(x + y + z); // okay (value of expression truncated to an int)
int sum3 = x + y + z; // ditto

避免vexing parse

Braced initialization还有一大特性在于其对C++的vexing parse免疫。C++ vexing parse可以解释为:当你试图使用默认构造函数来构造对象时,却发现自己只是声明了一个函数:

1
Widget w2(); //vexing parse!declares a function named w2 that returns a Widget!

但如果你使用大括号进行初始化操作,则不会被认为是一个函数声明:
1
Widget w3{}; // calls Widget ctor with no args


Braced initialization缺陷

 
Braced initialization并非完美无缺,它的存在也可能会导致一些你意料之外的错误。例如在Item2中我们曾经提及,auto会将经由Braced initialization初始化的变量推断为一个std::initializer_list.如此说来,可能你越喜欢auto,你对Braced initialization的兴趣就越少。

构造函数匹配

在调用构造函数时,只要不涉及std::initializer_list,小括号和大括号几乎没有区别:

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
Widget(int i, bool b); // ctors not declaring
Widget(int i, double d); // std::initializer_list params

};
Widget w1(10, true); // calls first ctor
Widget w2{10, true}; // also calls first ctor
Widget w3(10, 5.0); // calls second ctor
Widget w4{10, 5.0}; // also calls second ctor

但如果一个或多个构造函数声明其形参为std::initializer_list类型,则使用Braced initialization初始化对象时会几乎必然会调用形参类型为std::initializer_list的构造函数
1
2
3
4
5
6
7
8
9
10
11
class Widget {
public:
Widget(int i, bool b); // as before
Widget(int i, double d); // as before
Widget(std::initializer_list<long double> il); // added

};
Widget w1(10, true); // uses parens and, as before,calls first ctor
Widget w2{10, true}; // now calls std::initializer_list ctor(10 and true convert to long double)
Widget w3(10, 5.0); // uses parens and, as before,calls second ctor
Widget w4{10, 5.0}; // now calls std::initializer_list ctor(10 and 5.0 convert to long double)

显然,这里发生了严重的匹配错误,不仅如此,甚至拷贝和移动构造函数也可能会被std::initializer_list带偏:
1
2
3
4
5
6
7
8
9
10
11
12
13
class Widget {
public:
Widget(int i, bool b); // as before
Widget(int i, double d); // as before
Widget(std::initializer_list<long double> il); // as before
operator float() const; // convert to float

};
Widget w5(w4); // uses parens, calls copy ctor
Widget w6{w4}; // call std::initializer_list ctor
// (w4 converts to float, and float converts to long double)
Widget w7(std::move(w4)); // uses parens, calls move ctor
Widget w8{std::move(w4)}; // calls std::initializer_list ctor (for same reason as w6)

braced initializers与形参为std::initializer_list的ctor具备最高匹配度,当ctor无法被调用时,编译器甚至不会去理会别的构造函数(即使在类型上十分符合):
1
2
3
4
5
6
7
8
class Widget {
public:
Widget(int i, bool b); // as before
Widget(int i, double d); // as before
Widget(std::initializer_list<bool> il); // element type is now bool

};
Widget w{10, 5.0}; // error!narrowing conversions are prohibited inside braced initializers

只有在无法将braced initializer内的实参转换为std::initializer_list中的参数时编译器才会去调用其他构造函数(我认为本例是无法转换,而上一个例子说的是大括号内含有禁止narrow conversion语义,二者并不冲突),例如:
1
2
3
4
5
6
7
8
9
10
11
class Widget {
public:
Widget(int i, bool b); // as before
Widget(int i, double d); // as before
Widget(std::initializer_list<std::string> il);

};
Widget w1(10, true); // uses parens, still calls first ctor
Widget w2{10, true}; // uses braces, now calls first ctor
Widget w3(10, 5.0); // uses parens, still calls second ctor
Widget w4{10, 5.0}; // uses braces, now calls second ctor

最后,我们讨论一种极端情况:Widget同时具备无参构造函数与带有std::initializer_list的构造函数,当我们写下Widget w{}时,调用的是哪一个呢?答案是调用无参构造函数,原因在于空大括号意味着没有参数,而非一个空的std::initializer_list对象:

1
2
3
4
5
6
7
8
9
class Widget {
public:
Widget(); // default ctor
Widget(std::initializer_list<int> il); // std::initializer_list ctor

};
Widget w1; // calls default ctor
Widget w2{}; // also calls default ctor
Widget w3(); // most vexing parse! declares a function!

如果我们需要调用形参类型为std::initializer_list的构造函数,我们应当明确地指出参数为一个空std::initializer_list:

1
2
Widget w4({}); // calls std::initializer_list ctor with empty list
Widget w5{{}}; // ditto


实际问题

作为类的开发者,我们应当明确:如果在类中存在形参为std::initializer_list的构造函数,那么客户在使用Braced initialization时只会看到initializer_list版本,因此,我们应当合理设计构造函数,以便客户不至于产生误解。

如果你曾经的设计中不存在以std::initializer_list为参数的构造函数,当你添加了它之后,原本运行良好的客户端应用程序可能会匹配至新的构造函数然后发生雪崩。虽然别的重载函数更新后也会发生这种情况,但带有std::initializer_list的构造函数破坏性大得多,因为他并非与其他构造函数竞争,而是以一种近乎overshadow的方式抹杀了其余构造函数的存在(当然,如果不能发生转换它还是会乖乖交出构造权)。总之,在类中加入此类重载构造函数需要审慎。

作为类的使用者,我们应当在创建对象时谨慎选择初始化方式。大多数人会从()、{}中选出一种作为惯用方式,并且只有在情非得已时才会去使用另外一种。这两种初始化方式并无优劣之分,因此我们应当选择一种作为自己开发的惯用方式,并在日常使用中不断加深对它的理解。

如果你是一个模板的作者,可能你会发现自己并不确定在创建对象时应当使用哪一种初始化方式。例如,假设我们想从任意数量的参数中创建任意类型的对象:

1
2
3
4
5
template<typename T,typename... Ts>// type of object to create,types of arguments to use
void doSomeWork(Ts&&... params){
create local T object from params...

}

有两种方法可将上述伪代码实现(std::forward可见Item25):
1
2
T localObject(std::forward<Ts>(params)...); // using parens
T localObject{std::forward<Ts>(params)...}; // using braces

考虑以下调用程序:
1
2
3
std::vector<int> v;

doSomeWork<std::vector<int> >(10, 20);

如果doSomeWork在创建localObject时使用小括号,那么将创建一个包含10个元素的std::vector。如果doSomeWork使用大括号,则结果是一个包含2个元素的std::vector。只有调用者而非开发者才知道哪一个是他想要的(正确的)结果。

上述讨论正是标准库函数std :: make_unique和std :: make_shared所面临的问题(见Item21),这两个函数最终决定在template内部使用小括号,并在接口文档中加以注明。


总结

  1. Braced initialization是使用最为广泛的初始化语法,它可以防止narrow conversion,并且不受vexing parse的影响。
  2. 在构造函数重载解析期间,即使其他构造函数具备更高的匹配度, Braced initialization也不会放过任何一个与std :: initializer_list发生匹配的可能(除非真的无法转换)。
  3. 即使是同样的参数,使用大括号和小括号进行初始化可能结果会天差地别(例如std::vector)。
  4. 在template中创建对象时使用大括号还是小括号会很令人纠结。