创建对象时,区分小括号和大括号

本文最后更新于:May 4, 2022 am

本系列博客着重讨论现代C++在C++98上做出的更为细节的改动,本篇博客将会详细讨论在创建对象时,小括号和大括号的区别

关于初始化的讨论

C++11为初始化对象提供了多种语法,一些人认为这有点丰富得过头,还有部分人认为这就是一坨糟糕的堆砌。普遍来说,初始值可以通过括号、赋值符号,以及大括号来赋予

1
2
3
4
5
int x(0);

int y = 0;

int z{0};

在有些情况下,甚至可以同时使用大括号和赋值符号

1
int z = {0};

为了简化起见,下面的讨论中将会忽略赋值符号和大括号一起使用的情况,因为这和只使用大括号没有什么实质的区别,C++通常将这两者一视同仁

很多刚刚接触C++的开发者,见到使用赋值符号初始化一个变量时,会误以为此处发生了真正的”赋值“。

对于内建类型,比如int,这没什么区别,但当讨论用户自定义类型时,我们需要分清楚初始化和赋值的不同之处,因为这调用了不同的成员函数

1
2
3
4
5
6
7
8
Widget w1; 
//调用默认构造函数

Widget w2 = w1;
//调用拷贝构造函数,是初始化,而非赋值

w1 = w2;
//调用拷贝赋值运算符函数,是赋值,不是初始化

但即使有着这么多种初始化方式,在C++98中,部分语境下我们仍然无法声明所期望的初始化方式。比方说,我们无法直接声明一个STL容器,使得它创建后就有给定的值(比如无法声明一个std::vector<int>容器,并且使得其初始化之后内含$\set{1,3,5}$一共三个元素)

一致性初始化

为了解决多种初始化语句造成的混淆,以及处理原有的初始化表达不够丰富的情况,C++11引入了一致初始化方案:以一种初始化语言,并且在理论上,可以在任何语境下表达任何初始化方式。这种方式基于大括号,因此下文称之为大括号初始化。一致性初始化只是一个概念,具体在C++中通过大括号初始化实现。

大括号初始化使弥补了C++98所欠缺的表达能力,当我们希望声明一个初始内容是$\set{1,3,5}$的容器时,可以如此做

1
std::vector<int> v{1,3,5};

大括号初始化也可以用来声明非静态成员变量的初始值,赋值符号也有这一新特性,但是小括号则不支持

1
2
3
4
5
6
class Widget{
private:
int x{0}; //fine
int y = 0;//fine
int z(0); //error!
}

另一方面,不可拷贝的类型(比如std::atomic)只能使用小括号或者大括号来初始化,赋值符号则被踢出群聊

1
2
3
std::atomic<int> ai1{0}; //fine
std::atomic<int> ai2(0); //fine
std::atomic<int> ai3 = 0;//error!

这也是为什么大括号初始化是一致的,因为在任何场景下大括号初始化都能正常使用。

大括号初始化的一个很新颖的特性是,它会默认地阻止内建类型的收缩转型。如果被初始化的变量的类型不能100%地完全表达大括号内部的表达式的类型,编译会报错

1
2
3
4
double x=1, y=2;

int sum1 {x+y};
//error!收缩转型

如果使用赋值符号或者小括号进行初始化,编译器会略过类型检查,因为需要对老的代码保持兼容

1
2
3
4
int sum2(x+y);
//ok!
int sum3=x+y;
//ok!

免疫most vexing parse

另一个值得注意的优点是,大括号初始化对C++的令人烦恼的解析(Most vexing parse) - Wikipedia是免疫的。Most vexing parse这一现象在C中其实并不常见,但在C++中是一个问题,因为C++的一个原则是,任何可以被解释为声明的语句都必须被解释为声明。具体地,有时开发者仅仅是想调用一个函数,恰好这句话可以被解释为函数调用,也可以被解释为函数声明,编译器便根据标准,老老实实地把它当成声明对待,结果就出现了问题

举例来说,当我们希望调用Widget类的构造器来获得一个对象,通过有参构造时,一切正常进行,一旦我们试图调用无参构造函数,问题就出现了

1
2
3
4
5
Widget w1(10);
//ok!调用有参构造函数构造一个对象!
Widget w2();
//NO!编译器认为我们声明了一个函数,函数名是w2,参数为空,返回类型为Widget
//但是这句话也可以理解为声明一个变量w2,类型是Widget,并且调用无参构造函数为之初始化

但函数不能使用大括号来声明参数列表,因此使用括号初始化就可以逃过一劫

1
2
Widget w3{};
//ok!调用默认构造函数

看到这里,你可能会情不自禁地觉得,赞美C++11,赞美大括号初始化,后者的适用范围是如此之广,并且可以阻止隐式收缩转型和most vexing parse。那在使用新的标准写工程时,所以为什么不干脆把另外两种初始化方式的扬弃了呢?

局限性

大括号初始化也有它自己的问题,那就是在某些场合下它伴随的一系列令人意外的表现。这种情况通常比较少见,但是和三样东西脱不开干系——大括号初始化,std::initializer_list,以及构造函数重载版本的解析。

这是三种东西交融融在一起,发生的化学反应会使得我们的代码边的言行不一,看上去它会做一件事,而实际上做了另一件事

举例而言,Item2:理解auto的类型推导规则的讨论中提到,当一个使用auto声明的变量的右侧的表达式是大括号时,变量的类型将会被推导为std::initializer_list,而如果我们换一种声明方法,变量的类型就会更符合直觉。

其结果是,一个开发者越喜欢用auto,越会对大括号初始化不感冒

在调用构造函数时,仅当没有重载版本将std::initializer_list作为函数参数时,小括号初始化和大括号初始化是一致的

1
2
3
4
5
6
7
8
9
10
11
class Widget{
public:
Widget(int i, bool b);
Widget(int i, double d);
//构造函数的重载版本中,没有某个版本的参数是std::initializer_list
};

Widget w1(10, true); //调用第三行
Widget w11{10, true}; //调用第三行
Widget w2(10,10,123); //调用第四行
Widget w22{10,10.123};//调用第四行

然而,如果某一个构造函数的参数是std::initializer_list类型,在做重载解析时,编译器会极其偏爱那个重载版本,有多偏爱呢?如果有任何一种办法可以使用我们的大括号构造一个std::initializer_list对象,那么编译器就会不遗余力去做[1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Widget{
public:
Widget(int i, bool b);
Widget(int i,double d);
Widget(std::initializer_list<double> il);
//新增2个重载
};

Widget w1(10, true);
//调用第三行

Widget w11{10, true};
//将10和true都转型成double,调用第五行

Widget w2(10, 5.0);
//调用第四行

Widget w22{10, 5.0};
//将10和5.0都转型成double,调用第五行

不仅如此,有时候看起来应该调用拷贝构造器和移动构造器的调用,也会被使用std::initializer_list的构造器劫持[2]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Widget{
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<double> il);

operator double() const;//转型函数
};

Widget w3(w2); //调用拷贝构造器

Widget w4{w2}; //将w2转型成float,然后float被转型成long double,调用第五行

Widget w5(std::move(w2));//调用移动构造器

Widget w6{std::move(w2)};//调用第五行,原因同第十二行

编译器想要将大括号初始化匹配到以std::initializer_list为参数的构造器上的愿望是如此之强烈,有时甚至会导致这么一种情况:即使构造器的重载中,没有合适的std::initializer_list与实参相匹配,它仍然会头铁去硬匹配

1
2
3
4
5
6
7
8
9
class Widget{
public:
Widget(int i, bool b);
Widget(int i ,double b);
Widget(std::initializer_list<bool> il);
};

Widget w{10, 5.0};
//error! 出现内建类型的收缩转型

即使第四行的构造函数是参数的完美匹配,编译器还是头铁选择了第五行,并且尝试把int(10)double(5.0)转型为bool类型,这时收缩转型,由于大括号初始化明令禁止收缩转型,因此编译器报错

只有当编译器找不到参数转换的办法时,才会重新去看一眼其它类型的构造函数

1
2
3
4
5
6
7
8
9
10
11
class Widget{
public:
Widget (int i, bool b);
Widget (int i, double d);
Widget (std::initializer_list<std::string> il);
};

Widget w1(10, true);//调用第三行
Widget w2{10, true};//调用第三行
Widget w3(10, 5.0); //调用第四行
Widget w4{10, 5.0}; //调用第四行

将参数换成std::initializer_list<std::string>后,编译器开始变得正常,这是因为编译器不知道如何把intbool转换成std::string,也不知道如何将intdouble转换成std::string

这一特征引出了我们对大括号初始化和构造器重载探索的尽头,一个有趣且少见的情况。

假设我们需要初始化一个对象,如果在初始化时我们给了一个空的大括号,并且这个类恰好有默认构造函数和接收std::initializer_list的构造函数,那应该调用哪一个呢?

根据规则,应该调用默认构造函数,空大括号不代表一个空的std::initializer_list对象,而是代表没有参数[3]

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

Widget w1; //调用第三行
Widget w2{};//调用
Widget w3();//Most vexing parse,声明了一个函数,而非变量

如果就是希望用空的std::initializer_list对象来调用构造器,需要显式指明其作为构造函数的参数

1
2
3
4
5
6
Widget w4({});
//使用空的std::initializer_list对象来调用构造器

Widget w5{{}};
//需要根据情况而定,参考链接中的讨论
//https://scottmeyers.blogspot.com/2016/11/help-me-sort-out-meaning-of-as.html

到此为止,大括号初始化、std::initializer_list还有构造器重载之间的晦涩关系可能在你脑子里嗡嗡作响,这些东西究竟有什么用处呢,在实际开发中会遇到吗?直接受此影响的一个常用类就是std::vector,它有一个构造函数,使得我们在指定容器初始容量的同时指定每个元素的初始值,它同时还有一个构造函数,参数就是std::initializer_list,用来指定容器的初始元素

1
2
3
4
std::vector<int> v1(10,0);
//声明一个vector,初始有10个元素,每个元素的值是0
std::vector<int> v2{10,0};
//声明一个vector,初始有2个元素,是10和0

要点

当我们从上面讨论的细节中抽身而出,可以总结出两点。

首先,如果我们是某个类的设计者,我们需要明白,如果这个类的构造函数有若干个参数,并且有参数是是std::initializer_list的版本,使用大括号初始化的客户代码就只能看到以std::initializer_list为参数的构造器了。

因此,我们在设计构造函数时,最好能将它设计为不管用小括号还是大括号调用,结果都一样。std::vector构造函数的设计现在已经被认为是一个错误了,我们需要从中吸取教训,将自己的类设计得更好。

进一步的推论是,如果某个类以前没有构造器以std::initializer_list为参数,在后续的更新中加上了一个如此的版本,那么使用大括号进行对象构造的客户代码会发现自己的函数调用被编译器隐悄悄换掉了。当然,给任何函数添加新的重载版本都有可能导致这个情况,但问题的关键在于参数是std::initializer_list的构造器太强势,以至于其它函数很少被考虑到,在添加这种构造函数时一定要慎之又慎

另一点是,作为某个类的使用者,在创建对象时,我们需要仔细地在小括号和大括号之间进行选择。大多数开发者最终会偏向于只使用它俩之中的一种,而仅仅在不得不使用另一种的情况下做出改变。

使用大括号作为默认创建对象方式的开发者深受其泛用性、阻止收缩转型和免疫most vexing parse特点的吸引,并且保有理智,明白在特定情况下需要使用小括号来初始化对象。

使用小括号作为默认创建对象方式的开发者则认同其与C++98语法的一致性,在使用auto声明变量时不会将类型变成std::initializer_list类型,调用构造函数时能避免编译器的魂被std::initializer_list勾走。同时,祂们也承认使用大括号的必要性,在某些语境下改弦易辙。

理论上说,这两种方案都有其各自的特点,没有优劣之分,只需要选择一种并且一以贯之地执行

如果你是一个模板库的编写者,创建对象时小括号和大括号之间对峙会跟加尖锐,因为事实上来说,无法知道应该用小括号还是大括号。比如说,我们希望利用可变数量个参数来创建任意一个对象

1
2
3
4
template<typename T, typename ...Ts>
void doSomeWork(Ts&&... params){
//利用params... 来初始化一个T类型的对象
}

如何把第三行的注释变为代码,其实有两种写法

1
2
3
T localObject(std::forward<Ts>(params)...);
// or
T localObject{std::forward<Ts>(params)...};

再假设T的类型是std::vector<int>,那这两种初始化对象的方案所生成的对象就完全不同了,究竟哪一种是对的,库的作者完全不知道,只有函数的调用者才知道

这也是标准库函数std::make_unique和std::make_shared 面临的问题。标准库给出的解决方案是,函数内部使用小括号,并且将这个”特征“暴露在文档中

总结

大括号初始化方式是C++中适用范围最广的,它可以防止收缩转型和令人厌烦的解析(most vexing parse)

在选取构造函数版本时,即使有着更优的选择,编译器会不遗余力地将大括号初始化物匹配到参数为std::initializer_list的函数上

对于某些类而言,初始化时使用小括号和大括号很有不同,比如用两个参数来初始化std::vector


  1. 这里的代码和原著不同,原著的代码暂时无法通过编译,并且在勘误表上也没有找到相关说明。可以在这里看到原版的代码,第一行就是,问题出在std::initializer_list的模板参数数量上Effective Modern C++: 42 Specific Ways to Improve Your Use of C++11 and C++14 - Scott Meyers - Google 图书
  2. 关于构造函数劫持的部分,原著是如此书写的,但是笔者在自己的编译器上并没有发现这样的问题,在搜索引擎上也暂时没有找到相关的讨论
  3. 这里的说空的大括号代表没有参数,是要分情况讨论的,作者已经在自己博客上发表了勘误和进一步的讨论:The View from Aristeia: Help me sort out the meaning of “{}” as a constructor argument (scottmeyers.blogspot.com)

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!