C++ 万能引用和完美转发
万能引用与完美转发
万能引用(Universal Reference)
万能引用是指形如 T&& 的引用,但它必须出现在类型推导的语境中。它的特殊之处在于,它既可以绑定到左值,也可以绑定到右值。
template<typename T>
void func(T&& param); // param 是万能引用
int x = 10;
func(x); // 传入左值
func(10); // 传入右值
需要注意区分万能引用和普通右值引用:
template<typename T>
void f(T&& param); // 万能引用(有类型推导)
void g(int&& param); // 右值引用(无类型推导,类型已确定)
template<typename T>
void h(std::vector<T>&& param); // 右值引用(T&& 前有修饰,不是纯粹的 T&&)
引用折叠规则
万能引用能够工作的核心机制是引用折叠(Reference Collapsing)。C++ 规定,当出现"引用的引用"时,按以下规则折叠:
| 组合 | 折叠结果 |
|---|---|
T& & | T& |
T& && | T& |
T&& & | T& |
T&& && | T&& |
简单记忆:只要有一个是左值引用,结果就是左值引用;只有全是右值引用,结果才是右值引用。
当传入左值时,T 被推导为 int&,于是 T&& 变成 int& &&,折叠为 int&;当传入右值时,T 被推导为 int,于是 T&& 保持为 int&&。这就是万能引用能同时绑定左值和右值的原理。
完美转发(Perfect Forwarding)
完美转发解决的问题是:在函数模板内部,如何把接收到的参数原封不动地(保持其左值/右值属性)传递给下一个函数。
问题的根源在于:具名的参数变量本身都是左值,即使它绑定的是右值。
template<typename T>
void wrapper(T&& param) {
// 此处 param 是具名变量,它是左值
// 如果直接调用 target(param),右值属性就丢失了
target(param);
}
为了解决这个问题,需要使用 std::forward:
template<typename T>
void wrapper(T&& param) {
target(std::forward<T>(param)); // 保持原有的值类别
}
std::forward 的实现原理
std::forward 是一个有条件的强制类型转换。它根据模板参数 T 的类型来决定是否转换为右值:
template<typename T>
T&& forward(std::remove_reference_t<T>& arg) {
return static_cast<T&&>(arg);
}
它的工作逻辑结合了引用折叠:
- 当传入的是左值时,
T被推导为int&,forward返回类型int& &&折叠为int&,即返回左值引用,保持左值属性。 - 当传入的是右值时,
T被推导为int,forward返回类型int&&,即返回右值引用,恢复右值属性。
使用场景
只要一个函数的职责是“接收参数,然后继续交给别的函数/构造函数”,万能引用通常就很合适。
转发到的函数通常表现为重载,或者是某个已经实现了拷贝/移动语义的操作:
// 形式1:重载函数
void real_func(const Widget&) { /* 左值来了,拷贝 */ }
void real_func(Widget&&) { /* 右值来了,移动 */ }
// 形式2:类的构造函数(emplace 的情况)
new (ptr) Widget(std::forward<Args>(args)...);
// ↑ Widget 的拷贝构造 or 移动构造 在这里被选中
// 形式3:容器的成员函数
data.push_back(std::forward<T>(x));
// ↑ push_back(const T&) or push_back(T&&) 在这里被选中
使用万能引用最重要的判断依据是“是否需要转发”。如果当前函数就是参数的最终消费者(仅在内部使用),用它反而是过度设计,这种情况下,直接使用引用即可:
- 只读使用 → 用
const T&- 要修改调用方的变量 → 用
T&不使用万能引用的情况也类似,如果函数内部根本不需要拷贝或移动,只是只读使用,则无需额外实现
T&&,提供const T&即可接收左值和右值。但是如果函数内部需要对参数进行拷贝或移动,则需要进行左值和右值的区分,因此需要提供const T&和T&&重载。
一、编写通用的包装/转发函数
当你需要写一个函数模板,它只是把参数原封不动地传递给另一个函数时,就应当使用万能引用配合完美转发。这样既能接收左值也能接收右值,且不损失性能。
template<typename Func, typename... Args>
auto call_and_log(Func&& f, Args&&... args) {
std::cout << "调用函数...\n";
return std::forward<Func>(f)(std::forward<Args>(args)...);
}
二、实现工厂函数
标准库中的 std::make_unique、std::make_shared 就是典型例子。它们接收任意参数,并完美转发给对象的构造函数。
template<typename T, typename... Args>
std::unique_ptr<T> make_unique_impl(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
三、实现容器的 emplace 系列接口
vector::emplace_back、map::emplace 等接口通过完美转发,将参数直接传递到容器内部的原地构造,避免临时对象的创建和拷贝。
template<typename... Args>
void emplace_back(Args&&... args) {
// 在容器内存上原地构造
new (memory_slot) T(std::forward<Args>(args)...);
}
四、需要同时高效处理左值和右值时
如果不使用万能引用,为了兼顾左值和右值并保证性能,往往需要写多个重载版本。参数越多,重载组合数量呈指数增长(每个参数都有左值和右值两种形式)。万能引用可以用一个模板解决所有情况。
// 不用万能引用:需要多个重载
void f(const A& a, const B& b);
void f(A&& a, const B& b);
void f(const A& a, B&& b);
void f(A&& a, B&& b);
// 使用万能引用:一个模板搞定
template<typename T, typename U>
void f(T&& a, U&& b);
万能引用的替代方案:按值传递
万能引用配合完美转发虽然能高效处理左值和右值,但它也带来了模板膨胀、编译错误晦涩、某些类型无法转发等问题。在特定条件下,按值传递是一种更简单的替代方案,值得纳入考量。
假设有一个成员函数需要把参数拷贝进容器,为了兼顾效率,通常会写两个重载:
class Widget {
public:
void addName(const std::string& newName) // 左值:拷贝进容器
{ names.push_back(newName); }
void addName(std::string&& newName) // 右值:移动进容器
{ names.push_back(std::move(newName)); }
private:
std::vector<std::string> names;
};
这样做要维护两套代码,目标文件里也可能产生两个函数。
而使用万能引用可以合二为一:
template<typename T>
void addName(T&& newName)
{ names.push_back(std::forward<T>(newName)); }
但代价是:实现必须放在头文件;可能实例化出多个版本;某些类型无法通过万能引用传递;出错时编译信息难以理解。
其实还有一种既简单、又只需一个函数、还避开万能引用问题的写法——按值传递并移动:
class Widget {
public:
void addName(std::string newName) // 接受左值或右值
{ names.push_back(std::move(newName)); } // 内部一律移动
private:
std::vector<std::string> names;
};
这里对 newName 使用 std::move 是安全的,因为:
newName是与调用者完全独立的副本,改动它不影响调用者;- 这是它的最后一次使用,移动它不会造成后续问题。
在 C++11 中,这种写法能自动做到"左值拷贝、右值移动":
- 传入左值时形参
newName被拷贝构造; - 传入右值时形参
newName被移动构造。
三种方案的开销对比
假设重载/万能引用方案中,函数内部总是会对参数进行拷贝/移动(否则对比无意义,因为内部不拷贝/移动时按引用传递效率一定更高)
以左值和右值各调用一次为例:
| 方案 | 左值实参 | 右值实参 |
|---|---|---|
| 重载 | 一次拷贝 | 一次移动 |
| 万能引用 | 一次拷贝 | 一次移动 |
| 按值传递 | 一次拷贝 + 一次移动 | 两次移动 |
可以看出,按值传递相比按引用的两种方案,无论左值还是右值都会多出一次移动操作。因此它是一种"以少量性能换取简洁性"的折中方案。
使用条件
条款的标题"对于移动成本低且总是被拷贝的可拷贝形参,考虑按值传递",每个限定词都有其含义:
一、只是"考虑":它会比万能引用/重载方案多一次移动开销,是有代价的折中,并非最优。
二、仅限"可拷贝形参":对于只可移动类型(如 std::unique_ptr),只需要提供一个右值引用版本就是最优的。
// 只可移动类型:重载方案更优,只需一个函数
void setPtr(std::unique_ptr<std::string>&& ptr)
{ p = std::move(ptr); } // 整体只有一次移动
三、仅限"移动开销小"的类型:如果移动本身很昂贵,那多出来的一次移动就相当于一次不必要的拷贝,得不偿失。
四、仅限"总是被拷贝"的形参:如果函数内部可能因为某些条件而不拷贝,按值传递就白白付出了构造和析构副本的代价,而按引用可以避免。其实也就是:如果该函数就是最终消费者,不再次转发,则使用一个 const 引用时最佳的。
void addName(std::string newName) {
if (newName.length() >= minLen && newName.length() <= maxLen) {
names.push_back(std::move(newName));
}
// 若条件不满足,newName 的构造与析构就纯属浪费
}
即:只有当拷贝一定会发生时,按值传递才划算。
C++ 让"拷贝"和"移动"共存于一个类型中,根据值类别选择拷贝/移动;Rust 则让类型只能属于"Copy 类型"或"Move 类型"之一,移动是默认、Copy 是可选特例,而昂贵的深拷贝必须通过显式的
.clone()表达。不考虑借用检查器的情况下,C++ 的
const T&和T&对应 Rust 的&T和&mut T;右值引用
T&&是 C++ 中实现移动语义的方式(右值引用匹配 xvalue),在 Rust 中没有对应物,因为移动语义已内建为"按值传递"的默认行为。
评论