DongGu
类型推导

类型推导

C++98 只有一套类型推导规则,即针对函数 template 的规则。

C++11 对该规则进行了微调,并新增了两套规则:一套针对 auto,另一套针对 decltype

随后,C++14 扩展了 autodecltype 的适用场景。

类型推导的普及,不但将我们从繁复的类型拼写中解放出来,也增强了 C++ 软件的适应性,因为只需在源码某处修改类型,该变更就能通过类型推导自动传播到其他相关位置。

但是,类型推导也会使代码的逻辑判断更加困难,并且有些时候编译器推导出的类型和你认为的类型并不相同。

如果不理解 C++ 编译器类型推导的机理,就无法高效地进行现代 C++ 编程,因为现代 C++ 涉及类型推导的地方太多了:函数 template 的调用,auto 的使用以及 decltype 的使用等。

接下来我们会介绍 templateauto 以及 decltype 类型推导的原理,以及一些常见的类型推导”失效”的原因。

template 类型推导

template 类型推导可以说是 C++ 历史上最成功的设计之一,无数程序员即使不清楚其原理,也可以利用它写出正确合理的代码。

同时,template 类型推导也是 C++11 引入的 auto 的基石。

但是 template 类型推导的结果有些时候和我们的直觉并不相同,因此我们需要理解 template 类型推导的原理。

一个函数模板的代码大概是这样的:

1
2
template<typename T>
void f(ParamType param);

调用代码:

1
f(expr);

在编译过程中,编译器会利用 expr 推导 TParamType 的类型。这两种类型通常不同,因为 ParamType 通常包含修饰符,例如 const 或引用限定符。例如:

1
2
template<typename T>
void f(const T& param);

调用代码:

1
2
int x = 0;
f(x);

此时 T 的推导结果是 int,但是 ParamType 的推导结果是 const int&

在上面的例子中,T 的类型取决于 x 的类型,但是事实上会更复杂一些。

T 的类型不只与 expr 相关,也取决于 ParamType,总体来说分为三种情况:

  1. ParamType 是一个指针或者引用类型,但不是万能引用(universal reference,C++17 起官方定名为 forwarding reference,是一种特殊的引用类型)

  2. ParamType 是万能引用

  3. ParamType 既不是指针也不是引用类型

情况 1:ParamType 是指针或者引用类型,但不是万能引用

这种情况下,类型推导遵从两条规则:

  1. 如果 expr 是引用类型,忽略其类型的引用部分

  2. 然后将 expr 的类型与 ParamType 进行模式匹配,进而确定 T 的类型

例如:

1
2
template<typename T>
void f(T& param); // param 是引用类型

声明变量:

1
2
3
int x = 27;             // x 是 int
const int cx = x; // cx 是 const int
const int& rx = x; // rx 是指向 const int 的引用

此时调用的推导结果如下:

1
2
3
f(x);                   // T 是 int,param 的类型是 int&
f(cx); // T 是 const int,param 的类型是 const int&
f(rx); // T 是 const int,param 的类型是 const int&

在第一次调用中,x 是普通的 int 类型,TParamType 的推导结果显而易见。

在第二次和第三次调用中,由于 cxrx 指定的是 const 值,T 被推导为 const int,从而使参数类型变为 const int&

在第三次调用中,虽然 rx 是引用类型,但是推导结果没有引用,这与规定所述一致。

当然,如果是右值引用参数,推导过程完全相同,只是需要用右值引用参数来接收右值引用实参。

如果我们把 f 的参数类型从 T& 改为 const T&,此时的推导结果并没有什么特殊变化,只不过对于 cxrx,由于此时 param 已被声明为”对 const 的引用”,因此不再需要将 const 属性推导为 T 的一部分:

1
2
3
4
5
6
7
8
9
10
template<typename T>
void f(const T& param); // param 现在是对 const 的引用

int x = 27; // 同上
const int cx = x; // 同上
const int& rx = x; // 同上

f(x); // T 是 int,param 的类型是 const int&
f(cx); // T 是 int,param 的类型是 const int&
f(rx); // T 是 int,param 的类型是 const int&

如果 ParamType 是指针而不是引用类型,推导方式也是一样的:

1
2
3
4
5
6
7
8
template<typename T>
void f(T* param); // param 现在是指针类型

int x = 27; // 同上
const int *px = &x; // px 是指向 const int 的指针

f(&x); // T 是 int,param 的类型是 int*
f(px); // T 是 const int,param 的类型是 const int*

情况 2:ParamType 是万能引用

万能引用(universal reference),在 C++17 及后续标准中官方称为 forwarding reference(转发引用),是 C++11 引入的一项关键特性,主要用于实现高效且保持实参属性的完美转发(perfect forwarding)。

万能引用看起来和普通右值引用(T&&)一模一样,但它既能绑定左值,也能绑定右值:

  • 如果传入的是左值,万能引用就会折叠并绑定为左值引用(T&)。
  • 如果传入的是右值,万能引用就会绑定为右值引用(T&&)。

并不是所有写成 T&& 的地方都是万能引用,它必须同时满足以下两个条件:

  1. 必须存在类型推导:T 的类型必须是在调用时由编译器动态推导出来的。
  2. 形式必须精确为 T&&:不能带有 const 或其他修饰符(例如 const T&& 就只是普通的右值引用)。

这种工作机制基于 C++11 的引用折叠(reference collapsing)规则,当 ParamType 是万能引用时,推导结果也会遵循引用折叠规则。

这种情况下,类型推导也遵循两条规则:

  1. 如果 expr 是左值,则 TParamType 都会被推导为左值引用
  2. 如果 expr 是右值,则遵循情况 1 的推导规则

例如:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
void f(T&& param); // param 现在是万能引用

int x = 27; // 同上
const int cx = x; // 同上
const int& rx = x; // 同上

f(x); // x 是左值,因此 T 是 int&,param 的类型也是 int&
f(cx); // cx 是左值,因此 T 是 const int&,param 的类型也是 const int&
f(rx); // rx 是左值,因此 T 是 const int&,param 的类型也是 const int&
f(27); // 27 是右值,因此 T 是 int,param 的类型是 int&&

引用折叠发生了作用:

1
2
3
4
5
6
7
// f(x)推导T为int&
template<int&>
void f(int& && param);->void f(int& param); //发生引用折叠

// f(cx) f(rx)推导T为const int&
template<const int&>
void f(const int& && param);->void f(const int& param); //发生引用折叠

可见,当 ParamType 为万能引用时,对于左值与右值的推导规则有着显著差异,并且是唯一会导致 T 的推导类型为引用的情况。

情况 3:ParamType 既不是指针也不是引用

ParamType 既不是指针也不是引用时,我们处理的是按值传递:

1
2
template<typename T>
void f(T param); // param 现在按值传递

这意味着,无论调用函数时传入的参数类型是什么,param 都会发生拷贝,成为一个新的对象。这也引出了该种情况下的类型推导规则:

  1. 如果 expr 是引用类型,忽略其类型的引用部分
  2. 如果忽略 expr 的引用性后,expr 仍然是 const,也忽略。如果它是 volatile 的,也忽略

例如:

1
2
3
4
5
6
7
int x = 27;             // 同上
const int cx = x; // 同上
const int& rx = x; // 同上

f(x); // T 和 param 的类型都是 int
f(cx); // T 和 param 的类型也都是 int
f(rx); // T 和 param 的类型仍然是 int

可见,虽然 cxrx 代表 const 常量,但是其推导出的 param 并非 const,因为 param 是一个完全独立于 cxrx 的副本,我们认为 param 的修改不会影响 cxrx 不能修改的事实,因此在类型推导时会忽略其常量性。

现在,请考虑这样一种情况:

1
2
3
4
5
6
template<typename T>
void f(T param); // param 仍然是按值传递

const char* const ptr = "Fun with pointers"; // ptr 是指向常量对象的常量指针

f(ptr); // 传入 const char* const 类型的参数

此时 ptr 左边的 const 意味着指针 ptr 是常量,不能指向其他位置,也不能为 nullchar* 左边的 const 意味着 ptr 指向的字符串内容是常量。

ptr 传递给 f() 时,ptr 本身会拷贝给 param,此时 ptrconst 被忽略,但是 ptr 指向内容的 const 不会被忽略,因此最终的推导结果 TParamType 都为 const char*

数组与函数类型

在 C++ 的模板推导中,数组和函数在大多数情况下会退化为指针,但在特定引用类型下会保持原类型。这是来源于 C 语言中数组和函数名会退化为指针的特性。

数组类型的类型推导

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T>
void f_val(T param);

template <typename T>
void f_ptr(T* param);

int keyArray[13] = {0};

f_val(keyArray); // keyArray 退化为 int*
// T 是 int*,param 的类型是 int*

f_ptr(keyArray); // keyArray 退化为 int*,与 T* 模式匹配
// T 是 int,param 的类型是 int*

ParamType 为值或指针时,数组会退化为指针。

1
2
3
4
5
6
7
template <typename T>
void f_ref(T& param);

int keyArray[13] = {0};

f_ref(keyArray); // T 是 int[13],param 的类型是 int(&)[13]
// (指向长度为 13 的 int 数组的引用)

ParamType 为引用时,数组不会退化,T 会被推导为数组类型。

函数类型的类型推导

函数类型(如 void(int, double))与数组遵循完全相同的退化规则:按值传递时退化为函数指针,按引用传递时保留函数引用。

1
2
3
4
5
6
7
8
9
10
void someFunc(int, double);     // someFunc 是函数,类型为 void(int, double)

template<typename T>
void f1(T param); // f1 中 param 按值传递

template<typename T>
void f2(T& param); // f2 中 param 按引用传递

f1(someFunc); // param 推导为函数指针,类型为 void (*)(int, double)
f2(someFunc); // param 推导为函数引用,类型为 void (&)(int, double)

auto 类型推导

auto 是 C++11 引入的核心特性之一(在 C++14、C++17 和 C++20 中得到了持续增强)。它的主要作用是自动类型推导(automatic type deduction),即让编译器在编译期根据初始化的表达式自动推导出变量的类型,而不会带来任何运行时开销。

auto 的类型推导规则与 template 的类型推导规则几乎一样。虽然 auto 没有 template、function 和 parameters,但是 autotemplate 类型推导之间有一套直接的映射规则。

当使用 auto 声明变量时,auto 扮演了 template 类型推导中 T 的角色,而 auto 的整体修饰类型扮演了 template 类型推导中 ParamType 的角色。例如:

1
2
3
4
5
auto x = 27;

const auto cx = x;

const auto& rx = x;

其中 auto 扮演了 T 的角色,autoconst autoconst auto& 扮演了 ParamType 的角色。

auto 类型推导也遵循与 template 推导一样的三种情况:

  1. 类型说明符是指针或引用类型,但不是万能引用
  2. 类型说明符是万能引用
  3. 类型说明符既不是指针也不是引用

情况 1 和 3 正如刚才的例子:

1
2
3
auto x = 27;            // 情况 3(x 既不是指针也不是引用)
const auto cx = x; // 情况 3(cx 也不是)
const auto& rx = x; // 情况 1(rx 是非万能引用)

情况 2 例如:

1
2
3
auto&& uref1 = x;       // x 是 int 左值,因此 uref1 的类型是 int& (int& && -> int&)
auto&& uref2 = cx; // cx 是 const int 左值,因此 uref2 的类型是 const int& (const int& && -> const int &)
auto&& uref3 = 27; // 27 是 int 右值,因此 uref3 的类型是 int&&

template 类型推导对于数组和函数的性质在 auto 类型推导依旧适用,对于指针或者值类型的类型说明符,数组和函数名会退化为指针,对于引用则遵循普通引用和万能引用的引用折叠规则。例如:

1
2
3
4
5
6
7
8
const char name[] = "R. N. Briggs";     // name 的类型是 const char[13]
auto arr1 = name; // arr1 的类型是 const char*
auto& arr2 = name; // arr2 的类型是 const char (&)[13]

void someFunc(int, double); // someFunc 是函数,类型为 void(int, double)
auto func1 = someFunc; // func1 的类型是 void (*)(int, double)
auto& func2 = someFunc; // func2 的类型是 void (&)(int, double)
auto&& func3 = someFunc; // func3 的类型是 void (&)(int, double)

auto 类型推导与 template 类型推导在有些代码中会得到不同的结果:

C++11 引入了对统一初始化(uniform initialization)的支持,它允许使用花括号 {} 统一对基本类型、结构体、类对象以及容器进行初始化。

但是进行 auto 类型推导时,会得到意料之外的类型:

1
2
3
4
auto x1 = 27;           // 类型是 int,值是 27
auto x2(27); // 同上
auto x3 = { 27 }; // 类型是 std::initializer_list<int>,值是 { 27 }
auto x4{ 27 }; // 同上(C++11)

对于带赋值号的花括号列表初始化,auto 会将其硬编码推导为 std::initializer_list<T> 类型(注意:std::initializer_list 内部仅持有临时数组的指针,本质是一个轻量级的代理类)。

为了避免误导,在 C++17 中:

  • 带赋值号的拷贝列表初始化:auto x3 = { 27 };std::initializer_list<int>
  • 不带赋值号的直接列表初始化:auto x4{ 27 };int(如果写 auto x4{1, 2} 则直接编译报错)

但是 template 类型推导会报错:

1
2
3
4
5
6
auto x = { 11, 23, 9 };                 // x 的类型是 std::initializer_list<int>

template<typename T> // 带有参数的 template
void f(T param); // 与 x 声明等价的声明

f({ 11, 23, 9 }); // 错误!无法推导 T 的类型

这是为了避免泛型函数重载时产生模棱两可的歧义。如果模板需要接收花括号,必须显式声明形参为 std::initializer_list<T>

1
2
3
4
template<typename T>
void f(std::initializer_list<T> initList);

f({ 11, 23, 9 }); // T 推导为 int,initList 的类型是 std::initializer_list<int>

C++14 允许使用 auto 来指定函数的返回类型应由编译器推导,同时也允许在 lambda 表达式的形参声明中使用 auto。然而,这些 auto 的用法实际上采用的是 template 类型推导机制,而非 auto 类型推导机制。因此,如果一个返回类型为 auto 的函数试图返回一个花括号初始化列表(braced initializer),代码将无法通过编译:

1
2
3
4
auto createInitList()
{
return { 1, 2, 3 }; // 错误!无法推导 { 1, 2, 3 } 的类型
}

同样的事情也发生在 auto 作为 lambda 表达式的形参类型时:

1
2
3
4
5
6
std::vector<int> v;
...
auto resetV =
[&v](const auto& newValue) { v = newValue; }; // C++14
...
resetV({ 1, 2, 3 }); // 错误!无法推导 { 1, 2, 3 } 的类型

decltype 类型推导

template 类型推导或 auto 类型推导不同,decltype 类型推导结果是变量或表达式最真实、未经修改的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const int i = 0;                // decltype(i) 是 const int

bool f(const Widget& w); // decltype(w) 是 const Widget&
// decltype(f) 是 bool(const Widget&)

struct Point {
int x, y; // decltype(Point::x) 是 int
}; // decltype(Point::y) 是 int

Widget w; // decltype(w) 是 Widget

if (f(w)) ... // decltype(f(w)) 是 bool

template<typename T> // std::vector 的简化版本
class vector {
public:
...
T& operator[](std::size_t index);
...
};

vector<int> v; // decltype(v) 是 vector<int>
...
if (v[0] == 0) ... // decltype(v[0]) 是 int&

在 C++11 中,我们可以利用 decltype 支持容器/数组随机访问的函数模板 authAndAccess

1
2
3
4
5
6
template <typename Container, typename Index>
auto authAndAccess(Container& c, Index i) -> decltype(c[i])
{
authenticateUser();
return c[i];
}

使用尾置返回类型(trailing return type)decltype(c[i]) 是因为:在函数头写 decltype(c[i]) 时,参数 ci 还没有被声明,只有放到参数列表后面的 -> decltype(c[i]) 处,ci 才是合法的标识符。

在 C++14 中允许省略尾置返回类型,直接写 auto

1
2
3
4
5
6
template <typename Container, typename Index>
auto authAndAccess(Container& c, Index i)
{
authenticateUser();
return c[i];
}

但是这样存在一个问题:这里的 auto 使用的是 template 类型推导规则,返回值的引用类型会被忽略,导致返回的是一个右值。比如 authAndAccess(v, 5) 返回了一个临时对象的拷贝(右值),会导致 authAndAccess(v, 5) = 10 无法通过编译!

为了解决这个问题,C++14 引入了 decltype(auto)

1
2
3
4
5
6
template<typename Container, typename Index>
decltype(auto) authAndAccess(Container& c, Index i)
{
authenticateUser();
return c[i];
}

但是这样虽然可以正确返回引用类型,仍然存在问题:

  1. 无法接收右值容器(如临时容器对象)
  2. 无法正确转发右值容器的元素(缺乏万能引用与完美转发)

下面给出优化后的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// C++11 版本
template<typename Container, typename Index>
auto authAndAccess(Container&& c, Index i)
-> decltype(std::forward<Container>(c)[i]) // 使用万能引用,既能接收左值也能接收右值
{
authenticateUser();
return std::forward<Container>(c)[i]; // 完美转发容器,保留其原始左右值属性
}

// C++14 版本
template<typename Container, typename Index>
decltype(auto) authAndAccess(Container&& c, Index i) // 使用万能引用,既能接收左值也能接收右值
{
authenticateUser();
return std::forward<Container>(c)[i]; // 完美转发容器,保留其原始左右值属性
}

decltype(auto) 的使用并不局限于函数返回类型。当你希望对初始化表达式应用 decltype 类型推导规则来声明变量时,它同样非常方便:

1
2
3
4
5
6
7
Widget w;

const Widget& cw = w;

auto myWidget1 = cw; // auto 类型推导:myWidget1 的类型是 Widget

decltype(auto) myWidget2 = cw; // decltype 类型推导:myWidget2 的类型是 const Widget&

最后需要注意的是,C++ 规定:给变量名加一对括号,会将其从”变量名”变成”左值表达式”,但是 decltype 对于变量名和左值表达式的推导结果并不相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
decltype(auto) f1()
{
int x = 0;
...
return x; // decltype(x) 是 int,因此 f1 返回 int
}

decltype(auto) f2()
{
int x = 0;
...
return (x); // decltype((x)) 是 int&,因此 f2 返回 int&
}

第二种写法会使得函数返回一个局部变量的引用,属于未定义行为。


这次的插图来自画师 Fangpeii

图片地址:https://www.pixiv.net/artworks/120409457

文章内容参考:Effective Modern C++ 42 Specific Ways to Improve Your Use of C++11 and C++14 (Scott Meyers) Item1-Item3

本文作者:DongGu
本文链接:https://donggu.xyz/2026/08/03/现代C++学习/类型推导/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可