前置:最令人烦恼的解析(Most Vexing Parse,MVP)

最令人烦恼的解析(Most Vexing Parse,MVP)C++编程语言语法歧义的解析。C++的语法规则无法区分创建对象参数和指定函数类型这两种操作。在这种情况下,编译器被要求将相关行解释为函数类型的声明。

C风格的转换

一个简单的例子出现在当意图使用函数式转换来初始化变量时:

void f(double my_dbl) {
  int i(int(my_dbl));
}

上述第2行代码是模糊的。一个可能的解释是声明一个变量i,其初始值是通过将my_dbl转换为int类型得到的。C允许在函数参数声明周围有多余的括号;在这种情况下,i的声明实际上是一个函数声明,等价于下面的声明。

int i(int my_dbl);//声明一个名为i的函数,函数接受一个整数并返回一个整数.

另一个例子:

struct Timer{};
struct TimeKeeper {
 explicit TimeKeeper(Timer t);
 int get_time();
};
int main() {
  TimeKeeper time_keeper(Timer());
  return time_keeper.get_time();
}

这行代码

TimeKeeper time_keeper(Timer());

是模糊的

(1)定义一个time_keeper变量,类型为TimeKeeper,使用匿名的Timer实例进行初始化。

或者

(2)声明一个名为time_keeper的函数,该函数返回一个TimeKeeper类型的对象,并且有一个单一的(未命名的)参数,该参数的类型是一个(指向)不接受任何输入并返回Timer对象的函数。

解决方案

在变量声明的例子中,自C++11以来,首选的方法是使用统一(花括号)初始化。这还允许有限地省略类型名称:

// 以下任一方法均有效:
TimeKeeper time_keeper(Timer{});
TimeKeeper time_keeper{Timer()};
TimeKeeper time_keeper{Timer{}};
TimeKeeper time_keeper(  {});
TimeKeeper time_keeper{  {}};

C++11对象初始化的语法有多种,初始化值要用圆括号()或者花括号{}括起来,或者放到等号"="的右边。

int x(0);//使用圆括号初始化
int y = 0;//使用"="初始化
int z {0}; //使用花括号初始化

可以使用"="和花括号的组合,C++通常把它视作和只有花括号一样。

int z = { 0 };          //使用"="和花括号

对于用户定义的类型而言,赋值运算符和初始化涉及不同的函数调用:

Widget w1;//调用默认构造函数
Widget w2 = w1;//不是赋值运算,调用拷贝构造函数
w1 = w2;//是赋值运算,调用拷贝赋值运算符(copy operator=)

C++11使用统一初始化来整合这些混乱且不适于所有情景的初始化语法,统一初始化是指在任何涉及初始化的地方都使用单一的初始化语法。

它基于花括号,出于这个原因更喜欢称之为"括号初始化"。统一初始化是一个概念上的东西,而括号初始化是一个具体语法结构。使用花括号,创建并指定一个容器的初始元素变得很容易:

std::vector<int> v{ 1, 3, 5 };  //v初始内容为1,3,5

括号初始化也能被用于为非静态数据成员指定默认初始值。C++11允许"="初始化不加花括号也拥有这种能力。另一方面,不可拷贝的对象(例如std::atomic item40)可以使用花括号初始化或者圆括号初始化,但是不能使用"="初始化:

std::atomic<int> ai1{ 0 }; //没问题
std::atomic<int> ai2(0); //没问题std::atomic<int> ai3 = 0;       //错误!

括号表达式还有一个少见的特性,它不允许内置类型间隐式的变窄转换(narrowing conversion)。如果一个使用了括号初始化的表达式的值,不能保证由被初始化的对象的类型来表示,代码不会通过编译:

double x, y, z;
int sum1{x+y+z};//错误.double的和可能不能表示为int

使用圆括号和"="的初始化不检查是否转换为变窄转换,由于历史遗留问题它们必须要兼容老旧代码:

int sum2(x + y +z); //可以(表达式的值被截为int)
int sum3 = x + y + z; //同上

总结上面内容:

能用于各种不同的上下文,防止了隐式的变窄转换,防止C++解析问题。

括号初始化的缺点

(1)auto

这些行为使得括号初始化、std::initializer_list和构造函数参与重载决议时本来就不清不楚的暧昧关系进一步混乱。把它们放到一起会让看起来应该左转的代码右转。

Item2解释了当auto声明的变量使用花括号初始化,变量类型会被推导为std::initializer_list,但是使用相同内容的其他初始化方式会产生更符合直觉的结果。

(2)构造函数调用

在构造函数调用中,只要不包含std::initializer_list形参,那么花括号初始化和圆括号初始化都会产生一样的结果:

classWidget {public:  
    Widget(int i, bool b);  //构造函数未声明
    Widget(int i, double d); //std::initializer_list这个形参 
    …
};
Widget w1(10, true); //调用第一个构造函数
Widget w2{10, true}; //也调用第一个构造函数Widget w3(10, 5.0);        
//调用第二个构造函数
Widget w4{10, 5.0}; //也调用第二个构造函数

然而,如果有一个或者多个构造函数的声明包含一个std::initializer_list形参,那么使用括号初始化语法的调用更倾向于选择带std::initializer_list的那个构造函数。如果编译器遇到一个括号初始化并且有一个带std::initializer_list的构造函数,那么它一定会选择该构造函数。如果上面的Widget类有一个std::initializer_list<long double>作为参数的构造函数,就像这样:

class Widget { 
public:
    Widget(int i, bool b);//同上
    Widget(int i, double d);//同上
    Widget(std::initializer_list<long double> il);//新添加的
};

w2w4将会使用新添加的构造函数,即使另一个非std::initializer_list构造函数和实参更匹配:

Widget w1(10, true);    //使用圆括号初始化,同之前一样
                        //调用第一个构造函数

Widget w2{10, true};    //使用花括号初始化,但是现在
                        //调用带std::initializer_list的构造函数
                        //(10 和 true 转化为long double)

Widget w3(10, 5.0);     //使用圆括号初始化,同之前一样
                        //调用第二个构造函数 

Widget w4{10, 5.0};     //使用花括号初始化,但是现在
                        //调用带std::initializer_list的构造函数
                        //(10 和 5.0 转化为long double)

甚至普通构造函数和移动构造函数都会被带std::initializer_list的构造函数劫持:

class Widget { 
public:  
    Widget(int i, bool b);                              //同之前一样
    Widget(int i, double d);                            //同之前一样
    Widget(std::initializer_list<long double> il);      //同之前一样
    operator float() const;                             //转换为float
    …
};
Widget w5(w4);//使用圆括号,调用拷贝构造函数
Widget w6{w4};//使用花括号,调用std::initializer_list构造
//函数(w4转换为float,float转换为double)

Widget w7(std::move(w4));//使用圆括号,调用移动构造函数
Widget w8{std::move(w4)};//使用花括号,调用std::initializer_list构造
//函数(与w6相同原因)

std::vector作为受众之一会直接受到影响。

std::vector有一个非std::initializer_list构造函数允许你去指定容器的初始大小,以及使用一个值填满你的容器。但它也有一个std::initializer_list构造函数允许你使用花括号里面的值初始化容器。如果你创建一个数值类型的std::vector(比如std::vector<int>),然后你传递两个实参,把这两个实参放到圆括号和放到花括号不同。

std::vector<int> v1(10, 20);    //使用非std::initializer_list构造函数
                                //创建一个包含10个元素的std::vector,
                                //所有的元素的值都是20

std::vector<int> v2{10, 20};    //使用std::initializer_list构造函数
                                //创建包含两个元素的std::vector,
                                //元素的值为10和20

第一,作为一个类库作者,需要意识到如果一堆重载的构造函数中有一个或者多个含有initializer_list形参,用户代码如果使用了括号初始化,可能只会看到你initializer_list版本的重载的构造函数。因此,最好把构造函数设计为不管用户是使用圆括号还是使用花括号进行初始化都不会有什么影响。

第二,作为一个类库使用者,须认真在花括号和圆括号之间选择一个来创建对象。大多数开发者都使用其中一种作为默认情况,只有当他们不能使用这种的时候才会考虑另一种。默认使用花括号初始化的开发者主要被适用面广、禁止变窄转换、免疫C++最令人头疼的解析这些优点所吸引。这些开发者知道在一些情况下(比如给定一个容器大小和一个初始值创建std::vector)要使用圆括号。默认使用圆括号初始化的开发者主要被C++98语法一致性、避免std::initializer_list自动类型推导、避免不会不经意间调用std::initializer_list构造函数这些优点所吸引。建议是选择一种并坚持使用它。

总结

  • 花括号初始化是最广泛使用的初始化语法,它防止变窄转换,并且对于C++最令人头疼的解析有天生的免疫性
  • 在构造函数重载决议中,编译器会尽最大努力将括号初始化与std::initializer_list参数匹配,即便其他构造函数看起来是更好的选择。
  • 对于数值类型的std::vector来说使用花括号初始化和圆括号初始化会不同。
  • 在模板类选择使用圆括号初始化或使用花括号初始化创建对象是一个挑战。