《Effective Morden C++》Item 7: Distinguish between () and {} when creating objects

没有检索到摘要

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

引子

从本Item开始,我们就进入了第二个章节,本章节,S.M.通过对比C++的新老feature来鼓励大家使用new features. 本Item先从对象初始化开始。

正文

1.基础

C++11中引入了{}初始化,这样使得我们的初始化变得很丰富,我们可以用{}, ()= 三种初始化方法:

int x(0);
int y = 0;
int z{0};
int c = {0};

c++通常把c = {0}这种初始化方式看成和z{0}一样,后面我们不对其作出区分。那么x(0)y = 0又有什么区别呢?,对于基本类型来说没有任何区别,对于自定义类型则不一样:

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

那么对于{}()两种初始化又有什么区别呢,我们下面开始详细讨论这个故事。

2.{}初始化的基本功能

首先,我们先聊点C++11。在C++11里面,开发人员提出了一个概念 uniform initialization: 希望有一种统一的语法能够在任何情况下初始化一个对象。这个思想的一个实现就是{}初始化(尽管不完美)。

通过引入{}初始化,我们可以做如下初始化操作(C++11之前是难以实现的)。例如,直接初始化一个容器:

std::vector<int> v{ 1, 3, 5 }; // v's initial content is 1, 3, 5

可以给类中的非静态成员赋值,之前只能用=而不能用()完成:

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

此外,还可以给一些不能复制的变量初始化:

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

看了上面的例子,大家就能理解为什么说是uniform initialization了.

3. {}初始化的特殊特性

当然了,{}初始化也有自己的特殊之处:

首先是对于内置类型禁止窄化转换,而()=则允许这一点:

double x, y, z;
...

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

int sum2(x + y + z);        // okay
int sum3 = x + y + z;       // ditto

还有一个好处是免疫C++中最令人烦恼的歧义:most vexing parse.

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

但是如果把上面的()换成{},则变成了声明了一个Widget对象,且初始化参数为空。

由此可见,{}初始化除了统一以外,还有如上两个优点。所以大部分情况,我们都推荐用{}初始化。

4.{}初始化的缺点

当然{}初始化还是有一些坑的,尽管需要比较特殊的条件。但是并不是意味着开发者遇不到。我们下面也逐一介绍。

{}初始化,std::initializer_list和构造函数重载(overload)同时出现时,会出现奇特的情况。例如在Item 2里面我们提到过当auto关键字声明的对象使用{}初始化时,那么类型会被推导成std::initializer_list,哪怕此时存在一个更匹配的类型。在构造函数里面也会出现类似的情况。

事实上,当没有声明std::initializer_list相关的构造函数时,{}()调用同样的构造函数。但是如果存在至少一个以std::initializer_list为参数的构造函数时,当用户使用{}初始化时,编译器会尽量重载使用std::initializer_list构造函数而不是其他构造函数。例如:

class Widget {
public:
  Widget(int i, bool b);
  Widget(int i, double d);
  Widget(std::initializer_list<long double> il); // added
  ...
};

Widget w1(10, true);    // calls first ctor
Widget w2{10, true};    // uses braces, but now calls std::initializer_list ctor
                        // (10 and true convert to long double)

Widget w3(10, 5.0);     // calls second ctor
Widget w4{10, 5.0};     // uses braces, but now calls std::initializer_list ctor
                        // (10 and 5.0 convert to long double)

注意到上面的w2w4,由于使用{}初始化,因此都调用了std::initializer_list构造函数,哪怕需要隐式转换,哪怕有比它更合适的其他构造函数。

甚至,更极端地,在一些情况下,连拷贝构造函数和move构造函数,也会由于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;   // convert Widget to float
  ... 
};

Widget w5(w4);              // uses parens, calls copy ctor

Widget w6{w4};              // uses braces, calls 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)};   // uses braces, calls std::initializer_list ctor
                            // (for same reason as w6)

因为在使用{}初始化时,编译器过于偏爱std::initializer_list构造器,因此会想尽一切办法使用它:先将Widget对象转换成float类型,再调用std::initializer_list构造器,最后将float类型又隐式转换成long double类型完成构造!这显然是十分不合理,不自然的。

不仅仅如此,还有更糟糕的事情,例如:

class Widget {
public:
  Widget(int i, bool b);                     
  Widget(int i, double d);                   
  Widget(std::initializer_list<bool> il);    // element type is now bool
...                                          // no implicit conversion funcs
};
Widget w{10, 5.0};                           // error! requires narrowing conversions

上面我们将std::initializer_list参数换成bool,这样在构造w时,还是优先调用std::initializer_list构造器,发现需要将int 10double 5.0窄化转换成bool类型,而窄化转换在{}初始化中是被禁止的。此时会出现编译错误,尽管存在其他合法的构造函数!

按照这样说,岂不是{}初始化都会匹配std::initializer_list构造函数(如果存在的话)了么?也不全是,如果没法将{}中的变量类型转化成std::initializer_list中的参数类型时,则会使用其他构造函数。例如:

class Widget {
public:
  Widget(int i, bool b);              
  Widget(int i, double d);

  // std::initializer_list element type is now std::string
  Widget(std::initializer_list<std::string> il);
  ... // no implicit conversion funcs
}


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

由于不存在intdoublestd::string的转换,因此编译器老老实实地匹配了其他构造函数。

由此可见在涉及{}初始化和std::initializer_list的场景一定要小心慎重。

5.{}初始化的其他主意事项

最后我们看几个其他比较特殊的cases。

1.当std::initializer_list构造函数和默认构造函数同时存在时,如果你使用了{}初始化且内部没有其他参数,那么这个时候编译器怎么对待这次构造呢。事实上,我们有:

class Widget {
public:
  Widget();                                 // default cto  
  Widget(std::initializer_list<int> il);    // std::initializer_list ctor
  ...                                       // no implicit conversion funcs
};

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

如果你想让编译器使用std::initializer_list构造器,你应当像下面这样

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

我们需要注意到,如果将上面的()初始化换成{}初始化而其他的不变,编译器会认为你是调用了含有一个元素{}std::initializer_list构造器,而不是空的std::initializer_list构造器,具体的讨论见这个博客

Widget w4({}); // the constructor is called with a one-element     
               // std::initializer_list, not an empty one. 

第二个特殊情况是,在使用std::vector<numeric type>时,且传入参数为两个时,()初始化和{}初始化表现出截然不同的特性,这一点需要牢记:

std::vector<int> v1(10, 20);     // use non-std::initializer_list 
                                 // ctor: create 10-element
                                 // std::vector, all elements have
                                 // value of 20

std::vector<int> v2{10, 20};     // use std::initializer_list ctor: 
                                 // create 2-element std::vector,
                                 // element values are 10 and 20

最后一点,由于上面的几个特殊cases,导致{}初始化和()初始化在涉及到模板时,此时的选择更是无迹可寻。例如:

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

上面的函数内部,可以用如下两种方式来创建T对象,例如

T localObject(std::forward<Ts>(params)...);     // using parens

T localObject{std::forward<Ts>(params)...};     // using braces

两种方法都是可能的,而且对于外部调用者我们无法知道函数的创造者使用了哪个初始化方法。这样,当我们运行如下代码时:

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

会出现两种情况:可能创建了一个含有10个元素,每个都是20的对象,也可能创建了含有两个元素(10和20)的对象。而这完全由函数的创造者决定,我们调用人员无能为力,这显然是不能接受的。对于这种问题我们没有好的解决方案,只能在注释中写明。例如std::make_sharedstd::make_unique就使用了()初始化并在注释中说明了这一点。

总结

说了这么多,终于到总结部分了:

1.{}初始化是最广泛的初始化语法,它禁止窄化转换,并且对most vexing parse免疫。
2.如果用std::initializer_list重载了构造函数,那么{}初始化会尽量匹配std::initializer_list构造函数,哪怕需要做隐式转换,哪怕有其他更匹配的构造函数。这在一些极端情况可能导致编译错误。
3.(){}初始化在涉及到恰有两个参数的数字类型std::vector时会给出不同的构造结果,这一点尤要注意。
4.模板里面究竟使用()初始化还是{}初始化是一个很有挑战的事情,并没有很好的解决办法,只能再函数接口注释里写明白。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值