先说一个我在代码评审里遇见过的场景。项目里有人写了一个任务入队函数:
cpp复制template <typename T>
void enqueue(T&& task) {
queue_.push(std::move(task));
}
调用方传入的是一个左值任务对象,等后面从队列里取出来执行的时候,里面的数据已经被移得干干净净。问题出在哪?就出在 T&& 的身份上。很多C++开发能写出泛型代码,但不一定清楚编译器到底把 T 推断成了什么,而这恰恰是 C++ 模板参数推断与类型推演规则里最核心、也最容易出错的部分。
这篇文章想把这套规则系统捋一遍:函数模板的三种形参形态、万能引用与引用折叠、数组和函数的退化、auto 与模板推断的差异、C++17 的类模板参数推断(CTAD),以及一次真实的悬垂引用排查过程。适合写过一段时间 C++、但每次面对模板推导总有些“凭感觉”的读者;也适合那些已经把 std::forward 背下来,却说不出为什么要这么写的开发者。
1. 一种最容易被忽略的推导场景:模板参数与auto的隐藏等价关系
1.1 三种形参形态,三套不同的推断结果
模板参数推断的本质,是编译器根据调用表达式反推 T。同一个 T,会因为你把参数写成 T、T&、const T&、T&& 而得到完全不同的结果。这里先看最基础的四种形态。
cpp复制template <typename T>
void byValue(T param); // 按值
template <typename T>
void byLRef(T& param); // 非const左值引用
template <typename T>
void byCLRef(const T& param); // const左值引用
template <typename T>
void byForward(T&& param); // 转发引用/万能引用
假设有一个变量 const int cx = 1;,分别调用这四个函数,推导结果如下:
byValue(cx):T推导为int,不是const int。byLRef(cx):编译失败,因为T&无法绑定到const int上。byCLRef(cx):T推导为int,形参类型是const int&。byForward(cx):T推导为const int&,形参类型折叠后是const int&。
第一眼看上去很反直觉:为什么 byValue 会把顶层 const 丢掉?因为按值传参时,传入的实参会被复制到形参里,函数内部对拷贝的修改不会影响原对象。所以拷进来的对象是什么类型,和原对象带不带 const、带不带引用已经没有关系了。这就像你把一份合同复印件交出去,对方在复印件上写的批注不会改动原件。引用性是最先被剥掉的一层外衣,然后是顶层 const。
底层 const 不一样。比如实参指向 const int 的指针,按值传递后,指针本身可以改,但指针指向的对象依然是 const int,这个底层 const 会保留在推导结果里。
1.2 auto与模板推断的“同源”意味着什么
理解模板推导,其实也就能理解 auto。auto 和模板推导几乎是一回事,唯一的差别在 initializer_list 那类花括号初始化上,这一点后面专门讲。
可以把 auto 理解成某个隐藏函数模板的 T:
cpp复制auto x = expr;
等价于:
cpp复制template <typename T>
void deduce(T param);
deduce(expr); // T的推导结果就是x的类型
这带来一个很实用的推论:当你看到 const auto& v = something; 时,可以把它想象成 const T& 形态的模板推导;看到 auto&& r = something; 时,要意识到这是转发引用形态,something 是左值,auto 就推导为左值引用。
一个实际案例是 range-for:
cpp复制for (auto&& item : container)
