要是世上不曾存在 C++14 和 C++17 该有多好! constexpr 是好东西, 但是让编译器开发者痛不欲生; 新标准库的确好用, 但改语法细节未必是明智之举, 尤其是 3 年一次的频繁改动. C++ 带了太多历史包袱, 我们都是为之买账的一员.
我没那么多精力考虑 C++14/17 的问题, 所以本文基于 C++11 标准.
知其所以然, 是学习 C++ 越发复杂的语法的最佳方式. 因此, 我们从列表初始化的动机讲起.
动机
早在 2005 年, Bjarne Stroustrup 就提出要统一 C++ 中的初始化语法. 这是因为在 C++11 以前, 初始化存在一系列问题, 包括:
4 种初始化方式: X t1 = v;,X t2(v);,X t3 = { v };,X t4 = X(v);;
聚合 (aggregate) 初始化;
default 与 explicit;
......
虽然每一个都有办法解决, 但加在一起将会变得非常复杂, 对编译器和开发者都是负担. 换句话说, 唯一的需求就是一种统一的初始化语法, 其适用范围能涵盖先前的各种问题.
于是, 列表初始化诞生了.
语法
正因为列表初始化是为解决初始化问题而生, 列表初始化的适用范围是任何初始化. 你能想到的都写写看, 写对就是赚到.
当然, 全凭感觉是行不通的, 还是得讲点道理. 列表初始化分为两类: 直接初始化与拷贝初始化.
在直接初始化中, 无论构造函数是否 explicit, 都有可能被调用:
T object { arg1, arg2, ... };, 用 arg1, arg2, ... 构造 T 类型的对象 object-- 参数可以是一个值, 也可以是一个初始化列表, 下同;
Class { T member { arg1, arg2, ... }; };, 构造 member 成员对象 -- 花括号的优势在这里体现出来, 因为如果是圆括号的话 member 会被看作一个函数;
T { arg1, arg2, ... }, 构造临时对象;
new T { arg1, arg2, ... }, 构造 heap 上的对象;
Class::Class() : member{arg1, arg2, ...} {..., 成员初始化列表 -- 除了 2 以外, 其余都与用 () 初始化没有区别.
在拷贝初始化中, 无论构造函数是否 explicit 都会被考虑, 但是如果重载决议为一个 explicit 函数, 则此调用错误:
T object = {arg1, arg2, ...};, 与直接初始化中的 1 类似, 除了 explicit 以外都相同, operator = 不会被调用;
object = { arg1, arg2, ... }, 赋值语句, 调用 operator=;
Class { T member = { arg1, arg2, ... }; };, 与直接初始化中的 2 类似, explicit 同理;
function( { arg1, arg2, ... } ), 构造函数参数;
return { arg1, arg2, ... } ;, 构造返回值;
object[ { arg1, arg2, ... } ], 构造 operator[]的参数;
U( { arg1, arg2, ... } ), 构造 U 构造函数的参数.
4~7 可以概括为, 在该有一个对象的地方, 可以用一个列表来构造它. 这句话不是很严谨, 因为除了 operator()和 operator[]以外, 其他运算符的参数都不能用列表初始化.
还有一个要注意的地方, 是列表初始化不允许窄化转换(narrowing conversion), 即可能丢失信息的转换, 如 float 转换为 int.
- #include <iostream>
- #include <utility>
- struct Test
- {
- Test(int, int)
- {
- std::cout <<"Test(int, int)" << std::endl;
- }
- explicit Test(int, int, int)
- {
- std::cout << "explicit Test(int, int, int)" << std::endl;
- }
- void operator[](std::pair<int, int>)
- {
- std::cout <<"void operator[](std::pair<int, int>)" <<std::endl;
- }
- void operator()(std::pair<int, int>)
- {
- std::cout <<"void operator()(std::pair<int, int>)" <<std::endl;
- }
- };
- Test test()
- {
- return { 1, 2 };
- }
- int main()
- {
- Test t{ 1, 2 };
- Test t1 = { 1, 2 };
- Test t2 = { 1, 2, 3 }; // error
- t[{ 1, 2 }];
- t({ 1, 2 });
- }
- initializer_list
列表不是表达式, 更不属于任何类型, 所以 decltype({1, 2})是非法的, 这还适用于模板参数推导. 但是在以下几种情况中, 列表可以转换成 std::initializer_list<T > 实例:
直接初始化中, 对应构造函数参数类型为 std::initializer_list<T>;
拷贝初始化中, 对应参数类型为 std::initializer_list<T>;
绑定到 auto 上 (列表元素类型必须严格一致), 包括范围 for(range for) 循环 -- 当绑定 auto&& 时, 变量的实际类型为 std::initializer_list<T>&&, 这是转发引用的特例.
std::initializer_list 是为列表初始化提供的特殊的工具, 是一个轻量级的数组代理(proxy), 其元素类型为 const T. 虽然你能在 < initializer_list > 中看到 std::initializer_list 类模板的实现, 但它实际上是与编译器内部绑定的, 你无法用一个自己写的相似的类替换它(除非改编译器).
std::initializer_list 有构造函数, size,begin 和 end 函数, 用法与其他 STL 顺序容器类似. 迭代器解引用得到 const T & 类型, 元素是不能修改的.
std::initializer_list 带来的最明显的进步就是 STL 容器可以用列表来初始化, 无需再写那么多 push_back 了.
重载决议
- struct Test
- {
- Test(int, int)
- {
- std::cout <<"Test(int, int)" << std::endl;
- }
- Test(std::initializer_list<int>)
- {
- std::cout <<"Test(std::initializer_list<int>)" << std::endl;
- }
- };
如果我写 Test{1, 2}, 哪个构造函数会被调用呢? 回答这个问题, 需要对与列表相关的重载决议有所了解.
对于涉及到构造函数的列表初始化(不涉及到的包括聚合初始化等), 各构造函数分两个阶段考虑:
如果有构造函数第一个参数为 std::initializer_list, 没有其他参数或其他参数都有默认值, 则匹配该构造函数(这里似乎允许窄化转换, 我测试起来也是如此)--std::initializer_list 优先级高;
否则, 所有构造函数参与重载决议, 除了窄化转换不允许, 以及拷贝初始化与 explicit 的冲突依然有效.
所以上面那段程序中 Test{1, 2}会匹配第二个构造函数.
如果有多个 std::initializer_list 重载呢? 众所周知, 重载决议中参数转换有完美, 提升, 转换三个等级, std::initializer_list 参数的转换等级定义为所有元素中最差的(不允许窄化转换), 然后找出等级最高的调用, 如果有多个则为二义调用.
如果没有 std::initializer_list 重载呢? 由于从列表到参数本身就是转换, 属于最差的等级, 如果有多个函数可以通过参数转换后匹配, 则该调用就是二义调用; 只有当只有一个函数可行时才合法.
总结
列表初始化是一种万能的初始化语法, 适用范围广导致其规则比较复杂, 我们应当结合其动机来理解标准规定的行为.
列表初始化包括直接初始化与拷贝初始化, 后者涵盖了参数与返回值等情形. 当我们不想要隐式拷贝初始化时, 要用 explicit 关键字来拒绝.
列表不属于任何类型, 但一些情况下可以转换成 std::initializer_list. 在重载决议中, std::initializer_list 有更高的优先级.
来源: https://www.cnblogs.com/jerry-fuyi/p/12806284.html