前言
「原样转发」是中文技术圈对 perfect forwarding 的常见叫法,标准术语是完美转发。它要解决的问题是:一个中间层函数收到实参后,把实参原封不动地传给下一层,既不丢失const限定,也不把左值变成右值,更不产生多余拷贝。没有完美转发时,中间层要么按值接收(多一次拷贝),要么按T&接收(无法接受右值),要么按const T&接收(丢失可修改性),总有一头对不上。
可变参数模板(variadic templates)解决的是另一件事:让函数或类接受任意个数、任意类型的参数。C++11 引入参数包(parameter pack)后,std::vector::emplace_back、std::make_shared、std::tuple才能把「构造参数」原样转交给目标类型。完美转发与可变参数几乎总是成对出现:前者保证每个参数的值类别不变,后者保证参数个数不限。
常见误解集中在三处。第一,把T&&一律当成右值引用——只有在T需要推导时它才是转发引用(forwarding reference),std::vector<T>&&这样的具体类型仍是右值引用。第二,以为std::forward<T>(x)会「移动」x——它只是有条件地把x转成右值引用,是否真的发生移动取决于接收方。第三,以为参数包可以像数组一样下标访问——参数包只能通过展开或sizeof...使用。
一、转发引用与引用折叠
先看模板参数推导。对于template <typename T> void f(T&& x):
- 传入左值
int a,T被推导为int&,T&&经引用折叠成为int&; - 传入右值
42,T被推导为int,T&&就是int&&。
引用折叠规则只有四条:& &、& &&、&& &都折叠成&,只有&& &&折叠成&&。可以记成「只要出现一个左值引用,结果就是左值引用」。这就是为什么T&&能同时绑定左值和右值,也是「转发引用」名字的由来。
| 实参 | 推导出的T | T&&折叠结果 | 值类别 |
|---|
int a(左值) | int& | int& | 左值 |
const int a(左值) | const int& | const int& | 左值 |
42(右值) | int | int&& | 右值 |
std::move(a)(右值) | int | int&& | 右值 |
注意const会跟着T一起推导出来。这正是「原样」的含义:const、左值/右值属性都保留。
转发引用只在这种形式下成立:T&&且T是函数模板的模板参数、需要推导。以下都不是转发引用:void f(int&& x)(具体类型)、template <typename T> void f(std::vector<T>&& x)(具体模板,T虽推导但vector<T>不是T本身)、template <typename T> void f(const T&& x)(多了const)。类模板的成员函数里,T&&是否转发引用取决于T是类模板参数还是成员函数自己的模板参数:前者不推导,是右值引用。
二、std::forward 的作用
std::forward<T>(arg)的定义思路是static_cast<T&&>(arg)。当T被推导为int&时,T&&折叠为int&,转换结果是左值引用;当T是int时,结果是int&&。于是左值仍是左值,右值仍是右值。
// 适用于 C++17
#include <iostream>
#include <string>
#include <utility>
void sink(std::string& s) { std::cout << "lvalue: " << s << '\n'; }
void sink(std::string&& s) { std::cout << "rvalue: " << s << '\n'; }
template <typename T>
void relay(T&& value) {
sink(std::forward<T>(value)); // 保留值类别
}
int main() {
std::string name = "abc";
relay(name); // 按语义走左值重载
relay(std::string("xyz"));// 按语义走右值重载
return 0;
}如果把std::forward<T>(value)换成value,右值那次调用会变成左值(具名变量都是左值),走左值重载;如果换成std::move(value),左值那次会被强制转成右值,可能把调用方的对象搬空。三者区别如下:
| 写法 | 左值实参 | 右值实参 | 是否保留值类别 |
|---|
value | 左值 | 左值 | 否 |
std::move(value) | 右值 | 右值 | 否(一律右值) |
std::forward<T>(value) | 左值 | 右值 | 是 |
还要强调:std::move本身不移动任何东西,它只是一个到右值引用的static_cast;真正的移动发生在目标类型的移动构造/移动赋值被调用时。std::forward同理,它不转发任何东西,只是做类型转换。
三、可变参数模板:参数包与展开
可变参数模板用省略号声明参数包:template <typename... Args> void f(Args... args)。Args是类型包,args是值包。包本身不能直接使用,只能:
- 用
sizeof...(Args)或sizeof...(args)得到元素个数; - 在模式(pattern)后面加
...展开,如f(args...)、(args + ...); - 通过递归把包拆成「第一个 + 剩余」。
C++11/14 时代常用递归展开,需要一个非模板的重载作为终止条件:
// 适用于 C++17
#include <iostream>
void print_all() {
std::cout << '\n';
}
template <typename T, typename... Rest>
void print_all(const T& first, const Rest&... rest) {
std::cout << first << ' ';
print_all(rest...); // 包展开,递归到空包时调用上面的重载
}调用print_all(1, 2.5, "x")时,先匹配T = int、Rest = {double, const char*},打印1,再以rest...递归;最后一次rest为空,匹配无参重载。注意终止重载必须在递归模板之前声明,否则递归实例化时找不到它;这也是很多「编译到最后一层报错」的来源。
C++17 的折叠表达式(fold expression)把递归写法压缩成一行,且不需要终止重载:
// 适用于 C++17
template <typename... Args>
void print_all_fold(const Args&... args) {
((std::cout << args << ' '), ...); // 逗号折叠,左折叠
std::cout << '\n';
}(pattern, ...)是左折叠,展开为((a0 , a1) , a2) ...;(... , pattern)是右折叠。还有+、*、&&、||等二元运算符可折叠,空包时&&得true、||得false、,得void(),其他运算符对空包是编译错误。折叠表达式需要 C++17,C++11/14 只能用递归或初始化列表技巧。
四、完美转发工厂
把两者结合,就得到标准库emplace_back、make_unique、make_shared的雏形:接受任意构造参数,原样转交给目标类型的构造函数。
// 适用于 C++17
#include <memory>
#include <string>
#include <utility>
#include <vector>
class Widget {
public:
Widget(std::string name, int id)
: name_(std::move(name)), id_(id) {}
const std::string& name() const { return name_; }
int id() const { return id_; }
private:
std::string name_;
int id_;
};
template <typename T, typename... Args>
std::unique_ptr<T> make_object(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
template <typename T, typename... Args>
void emplace_into(std::vector<T>& vec, Args&&... args) {
vec.emplace_back(std::forward<Args>(args)...);
}Args&&...对每个实参分别推导,std::forward<Args>(args)...对每个参数分别转发,两个省略号分别展开类型包和值包,顺序一一对应。new T(...)里如果某个参数是左值std::string,forward后仍是左值,匹配拷贝构造;如果是std::move(s),则匹配移动构造。
标准库从 C++14 起提供std::make_unique,C++11 没有;上面的make_object在 C++11 下可用,但在 C++14 及以后应优先用std::make_unique<T>(...),它把new包在库内部,异常安全更好,也更简洁。
五、完整示例:带计数的转发包装
下面这个例子把转发引用、可变参数、std::forward和折叠表达式放在一起:一个Logged包装器在构造目标对象前打印参数个数,然后把参数原样转交。
// 适用于 C++17
#include <iostream>
#include <memory>
#include <string>
#include <utility>
class Connection {
public:
Connection(std::string host, int port)
: host_(std::move(host)), port_(port) {
std::cout << "connect " << host_ << ':' << port_ << '\n';
}
private:
std::string host_;
int port_;
};
template <typename T, typename... Args>
std::unique_ptr<T> logged_create(Args&&... args) {
std::cout << "creating with " << sizeof...(Args) << " args\n";
return std::make_unique<T>(std::forward<Args>(args)...);
}
int main() {
std::string host = "example.invalid";
auto c1 = logged_create<Connection>(host, 80); // 左值 host,拷贝
auto c2 = logged_create<Connection>(std::string("h"), 443); // 右值,移动
return 0;
}sizeof...(Args)展开为实参个数,不产生运行期开销。c1的host是左值,Args推导为std::string&,转发后调用拷贝构造;c2传临时对象,Args为std::string,转发后调用移动构造。两者都只经过一层make_unique,没有额外拷贝。
常见坑点
- ❌ 把
T&&一律当右值引用,写出void f(std::vector<T>&& v)后奇怪为什么左值传不进来。✅ 只有T&&且T是待推导的模板参数时才是转发引用;具体类型是右值引用。
- ❌ 在转发函数里写
sink(value),右值实参退化成左值。✅ 写sink(std::forward<T>(value))。
- ❌ 用
std::move代替std::forward,把本应保留的左值搬空。✅ 中间层用std::forward<T>,只有确定要放弃所有权时才用std::move。
- ❌ 对同一个参数
std::forward两次,第二次可能访问已被移走的值。✅ 每个参数只转发一次;需要多次使用就先复制到局部变量。
- ❌
make_object<T>({1, 2})用花括号初始化列表,模板推导不出Args。✅ 显式写出类型,如make_object<T>(std::initializer_list<int>{1, 2}),或改用能接受std::initializer_list的重载。
- ❌ 可变参数递归模板缺少空包终止重载,最后一层报「no matching function」。✅ 提供无参重载,或在 C++17 下改用折叠表达式。
- ❌ 转发引用构造函数贪婪匹配,把拷贝构造也劫持了(
template <typename U> Widget(U&&)匹配Widget&)。✅ 用std::enable_if排除自身类型(C++17 可用std::is_same配合if constexpr的思路),C++20 可用 concepts 约束。
- ❌ 在递归展开中对包使用下标
args[0]。✅ 参数包不支持下标,用递归拆分、折叠表达式或std::apply(C++17)配合std::tuple。
总结
| 概念 | 关键点 |
|---|
| 转发引用 | T&&且T待推导;左值推出T&,右值推出T |
| 引用折叠 | 只要出现左值引用,结果就是左值引用 |
std::forward | 有条件转换为右值引用,保留值类别 |
std::move | 无条件转换为右值引用,本身不移动 |
| 参数包 | sizeof...取个数,模式加...展开 |
| 展开方式 | C++11/14 递归 + 终止重载;C++17 折叠表达式 |
| 典型应用 | emplace_back、make_unique、make_shared、tuple构造 |
完美转发的本质是「让类型系统记住实参的值类别,并在每一层原样传递」,可变参数模板则把这件事从固定参数推广到任意个数。理解引用折叠和std::forward的转换语义,比记住emplace_back的用法更重要——前者能解释后者为什么这样设计,也能解释为什么传错值类别时行为会悄悄改变。