Effective Modern C++ 条款7 创建对象时区分( )和{ }

    xiaoxiao2025-02-08  12

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

    基于你的看法,C++11的对象初始化的语法选择是不堪和混乱的。总的来说,初始值可以借助大括号”{}”,等号”=”,圆括号”()”:

    int x(0); // 初始值在圆括号内 int y = 0; // 初始值跟在等号后面 int z{0}; // 初始值在大括号内

    在很多例子中,我们可以同时使用大括号和等号: int z = {0}; 在本条款剩余的内容中,我会忽略这种语法,因为C++通常会把这种语法当作只用大括号语法对待。

    使用等号初始化经常会让C++初学者认为会进行一次赋值,但不是那样的。对于内置类型,例如int,初始化和赋值操作的差别是模糊的,但是对于用户定义的类,区分初始化和赋值操作是很重要的,,因为这会导致不同的函数调用:

    Widget w1; // 调用默认构造函数 Widget w2 = w1; // 不是赋值操作,调用拷贝构造函数 w1 = w2; // 赋值操作,调用operator=函数

    就算C++98有多种初始化语法,但是有些情况也无法得到想要的初始值,例如,STL容器无法直接用一组数来初始化。

    因为初始化的语法很混乱,而且有些情况无法实现,所以C++11提出了统一初始化(uniform initailization)语法:一种至少在概念上可以用于表达任何值的语法。它的实现基于大括号,所以我称之为大括号初始化(braced initialization)。统一初始化是一个想法,大括号初始化是句法表现。

    大括号初始化可以让你描述以前无法描述的初始值。使用大括号可以更容易的初始化容器: std::vector<int> v{1, 3, 5};

    大括号也可以用于类内成员的默认初始值,在C++11中,等号”=”也可以实现,但是圆括号”()”则不可以:

    class Widget { ... private: int x{ 0 }; // x的默认初始值为0 int y = 0; // 同上 int z( 0 ); // 报错 }

    另一方面,不可拷贝对象(例如,std::atomic)可以用大括号和圆括号初始化,但不能用等号:

    std::atomic<int> ai1{ 0 }; // 可以 std::atomic<int> ai2( 0 ); // 可以 std::atomic<int> ai3 = 0; // 报错

    所以不难理解为什么大括号初始化会被称为”统一的“,因为在C++三种初始化语法中,只有大括号初始化可以用于任何情况。

    大括号初始化有一个奇怪的特性,就是它会禁止内值类型间的隐式的范围窄化转换(narrowing conversions,也就是精度降低,范围变窄)。如果被初始化的变量的类型无法保证能被大括号内的值的类型所表述(本人理解是发生了类型截断,溢出等情况),那么代码无法通过编译(本人测试代码可以编译运行…):

    double x, y, z; ... int sum1{x + y + z}; // 报错,double值的和可能在int中无法表述

    使用圆括号和等号初始化不会检测隐式的窄化转换,这是为了与旧代码兼容:

    int sum2(x + y + z); //通过,值被截断为int int sum3 = x + y + z; // 同上

    大括号初始化的另一个值得注意的特性是它会免疫C++中的most vexing parse(最让人头痛的歧义?)。当开发者想要一个默认构造的对象时,经常受到most vexing parse的折磨,因为程序会不经意地声明个函数代替构造对象。这根本原因是当你你想要调用一个带参数的构造函数时,你可以这样:

    Widget w1(10); // 调用Widget的带参构造函数

    但当你尝试用类似的语法调用无参构造时,你声明了个函数,而不是创建对象:

    Widget w2(); // most vexing parse,声明了一个返回Widget的函数

    使用大括号包含参数是无法声明为函数的,所以使用大括号默认构造对象不会出现这个问题:

    Widget w3{};

    我们讲了很多大括号初始化的内容,这种语法可以用于多种场景,还可以避免隐式范围窄化转换,又免疫C++的most vexing parse问题。一举多得,那么为什么这条款不起名为”用大括号初始化语法替代其他“呢?

    大括号初始化的缺点是它有时会显现令人惊讶的的行为。这些行为的出现是因为与std::initializer_list混淆了。它们交互时会让代码不像表面上那样运行。例如,在条款2中讲述auto推断带大括号初始值时,会把类型推断为std::initializer_list。

    在构造函数中,只要形参不带有std::initializer_list,圆括号和大括号行为一致:

    class Widget { public: Widget(int i, bool b); Widget(int i, double d); ... }; Widget w1(10, true); // 调用第一个构造函数 Widget w2{10, true}; // 调用第一个构造函数 Widget w3(10, 5.0); // 调用第二个构造函数 Widget w4{10, 5.0}; // 调用第二个构造函数

    但是,如果构造函数的形参带有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); ... };

    那么w2和w4会使用新的构造构造函数,尽管std::initializer_list的元素是long double,比起另外两个构造函数的参数来说是更差的匹配:

    Widget w1(10, true); // 使用圆括号,调用第一个构造函数 Widget w2{10, true}; // 使用大括号,调用第三个构造函数 // 10 和 true被转换为long double Widget w3(10, 5.0); // 使用圆括号,调用第一个构造函数 Widget w4{10, 5.0}; // 使用大括号,调用第三个构造函数 // 10 和 true被转换为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}; // 使用大括号,调用第三个构造函数 // 原因是先把w4转换为float,再把float转换为long dobule Widget w7(std::move(m4)); // 使用圆括号,调用移动构造函数 Widget w8{std::move(m4)}; // 使用大括号,调用第三个构造函数,理由同w6

    编译器用带有std::initializer_list构造函数匹配大括号初始值的决心是 如此的坚定,就算带有std::initializer_list的构造函数是无法调用的:

    class Widget { public: Widget(int i, bool b); Widget(int i, double d); Widget(std::initializer_list<bool> il); // long double 改为 bool ... }; Widget w{10, 5.0}; // 报错,因为发生范围窄化转换

    就算是这样,编译器也会忽略另外两个构造函数(第二个还是参数精确匹配的),并且尝试调用带有std::initializer_list<bool>的构造函数。而调用第三个构造函数会让一个int(10)值和一个double(5.0)值转换为bool类型。这两个转换都是范围窄化转换(bool的大小不能准确描述它们的值),然而窄化转换在大括号初始化语法中是被禁止的,所以这个函数调用无效,代码无法编译通过。

    只有当大括号内的值无法转换为std::initializer_list元素的类型时,编译器才会使用正常的重载选择方法,例如把上面的std::initializer_list<bool>改为std::initializer_list<std::string>,那么那些非std::initializer_list构造函数会重新成为候选函数,因为没有办法从数值转换为std::string:

    class Widget { public: Widget(int i, bool b); Widget(int i, double d); Widget(std::initializer_list<std::string> il); // bool 改为 std::string ... }; 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进行构造。

    正确答案是你将使用默认构造,一个空的大括号表示的是没有参数,而不是一个空的std::initializer_list:

    class Widget { public: Widget(); Widget(std::initializer_list<int> il); ... }; Widget w1; // 调用默认构造函数 Widget w2{}; // 调用默认构造函数 Widget w3(); // 出现most vexing parse,声明了一个函数

    如果你想要用一个空的std::initializer_list参数来调用带std::initializer_list构造函数,那么你需要把大括号作为参数,即把空的大括号放在圆括号内或者大括号内:

    Widget w4({}); // 用了一个空的list来调用带std::initializer_list构造函数 Widget w5{{}}; // 同上

    此时此刻,大括号初始化,std::initializer_list,构造函数重载之间的复杂关系在你的大脑中冒泡,你可能想要知道这些信息会在多大程度上关系到你的日常编程。可能比你想象中要多,因为std::vector就是一个被它们直接影响的类。std::vector中有一个可以指定容器的大小和容器内元素的初始值的不带std::initializer_list构造函数,但它也有一个可以指定容器中元素值的带std::initializer_list函数。如果你想要创建一个数值类型的std::vector(例如std::vector),然后你要传递两个值作为构造函数的参数,那么使用大括号与圆括号的行为是不同的:

    std::vector<int> v1(10, 20); // 使用不带std::initializer_list的构造函数 // 创建10个元素的vector,每个元素的值为20 std::vector<int> v2{10, 20}; // 使用带std::initializer_list的构造函数 // 创建2个元素的vector,元素值为10和20

    我们先忽视std::vector和圆括号,大括号,构造函数重载规则。这次讨论不涉及两个要素。首先,作为一个类的作者,你需要知道如果你的构造函数集中包含一个带std::initializer构造函数,客户代码中使用了大括号初始化的话看起来好像只有带std::initializer构造函数。因此,你最好把构造函数设计得重载调用不受大括号和圆括号影响。换句话说,把上面std::vector出现的情况中当作错误,自己写代码时应该避免这样。

    一种纠纷,比如说,你的类一开始不含有带std::initializer_list构造函数,后来你加了一个,那么用户会发现,原理使用大括号初始化时选择的是不带std::initializer_list构造函数,而现在全部都选择带std::initializer_list构造函数。当然,这种事情在你为重载函数再添加一个实现时也有可能发生:本来是调用旧的重载函数可能会选择新加入的函数。不过std::initializer_list构造函数的不同之处是,带std::initializer_list构造函数不用与其它构造函数竞争,它直接遮蔽了其它的构造函数。所以加入带std::initializer_list需要深思熟虑。

    第二个要讲的是作为类的使用者(用户),你创建对象时必须在使用大括号还是圆括号上仔细考虑。大多数开发者最后会选择一种符号作为默认使用,而使用另一种符号当它无可避免的时候。使用大括号作为默认符号的人喜欢大括号的广泛适用性,禁止窄化转换,避免C++的most vexing parse。这些人知道在某种情况下(例如,上面的vector),圆括号是必须的。另一方便,使用圆括号作为默认符合的人们,沿袭着C++98的语法传统,又可以避免auto-deduced-a-std::initializer_list问题,还知道重载构造函数的选择不受std::initializer_list构造函数的拦截,他们也会让步当只有大括号才能实现时(例如用特殊的值来初始化容器)。这里没有哪个更好的说法,所以我的建议是选择一种符号并且大部分情况下使用它。


    如果你是模板的作者,那么大括号和圆括号的关系会变得很难搞,因为你太可能使用哪一种。例如,你想通过随意的数值参数来创建一个数值类型对象。一个可变参数模板在概念上可以很直接的实现:

    template <typename T, typename... Ts> void doSomeWork(Ts&&... params) { create local T object from params... //伪代码 ... }

    在伪代码中有两种选择:

    T localObject(std::forward<Ts>(params)...); // 使用圆括号 T localObject{std::forward<Ts>(params)...}; // 使用大括号

    然后考虑以下代码:

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

    如果doSomeWork使用的是圆括号的方式创建对象,那么局部对象std::vector有10个元素。如果doSomeWork使用的是大括号的方式创建对象,那么局部对象std::vector只有2个元素。是要使用哪一种方式呢,模板的作者是不知道的,只有用户知道他想使用哪一种方式。

    这正是标准库函数std::make_unique和std::make_shared所面临的一个问题(条款21)。这些函数的解决办法是强制要求把参数写在圆括号内,然后在接口中说明这个决策。


    总结

    需要记住的3点:

    大括号初始化是适用范围最广泛的初始化语法,它可以防止范围窄化转换和免疫C++的most vexing parse问题。在选择重载构造函数期间,大括号初始化会尽可能的匹配带std::initializer_list构造函数,尽管看起来匹配其它的构造函数刚好。使用大括号和圆括号初始化导致巨大差异的一个例子是用两个参数创建std::vector<numeric type>对象。在模板中选择用大括号还是圆括号创建对象是具有挑战性的。
    转载请注明原文地址: https://ju.6miu.com/read-1296212.html
    最新回复(0)