条款7:区别使用( )与{ }创建对象

ZaynPei Lv6

复习初始化语法

在 C++11 中, 进行对象初始化的语法大致可以分为三类: - 使用小括号, 如 int x(10); - 使用大括号, 如 int x{10}; - 使用等号, 如 int x = 10; 当然, 很多情况下也可以使用一个等号和大括号来初始化对象, 如:int z = { o } ;, 下面的讨论我们将忽略这种”等号加大括号“语法,因为 C++ 通常会把它和只有大括号的语法同样处理。

容易引起歧义的是, 使用等号来书写初始化语句往往会让 C++新手误以为这里面会发生一次赋值,但实际上却是没有的。我们要明白, 初始化和赋值这两种行为背后调用的是不同的函数:

1
2
3
Widget w1;  // 调用的是默认构造函数
Widget W2 = w1; // 不是赋值而是初始化, 调用的是拷贝构造函数
w1 = W2; // 是赋值, 调用的是拷贝赋值运算符

统一初始化

尽管有了那么多初始化语法,但在 C++98 中却仍然没有办法来表达某些想要进行的初始化。比如,之前是没有办法直接指定 STL 容器在创建时持有N个特定集合的 值的(比如持有 1 、3 、5)

为了着手解除众多的初始化语法带来的困惑,也为了解决这些语法不能覆盖所有初始化场景的问题, C++11 引入了统一初始化:单一的、至少从概念上可以用于一切场合的初始化语法。它的基础是大括号形式的初始化

使用大括号来指定容器的初始内容是非常简单的:

1
2
std: :vector<int> v{ 1, 3, 5 }; 
std: :vector<int> v2 = { 1, 3, 5 }; // 与上述等价
大括号同样可以用来为非静态成员指定默认初始化值,这项能力(在 C++11 中新增)也可以使用 “=” 的初始化语法,却不能使用小括号:
1
2
3
4
5
6
class Widget { 
private:
int x{ 0 };
int y = 0;
// int z(0) 不可行!
};

另一方面, 不可复制的对象(如 std::atomic 型别的对象,参见条款 40)可以采用大括号和小括号来进行初始化,却不能使用 “=” 来初始化.

1
2
3
std::atomic<int> ai{ 0 };  // 可行
std::atomic<int> ai2(0); // 可行
std::atomic<int> ai3 = 0; // 错误

这么一来,就很容易理解为何大括号初始化被冠以“统一” 之名了。在 C++的各种初始化表达式的写法中,只有大括号适用所有场合

禁止内建型别之间进行隐式窄化型别转换

大括号初始化有一项新特性,就是它禁止内建型别之间进行隐式窄化型别转换(narrowing conversion) 。如果大括号内的表达式无法保证能够采用进行初始化的对象来表达,则代码不能通过编译:

1
2
double x, y, z; 
int sum{ x + y + z }; // 错误,double 型别之和在大括号内无法窄化转换为 int 表达
而采用小括号和“=“的初始化则不会进行窄化型别转换检查,因为如果那样的话就会破坏太多的遗留代码了:
1
2
int sum2( x + y + z) ;  // 没问题(表达式的值被截断为 int
int sum3 = x + y + z; // 同上

最令人苦恼之解析语法 (mostvexing parse) 免疫。

大括号初始化的另一项值得一提的特征是,它对于 C++ 的最令人苦恼之解析语法 (mostvexing parse) 免疫。 C++ 规定:任何能够解析为声明的都要解析为声明,而这会带来副作用。所谓最令人苦恼之解析语法就是说,程序员本来想要以默认方式构造一个对象,结果却不小心声明了一个函数。这个错误的根本原因在于构造函数调用语法。

当你想以传参方式调用构造函数时,可以这样写:widget w1(10);

但如果你试图用对等语法来调用一个没有形参的 Widget 构造函数的话,那结果却变成了声明了一个函数而非对象:Widget w2 ();

这个语句实际上声名了一个名为 w2 、返回widget对象的函数!

由于函数声明不能使用大括号来指定形参列表,所以使用大括号来完成对象的默认构造没有上面这个问题:widget w3{}; // 调用没有形参的构造函数

大括号的缺陷

关于大括号初始化,也说了不少了。这种语法可以应用的语境最为宽泛,可以阻止隐式窄化型别转换,还对最令人苦恼之解析语法免疫。那么,为什么本章的章名不干脆换成“优先选用大括号初始化语法”之类呢?

大括号初始化的缺陷在于伴随它有时会出现的意外行为, 这种行为源于大括号初始化物、std::initializer_list 以及构造函数重载决议之间的纠结关系。这几者之间的相互作用可以使得代码看起来是要做某一件事,但实际上是在做另一件事。

先认识 std::initializer_list

std::initializer_list(初始化列表)是一个轻量级的类模板,它的主要目的是让我们可以方便地使用花括号 {} 来初始化对象或向函数传递一组值。它是 C++11 中统一初始化 (Uniform Initialization) 语法的重要基石。

下面我们介绍它的几个特性: - 轻量级代理 (Lightweight Proxy): std::initializer_list 本身不拥有它所表示的元素。它更像一个“代理”或“视图”,内部通常只包含两个成员:一个指向元素序列头部的指针,以及一个表示元素数量的计数器。这些元素本身被编译器存放在一个临时的、只读的数组中。因为 std::initializer_list 只是“指向”这个临时数组,所以它的创建和拷贝开销非常小。 - 同质性 (Homogeneous): 一个 std::initializer_list 对象中的所有元素都必须是 T 类型,或者可以隐式转换为 T 类型。例如,std::initializer_list 只能持有整数。 - 只读性 (Read-only): 你不能修改通过 std::initializer_list 访问的元素。它提供的迭代器是 const 迭代器,返回的是对 const T 的引用。这保证了初始化数据源的安全性。

大括号初始化 auto 类型变量

当使用 auto 声明变量时,大括号初始化会导致一个可能出人意料的结果。具体细节可以参考条款2:理解auto型别推导。简单来说,如果用大括号初始化表达式来初始化一个 auto 型别的变量,那么推导出来的型别就会是 std::initializer_list。但如果使用相同的表达式来初始化一个非 auto 型别的变量,或者用小括号或”=“的方式来初始化 auto 型别的变量,auto 就会推导出你想要的型别

规则:编译器强烈优先选择 std::initializer_list 构造函数

首先, 在构造函数被调用时, 如果形参中没有任何一个具备 std:: initializer_list 型别,那么小括号和大括号的意义就没有区别:

1
2
3
4
5
6
7
8
9
10
class Widget { 
public:
Widget(int i, bool b); // 构造函数的形参中没有任何一个具备 std::initializer_list 型别
Widget(int i, double d); // std::initializer_list 型别
};

Widget w1(10, true); // 调用的是第一个构造函数
Widget w2{10, true}; // 调用的还是第一个构造函数
Widget w3(10, 0.0); // 调用的是第二个构造函数
Widget w4{10, 0.0}; // 调用的还是第二个构造函数
然而, 当使用大括号初始化语法进行对象构造时,如果类中存在一个或多个构造函数接受 std::initializer_list 作为参数,编译器会强烈优先选择这些重载版本。这种偏好非常强烈,以至于它会覆盖其他看起来更匹配的构造函数,甚至包括复制和移动构造函数 。

同时, 编译器还会尽一切可能将大括号内的参数转换为 std::initializer_list 中元素的类型,即使这意味着需要进行类型转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <initializer_list>

class Widget {
public:
Widget(int i, double d) { /* ... */ } // #1
Widget(std::string str) { /* ... */ } // #2
// initializer_list 构造函数,但元素类型会导致窄化
Widget(std::initializer_list<bool> il) { /* ... */ }
};

int main() {
Widget w0(10, 5.0); // 小括号正常匹配
Widget w1{"Hello"}; // 调用#2
Widget w2{10, 5.0}; // 错误!
}
上述示例中, 我们可以看到, 如果大括号初始化语法中,编译器会优先调用 std::initializer_list 构造函数。在此基础上又分为两种情况: - 窄化转换导致编译失败(“否决”行为) - w2这个场景展示的是,即使存在一个其他方面看起来很匹配的构造函数,但只要 std::initializer_list 版本的构造函数可以通过窄化转换来匹配,编译器就会优先尝试它,并且假如在此过程中因为窄化转换被禁止而报错,编译器就不会再考虑其他选项而直接编译失败。 - 非窄化转换导致调用普通构造函数 - 这个场景展示的是,当大括号内的参数完全无法被转换(而不是窄化转换失败)为 std::initializer_list 的元素类型时,编译器会放弃initializer_list 构造函数,回到正常的重载决议流程中,并选择其他匹配的构造函数。

总之,即使存在精确匹配的普通构造函数,只要 std::initializer_list 版本的构造函数可以通过非窄化转换被调用,编译器就会选择它。不过如果转换是窄化的,则调用会失败,即使其他构造函数可以成功匹配 。

另一个方向是, 即使是平常会执行复制或移动的构造函数也可能被带有 std:: initializer_list 型别形参的构造函数劫持:

1
2
3
4
5
6
7
8
9
10
11
12
class Widget { 
public:
Widget(int i, bool b);
Widget(int i, doubled);
Widget(std: :initializer_list<long double> il);
operator float() const; // 强制转换成 float 型别
};

Widget w1(w4); // 使用小括号,调用的是拷贝构造函数
Widget w2{w4}; // // 使用大括号,优先调用的是带有 std::initializer_list 型别形参的构造函数(即使看起来像拷贝构造). 这里w4的返回值被强制转换为float, 随后float又被强制转换为long doube
Widget w3(std::move(w4)); // 同理
Widget w4{std::move(w4)}; // 同理

特殊情况:空大括号

需要注意的是, 上述情况有个例外: 当使用一对空大括号 {} 进行初始化时,如果类同时拥有默认构造函数和 std::initializer_list 构造函数,C++ 规定优先调用默认构造函数。空大括号被解释为“没有参数”,而不是“一个空的 std::initializer_list” 。

1
2
3
4
Widget w2{}; // 调用默认构造函数 

Widget w4({}); // 调用 std::initializer_list 构造函数
Widget w5{{}}; // 同上

如果你确实想用一个空的列表来调用 std::initializer_list 构造函数,需要将空大括号作为参数显式传递 。

经典案例: std::vector 的初始化

对于我们今天讨论的大括号还是小括号初始化, 直接受到影响的一个类就是 std::vector

std::vector 有多个可以重载的构造函数, 其中一个构造函数的形参中没有任何一个具备 std:: initializer_list 型别的构造函数,它允许你指定容器的初始尺寸,以及一个初始化时让所有元素拥有的值; 但它还有个带 std: :initializer_list 型别形参的构造函数,允许你逐个指定容器中的元素 值。

因此, 如果你要创建一个元素为数值型别的 std:: vector(比如 std::vector), 并传递了两个实参给构造函数的话,你把这两个实参用小括号还是大括号括起来,结果会大相径庭:

1
2
3
4
5
// 创建一个包含 10 个元素的 vector,每个元素的值都是 20 
std::vector<int> v1(10, 20);

// 创建一个包含 2 个元素的 vector,元素值分别为 10 和 20
std::vector<int> v2{10, 20};

在上述示例中, 我们可以看到, v1 使用小括号,调用了指定容器尺寸和初始值的构造函数 。而 v2 使用大括号,由于 std::vector 有一个接受 std::initializer_list 的构造函数,编译器优先选择了这个版本 。

启示

对于类的设计者而言,一个核心结论是,在设计构造函数时必须意识到 std::initializer_list 带来的影响。

如果在重载的构造函数中,有任何一个接受 std::initializer_list 类型的形参,那么使用大括号初始化的客户端代码将极有可能只会匹配到这个版本的构造函数 。这种行为的优先级非常高,以至于它不仅仅是与其他重载版本竞争,而是可能完全掩盖它们,导致其他构造函数“连露脸的机会都不给” 。

因此,最佳实践是设计类的构造函数时,应确保客户无论使用小括号还是大括号,都不会意外地改变被调用的重载版本。从这个角度看,std::vector 的接口设计常被视为一个反面教材,应当从中吸取教训 。

对于使用类的程序员来说,在创建对象时应该仔细思考是选用小括号 () 还是大括号 {} 初始化。目前并没有一个统一的定论,因为两种方式各有优劣,开发者通常会根据自己的偏好选择一种作为默认风格。

偏好使用大括号 {} 的开发者 看重的是其更广泛的适用场景、能够禁止隐式的窄化类型转换,以及对 C++“最令人苦恼的解析语法”免疫的特性 。他们也承认,在某些特定情况下(例如为 std::vector 指定初始尺寸和元素值),使用小括号是必需的 。

偏好使用小括号 () 的开发者 则倾向于保持与 C++98 语法的传统一致性 。这样做可以避免因 auto 类型推导产生的意外(即推导为 std::initializer_list),并且不会意外地触发接受 std::initializer_list 的构造函数 。他们同样承认,在某些场景下(例如用一组初始值创建容器),必须使用大括号 。

最终,由于两种风格各有合理的理由,最好的建议是开发者可以根据团队的偏好任选一种,并在此后的代码中保持一致 。