返回

C++ 中移动语义和完美转发的联系和区别

对 C++ 中移动语义、完美转发相关概念进行整理。

简介

C++ 中移动语义的用法和实现原理 中我整理了右值引用以及移动语义的相关概念。在不涉及类型推断的适时候,移动语义一般能满足需求。但是在涉及类型推断时常有不足,因此需要转发概念的引入。这篇博客先从引用折叠引入,然后结合类型推断判断移动语义满足不了的场景,最后整理完美转发的概念。

引用折叠

通常来说,我们不能声明一个引用的引用,其中的道理是,当我们初始化了一个引用之后。由于引用本身不是一个对象,它只是引用对象的一个别名。所以我们没有办法判断一个符号是引用还是对象(引用的表现和对象完全一致)。因此对引用的引用是不合理的,但是在涉及到类型推导或者类型别名时,编译器有可能会生成“引用的引用”的代码,因此此时会触发引用折叠现象。如下所示:

int main() {
    using lrint = int&;
    using rrint = int&&;

    int a = 1;

    lrint b = a;
    // (int&)& c = b;  // error!

    lrint& c = a;
    lrint&& d = a;

    rrint&& e = 1;
    return 0;
}

上述程序能编译通过,我们虽然不能显式的声明一个引用的引用如((int&)& c = b),但是通过 using, typedef 或者模板时会生成类似的类型,先通过 C++ insights 看一下编译器会如何理解这种情况:

可以看到,编译器会将这些自动生成的“引用的引用”推导为左值引用和右值引用的一种,根据 C++ 标准,当引用的引用被类型操作,如模板,typedef 是生成时,会触发引用折叠现象,标准给定,只要组合情况包含左值引用,结果会被折叠为左值引用,否则一律为右值引用,即只有右值引用的右值引用(rvalue reference to rvalue reference),会被折叠为右值引用。

引用折叠现象导致会出现一个问题,当我们看到一个涉及类型推导的右值引用时,它是右值引用还是左值引用呢?如下所示:

template <typname T>
void func(T&& t) {}

在不了解引用折叠时,我们会认为这就是一个左值引用。当如果 T = int& 时,根据引用折叠的规则,T&& 会被折叠为 int& 是一个左值引用,因此上述函数实际上可以接收左值或者右值,如下所示:

#include <iostream>

template <typename T>
void print(T&& t) {
    std::cout << t << std::endl;
}

int main() {
    int a = 1;
    print(a);

    print(10);
    return 0;
}

通过 C++ insight 的结果可以看到,编译器会为我们自动生成 int&int&& 两个版本的模板函数。

模板中的参数转发

考虑以下场景:

#include <iostream>

void showValue(const int& val) { std::cout << "lvalue: " << val << std::endl; }

void showValue(int&& val) { std::cout << "rvalue: " << val << std::endl; }

template <typename T>
void print(T&& t) {
    showValue(t);
}

int main() {
    int a = 1;
    print(a);

    print(std::move(a));
    print(10);
    return 0;
}

例子中,print 为一个模板函数可以同时接收左值或者右值。当接收参数为左值时,如 aT 会被推导为 int&,因此 T&& 为左值引用;当接收参数为右值时,如:std::move(a)(将亡值),10 (纯右值)时,T 会被推导为 int,因此 T&& 为右值引用。在 print 函数内部,我们调用了另一个外部函数,该函数同时有左值引用和右值引用重载版本,我们当然是希望当传入参数为左值时调用左值引用版本,右值时调用右值引用版本。但程序运行结果如下所示:

$ /home/xt/code_collections/cpp/build/cpp-forward/forward_example3
lvalue: 1
lvalue: 1
lvalue: 10

可以看到,三种传参的情况都被识别为左值传入。其原因在于我们传入的参数为 t,而 t 本身作为一个形参(也许是左值引用或者右值阴影类型) 是一个左值(有名字)。因此这里直接传入 t 都会调用左值引用版本的 showValue() 函数。因此这里我们需要使用完美转发。

完美转发

在上面的例子中,如果我们需要编译器按我们传入参数的值类型(而不是形参 t 的值类型)调用相应的函数重载版本,我们需要使用 std::forward,如下所示:

#include <iostream>

void showValue(const int& val) { std::cout << "lvalue: " << val << std::endl; }

void showValue(int&& val) { std::cout << "rvalue: " << val << std::endl; }

template <typename T>
void print(T&& t) {
    showValue(std::forward<T>(t));
}

int main() {
    int a = 1;
    print(a);

    print(std::move(a));
    print(10);
    return 0;
}

代码中,std::forward<T>(t) 可以按 t 的传入类型相应地将其转化为左值或者右值,因此达到一个完美转发的效果。根据我们之前的推导有:

  • 当传入类型为右值时,T = int,因此调用 std::forward<int>(t) 可以将 t 转为右值
  • 当传入类型为左值时,T = int&,因此调用 std::forward<int&>(t) 可以将 t 转为左值

std::move 和 std::forward 的实现

要理解 std::movestd::forward 的功能,最好还是通过阅读源码的实现来理解。以下源码基于 GCC 9.3.0。

std::move 的实现如下所示:

  /**
   *  @brief  Convert a value to an rvalue.
   *  @param  __t  A thing of arbitrary type.
   *  @return The parameter cast to an rvalue-reference to allow moving it.
  */
  template<typename _Tp>
    constexpr typename std::remove_reference<_Tp>::type&&
    move(_Tp&& __t) noexcept
    { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

可以看到,std::move 只做了一件事情,即对传入类型进行静态类型转换为其对应去除引用后的类型(int&,int,int&& 通过 std::remove_reference<_Tp>::type 都会得到 int)的右值引用,即将其转换为右值。

std::forward 的实现如下所示:

  /**
   *  @brief  Forward an lvalue.
   *  @return The parameter cast to the specified type.
   *
   *  This function is used to implement "perfect forwarding".
   */
  template<typename _Tp>
    constexpr _Tp&&
    forward(typename std::remove_reference<_Tp>::type& __t) noexcept
    { return static_cast<_Tp&&>(__t); }

  /**
   *  @brief  Forward an rvalue.
   *  @return The parameter cast to the specified type.
   *
   *  This function is used to implement "perfect forwarding".
   */
  template<typename _Tp>
    constexpr _Tp&&
    forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
    {
      static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
		    " substituting _Tp is an lvalue reference type");
      return static_cast<_Tp&&>(__t);
    }

可以看到,std::forward 有两个重载版本,分别用来转发左值和右值。我们来看一下怎么通过模板参数来起作用。当传入参数的类型为 int 那么无论入参本身是左值引用还是右值引用,typename std::remove_reference<_Tp>::type = int

因此,当传入参数为右值引用时(参数本身可以是左值),即我们上面推导得到 _Tp = int_Tp = int&& 也不影响)。则会对应上 int&& 即:forward(typename std::remove_reference<_Tp>::type&& __t) 重载版本,因此会被静态类型转换为 int&& 即右值引用。

如果传入参数为左值引用时,我们上面推导得到 _Tp = int&,则会对应上 forward(typename std::remove_reference<_Tp>::type& __t 的重载版本,同样通过静态类型转换返回 (int&) &&,由于引用折叠的原因,被折叠为 int& 的左值引用。因此通过这两个重载函数,我们实现了无视入参本身的值类型,而是根据其初始化对象的类型进行转换。

因此,可以看出无论是 std::move 或者 std::forward 本质上都只是静态类型转换,不会在运行时有任何行为。

而由于带类型推断的 T&& 既可以接收左值引用也可以接收右值引用,一般称其为通用引用 (Universal Reference)或者转发引用 Forward Reference

参考

Built with Hugo
Theme Stack designed by Jimmy