C++ 模板参数推导问题小记(模板类的模板构造函数)

发布时间:2026/7/2 11:52:54
C++ 模板参数推导问题小记(模板类的模板构造函数) 问题代码在编写一个代表空间点的模板类point时我打算为它添加一个模板构造函数代码templatetypename T, std::size_t N struct point { using value_type scalarT; value_type _v[N]; point() : _v{ value_type{} } {} templatetypename U explicit point(const U (arr)[N]) { if constexpr(std::is_same_vvalue_type, U) memcpy(_v, arr, n * sizeof(value_type)); else { for(std::size_t i 0; i ! N; i) _v[i] static_castvalue_type(arr[i]); } } }; pointint, 3 pi3({ 0, 1, 2 });代码中的scalar是 这篇 笔记中提到用于类型限制的别名模板用以排除非数值类型的模板实例化。templatetypename U point(const U (arr)[N])这个构造函数的意图是point只接受长度为N的数组进行初始化。一切看起来没什么问题但是当我写下这样的初始化代码时发现代码仍然能够正常通过编译代码pointint, 3 pi3({ 0, 1 });为什么料想之中的长度限制并没有起作用问题分析分析pointint, 3 pi3({ 0, 1 });这句代码编译器是如何处理它的1.pointint, 3 pi3指定了pi3这个实例的T为intN为32.pi3({ 0, 1 })是一个单参数构造语句尝试匹配接受单个参数的构造函数匹配到接受数组引用的自定义构造函数templatetypename U pointint, 3::point(const U (arr)[3])3. 根据调用参数{ 0, 1 }即[int, int]推导U为int构造函数实例化为pointint, 3::pointint(const int (arr)[3]);4. 使用{ 0, 1 }对一个临时的int [3]进行列表初始化初始化结果为{ 0, 1, 0 }随后传入构造函数。point类的模板参数N在类的实例化时被指定为3在成员模板构造函数实例化期间它是已知的函数参数推导过程对它没有任何影响这句代码能够通过编译的根本原因是长度为3的数组能够被只有2个元素的初始化列表初始化。而我由于对初始化细节了解不全面加之模板代码对问题分析有一定的干扰一时没有抓住本质写出了这段一厢情愿的代码。问题解决解决方法很简单把数组的维度也作为模板参数参与推导然后对它进行约束就能实现这个目的了代码templatetypename U, std::size_t M, typename std::enable_if_tM N explicit point(const U (arr)[M]) { //... }; int iarr[] { 0, 1, 2 }; pointint, 3 pi30(iarr);//OK pointint, 3 pi31({ 0, 1, 2 });//OK pointint, 3 pi3({ 0, 1 });//无法通过编译现在数组的维度M需要从构造函数的参数推导出来如果M与N不相等构造函数实例化失败。问题到此就可以结束了但是不妨来分析一下({ ... })这种初始化写法。C 的初始化首先复习一下基础知识不考虑拷贝构造的情况下C 的初始化有两种1.(...)即直接初始化这种调用适用于类类型直接要求调用类的某个构造函数。所有用户自定义和编译器合成版本的构造函数都会被加入候选列表随后根据重载函数匹配规则选出匹配度最高的一个进行调用无匹配项或多个项都具有最佳匹配度时匹配失败。这种初始化语法有个缺陷 —— 可能会被解析为函数声明在这些情况下解析的结果往往很反直觉所以被称为 最令人烦恼的解析。2. { ... }{ ... }即 列表初始化在 C11 标准之前列表初始化只能用来对 聚合类型 进行初始化。上文中使用{ 0, 1 }将一个临时的int [3]初始化为{ 0, 1, 0 }就属于聚合类型的列表初始化。更加详细的规则不是本文的重点关注对象感兴趣的话可以到 这里 阅读。值得一提的是MSVC测试版本为 _MSC_VER1943支持使用(...)对聚合类型进行列表初始化但这并不被 C 标准采纳属于 MSVC 方言不具备可移植性使用时须当心。C11 引入了统一初始化语法使得任何类型都能够使用列表初始化语法进行初始化同时新增了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::vectorint vec);//函数签名 foo(0, { 0, 1, 2 });//调用当它被用于类的初始化时编译器自动匹配构造函数调用匹配规则与普通函数是一样的。再探point的初始化前面对point构造的分析只是简化版让我们再次详细分析这一句代码的解析过程pointint, 3 pi3({ 0, 1 });这个调用中的({ 0, 1 })会匹配所有接受单个参数、名为point的函数。查看一下候选的函数编译器发现有三个用户定义的接受数组引用的构造函数自动合成的拷贝构造函数以及自动合成的移动构造函数。分别分析它们的匹配情况匹配接受数组引用的构造函数pointint, 3::pointint(const int (arr)[3])此构造函数的形参是const int ()[3]。根据聚合类型的列表初始化规则指定长度的数组可被元素数量小于或等于其长度的初始化列表初始化。此处创建一个临时数组int [3]并且被初始化为{ 0, 1, 0 }绑定到数组引用形参上函数匹配成功。匹配拷贝构造函数pointint, 3::point(const pointint, 3 )其形参是const pointint, 3 。这里需要创建一个临时的pointint, 3相当于pointint, 3 temp{ 0, 1 };。显然pointint, 3既不是聚合类型也没有接受(int, int)的构造函数更没有接受std::initializer_listint的构造函数。这样一个临时的变量无法被创建出来所以拷贝构造函数匹配失败。移动构造函数的情形与拷贝构造函数相同无法匹配。最终自定义的那个接受数组引用的构造函数被选中用来初始化这个pointint, 3实例。语义检查与优化point类的定义使得({ 0, 1 })无法匹配到拷贝构造或移动构造函数但是如果我们定义下面这样一个能被两个int参数构造的类代码struct S { S(int, int) {} //S(const S ) delete;//如果删除拷贝构造函数按照语言规则移动构造函数也不会自动合成 }; S s({ 0, 1 });//若拷贝构造函数被删除无法通过编译 S s1 S{ 0, 1 };//若拷贝构造函数被删除可以通过编译C17之后按照前面讲述的s的构造会调用用户定义的构造函数和编译器合成的移动构造函数即先构造一个临时的S对象再用这个临时对象调用移动构造函数。这样的构造过程显然是冗余的中间这个临时对象被创建出来后立即用于后续的构造没有任何可能会被修改所以它的构造完全可以直接发生在最终目标位置。大多数编译器确实会优化掉这个中间过程但是如果我们像代码注释中那样让S的移动构造函数和拷贝构造函数不可用编译器会提示s的构造中引用了被删除的函数。那些确实会执行这项优化的编译器为什么必须检查一定不会被引用的函数的可用性呢这是因为编译器执行代码优化的基础是代码必须按照语言标准进行编写而确保代码符合语言标准的工作是由语义检查环节完成的。也就是说代码优化必须在语义检查通过后才能执行实际的编译中这两个环节之间还会有其他操作那么为什么语义同样是调用移动构造函数的s1构造语句不会报错呢我们知道从 C17 开始有一些 拷贝省略 是强制施行的即原来这些被视作优化的拷贝省略形式被纳入标准行为。其中就包括上述代码中s1的构造情形既然s1构造中省略拷贝步骤已是标准行为编译器的语义检测就只需要检查