Effective Modern C++: Item 7 -> 创建对象时分清()和{}

当说到大名鼎鼎的特性,C++11和C++14有许多可以吹。auto,智能指针,移动语义,lambda表达式,并行—-每一个都是如此重要,值得我用一个章节来阐述。掌握这些特性很重要,但是成为一名有效率的现代C++程序员也需要走过一系列更小的步骤。每一步回答一个专门的问题,这些问题是从C++98转向现在C++11过程会出现的。当你创建对象时,什么时候应该使用大括号而不是小括号?为什么别名声明要比typedef要好?constexpr和const有什么区别?const成员函数和线程安全之间有什么关系?这份列表还在继续。本章将一个一个的提供解答。

创建对象时分清()和{}

取决于你的观点,C++11中的对象初始化语法选择要么就表现地非常不堪,要么就非常令人迷惑。作为一个通用规则,指定初始化值可以使用大括号,等号或者小括号。

int x(0);       //initializer is in parentheses

int y = 0;      //initializer follows "="

int z{0};       //initializer is in braces

在许多情况下,也可以共同使用一个等于号和大括号:

int z = {0};    //initializer uses "=" and braces

对于本条款后面的部分,我会忽略这种等号+大括号的语法形式,因为C++对其的处理方式一般和只有大括号那种一样。

迷惑方指出使用一个等号来初始化经常误导C++新手,让他们误认为这是赋值在其作用,即使那并不是。对于内建类型如int,区别很理论化,但是对于用户自定义类型,能够区分初始化和赋值就很重要,因为不同的函数会被涉及到:

Widget w1;      //call default constructor

Widget w2 = w1; //not an assignment; call copy ctor

w1 = w2;        //an assignment; call copy operator=

即使在一些初始化语法上,也有一些情况下,C++98是没法表达出一个想要的初始化的。例如,不可能直接指示一个STL容器被创建时应该持有一个专门的值的集合(比如1,3,5)。

为了解决多个初始化语法的困惑,同时也基于它们并没有涵盖所有的初始化场景,C++11引入了统一初始化(uniform initialization):一个单一的初始化语法,但是可以,至少在概念上可以,被用在任何地方,表达任何事物。它基于大括号,而基于这个原因我更喜欢称之为大括号初始化。“统一初始化”是一个主意,“大括号初始化”则是一个语法结构。

大括号初始化能够让你表达出之前无法表达式东西。使用大括号,指定一个容器的初始化内容就很容易:

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

大括号也能够被用来指定non-static数据成员的默认初始化值。这一能力—对C++11来说是全新的—也被用于等号初始化语法上,但没有包括小括号:

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

另一方面,不可拷贝对象(如std::atomic —参考Item 40)可以使用大括号或者小括号进行初始化,但是不能用等号:

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

很容易理解为什么大括号初始化被称为“统一初始化”。在C++三种初始化表达式的方式中,只有大括号可以用于所有地方。

大括号初始化的新奇特性就是它不允许内置类型间的隐性收缩转换(implicit narrowing conversions)。如果大括号初始化里面的表达式的值不能够被要被初始化的对象类型所表达,那么该代码不能够通过编译:

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

而使用小括号和等号进行的初始化则不检查收缩转换,因为这样可能会破坏太多目前合法的代码:

int sum2(x+y+z);    //ok (value of expression truncated to as int)
int sum3 = x+y+z; //ditto

大括号初始化另一个值得注意的特性就是它对于C++的most vexing parse免疫。C++规则的一个副作用就是任何可以被解析成声明的东西一定会被翻译成声明,most vexing parse最常影响开发者的是当他们想要默认构造一个对象时,但是最终却以声明一个函数告终。问题的根源在于如果你想要调用一个只有一个参数的构造器,你可以像这样写:

Widget w1(10);  //call Widget ctor with argument 10

但是如果你尝试使用相同的语法来调用Widget没有参数的构造器,你其实是声明了一个函数,而不是一个对象:

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

而函数是不能用大括号来包含参数列表进行初始化的,所以使用大括号来默认构造一个对象就没有上面的问题:

Widget w3{};    //calls Widget ctor with no args

所以对于大括号初始化其实有很多可以说的。这种语法可以被用于最广泛的上下文中,它防止了隐性收缩变换,并且它对于C++的most vexing parse免疫。三连胜式的好处!所以为什么本条款的名字不取成类似“完美的大括号初始化语法”这种?

大括号初始化的缺点是伴随它的有时候出现的令人吃惊行为。这种行为是由大括号初始化表达式,std::initializer_list和构造函数的重载决议之间错综复杂的关系造成的。它们之间的互动会导致代码看起来像是应该做这件事,但实际上做的确实另一件事。例如,Item 2解释了当一个auto声明的变量使用了大括号初始化表达式,那么推断出来的类型就是std::initializer_list,尽管按照其他声明方式来使用相同的初始化表达式会得到一个更加直观的类型。结果就是,你越喜欢用auto,你可能就越不对大括号初始化感兴趣。

在构造函数的调用中,小括号和大括号有着相同的含义,只要std::initializer_list参数没有涉及到:

class Widget{
public:
    Widget(int i, bool b);       //ctor 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类型的参数,那么使用大括号初始化的调用则更加倾向于使用接受std::initializer_list参数的重载版本。非常强烈!如果编译器能够用接受std::initializer_list的构造函数来表达使用大括号初始化的调用,那么编译器肯定会选择这种方式。比如,如果上面的Widget类再加上一个接受std::initializer_list的构造函数:

class Widget{
public:
    Widget(int i, bool b);//as before
    Widget(int i, doubel d);//as before

    Widget(std::initializer_list<long double> il);//added
    ...
}

那么Widget对象w2和w4就要使用新的构造函数来构造了,尽管std::initializer_list中元素类型是long double,跟非std::initializer_list的构造函数相比,其对于两个参数的匹配程度都更差了!看:

Widget w1(10,true);//uses parens and ,as before,
                    //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);//uses parens and ,as before,
                    //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)

即使是正常情况下应该使用的复制和移动构造函数也可能被std::initializer_list构造器劫持:

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

编译器对于将使用大括号初始化与接受std::initializer_list的构造函数进行匹配的决定非常强烈,即使这最佳匹配的std::initializer_list构造函数不能被调用,它也坚持调用它。例如:

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
    ...                                    //no implicit conversion funcs
};

Widget w{10,5.0};   //error!requires narrowing conversions

这里,编译器会忽略前两个构造函数(第二个其实和那两个参数类型完全匹配)并且尝试调用接受std::initializer_list<bool>的构造函数。调用该构造函数需要将int(10)和double(5.0)转换成bool。这两个转换都是收缩转换(bool不能准确的表达出其中任意一个的值),但是收缩转换在大括号初始化中是禁止的,所以这个调用是非法的,代码编译报错。

只有当编译器没有办法将大括号初始化表达式中的参数类型转换成std::initializer_list中的类型,它才会按照正常的重载决议来选择重载函数。例如,如果我们将std::initializer_list<bool>构造函数替换成std::initializer_list<std::string>构造函数,那么non-std::initializer_list的构造函数就又称为候选成员啦,因为是没有办法将int和bool转换成std::string类型的。

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

    //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 callls second ctor
Widget w4{10,5.0};  //uses braces,now calls second ctor

我们对于大括号初始化和构造函数重载的说明到这差不多就快结束了,但是有一个有趣的边缘case需要注意。假设你使用一个空的大括号来构造一个对象,这种方式既支持默认的构造函数,又支持std::initializer_list构造函数。那么这个空大括号到底意味着什么?如果它们表示”没有参数”,那么应该调用默认构造函数,但是如果它们表示”空的std::initializer_list”,那么应该调用std::initializer_list构造函数。

规则指定应该调用默认构造函数。空的大括号表示没有参数,而不是一个空的std::initializer_list:

class Widget {
public:
    Widget(); // default ctor
    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的std::initializer_list构造函数,你可以在构造函数参数里面这么做–将空的大括号放到大括号或者小括号里面:

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

Widget w5{{}};          //ditto

这个时候,随着大括号初始化表达式,std::initializer_list和构造函数重载之间晦涩难懂的规则在你脑海中旋转,你可能想知道这部分信息跟你平时编程到底有多大关系。比你想象的还要大,因为一个直接受其影响的类就是std::vector。std::vector有一个non-std::initializer_list的构造函数,其允许你指定容器的初始大小和每一个元素的初始值,但是std::vector还有一个接受std::initializer_list的构造函数,允许你指定容器中的初始值。如果你创建了一个数值类型的std::vector(比如说一个std::vector<int>)并且传递了两个参数进去,你选择使用大括号还是小括号将会产生巨大的不同:

std::vector<int> v1(10,20);//use non-std::initializer_list ctor: 
                            //create 10-elemtns 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

但是咱们从std::vector和大括号,小括号以及构造函数重载决议规则中跳回来。上面的讨论中有两点主要收获。第一,作为一个类作者,你需要意识到如果你的重载构造函数中包含一个或几个接受std::initializer_list的函数,那么使用大括号初始化表达式的客户代码可能只能看到std::initializer_list的重载构造函数。作为结果,最好设计你的构造函数,使得重载调用不会被客户使用大括号还是小括号影响。换句话说,你应该从std::vector中学习,虽然这些在现在看来可能是错误的,然后设计你的类去避免它。

从上面得到的一个启发就是如果你有一个类,它没有std::initializer_list构造函数,然后你加了一个,那么使用大括号初始化的客户代码可能发现以前决议成non-std::initializer_list构造函数的调用现在决议成了新函数。当然,当你在一堆重载函数中增加一个新函数,这种事情随时都可能发生:以前决议成一个老的重载函数的调用现在可能开始调用新的函数了。std::initializer_list构造函数重载不同之处在于,它不是单纯的和其他重载进行竞争,而是它的存在会把其他重载置于一种编译器基本上都不会考虑的境地。所以,增加这种重载真的需要深思熟虑。

第二,作为一个类的使用者,你在创建对象的时候必须在大括号和小括号之间认真选择。大部分开发者一般就选择一种作为默认,只有在不得已的时候才使用另外一种。默认使用大括号的那群人被大括号无敌的适用性,对收缩转换的禁止性以及对C++ most vexing parse的免疫性所吸引。这群人明白在一些case里(比如创建一个给定大小和初始值的std::vector时),小括号是必须的。另一方面,小括号的拥护者们则被它与C++98语法传统的一致性,其对于auto关于std::initializer_list的类型推断导致的问题的免疫性,还有当创建对象时其构造函数不会无意被std::initializer_list构造函数伏击等事实所吸引。他们承认有些时候只有大括号可以做到(比如创建一个拥有 特定值的容器)。谁更好其实并没有一个定论,我的建议就是选择一个并且坚持使用。

如果你是一个模板作者,大括号和小括号之间的关系就紧张到白热化了,因为,一般来说,根本没法知道哪一个会被使用到。例如,假设你想创建一个任意类型的接收任意数量参数的对象,一个可变模板让这一切变得直观:

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...
    ...
}

有两种方法可以将上面的伪代码转换成真实代码(关于std::forward请参考Item 25)

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

所以考虑下面的调用代码:

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

如果doSomeWork在创建localObject的时候使用的是小括号,那么结果就是std::vector有10个元素。而如果doSomeWork使用的是大括号,则结果就是std::vector只有2个元素。哪一个是正确的?doSomeWork的作者没法知道。只有调用者才知道。

而这正是标准库函数std::make_unique和std::make_shared(见Item 21)所面临的问题。这些函数通过内部使用小括号并且将该决定记录下来作为其接口的一部分来解决这个问题。

要点记忆

  • 大括号初始化语法是用处范围最广的初始化语法,它可以防止收缩转换并且对C++的most vexing parse免疫
  • 在构造函数重载决议中,大括号初始化会尽一切可能匹配std::initializer_list参数版本的构造函数,即使其他构造函数似乎看起来更加匹配
  • 一个使用大括号和小括号会造成巨大差异的例子就是创建一个有两个参数的std::vector<数值类型>的对象
  • 在模板内的对象创建过程中选择大括号还是小括号是很有挑战性的。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值