Skip to content

完美转发:保持值类别的精确传递

写过模板函数的朋友大概都撞上过这个困境:接收一个参数,再把它传给另一个函数,传左值进去希望对方收到左值,传右值进去希望对方收到右值。听起来简单,对吧?但在 C++11 之前这几乎做不到,要么写两个重载(一个收左值引用,一个收右值引用),要么干脆全用 const 引用接收,把"这是右值"的信息丢了,移动语义的性能优势也没了。天啊,效率和性能这下没法兼顾,烦人!

不过没事,C++11 同期给出的完美转发(perfect forwarding)就是来解决这个问题的。它让咱们只写一个模板,就能把参数的值类别原封不动地"转发"给目标函数。

一句话:以前参数转交到别处总得写 const T& 和 T&& 两份,现在不用了,用 std::forward 转发(或者说透传)。

从一个实际的问题开始

假设咱们在写一个简单的工厂函数,用来创建 std::string 对象:

cpp
// 版本一:按 const 引用接收
std::string make_string(const std::string& s)
{
    return std::string(s);  // 总是拷贝构造
}

// 版本二:按右值引用接收
std::string make_string(std::string&& s)
{
    return std::string(std::move(s));  // 总是移动构造
}

版本一能接受左值,但传入右值时也会拷贝,因为您用 const 引用接收,丢掉了"这是一个右值"的信息。版本二能接受右值并正确移动,但传入左值时编译直接报错,因为右值引用不能绑定左值。

为了同时支持两种情况,您得写两个重载:

cpp
std::string make_string(const std::string& s)
{
    return std::string(s);
}

std::string make_string(std::string&& s)
{
    return std::string(std::move(s));
}

两个参数呢?四个重载(const& + const&、const& &&、&& const&、&& &&,2×2)。三个参数就八个。完蛋,工程里一堆成员要处理,这么写非炸不可,显然没法持续下去。

万能引用——不是所有 T&& 都是右值引用

Scott Meyers 给这种特殊的 T&& 起了名字叫"万能引用"(universal reference),C++ 标准术语叫"转发引用"(forwarding reference)。它长得和右值引用一模一样(欸,笔者也有点想不通为什么,不知道有没有 C++ 大佬能给讲讲为啥偏偏长一样,小生受教!),但行为完全不同。

关键区别在于类型推导的上下文。普通的右值引用 std::string&& 只能绑定右值,这是固定的。但模板参数推导中的 T&& 会根据传入的实参自动调整:传左值进来,T 推导为左值引用类型,T&& 通过引用折叠变成左值引用;传右值进来,T 推导为非引用类型,T&& 就是右值引用。

cpp
template<typename T>
void identify(T&& arg)
{
    // arg 到底是左值引用还是右值引用?取决于调用时传入的实参
}

std::string name = "Alice";

identify(name);              // 传左值,T = std::string&,T&& = std::string&
identify(std::string("Bob")); // 传右值,T = std::string,T&& = std::string&&

万能引用的出现有两个必要条件,缺一不可:第一,类型必须通过模板参数推导(template<typename T> 中的 T);第二,声明形式必须恰好是 T&&,不能加 const 或其他修饰。您若写 const T&&,那它就是普通的 const 右值引用,不是万能引用。您若写 std::vector<T>&&,也不是万能引用,T 虽然被推导了,但 std::vector<T>&& 这个整体不是 T&& 的形式。

cpp
template<typename T>
void forwarding(T&& x);      // 万能引用 ✓

template<typename T>
void not_forwarding(const T&& x);  // const 右值引用,不是万能引用 ✗

template<typename T>
void also_not(std::vector<T>&& x); // vector 右值引用,不是万能引用 ✗

// auto&& 也是万能引用(C++11 之后)
auto&& universal = some_expression;  // 万能引用 ✓

auto&& 也遵循同样的推导规则:如果 some_expression 是左值,universal 是左值引用;如果是右值,universal 是右值引用。这在 range-based for 循环和 lambda 捕获中很常见。

引用折叠——四种组合的最终结果

这一部分大量参考了《Effective Modern C++》:

万能引用之所以能工作,靠的是引用折叠(reference collapsing)。当编译器推导 T&& 的时候,可能出现"引用的引用",比如 T 被推导为 std::string&,那 T&& 就变成了 std::string& &&。C++ 不允许直接写"引用的引用",但在模板推导的上下文中,编译器会按四条规则来折叠它:

T& & 折叠为 T&T& && 折叠为 T&T&& & 折叠为 T&T&& && 折叠为 T&&

不用死记这四条,记一个简洁的规律就够:只要有一个是左值引用(&),结果就是左值引用。只有两个都是右值引用(&& &&),结果才是右值引用。

咱们用具体的推导过程验证一下。传入左值 name 时,T 被推导为 std::string&,于是 T&& 变成 std::string& &&,按第二条规则折叠为 std::string&,参数类型是左值引用。传入右值 std::string("Bob") 时,T 被推导为 std::string(非引用类型),于是 T&& 就是 std::string&&,参数类型是右值引用,没有折叠发生,因为原本就没有"引用的引用"。

展开代码 (共 21 行)收起代码
cpp
template<typename T>
void show_type(T&& arg)
{
    // 使用 type_traits 来查看推导后的类型
    using Decayed = std::decay_t<T>;

    if constexpr (std::is_lvalue_reference_v<T>) {
        std::cout << "  左值引用\n";
    } else {
        std::cout << "  右值引用(或非引用)\n";
    }
}

int main()
{
    std::string name = "Alice";
    show_type(name);                // T = std::string&, 输出"左值引用"
    show_type(std::string("Bob"));  // T = std::string, 输出"右值引用"
    show_type(std::move(name));     // T = std::string, 输出"右值引用"
    return 0;
}

引用折叠不只出现在函数模板中。auto&& 的推导、typedefusing 别名的实例化、以及 decltype 的某些用法中都会触发引用折叠。不过函数模板中的万能引用是最常见的场景。

std::forward——有条件的类型转换

好,这里才是重点(如果您只关心怎么用的话)。理解了万能引用和引用折叠,std::forward 就很简单了。它的作用是:传入的是右值时,把参数转成右值引用;传入的是左值时,保持左值引用不变。本质上是一个有条件的、更聪明的 static_cast。(一句话,嘿,这小东西记得您传的是左值还是右值,原样透传到别处去了。)

咱们可以自己实现一个简化版本来理解它的原理:

cpp
// 简化版 std::forward 的实现
template<typename T>
constexpr T&& my_forward(std::remove_reference_t<T>& t) noexcept
{
    return static_cast<T&&>(t);
}

template<typename T>
constexpr T&& my_forward(std::remove_reference_t<T>&& t) noexcept
{
    static_assert(!std::is_lvalue_reference_v<T>,
                  "Cannot forward an rvalue as an lvalue");
    return static_cast<T&&>(t);
}

这两个重载配合引用折叠完成了"有条件转换"的逻辑。传入左值时,T 被推导为 U&(U 是实际类型),static_cast<T&&> 就是 static_cast<U& &&>,折叠为 U&,返回左值引用。传入右值时,T 被推导为 Ustatic_cast<T&&> 就是 static_cast<U&&>,返回右值引用。

关键的洞察在于:std::forward 的"条件性"来自模板参数 T 携带了原始实参的值类别信息,不在 std::forward 自身的逻辑里。当万能引用接收左值时,T 被推导为 U&,这个 & 就像一枚印章,把"这是左值"的信息刻在了类型里。std::forward 通过 static_cast<T&&> 和引用折叠把这枚印章"解印"出来。

完美转发在标准库中的应用

完美转发在 C++ 标准库里无处不在。最经典的例子是 std::make_uniquestd::make_shared,它们接收任意参数,然后原封不动地转发给 unique_ptr/shared_ptr 所管理对象的构造函数。

cpp
// std::make_unique 的简化实现
template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args)
{
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

这里的 Args&&... args 是万能引用的参数包。每个 Args 独立推导,所以如果您传入一个左值和一个右值,它们各自的值类别都被保留。std::forward<Args>(args)... 把每个参数按照它原始的值类别转发给 T 的构造函数。

cpp
struct User {
    std::string name;
    int id;

    User(std::string n, int i) : name(std::move(n)), id(i) {}
};

int main()
{
    std::string name = "Alice";
    auto user = std::make_unique<User>(std::move(name), 42);
    // std::move(name) 是右值 → name 被移动进 User 的构造函数
    // 42 是右值 → int 没有"移动"的概念,就是值传递

    auto user2 = std::make_unique<User>("Bob", 100);
    // "Bob" 是 const char* 右值 → 用于构造 std::string 参数
    return 0;
}

另一个经典例子是 std::vector::emplace_back。它接收的是构造参数,不是现成对象,直接在 vector 的内存里原位构造新元素,比 push_back 更高效,连移动都省了。

cpp
std::vector<std::string> words;
words.emplace_back("hello");          // 直接在 vector 中构造 std::string("hello")
words.emplace_back(std::string("hi")); // 传入右值,移动构造

std::string word = "world";
words.emplace_back(std::move(word));   // 传入右值,移动构造

常见错误——什么不该 forward

std::forward 虽然强大,但用错了地方会引入微妙的 bug。最重要的规则是:只对万能引用使用 std::forward

cpp
// 错误 1:对非万能引用使用 std::forward
void process(const std::string& s)
{
    // s 不是万能引用!它是 const 左值引用,固定类型
    // std::forward<const std::string&>(s) 永远返回 const 左值引用
    // 在这里用 std::forward 没有任何意义,还容易误导读者
    consume(std::forward<const std::string&>(s));  // 不要这样做
    consume(s);  // 直接传就好
}

在非模板的普通函数里,参数类型是固定的,不存在"根据传入实参决定左值还是右值"的情况。对这种固定类型的参数使用 std::forward 纯属添乱,只会让代码的意图变得模糊。

cpp
// 错误 2:多次 forward 同一个参数
template<typename T>
void double_forward(T&& x)
{
    target(std::forward<T>(x));  // 第一次 forward
    target(std::forward<T>(x));  // 危险!如果 x 是右值,第一次已经"偷走"了
}

如果 x 是右值引用,第一次 std::forward<T>(x) 会把 x 转成右值传递给 targettarget 可能已经偷走了 x 的资源。第二次再 forward 时,x 已经处于"有效但未指定"的状态,您把一个可能已经空了的右值传了出去。这就是所谓的"use-after-move",虽然编译器不会报错,但运行时行为不可预测。

cpp
// 错误 3:在返回语句中用 std::forward + decltype(auto)
template<typename T>
decltype(auto) bad_return(T&& x)
{
    return (std::forward<T>(x));  // 危险!可能返回悬空引用
}

这里的 decltype(auto) 会根据 return 表达式推导返回类型,所以返回类型取决于 std::forward<T>(x) 的结果。当您传入一个右值时,T 被推导为非引用类型(比如 std::string),std::forward<std::string>(x) 返回 std::string&&decltype(auto) 推导出的返回类型就是 std::string&&。但这个右值引用指向的是函数参数 xx 在函数返回时就销毁了。调用者拿到的引用指向了一块已经不存在的内存,经典的悬空引用,GCC 的 -Wdangling-reference 会对此发出警告。

传入左值时 T 被推导为 U&(比如 std::string&),std::forward<std::string&>(x) 通过引用折叠返回 std::string&,引用链最终指向调用者的原始变量,仍然存活,所以是安全的。但问题在于这个函数模板对左值安全、对右值危险,而 decltype(auto) 又无法在签名中体现这种区分,非常容易在维护时被误用。

如果您真的需要在返回语句中做转发,确保返回类型是值类型(T 而不是 decltype(auto)),这样右值场景下会触发移动构造而不是返回引用。上一节缓存包装器中的 emplace_get 就是一个正确的例子:它返回 Value&(固定类型,不是转发来的),只对参数使用 std::forward

通用示例——通用的缓存包装器

咱们用完美转发来写一个实用的例子:一个通用的缓存包装器模板,它可以缓存任意函数调用的结果,并且完美转发所有参数。

展开代码 (共 117 行)收起代码
cpp
// perfect_forwarding.cpp -- 完美转发演示
// Standard: C++17

#include <iostream>
#include <string>
#include <utility>
#include <map>
#include <functional>

/// @brief 一个简单的缓存包装器
/// 完美转发函数参数,同时保持值类别信息
template<typename Key, typename Value>
class Cache
{
    std::map<Key, Value> storage_;

public:
    /// @brief 查找或插入:如果 key 不存在则用 args 构造 Value
    template<typename... Args>
    Value& emplace_get(const Key& key, Args&&... args)
    {
        auto it = storage_.find(key);
        if (it != storage_.end()) {
            std::cout << "  [缓存命中] key = " << key << "\n";
            return it->second;
        }

        std::cout << "  [缓存未命中] key = " << key << ",构造新值\n";
        auto [new_it, inserted] = storage_.emplace(
            std::piecewise_construct,
            std::forward_as_tuple(key),
            std::forward_as_tuple(std::forward<Args>(args)...)
        );
        return new_it->second;
    }

    std::size_t size() const { return storage_.size(); }
};

/// @brief 被包装的"昂贵"操作
class ExpensiveData
{
    std::string label_;
    int value_;

public:
    /// @brief 从字符串和整数构造
    ExpensiveData(std::string label, int value)
        : label_(std::move(label))
        , value_(value)
    {
        std::cout << "  [ExpensiveData] 构造: " << label_
                  << " = " << value_ << "\n";
    }

    /// @brief 从字符串构造(重载)
    explicit ExpensiveData(std::string label)
        : label_(std::move(label))
        , value_(0)
    {
        std::cout << "  [ExpensiveData] 构造(仅标签): " << label_ << "\n";
    }

    const std::string& label() const { return label_; }
    int value() const { return value_; }
};

/// @brief 通用的转发包装器——演示完美转发的核心用法
template<typename Func, typename... Args>
auto invoke_and_log(Func&& func, Args&&... args)
    -> std::invoke_result_t<Func, Args...>
{
    std::cout << "  [invoke_and_log] 调用前\n";
    auto result = std::invoke(
        std::forward<Func>(func),
        std::forward<Args>(args)...
    );
    std::cout << "  [invoke_and_log] 调用后\n";
    return result;
}

int main()
{
    std::cout << "=== 1. 缓存包装器 ===\n";
    Cache<std::string, ExpensiveData> cache;

    // 第一次调用:缓存未命中,构造新值
    // 传入右值字符串和整数
    cache.emplace_get("alpha", "first", 100);

    // 第二次调用:同样的 key,缓存命中
    cache.emplace_get("alpha", "first", 200);

    // 新 key,传入右值字符串(单参数构造)
    std::string label = "beta";
    cache.emplace_get("beta", std::move(label));
    // label 已被移动,不要再使用

    std::cout << "  缓存大小: " << cache.size() << "\n\n";

    std::cout << "=== 2. 转发包装器 ===\n";
    auto add = [](int a, int b) -> int {
        return a + b;
    };

    int x = 10;
    int result = invoke_and_log(add, x, 20);
    std::cout << "  结果: " << result << "\n\n";

    std::cout << "=== 3. make_unique 风格的工厂 ===\n";
    // 演示完美转发在构造函数参数传递中的效果
    auto data = std::make_unique<ExpensiveData>("gamma", 42);
    std::cout << "  data: " << data->label() << " = " << data->value() << "\n\n";

    std::cout << "=== 程序结束 ===\n";
    return 0;
}

编译运行:

bash
g++ -std=c++17 -Wall -Wextra -o perfect_forwarding perfect_forwarding.cpp
./perfect_forwarding

预期输出:

text
=== 1. 缓存包装器 ===
  [缓存未命中] key = alpha,构造新值
  [ExpensiveData] 构造: first = 100
  [缓存命中] key = alpha
  [缓存未命中] key = beta,构造新值
  [ExpensiveData] 构造(仅标签): beta
  缓存大小: 2

=== 2. 转发包装器 ===
  [invoke_and_log] 调用前
  [invoke_and_log] 调用后
  结果: 30

=== 3. make_unique 风格的工厂 ===
  [ExpensiveData] 构造: gamma = 42
  data: gamma = 42

=== 程序结束 ===

emplace_get 中的 Args&&... args 是万能引用参数包。当您传入 ("first", 100) 时,Args 被推导为 const char (&)[6]int(近似理解为 const char*int)。std::forward<Args>(args)... 把这些参数原封不动地转发给 ExpensiveData 的构造函数,构造函数拿到的参数类型和值类别与您直接传给它时完全一致。

当传入 std::move(label) 时,Args 被推导为 std::string(非引用),std::forward 把它转成右值引用,ExpensiveDatastd::string 参数通过移动构造来初始化,避免了字符串的深拷贝。这就是完美转发的威力:一个模板,自动处理所有值类别的组合。

动手实验——验证引用折叠

为了加深理解,咱们来写一个小程序,用 std::is_same_v 来验证引用折叠的结果:

展开代码 (共 49 行)收起代码
cpp
// ref_collapsing.cpp -- 引用折叠验证
// Standard: C++17

#include <iostream>
#include <type_traits>
#include <string>

template<typename T>
void show_deduction(T&& /* arg */)
{
    // T 的推导结果
    if constexpr (std::is_lvalue_reference_v<T>) {
        std::cout << "  T = 左值引用类型\n";
    } else {
        std::cout << "  T = 非引用类型(右值)\n";
    }

    // T&& 的最终类型(经过引用折叠)
    using ParamType = T&&;
    if constexpr (std::is_lvalue_reference_v<ParamType>) {
        std::cout << "  T&& = 左值引用\n\n";
    } else {
        std::cout << "  T&& = 右值引用\n\n";
    }
}

int main()
{
    std::string name = "Alice";
    const std::string cname = "Bob";

    std::cout << "传入非 const 左值:\n";
    show_deduction(name);
    // T = std::string&, T&& = std::string& && → std::string&

    std::cout << "传入 const 左值:\n";
    show_deduction(cname);
    // T = const std::string&, T&& = const std::string& && → const std::string&

    std::cout << "传入右值(临时对象):\n";
    show_deduction(std::string("Charlie"));
    // T = std::string, T&& = std::string&&

    std::cout << "传入右值(std::move):\n";
    show_deduction(std::move(name));
    // T = std::string, T&& = std::string&&

    return 0;
}

编译运行:

bash
g++ -std=c++17 -Wall -Wextra -o ref_collapsing ref_collapsing.cpp
./ref_collapsing

输出:

text
传入非 const 左值:
  T = 左值引用类型
  T&& = 左值引用

传入 const 左值:
  T = 左值引用类型
  T&& = 左值引用

传入右值(临时对象):
  T = 非引用类型(右值)
  T&& = 右值引用

传入右值(std::move):
  T = 非引用类型(右值)
  T&& = 右值引用

这组输出完美地印证了引用折叠规则:传入左值(无论是否 const)时,T 被推导为引用类型,T&& 折叠为左值引用。传入右值时,T 被推导为非引用类型,T&& 就是右值引用。const 的信息也通过 T 传递下去了,虽然这个简化程序没有区分 const 和非 const,但 T 中确实包含了 const 修饰符,std::forward 会正确地保留它。

在线运行

在线运行引用折叠示例,验证万能引用的类型推导规则:

Compiler Explorer

完美转发:万能引用与引用折叠

在线运行并观察传入左值和右值时模板参数 T 的推导结果。

code/examples/vol2/04_perfect_forwarding.cpp

v0.10.0-6-gbcee94e · bcee94e · 2026-08-20