C++ 模板参数推导问题小记(模板类的模板构造函数)
2026/7/2 11:52:40 网站建设 项目流程

问题代码

在编写一个代表空间点的模板类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 });

为什么料想之中的长度限制并没有起作用?

问题分析

分析point<int, 3> pi3({ 0, 1 });这句代码,编译器是如何处理它的:

1.point<int, 3> pi3指定了pi3这个实例的TintN3

2.pi3({ 0, 1 })是一个单参数构造语句,尝试匹配接受单个参数的构造函数,匹配到接受数组引用的自定义构造函数template<typename U> point<int, 3>::point(const U (&arr)[3])

3. 根据调用参数{ 0, 1 },即[int, int]推导Uint,构造函数实例化为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 });//无法通过编译

现在数组的维度M需要从构造函数的参数推导出来,如果MN不相等,构造函数实例化失败。

问题到此就可以结束了,但是不妨来分析一下({ ... })这种初始化写法。

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的构造函数,并且你能够正确处理它与其他构造函数的关系,不要轻易定义这个构造函数。

({ ... })是如何解析的

有了前面的铺垫,这个初始化语句就很好理解了。外层的()指定要调用某个函数,该函数能够匹配只有一个参数的调用形式,内层的{ ... }作为一个初始化列表对这个参数进行初始化。

列表初始化 指定的情形是直接包含这种初始化形式的,并且解释得非常详细:

其实我们平时也经常在函数调用时使用这种语法:

代码

void foo(int i, const std::vector<int> &vec);//函数签名 foo(0, { 0, 1, 2 });//调用

当它被用于类的初始化时,编译器自动匹配构造函数调用,匹配规则与普通函数是一样的。

再探point的初始化

前面对point构造的分析只是简化版,让我们再次详细分析这一句代码的解析过程:

point<int, 3> pi3({ 0, 1 });这个调用中的({ 0, 1 })会匹配所有接受单个参数、名为point的函数。查看一下候选的函数,编译器发现有三个:用户定义的接受数组引用的构造函数,自动合成的拷贝构造函数,以及自动合成的移动构造函数。分别分析它们的匹配情况:

匹配接受数组引用的构造函数point<int, 3>::point<int>(const int (&arr)[3]),此构造函数的形参是const int (&)[3]。根据聚合类型的列表初始化规则,指定长度的数组可被元素数量小于或等于其长度的初始化列表初始化。此处创建一个临时数组int [3]并且被初始化为{ 0, 1, 0 },绑定到数组引用形参上,函数匹配成功。

匹配拷贝构造函数point<int, 3>::point(const point<int, 3> &),其形参是const point<int, 3> &。这里需要创建一个临时的point<int, 3>,相当于point<int, 3> temp{ 0, 1 };。显然,point<int, 3>既不是聚合类型,也没有接受(int, int)的构造函数,更没有接受std::initializer_list<int>的构造函数。这样一个临时的变量无法被创建出来,所以拷贝构造函数匹配失败。

移动构造函数的情形与拷贝构造函数相同,无法匹配。

最终,自定义的那个接受数组引用的构造函数被选中用来初始化这个point<int, 3>实例。

语义检查与优化

point类的定义使得({ 0, 1 })无法匹配到拷贝构造或移动构造函数,但是如果我们定义下面这样一个能被两个int参数构造的类:

代码

struct S { S(int, int) {} //S(const S &) = delete;//如果删除拷贝构造函数,按照语言规则,移动构造函数也不会自动合成 }; S s({ 0, 1 });//若拷贝构造函数被删除,无法通过编译 S s1 = S{ 0, 1 };//若拷贝构造函数被删除,可以通过编译(C++17之后)

按照前面讲述的,s的构造会调用用户定义的构造函数和编译器合成的移动构造函数,即先构造一个临时的S对象,再用这个临时对象调用移动构造函数。这样的构造过程显然是冗余的,中间这个临时对象被创建出来后立即用于后续的构造,没有任何可能会被修改,所以它的构造完全可以直接发生在最终目标位置。

大多数编译器确实会优化掉这个中间过程,但是如果我们像代码注释中那样让S的移动构造函数和拷贝构造函数不可用,编译器会提示s的构造中引用了被删除的函数。那些确实会执行这项优化的编译器,为什么必须检查一定不会被引用的函数的可用性呢?

这是因为编译器执行代码优化的基础是代码必须按照语言标准进行编写,而确保代码符合语言标准的工作是由语义检查环节完成的。也就是说,代码优化必须在语义检查通过后才能执行(实际的编译中这两个环节之间还会有其他操作),那么为什么语义同样是调用移动构造函数的s1构造语句不会报错呢?

我们知道,从 C++17 开始,有一些 拷贝省略 是强制施行的,即原来这些被视作优化的拷贝省略形式被纳入标准行为。其中就包括上述代码中s1的构造情形:

既然s1构造中省略拷贝步骤已是标准行为,编译器的语义检测就只需要检查

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询