理解移动语义、引用折叠及完美转发

右值引用

C++11之前,所有引用都是左值引用,也就是对左值的引用。左值一般放在赋值表达式左边(当然这样说并不严格,"hello world"这样的字面值存放在.rodata段,也是左值,具体可参考[🔗谈谈C++的左值右值,左右引用,移动语意及完美转发]这篇文章),是在堆或栈上分配的命名对象,它们有明确的内存地址。而左值的另一位朋友右值,在赋值表达式右边,没有可识别的内存地址。如果从硬件层面理解,右值只存在于临时寄存器中。比如下面这段代码:

int a = 1;
int &b = a;

很明显,这里a是左值,1是右值,b是一个左值引用,也就是a的别名。再比如这段:

int &a = 1;

g++编译,会显示错误如下:

non-const lvalue reference to type 'int' cannot bind to a temporary of type 'int'

意思是非常量左值引用不能指向右值。大家都不会犯这样的错,这里想说的是,我们还可以使用常量左值引用来指向右值,像这样:

const int &a = 1; //常量左值引用

问题来了,常量左值引用为什么可以指向右值?因为const常量值不可修改,可以理解为内部产生了一个临时量,可以取到地址。类似于以下:

const int tmp = 1; 
const int &a = tmp;

可以看到,const Type &C++ 中一个常见的习惯,函数的参数使用常量引用const Type &接收,以避免创建不必要的临时对象:

void func(const std::string& a);
func("hello");

但是这种方式有个缺点,就是没法修改这个const常量,有一定局限性。C++11引入的这位新朋友,右值引用,一定程度上解决了其中的这个问题。右值引用,Type&&,用来指向右值,并且可以修改右值。

void func(const std::string&& a){
    a = "world"; //修改右值
}
func("hello");

OK,到这里简单总结下:

  1. 左值可以寻址,右值不可以寻址,这是它们的关键区别;
  2. 函数传参使用左右值引用可以避免拷贝,但右值引用更为灵活。

那么,右值引用的具体应用场景是什么?

移动语义提升性能

右值引用有一个非常重要的作用是支持移动语义。而相对于移动语义,拷贝语义可能比较好理解。比如下面代码,我们可以定义拷贝构造函数来实现对象的深拷贝,如果没有定义,编译器会有默认实现,是浅拷贝。

class Stack {
public:
    Stack(int size = 100) : size_(size) {
        cout << "构造函数" << endl;
        stack_ = new int[size];
    }
    Stack(const Stack &src) : size_(src.size_) {
        cout << "拷贝构造函数" << endl;
        stack_ = new int[src.size_];

        //深拷贝
        for (int i = 0; i < size_; ++i)
            stack_[i] = src.stack_[i];
    }
    ~Stack() {
        cout << "析构函数" << endl;
        delete[] stack_;
        stack_ = nullptr;
    }
private:
    int size_;
    int *stack_;
};

int main() {
    Stack stack(10);
    Stack stack2 = stack;
}

运行结果为:

构造函数
拷贝构造函数
析构函数
析构函数

除此之外,在某些场景,比如被拷贝者之后不再需要,我们其实可以使用std::move触发移动语义,避免深拷贝,提升性能。所以在上面代码中,我们可以加一个移动构造函数,这种方式在STL和自定义类广泛应用。

Stack(Stack &&src) : size_(src.size_) {
    cout << "移动构造函数" << endl;
    stack_ = src.stack_;
    src.stack_ = nullptr;
}

int main(){
    Stack stack(10);
    //Stack stack2 = stack; //走拷贝构造
    Stack stack2 = std::move(stack); //走移动构造
}

运行的输出是:

构造函数
移动构造函数
析构函数
析构函数

这里,std::move的作用是把左值转换为右值引用,而移动构造函数的作用是传入对象的所有权转让给当前对象,然后掏空了传入对象。

std::move的具体实现

大家可能以为std::move施展了什么神奇的魔法,其实并没有,仅仅做了static_cast类型转换而已,真正的移动操作是在移动构造函数或者移动赋值操作符中发生的。可以瞧一瞧代码,来更具体的看下std::move的实现,在我的GCC 8.5下的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函数的形参为接收一个对象的万能引用,返回一个指向同对象的引用。可以看到返回值为:

constexpr typename std::remove_reference<_Tp>::type&&

&&表明std::move函数返回的是一个右值引用。但是模板可接收任意类型参数,若_Tp碰巧为一个左值引用,那么根据引用折叠原理,_Tp&&就变成了左值引用。为了避免这种情况的发生,调用std::remove_reference模板方法至_Tp类型,可去除原类型的引用属性,然后再赋予::type &&于非引用类型之上。这么一来可确保std::move返回的一定是右值引用所引用的右值,这一点十分重要。因此std::move函数最终将实参转换成了右值,这就是该函数所做的事情。

补充一点,一开始我理解为返回右值引用,可我同时也困惑于此,如果强制转为&&这样的右值引用后并返回,右值引用它本身是左值,返回右值引用相当于返回左值,那为啥说std::move返回的一定是右值呢?

带着疑问思考以后,我突然大悟!其实是我对static_cast函数本身不够了解,上述代码返回的并非是右值引用&&,而是右值引用所引用的那个右值,就像你实现了一段这样的代码:int *p1 = static_cast<int *>(&a); 这段代码返回的是int *的指针吗?其实返回的是a的地址,也就是指针所对应的区域,那同样的道理,返回右值引用也不是单纯的返回右值引用本身,而是右值引用所引用的右值。所以std::move返回的是形参__t,只不过这个形参__t在去除了引用属性的前提下被一个右值引用所引用,因此这个__t一定是一个右值,所以std::move返回的值就一定是一个右值,而不是右值引用本身,注意区分。

关于上面说的,再补充说一下,其实返回的就是右值引用,但是这里返回的右值引用并非具名右值引用,具名右值引用才是左值,就像你的函数返回int类型的a变量的时候,变量本身是左值,但是返回的时候是以右值的形式被接收的,即int b = func();,此时func()返回的是a变量。

我起初在知乎上看到有人说“返回右值引用”,也对此有疑惑,听听别人是怎么说的吧。“具名右值引用变量才是左值。函数类型如果不是左值引用那么都应该是右值类型(包括右值引用)。这儿返回类型使用右值引用是对需要移动对象的引用,因为移动函数中需要对移动对象进行修改从而达到资源移动的目的。”

其中,std::remove_reference的作用去除_Tp中的引用部分,无论_Tp是左值还是右值,只获取其中的类型。我们来简化一下,当_Tpstring时,这个函数其实就是:

string&& move(string&& __t) {
    return static_cast<string&&>(__t);
}

所以,不管传参是左值右值,最后返回的一定是个右值引用。实际上,std::move运行期不做任何事情,因为编译后不会生成可执行代码,内部只是变量地址的透传,完全可以被优化掉。

当然如果采用C++14标准,std::move就可以用更简明扼要的方式实现。有了函数返回值类型推导和标准库中的模板std::remove_reference_tstd::move可以这样写:

#if __cplusplus > 201103L
  /// Alias template for remove_reference
  template<typename _Tp>
    using remove_reference_t = typename remove_reference<_Tp>::type;
//-----------------------------------------------------------------//
  //move C++14写法
  template<typename _Tp>
    decltype(auto) //类型自动推导
    move(_Tp&& __t) noexcept
    { return static_cast<std::remove_reference_t<_Tp>&&>(__t); } //省略typename和::type 更为简洁

右值是可以被移动的,所以在一个对象上实施了std::move,就是告诉编译器该对象具备可移动的条件。

std::move源码中参数_Tp &&看起来像是个右值引用,但是在使用时却可以接收左值。

  /**
   *  @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); }
int main(){
    std::string a = "hello";
    std::string&& b = std::move(a); //参数a是左值
}

实际上,这种情况下参数T &&是一个万能引用,或称做通用引用(Universal References)。这块内容基本上只涉及到模板编程,但是为了能更好的啃下各种C++开源库,还是得了解下万能引用以及完美转发的概念。

万能引用与引用折叠

如果一个函数模板参数类型为T &&,其中T需要推导,那么T &&就是一个未定义的引用类型,称为万能引用,它既能绑定右值,又能绑定左值。 注意,只有当发生自动类型推断时(比如函数模板的类型自动推导,或者auto关键字),&&才是一个万能引用。

万能引用说完了,接着来聊引用折叠(Reference collapsing),因为完美转发(Perfect Forwarding)的概念涉及引用折叠。一个模板函数,根据定义的形参和传入的实参的类型,我们可以有下面四中组合:

左值-左值 T& &			# 函数定义的形参类型是左值引用,传入的实参是左值引用
左值-右值 T& &&			# 函数定义的形参类型是左值引用,传入的实参是右值引用
右值-左值 T&& &			# 函数定义的形参类型是右值引用,传入的实参是左值引用
右值-右值 T&& &&			# 函数定义的形参类型是右值引用,传入的实参是右值引用

但是C++中不允许对引用再进行引用,对于上述情况的处理有如下的规则:所有的折叠引用最终都代表一个引用,要么是左值引用,要么是右值引用。规则是:如果任一引用为左值引用,则结果为左值引用。否则(即两个都是右值引用),结果为右值引用。

即就是前面三种情况代表的都是左值引用,而第四种代表的右值引用。

举个简单的例子:

template<typename T>
void func(T &&param) {

}
int main() {
    //例子1
    func(1); //1是右值, param是右值引用
    int a = 2;
    func(a); //a是左值, param是左值引用

    //例子2
    std::string b = "hello";
    auto &&c = b; //auto&&绑定左值
    auto &&d = "world"; //auto&&绑定右值
}

例子中,T是一个模板,那么T就可能是intint &int &&,最后参数就可能变成(int && &&param)。由于C++禁止reference to reference的情况,所以编译器会对L2LL2RR2LR2R这四种引用做处理,折叠为单一引用,也就是引用折叠,具体就是:

  1. T& &T&& &T& &&都折叠成T&
  2. T&& &&折叠成T &&

这个比较好记,只要出现左值引用,都会最终折叠为左值引用。

完美转发

有了上面的概念之后,完美转发(Perfect Forwarding)这一块就很好理解了。万能引用 + 引用折叠 + std::forward一起构成了完美转发的机制。简单一点讲就是,std::forward会将输入的参数原封不动地传递到下一个函数中,如果是左值,传递到下一个函数还是左值,如果是右值,传递到下一个函数还是右值。所谓perfect,指的就是不仅能准确地转发参数的值,还能保证其左右值属性不变。为什么需要这个机制?

先看下面这段代码会输出什么:

template<typename T>
void func(T &param) {
    cout << "传入左值" << endl;
}
template<typename T>
void func(T &&param) {
    cout << "传入右值" << endl;
}

template<typename T>
void test(T &&t) { //参数t,万能引用
    func(t);
}

int main() {
    int a = 1;
    test(a);
    test(1);
}

输出是:

传入左值
传入左值

可以发现,无论传入左值右值,最终都调用了左值那个函数,和预期并不一致。这是因为,无论调用test函数模板传递的是左值还是右值,对于函数内部的参数t来说,它有自己的名称,也可以获取地址,因此它永远都是左值。也就是说,传递给func函数的参数t一直是左值。(被声明的左值引用和右值引用本身就是一个左值,可以寻址)

上面这段话理解了之后,我们可以使用std::forward来改造下test函数,让它足够perfect

template<typename T>
void test(T &&param) {
    func(std::forward<T>(param));
}

到这里,应该理解C++出现完美转发的动机了。在C++很多场景中,是否实现参数的完美转发,直接决定了这个参数的传递过程使用的是移动语义还是拷贝语义。最后,我们再瞄一眼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); }

  template<typename _Tp>
    constexpr _Tp&&
    forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
    {
      static_assert(!std::is_lvalue_reference_v<T>,
                "Cannot forward rvalue as lvalue.");
      return static_cast<_Tp&&>(__t);
    }

前文已经说过,typename std::remove_reference<_Tp>::type的作用是去掉参数_Tp的引用,只保留类型。 我们根据_Tp的引用类型分别简化下模板代码:

  //情况1:接收左值,_Tp被推导为string&,那么_Tp&&就是string& &&,折叠为string&
  string& forward(string& __t) {
    return static_cast<string&>(__t); 
  }

  //情况2:接收右值,_Tp被推导为string&&,那么_Tp&&就是string&& &&,折叠为string&&
  string&& forward(string&& __t) {
    return static_cast<string&&>(__t); 
  }

到这里,我们已经讲清楚了移动语义和完美转发,右值引用的作用就是支持这些机制。

参考资料

posted @ 2022-09-17 20:20  miseryjerry  阅读(373)  评论(0编辑  收藏  举报