本篇主要是为了记录在编写一个模板类的模板构造函数中遇到的初始化问题,以及针对这个问题展开的相关知识整理,文章就以引发这个问题的代码为标题了。
问题代码
在编写一个代表空间点的模板类 point 时,我打算为它添加一个模板构造函数:
代码
template<typename T, std::size_t N> struct point { using value_type = scalar<T>; value_type _v[N];
point() : _v{ value_type{} } {}
template<typename U> explicit point(const U (&arr)[N]) { if constexpr(std::is_same_v<value_type, U>) memcpy(_v, arr, n * sizeof(value_type)); else { for(std::size_t i = 0; i != N; ++i) _v[i] = static_cast<value_type>(arr[i]);//抑制“” } } };
point<int, 3> pi3({ 0, 1, 2 });
代码中的 scalar 是 这篇 笔记中提到用于类型限制的别名模板,用以排除非数值类型的模板实例化。
template<typename U> point(const U (&arr)[N]) 这个构造函数的意图是 point 只接受长度为 N 的数组进行初始化。
一切看起来没什么问题,但是当我写下这样的初始化代码时,发现代码仍然能够正常通过编译:
代码
point<int, 3> pi3({ 0, 1 }); {{the-copyright}}*本*文*由*博*客*园* @saltymilk *原*创*{{/the-copyright}}
为什么料想之中的长度限制并没有起作用?
问题分析
分析 point<int, 3> pi3({ 0, 1 }); 这句代码,编译器是如何处理它的:
1. point<int, 3> pi3 指定了 pi3 这个实例的 T 为 int,N 为 3;
2. pi3({ 0, 1 }) 是一个单参数构造语句,尝试匹配接受单个参数的构造函数,匹配到接受数组引用的自定义构造函数 template<typename U> point<int, 3>::point(const U (&arr)[3]);
3. 根据调用参数 { 0, 1 },即 [int, int] 推导 U 为 int,构造函数实例化为 point<int, 3>::point<int>(const int (&arr)[3]);
4. 使用 { 0, 1 } 对一个临时的 int [3] 进行列表初始化,初始化结果为 { 0, 1, 0 },随后传入构造函数。
point 类的模板参数 N 在类的实例化时被指定为 3,在成员模板构造函数实例化期间它是已知的,函数参数推导过程对它没有任何影响,这句代码能够通过编译的根本原因是长度为 3 的数组能够被只有 2 个元素的初始化列表初始化。
而我由于对初始化细节了解不全面,加之模板代码对问题分析有一定的干扰,让我一时没有抓住本质,写出了这段一厢情愿的代码。
问题解决
解决方法很简单,把数组的维度也作为模板参数参与推导,然后对它进行约束就能实现这个目的了:
代码
template<typename U, std::size_t M, typename = std::enable_if_t<M == N>> explicit point(const U (&arr)[M]) { //... };
int iarr[] = { 0, 1, 2 }; point<int, 3> pi30(iarr);//OK point<int, 3> pi31({ 0, 1, 2 });//OK point<int, 3> pi3({ 0, 1 });//无法通过编译 {{the-copyright}}*本*文*由*博*客*园* @saltymilk *原*创*{{/the-copyright}}
现在数组的维度 M 需要从构造函数的参数推导出来,如果 M 与 N 不相等,构造函数实例化失败。
问题到此就可以结束了,但是不妨来分析一下 ({ ... }) 这种初始化写法。
C++ 的初始化
首先复习一下基础知识,不考虑拷贝构造的情况下,C++ 的初始化有两种:
1. (...),即直接初始化
这种调用适用于类类型,直接要求调用类的某个构造函数。所有用户自定义和编译器合成版本的构造函数都会被加入候选列表,随后根据重载函数匹配规则选出匹配度最高的一个进行调用,无匹配项或多个项都具有最佳匹配度时匹配失败。
这种初始化语法有个缺陷 —— 可能会被解析为函数声明,在这些情况下,解析的结果往往很反直觉,所以被称为 最令人烦恼的解析。
2. = { ... } & { ... },即 列表初始化
在 C++11 标准之前,列表初始化只能用来对 聚合类型 进行初始化。上文中使用 { 0, 1 } 将一个临时的 int [3] 初始化为 { 0, 1, 0 } 就属于聚合类型的列表初始化。更加详细的规则不是本文的重点关注对象,感兴趣的话可以到 这里 阅读。
值得一提的是,MSVC(测试版本为 _MSC_VER=1943)支持使用 (...) 对聚合类型进行列表初始化,但这并不被 C++ 标准采纳,属于 MSVC 方言,不具备可移植性,使用时须当心。
C++11 引入了统一初始化语法,使得任何类型都能够使用列表初始化语法进行初始化,同时新增了 std::initializer_list 来支持统一的列表初始化语法。
列表初始化语法杜绝了将初始化语句解析为函数声明语句的可能,并且阻止了 窄化转换,使初始化更加简洁安全。
在使用列表初始化器初始化对象时,接受 std::initializer_list 的构造函数具有无与伦比的重载匹配优先级,即使无法正确构造一个 std::initializer_list 且其他函数能够精确匹配参数时也可能直接屏蔽其他构造函数,直接报错而不尝试其他重载版本(Scott Meyers, Effective Modern C++, Item 7)。所以除非你非常确定自己的类需要一个接受 std::initializer_list 的构造函数,并且你能够正确处理它与其他构造函数的关系,不要轻易定义这个构造函数。
({ ... }) 是如何解析的
有了前面的铺垫,这个初始化语句就很好理解了。外层的 () 指定要调用某个函数,该函数能够匹配只有一个参数的调用形式,内层的 { ... } 作为一个初始化列表对这个参数进行初始化。
列表初始化 指定的情形是直接包含这种初始化形式的,并且解释得非常详细: