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 被推导为 intforward 返回类型 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_uniquestd::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_backmap::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 是安全的,因为:

  1. newName 是与调用者完全独立的副本,改动它不影响调用者;
  2. 这是它的最后一次使用,移动它不会造成后续问题。

在 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 中没有对应物,因为移动语义已内建为"按值传递"的默认行为。

评论