C++使用()和{}创建对象的区别
本文主要参考《effective morden cpp》的条款7,结合了自己的理解
在C++中,我们初始化有一下几种方式:
C++
int x(0); //使用圆括号初始化
int y = 0; //使用"="初始化
int z{ 0 }; //使用花括号初始化
int z = { 0 }; //使用"="和花括号
在C++中,int z = { 0 };
,这个等号初始化被视作和只有花括号一样int z{ 0 };
,所以接下来我们只会讨论其中一种情况。由此可见,C++对象初始化的语法十分丰富,让人难以选择,或是乱的一塌糊涂。"乱的一塌糊涂"是指在初始化中使用"="可能会误导C++新手,使他们以为这里发生了赋值运算,然而实际并没有。
对于C++中的内置类型比如int或是bool可能没什么区别,但是对于用户定义的类型而言,区别赋值运算符和初始化就非常重要了,因为它们涉及不同的函数调用:
C++
Widget w1; //调用默认构造函数
Widget w2 = w1; //不是赋值运算,调用拷贝构造函数
w1 = w2; //是赋值运算,调用拷贝赋值运算符(copy operator=)
C++使用统一初始化来整合这些不同情景的初始化语法,所谓统一初始化是指在任何涉及初始化的地方都使用单一的初始化语法。 这个初始化是基于花括号的,也可以叫括号初始化。
括号初始化让你可以表达以前表达不出的东西。使用花括号,创建并指定一个容器的初始元素变得很容易,括号初始化也能被用于为非静态数据成员指定默认初始值:
C++
std::vector<int> v{ 1, 3, 5 }; //v初始内容为1,3,5
class Widget{
...
private:
int x{ 0 }; //没问题,x初始值为0
int y = 0; //也可以
int z(0); //错误!
}
另一方面,不可拷贝的对象可以使用花括号或者圆括号初始化,因为如果使用"="初始化的话会触发拷贝赋值函数,而不可拷贝的对象通常没有这个函数或者以删除:
C++
std::atomic<int> ai1{ 0 }; //没问题
std::atomic<int> ai2(0); //没问题
std::atomic<int> ai3 = 0; //错误!
括号表达式还有一个少见的特性,即它不允许内置类型间隐式的变窄转换。如果一个使用了括号初始化的表达式的值,不能保证由被初始化的对象的类型来表示,代码就不会通过编译。但是使用圆括号和"="的话就不检查是否转换为变窄转换:
C++
double x, y, z;
int sum1{ x + y + z }; //错误!double的和可能不能表示为int
int sum2(x + y +z); //可以(表达式的值被截为int)
int sum3 = x + y + z; //同上
最重要的是使用括号初始化对于C++最令人头疼的解析问题天生免疫。C++规定任何可以被解析 为一个声明的东西必须被解析为声明,比如我们可能想创建一个使用默认构造函数构造的对象,却不小心变成了函数声明。接下来的应用场景可以说明问题:
C++
Widget w1(10); //使用实参10调用Widget的一个构造函数
//使用相似的语法调用Widget无参构造函数,它就会变成函数声明
Widget w2(); //最令人头疼的解析!声明一个函数w2,返回Widget
//由于函数声明中形参列表不能带花括号,所以使用花括号初始化表明你想调用默认构造函数构造对象就没有问题
Widget w3{}; //调用没有参数的构造函数构造对象
但是使用括号初始化也有一定的缺点,我们通过auto和{}共同使用来解释。auto x = { 27 };
这段代码看起来x是一个int类型的变量,但是实际上是std::initializer_list<int>
类型,当用auto
声明的变量使用花括号进行初始化,auto
类型推导推出的类型则为std::initializer_list
。如果这样的一个类型不能被成功推导(比如花括号里面包含的是不同类型的变量),编译器会拒绝这样的代码:
C++
auto x5 = { 1, 2, 3.0 }; //错误!无法推导std::initializer_list<T>中的T
在构造函数调用中,只要不包含std::initializer_list
形参,那么花括号初始化和圆括号初始化都会产生一样的结果。然而,如果有一个或者多个构造函数的声明包含一个std::initializer_list
形参,那么使用括号初始化语法的调用更倾向于选择带std::initializer_list
的那个构造函数。如果编译器遇到一个括号初始化并且有一个带std::initializer_list的构造函数,那么它一定会选择该构造函数。
下面看例子:
C++
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<long double> il);
...
};
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
的构造函数劫持:
C++
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::initializer_list
的构造函数的决心是如此强烈,以至于就算带std::initializer_list
的构造函数不能被调用,它也会硬选。
C++
class Widget {
public:
Widget(int i, bool b); //同之前一样
Widget(int i, double d); //同之前一样
Widget(std::initializer_list<bool> il); //现在元素类型为bool
... //没有隐式转换函数
};
Widget w{10, 5.0}; //错误!要求变窄转换
这里,编译器会直接忽略前面两个构造函数,然后尝试调用std::initializer_list<bool>
构造函数。调用这个函数将会把int(10)
和double(5.0)
转换为bool
,由于会产生变窄转换(bool
不能准确表示其中任何一个值),括号初始化拒绝变窄转换,所以这个调用无效,代码无法通过编译。
只有当没办法把括号初始化中实参的类型转化为std::initializer_list
时,编译器才会回到正常的函数决议流程中。比如我们在构造函数中用std::initializer_list<std::string>
代替std::initializer_list<bool>
,这时非std::initializer_list
构造函数将再次成为函数决议的候选者,因为没有办法把int
和bool
转换为std::string
:
C++
class Widget {
public:
Widget(int i, bool b); //同之前一样
Widget(int i, double d); //同之前一样
//现在std::initializer_list元素类型为std::string
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
构造函数在我们日常C++编程很常见。std::vector
有一个非std::initializer_list
构造函数允许你去指定容器的初始大小,以及使用一个值填满你的容器。但它也有一个std::initializer_list
构造函数允许你使用花括号里面的值初始化容器。如果你创建一个数值类型的std::vector
(比如std::vector<int>
),然后你传递两个实参,把这两个实参放到圆括号和放到花括号中有天壤之别:
C++
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
到此,我们可以得出一些重要的结论,首先你需要意识到如果一堆重载的构造函数中有一个或者多个含有std::initializer_list
形参,用户代码如果使用了括号初始化,可能只会看到你std::initializer_list
版本的重载的构造函数。因此,你最好把你的构造函数设计为不管用户是使用圆括号还是使用花括号进行初始化都不会有什么影响。
在实际开发中,如果一个类没有std::initializer_list
构造函数,然后你添加一个,用户代码中如果使用括号初始化,可能会发现过去被决议为非std::initializer_list
构造函数而现在被决议为新的函数。当然,这种事情也可能发生在你添加一个函数到那堆重载函数的时候:过去被决议为旧的重载函数而现在调用了新的函数。std::initializer_list
重载不会和其他重载函数比较,它直接盖过了其它重载函数,其它重载函数几乎不会被考虑。所以如果你要加入std::initializer_list
构造函数,请三思而后行。
我们在考虑使用()还是{}创建对象时也要谨慎考虑,应该选择其中一种作为默认情况,只有当他们不能使用这种的时候才会考虑另一种。默认使用花括号初始化的开发者主要被适用面广、禁止变窄转换、免疫C++最令人头疼的解析这些优点所吸引。默认使用圆括号初始化的开发者主要被C++98语法一致性、避免std::initializer_list
自动类型推导、避免不会不经意间调用std::initializer_list
构造函数这些优点所吸引。
在模板函数初始化中也会出现歧义:
C++
template<typename T,
typename... Ts>
void doSomeWork(Ts&&... params)
{
create local T object from params...
...
}
T
是你要创建的对象的类型 ,比如 std::string
、MyClass
等。Ts...
是一个参数包 ,表示任意数量和类型的参数,作为传入 T
构造函数的实参。
Ts&&...
表示万能引用(forwarding reference) ,可以绑定到左值或右值。params...
是函数参数包,实际调用中会被展开成多个参数。
我们有两种方式实习这个伪代码:
c++
T localObject(std::forward<Ts>(params)...); //使用圆括号
T localObject{std::forward<Ts>(params)...}; //使用花括号
考虑这样的调用代码:
C++
std::vector<int> v;
...
doSomeWork<std::vector<int>>(10, 20);
如果doSomeWork
创建localObject
时使用的是圆括号,std::vector
就会包含10个元素。如果doSomeWork
创建localObject
时使用的是花括号,std::vector
就会包含2个元素。哪个是正确的?doSomeWork
的作者不知道,只有调用者知道。所以我们为了避免歧义,应该统一在文档中规定使用圆括号还是花括号。