C++ 完美转发 (Perfect Forwarding)

黎 浩然/ 5 12 月, 2023/ C/C++, 计算机/COMPUTER/ 0 comments

完美转发

在 C++ 中,当我们编写一个通用函数(通常是模板函数),它接收一些参数,然后把这些参数原封不动地传递给另一个函数时,就希望能实现完美转发。所谓“原封不动”,指的是不仅传递参数的值,还要保留其值类别(Value Category)——也就是说,如果传入的是左值,那么在转发时它仍然是左值;如果传入的是右值,那么在转发时它也仍然是右值。

这听起来简单,但由于 C++ 的引用折叠规则,如果不使用 std::forward,直接转发模板函数中接收到的参数会遇到问题。

std::forward 的工作原理

std::forward 的神奇之处在于它与万能引用(Universal Reference / Forwarding Reference) (T&& 在模板参数推导时) 以及引用折叠规则协同工作。

std::forward 的大致签名是这样的:

template<typename T>                // For lvalues (T is T&),
T&& std::forward(T&& param)         // take/return lvalue refs.
{                                   // For rvalues (T is T),
    return static_cast<T&&>(param); // take/return rvalue refs.
}

为什么需要 std::forward?

考虑以下场景:

#include <iostream>
#include <string>
#include <utility> // for std::forward

// 这是一个接收左值或右值的函数
void process_value(std::string& s) {
    std::cout << "Processing lvalue: " << s << std::endl;
}

void process_value(std::string&& s) {
    std::cout << "Processing rvalue: " << s << std::endl;
}

// 尝试转发参数的模板函数
template <typename T>
void wrapper(T&& arg) { // arg 是一个万能引用
    // 错误示范:直接转发,arg在函数内部永远是左值
    // process_value(arg); // 总是调用 process_value(std::string&)

    // 正确示范:使用 std::forward 实现完美转发
    process_value(std::forward<T>(arg)); // 根据传入的arg是左值还是右值,转发为左值或右值
}

int main() {
    std::string s1 = "hello";

    wrapper(s1);        // 传入左值 s1,期望转发为左值
    wrapper("world");   // 传入右值 "world",期望转发为右值

    return 0;
}

在上面的 wrapper 函数中:

  • 当 s1 (一个左值) 传入 wrapper 时,T 被推导为 std::string&。arg 的类型经过引用折叠是 std::string&。
    • 如果你直接 process_value(arg);,因为 arg 在 wrapper 函数内部是一个具名变量(一个左值),所以它会调用 process_value(std::string&)。这是正确的。
  • 当 “world” (一个右值) 传入 wrapper 时,T 被推导为 std::string。arg 的类型是 std::string&&。
    • 如果你直接 process_value(arg);,问题就来了。即使 arg 自身是右值引用,但由于它在 wrapper 内部有一个名字 (arg),它仍然被视为一个左值。所以,process_value(arg); 仍然会调用 process_value(std::string&),而不是我们期望的 process_value(std::string&&)。这违背了完美转发的初衷。
  • 使用 std::forward<T>(arg):
    • 当 T 是 std::string& 时 (wrapper(s1)),std::forward<std::string&>(arg) 会返回 std::string&,从而调用 process_value(std::string&)。
    • 当 T 是 std::string 时 (wrapper(“world”)),std::forward<std::string>(arg) 会返回 std::string&&,从而调用 process_value(std::string&&)。

在 C++ 中,右值引用需要“小心翼翼地保留”,一不小心就会“衰变”为左值。 这正是 C++ 右值引用和移动语义中最容易出错但又至关重要的一点。这个现象被称为“具名右值引用是左值”(A named rvalue reference is an lvalue)。

这样,无论传入 wrapper 的参数是左值还是右值,std::forward 都能确保它们以原始的值类别被转发给 process_value,实现了完美转发

std::remove_reference

  1. std::remove_reference 的作用:剥离引用,得到底层类型。
    • 它的任务是纯粹地获取一个引用类型所指向的原始非引用类型
    • 例如:std::remove_reference<int&>::type 是 int.
    • std::remove_reference<int&&>::type 也是 int.
    • std::remove_reference<int>::type 还是 int.
  2. std::forward 如何利用这个底层类型结合引用折叠规则,来“保留”原始引用类型:std::forward 的大致实现(简化版)看起来像这样:
// 这是 C++ 标准库中 std::forward 的两种重载(大致形式)
// 当 T 是一个左值引用时,选择第一个
template <typename T>
constexpr T&& forward(typename std::remove_reference<T>::type& arg) noexcept {
    return static_cast<T&&>(arg); // 核心是这个 static_cast
}

// 当 T 是一个非引用类型时,选择第二个
template <typename T>
constexpr T&& forward(typename std::remove_reference<T>::type&& arg) noexcept {
    return static_cast<T&&>(arg); // 核心是这个 static_cast
}

template <typename T>
void wrapper(T&& arg_from_wrapper) { // 这里的 T&& arg_from_wrapper 是万能引用!
    // 实际调用 std::forward 的地方
    some_other_function(std::forward<T>(arg_from_wrapper));
}

这里的关键在于 static_cast<T&&>(arg)。

  • T 是模板参数,它根据传入 arg_from_wrapper 的参数原始值类别被推导出来。
    • 如果传入 arg_from_wrapper 的是左值 (some_lvalue),那么 T 会被推导为 X& (例如 int&) (根据万能引用的特殊推导规则)。
    • 如果传入 arg_from_wrapper 的是右值 (some_rvalue),那么 T 会被推导为 X (例如 int) (根据万能引用的特殊推导规则)。
  • 现在我们看 static_cast<T&&>(arg) 中的 T&&。
    • 情况一:传入的是左值
      • T 是 X&。
      • T&& 变成了 (X&)&&。
      • 根据引用折叠规则 X& && 变成 X&。
      • 所以 static_cast 的目标类型是 X&。
      • 结果:arg 作为左值引用被转发出去。
    • 情况二:传入的是右值
      • T 是 X。
      • T&& 变成了 X&&。
      • 这里没有复杂的折叠,就是 X&&。
      • 所以 static_cast 的目标类型是 X&&。
      • 结果:arg 作为右值引用被转发出去。

std::remove_reference 确实用于获取一个类型的基础形式,也就是剥离了引用后的类型。这个剥离后的类型本身并不是用来“保留引用”的。

真正负责“保留引用类型”(即保持原始值类别)的是 std::forward 中内部的 static_cast<T&&>(arg)。这个 static_cast 利用了:

  1. T 模板参数的原始推导结果(它包含了传入参数是左值还是右值的信息)。
  2. 引用折叠规则(将 T 与 && 结合后,得到正确的引用类型)。

所以,std::remove_reference 提供了基础类型,而 T&& 配合引用折叠在 static_cast 中实现了最终的“引用类型保留”行为,这三者协同工作,才构成了完美转发的精髓。

赋值运算符不能保留原始的引用类型

直接使用赋值运算符(=)不能保留原始的引用类型(左值还是右值)

让我们明确一下“保留引用类型”在这里的含义。当我们谈论完美转发和 std::forward 时,我们希望在转发参数时,目标函数能接收到与原始传入参数相同的值类别(左值或右值)。

赋值运算符的原理:

无论是拷贝赋值运算符 (operator=(const T&)) 还是移动赋值运算符 (operator=(T&&)),它们执行的都是赋值操作

  • 赋值操作的左侧是一个左值(一个具名变量,如 a = b 中的 a)。
  • 赋值操作的结果(表达式 a = b 的结果)本身是一个左值(通常是对 a 的引用,取决于重载的实现)。

赋值操作的目的是将右侧的值赋给左侧的变量,而不是将右侧的值类别(左值性或右值性)传播出去。

为什么不能保留?

考虑以下例子:

#include <iostream>
#include <string>
#include <utility> // for std::forward

// 接收左值或右值的重载函数
void print_value_category(std::string& s) {
    std::cout << "Detected lvalue: " << s << std::endl;
}

void print_value_category(std::string&& s) {
    std::cout << "Detected rvalue: " << s << std::endl;
}

// 尝试用赋值运算符转发的函数(错误示范)
template <typename T>
void bad_forward_wrapper(T&& arg) { // 万能引用,转发引用
    std::string temp_str;
    temp_str = arg; // 这是一个赋值操作!
    // 这里的 temp_str 永远是一个具名变量(左值)
    // 所以调用 print_value_category(std::string&)
    print_value_category(temp_str);
}

// 正确使用 std::forward 的函数
template <typename T>
void good_forward_wrapper(T&& arg) {
    print_value_category(std::forward<T>(arg)); // 完美转发
}

int main() {
    std::string s_lvalue = "hello";

    std::cout << "--- Testing bad_forward_wrapper ---" << std::endl;
    bad_forward_wrapper(s_lvalue);      // 传入左值
    bad_forward_wrapper("world");       // 传入右值

    std::cout << "\n--- Testing good_forward_wrapper ---" << std::endl;
    good_forward_wrapper(s_lvalue);     // 传入左值
    good_forward_wrapper("world");      // 传入右值

    return 0;
}

运行结果会是:

--- Testing bad_forward_wrapper ---
Detected lvalue: hello
Detected lvalue: world

--- Testing good_forward_wrapper ---
Detected lvalue: hello
Detected rvalue: world

分析 bad_forward_wrapper:

无论你传入 bad_forward_wrapper 的 arg 是左值引用还是右值引用,在函数内部,temp_str = arg; 这一行都是一个赋值操作。

  • temp_str 是一个具名变量,因此它本身就是一个左值
  • 当 print_value_category(temp_str); 被调用时,temp_str 作为一个左值参数被传递,所以永远会匹配到 print_value_category(std::string&) 这个重载。

这说明,赋值操作并不会将参数的原始值类别(左值性或右值性)传递给被赋值的变量,因为被赋值的变量本身就具有它自己的值类别(作为具名变量,它就是左值)。

完美转发的关键在于传递表达式本身的值类别,而不是通过赋值创建一个新的变量。 std::forward 通过类型转换 (static_cast<T&&>(arg)) 来实现这一点,它改变的是表达式 arg 在后续函数调用中的值类别,而不是创建一个新的变量。

所以,赋值运算符无法实现完美转发,它只能将数据拷贝或移动到一个新的(具名的)左值变量中,从而失去了原始参数的值类别信息。

Share this Post

Leave a Comment

您的邮箱地址不会被公开。 必填项已用 * 标注

*
*