类模板参数推导

类模板参数推导
苏丙榅1. 什么是类模板参数推导
在 C++17 之前,我们在实例化一个类模板时,必须显式地把模板参数写在尖括号 < > 里,即使这些参数很明显可以从构造函数推导出来。举个例子,std::pair 是一个模板类:
1 | // C++17 之前,必须手动指定类型 |
我们会发现,std::pair(42, "hello") 明明能看出第一个是 int,第二个是 const char*(或者 std::string),为什么还要写那么长呢?
C++17 允许编译器根据我们在构造函数中传入的参数,自动推断出模板参数 T 到底是什么类型,这就叫做类模板参数推导(Class Template Argument Deduction,简称 CTAD)。简单说就是:编译器变得更聪明了,能猜出我们需要什么类型!
先看几个最直观的例子:
1 | // C++17 之前 |
1 | // C++17 之前 |
1 | // C++17 之前 |
这不仅仅是为了省事,这让代码更易读,尤其是在类型名字非常长(比如某些 iterators 或 function objects)的时候。
接下来再给大家讲解一下编译器推导模板参数的规则,一共有三步:
- 编译器看到我们创建类模板对象时没写模板参数
- 编译器查看创建对象时使用的构造函数
- 根据构造函数的参数类型反推出模板参数
1 |
|
这个特性在日常开发中最爽的地方就是配合标准库容器使用。以前写 std::pair、std::map 是最烦的,因为两个类型可能很长。
1 |
|
2. 推导指引的使用
虽然编译器很聪明,但有时候它推导出来的结果并不是我们想要的,或者它根本推导不出来。这就是我们需要显式定义推导指引,需要推导引导主要分为以下三种情况:
构造函数是独立模板
如果构造函数是独立模板(内部 template),参数类型是
U,和类模板T脱节,编译器就猜不出 T,这时就要手动写推导指引告诉编译器映射关系。构造函数没有参数(无参构造),CTAD 完全无法自动推导
C++17 的 CTAD 最初主要依赖于构造函数。如果一个类是聚合类(没有用户定义的构造函数,只有成员变量),编译器早期版本无法直接推导。C++17 后期及 C++20 虽然支持聚合类的隐式推导,但在处理
std::initializer_list等复杂场景时,仍需指引来明确意图。构造函数参数类型和类模板参数不是直接一一对应(类型转换、包装)
CTAD 默认规则:拿构造函数实参的类型,原样匹配、推导类模板参数; 如果我们想要:传入 A 类型,但是希望类模板参数推导成 B 类型(自动做类型转换 / 包装),默认 CTAD 做不到,就需要手写推导指引。
推导指引的语法格式如下:
1 | template <template-parameters> |
template <template-parameters>:这是推导指引自己所需的模板参数。作用:定义指引中用到的类型变量。
是否可选:是的。如果推导规则不涉及泛型映射,就不需要写这个。
ClassName(ConstructorParameters):这部分看起来非常像一个构造函数的声明(没有函数体)。作用:编译器在代码中看到对象初始化时,会拿初始化的参数来匹配这里的
ConstructorParameters。注意:这里只是描述参数的样子,并不一定要求类中真的有一个一模一样签名的构造函数(尽管通常都有)。
-> DeducedClassName<ActualTypeArgs>:箭头->分隔了左边和右边。- 作用:告诉编译器最终生成的类类型是什么。
DeducedClassName:通常就是类模板本身的名字。<ActualTypeArgs>:这里才是真正决定类模板参数T是什么的地方。你可以在这里对类型进行修改(例如去掉引用、强制转为int等)。
这个语法的作用就像是一座翻译桥,它告诉编译器:“当你看到右边(构造函数)这种形式的参数时,请把左边的类模板实例化为右边(目标类型)指定的样子”。
推导指引必须定义在类模板的定义之后,且位于同一个命名空间作用域中(通常就在类定义下面)。
2.1 构造函数是独立模板
下面的例子中用到了两套独立的模板:外层是类模板参数为T,内层是函数模板模板参数为U:
template<typename T>→ 类模板参数:实例化WrapperGood<XXX>/WrapperBad<XXX>的时候就确定死了,它的对象的成员 val 的类型就是 T。一旦创建这个类,T 就固定不变。template<typename U>→ 构造函数本身是一个函数模板,U 是构造时推导的类型。
1 |
|
如果编译上面的代码会发现编译器报错。这段代码编译不过去的核心原因在于 C++ 类模板参数推导(CTAD)无法推导构造函数模板的模板参数 T。
具体来说,当写下 WrapperBad wb(x); 时:
- CTAD 机制启动:编译器尝试根据构造函数的参数来推导
WrapperBad的模板参数T。 - 构造函数匹配:它找到了构造函数
template <typename U> WrapperBad(U&& u)。 - 推导失败:
- 构造函数本身是一个函数模板,它有自己的模板参数
U。 - 根据参数
x(左值int),编译器可以推导出U为int&。 - 编译器无法自动推导出类模板参数
T和U之间的关系。它不知道T应该等于U,还是std::decay_t<U>,或者是其他什么类型。
- 构造函数本身是一个函数模板,它有自己的模板参数
- 由于
T无法推导,因此编译器报错。
为什么 WrapperGood wg(x) 成功?因为有类型推导引导。
1 | template <typename U> |
- 推导过程:编译器看到
WrapperGood wg(x)。它查找是否存在推导指引。 - 应用指引:它发现了
WrapperGood(U&&) -> WrapperGood<std::decay_t<U>>。 - 强制关联:指引明确告诉编译器:“如果你看到构造函数参数是
U&&,那么类模板参数T必须是std::decay_t<U>”。- 这里
x是int,推导出U为int。 - 指引强制
T为std::decay_t<int>,即int。
- 这里
- 结果:
T被成功推导为int,编译通过。
2.2 聚合类的初始化
聚合类没有构造函数,直接使用成员变量。虽然 C++20 开始支持部分隐式推导,但在 C++17 中或者为了保证跨版本的稳定性,通常需要指引。特别是当使用 {} 初始化时。
1 |
|
2.3 类型转换与别名推导
有时候构造函数接受的参数类型并不是 T,而是我们需要将类实例化为 T。
1 |
|
MyString str1(s);:调用MyString(std::string)MyString str2("world");- 推导指引将其推导为
MyString<std::string> - 匹配构造函数
MyString(std::string),world隐式转换为std::string - 根本不会进入
MyString(U u),所以不会调用to_string,也不会报错。
- 推导指引将其推导为
MyString str3(100);: 匹配MyString(U u),U=int,调用to_string(100)
我在模板构造函数中加了这一行约束:
1 | typename = typename std::enable_if<std::is_arithmetic<U>::value>::type |
它的意思是:
- 只有当
U是数值类型(整数或浮点数)时,这个构造函数才存在。 - 如果传入
"world"(const char*),因为不是数值类型,编译器会认为这个构造函数不存在。
配合推导指引 MyString(const char*) -> MyString<std::string>;,编译器会选择正确的路径去创建对象,从而避开了不合法的 std::to_string("...") 调用。
3. 推导指引的分类
推导指引,就是告诉编译器「如何把构造实参类型,映射为类模板参数类型」的规则,是 CTAD 能够生效的核心依据。所有推导指引在 C++17 标准中,被严格分为两大核心类别:隐式推导指引、显式推导指引。
- 隐式推导指引(编译器自动生成,无需手写)
- 显式推导指引(开发者手动手写)
- 非模板型显式推导指引(固定类型映射)
- 模板型显式推导指引(通用类型映射,支持万能引用/数组推导)
在 C++17 中,类模板推导指引可以有多个,并且它们共同组成了一套重载集供编译器选择。推导指引本质上是为编译器提供了一个假想的构造函数列表。
3.1 隐式推导指引
编译器会自动从类模板的普通构造函数、模板构造函数中,自动生成一条实参类型原样映射类模板参数的指引,全程无需开发者手写代码。
1 | // 类模板 |
当构造函数是独立模板参数 U(万能引用构造),类模板参数 T 与构造参数 U 完全脱节时,隐式指引彻底失效,编译器无法建立映射关系。
1 | template <typename T> |
3.2 显式推导指引
当隐式指引无法满足需求(需要类型转换、类型包装、关联脱节模板参数)时,开发者手动书写的推导规则,优先级高于隐式指引。
3.2.1 非模板型显式推导指引(固定映射)
指引本身无自定义模板参数,可以用于固定的类型转换场景:传入 A 类型,强制推导为 B 类型。
示例:传入 int,强制推导 long 类型
1 | template<typename T> |
非模板推导指引也可以用于特定类型的精确匹配,优先级通常高于模板指引。
1 | // 精确匹配 std::string |
3.2.2 模板型显式推导指引(通用映射,核心常用)
指引自带独立模板参数,可捕获任意实参类型,实现通用推导,是工程中最常用的形式,完美解决万能引用、数组退化、类型清洗问题。
示例1:修复万能引用构造的 CTAD 失效问题
很多时候类模板想存值类型,但构造函数是万能引用 U&&,隐式 CTAD 会推导出引用 /const,不符合预期。
1 |
|
如果不写这条指引,这个万能引用构造会导致 CTAD 编译报错(类模板 T 无法被推导)。
示例2:一对多转换:传入 pair,提取元素构造类模板
1 | template<typename T1, typename T2> |
3.3 特殊修饰:explicit 推导指引
就像构造函数可以加 explicit 一样,显式推导指引也可以添加 explicit 修饰,用于限制拷贝初始化,仅允许直接初始化。
1 | template<typename T> |
一定要注意:此处explicit 修饰的是推导指引,不是构造函数!
3.4 继承构造函数与推导指引
在下面这个例子中,我们定义了一个基类 Base 和一个派生类 Derived。Derived 继承了 Base 的构造函数,同时我们为 Derived 定义了自己的推导指引。
1 |
|
如果类继承了基类的构造函数 (using Base::Base;),这些构造函数也会参与推导。如果定义了新的推导指引,它们会与继承的构造函数生成的推断进行竞争。
基类
Base:它的构造函数
Base(T)会自动生成隐式推导指引template<typename T> Base(T) -> Base<T>;。继承
using Base::Base;:在 C++ 中,如果一个构造函数模板(或者这里作为模板类成员的构造函数)被引入到派生类中,它会自动携带生成推导指引的能力。通过
using Base::Base;,Derived类隐式地获得了以下推导指引:1
2template <typename T>
Derived(T) -> Derived<T>;显式推导指引
Derived(int) -> Derived<double>;:这是我们定义的人为干预规则。根据 C++ 标准(特别是针对推导指引的重载决议和查找规则),显式定义的推导指引会抑制由继承构造函数生成的隐式推导指引。
运行结果:
当编译器看到
Derived d2(100)时,它不会去寻找继承来的 如何推导为Derived<int>的方法。相反,它直接匹配到了我们显式定义的Derived(int) -> Derived<double>。因此,d2最终的类型是Derived<double>。
如果在类中使用了 using Base::Base;,并且不想让隐式推导生效(或者想修改推导逻辑),只要为相关参数定义了显式推导指引,这些显式指引就会赢得竞争,编译器将不再考虑继承构造函数原本会生成的推导方式。




















