美文网首页程序员
右值引用那些事儿

右值引用那些事儿

作者: 金戈大王 | 来源:发表于2019-07-19 22:24 被阅读6次

    本文原名《Rvalue Refernces, Move Semantics, and Perfect Forwarding》,发表于公司博客,现分享给大家。

    前言

    本文将要介绍的是C++11新特性中的精华——右值引用,Move语义,完美转发。也许你不曾听说过这些概念,但不用担心,阅读本文将会使你对它们有一个大致的了解。当然,不经历千百行代码的历练,你永远无法理解C++的真谛。本文旨在抛砖引玉,请务必带着批判的心情阅读。

    预备知识

    先来复习一下C++中的几种特殊函数,它们分别是Copy constructor、Copy assignment operator、Move constructor(C++11)、Move assignment operator(C++11)。其中,后两者是C++11中新增的。这几个函数的原型和调用途径如下:

    public:
        A(const A& a)
        {
            // Copy constructor
        }
        A& operator=(const A& a)
        {
            // Copy assignment operator
        }
        A(A&& a)
        {
            // Move constructor
        }
        A& operator=(A&& a)
        {
            // Move assignment operator
        }
    };
    
    A a;                    // Constructor
    A a1 = a;               // Copy constructor
    A a2(a);                // Copy constructor
    a2 = a;                 // Copy assignment operator
    A a3 = std::move(a);    // Move constructor
    A a4(std::move(a));     // Move constructor
    A a5(A());              // Move constructor
    a5 = std::move(a);      // Move assignment operator
    

    这里有几点需要注意:

    • 这几种函数的参数类型并不是固定的,比如Copy constructor的参数也可以是A& a,Move constructor的参数也可以是const A&& a。上面所写的是编译器默认生成的版本。我们也可以自己提供多个重载的版本。
    • 调用constructor还是assignment operator,取决于该语句构造了新的对象还是更新了原有对象的值。
    • 调用copy还是move,取决于参数是左值还是右值。
    • 上面只列出了直接构造或拷贝的使用场景。事实上,拷贝和移动语义更多地发生在函数调用期间,比如,函数参数为值传递,那么实参类型是左值还是右值就决定了调用拷贝构造还是移动构造。

    那么,对于最后两点,如何区分左值和右值呢?

    答案是,靠经验和直觉。别不信,看看文末参考资料中Value categories是怎么写的就明白我的意思了。不过我来做个简单的解释,足够你理解左值右值这一概念。

    我们习惯于认为,C++中的表达式(这里的表达式是广义的表达式,官方定义:an operator with its operands, a literal, a variable name, etc)分为两种,左值lvalue和右值rvalue。其中,左值源自于“等号左边的值”。能够放在等号左边,说明它其实是一个标识符,标记着某个对象。也说明它具有地址,可以用&取出该表达式所表示的对象的地址。而右值源自于“等号右边的值”,不能取地址,通常是个临时对象,用于初始化其它变量。

    但是!实际上表达式不只有这两种类型,严格来讲,表达式有三种类型——lvalue、prvalue(pure rvalue)和xvalue(eXpiring value)。lvalue与我们通常认为的左值一致。prvalue与我们通常认为的右值一致。而xvalue则是C++11引入的右值引用。expiring value意思是“即将过期的值”,是对右值引用恰如其分的描述。右值引用既然是一个引用,那么它必然是通常意义上的左值,但又由于它引用的是一个右值,目的只是为了重复利用右值的资源,它是稍纵即逝的,所以单独把它归为一类。

    这里需要提醒大家的一点是,左值、右值并不是与引用、非引用一一对应的。如果我们仔细回想,会发现左值引用一定是左值,非引用可以是左值也可以是右值,右值引用与非引用一样,可以是左值也可以是右值。怕大家混淆概念,请看下面这个表格:


    为了加深理解,再举个例子,接着上面的代码:

    A&& a6 = std::move(a);  // Bind a6 to an rvalue moved from a.
    A&& a7 = a6;            // Error! Can not bind a7 to an lvalue.
    A&& a8 = std::move(a6); // Bind a8 to an rvalue moved from a6.
    

    这三行代码,第一行创建了一个右值引用a6,第二行试图把右值引用a7绑定到a6,第三行试图把右值引用a8绑定到move后的a6。第二行编译出错。用上面的表格很容易解释这件事情,第二行的a6是右值引用,但它此时是作为显式声明的变量,所以它是个左值,也就不能被绑定到a7上。而第三行的std::move(a6)的返回值是个右值引用,而且是个临时变量,所以它是个右值,也就可以被绑定到a8上。

    在进入正题前,我们再看一个移动构造函数的具体实现,方便大家理解move语义存在的价值。

    class MyVector {
        int* data_;
        size_t size_;
    
    public:
        MyVector(): size_(100) {
            data_ = new int[size_];
        }
    
        ~MyVector() {
            delete[] data_;
        }
    
        MyVector(const MyVector& my_vector) {                                       // Copy constructor
            size_ = my_vector.size_;
            data_ = new int[size_];
            std::copy(my_vector.data_, my_vector.data_ + my_vector.size_, data_);
        }
    
        MyVector(MyVector&& my_vector) {                                            // Move constructor
            size_ = my_vector.size_;
            data_ = my_vector.data_;
            my_vector.size_ = 0;
            my_vector.data_ = nullptr;
        }
    
        // Should define copy assignment operator here
    
        // Should define move assignment operator here
    };
    
    MyVector my_vector;
    MyVector my_vector1 = my_vector;                                                // my_vector is lvalue, thus copy constructor is invoked.
    MyVector my_vector2 = std::move(my_vector);                                     // std::move(my_vector) is rvalue, thus move constructor is invoked.
    

    这里,我们实现了一个简单的数组类,自定义了拷贝构造函数和移动构造函数。在拷贝构造函数中,把旧data_数组中的每个元素依次赋值到新的对象中。在移动构造函数中,直接把旧data_数组的指针赋值给新对象,从而避免了数据的拷贝。但移动后,需要把旧对象的size_标记为0,把data_指针置空,以表示所有权的转移。这个简单的例子揭示了移动语义存在的价值,因为有些情况下,数据是可以转移所有权的,而不必拷贝一份。

    在更进一步之前,请大家务必理解上面的内容,这是一切关于右值引用和move语义的基石。

    现在,预备知识到此为止,准备迈入新世界的大门吧。

    初识std::move与std::forward

    先抛出一句真理,请读者牢记在心:“std::movestd::forward不在运行期做任何事情。”也就是说,编译成机器码后,这两个函数是没有代码的,它们只是在编译时做了一些非常非常简单的操作。百闻不如一见,我们直接看看std::move的实现便知:

    decltype(auto) move(T&& param)
    {
        using ReturnType = remove_reference_t<T>&&;
        return static_cast<ReturnType>(param);
    }
    

    这是C++14版本的实现,使用了通用引用T&&作为参数类型,decltype(auto)自动推导返回值类型。简单解释一下,通用引用T&&可以接受任何类型的参数,且自动适配左值和右值。如果实参是左值,param就被推导为左值引用,如果实参是右值,param就被推导为右值引用。至于为什么会有这种效果,在后面“引用折叠”的部分将会详细解释。remove_reference_t<T>是一个标准库函数,它会去掉T中包含的引用修饰符,比如把int&变成int,把const std::string&变成const std::string。如果你还不熟悉类型推导,请先移步《模板类型推导与auto》学习,否则接下来的内容会很不友好。

    总而言之,std::move函数只做了一件事,修改param所持有的修饰符,并返回。无论输入的参数是什么类型,是左值还是右值,统统转换成右值引用并返回。这就是std::move所做的事情。

    std::move好像什么都没move...”

    恭喜你,理解到这一层,离真相就不远了。

    所谓move语义,强调的是语义,而不是实实在在的东西。一个对象,它是左值还是右值,其实根本不影响它在内存中的存储形式,只产生C++语法层面的影响。而C++语法层面的影响,是由编译器来承受的。比如,同样是一行赋值语句,如果等号右边是左值,编译器就会调用拷贝赋值运算符,如果是右值,就会调用移动赋值运算符。

    那么真相是什么?std::move相当于告诉编译器,现在,我的返回值是一个右值,无论它之前是什么,请务必按照右值的规则来对待它。类似的,std::forward则是告诉编译器,我的返回值既可能是左值,也可能是右值,当传给我的参数是右值引用时,我返回右值,否则我就返回左值。

    区分通用引用与右值引用

    读者可能读到这里已经有些晕头转向了,特别是前面提到的通用引用T&&,看起来明明是右值引用嘛。因此本节特地来解释一下,如何区分通用引用与右值引用。

    先举几个例子,看看这些T&&分别代表什么:

    
    Widget&& var1 = Widget();       // rvalue reference
    
    auto&& var2 = var1;             // universal reference
    
    template<typename T>
    void f(std::vector<T>&& param); // rvalue reference
    
    template<typename T>
    void f(T&& param);              // universal reference
    
    template<typename T>
    class vector {
        void push_back(T&& x);      // rvalue reference
    }
    

    总结起来其实就是这么两点:

    • 如果T不是模板参数,而是具体的类型,T&&一定是右值引用。
    • 如果T是模板参数,T&&一般情况下是通用引用。除了下面两种例外情况:
      • 如果T是模板参数,但param的类型不直接是T,比如std::vector<T>&&,那么std::vector<T>&&是右值引用。
      • 如果T是模板参数,但不需要自动推导,比如已经在类实例化的时候手动指定过了,那么T&&是右值引用。

    用std::move处理右值引用,用std::forward处理通用引用

    在前面几节,我们努力地理解什么是右值,什么是右值引用和通用引用。但始终没有回答这样一个问题:“什么时候我应该使用右值引用?如何使用?”本节就来回答这个问题。

    当我们设计一个函数的时候,我们一般如何决定参数类型?以我的经验,如果参数只读不写,那么我一般会设计为const T&的形式;如果参数既读又写,那么我会设计为T&;如果函数内部想要对参数的副本进行操作,或者参数是基本数据类型,那么我会设计为T。如果参数是个临时对象,又想避免拷贝的代价,那就得使用右值引用T&&了,这便是右值引用存在的价值。注意,细心的同学可能会记得,const T&也可以绑定临时变量。但对于函数的设计者来说,我们无从得知实参的真实类型,所以我们无权复用const T&中的资源。只有当参数声明为T&&后,调用者和函数双方相当于达成了共识——参数的所有权移交给函数方,函数内部可以随意“挪用”参数中的资源。

    现在,假设我们作为函数的设计者,看看如何设计一个参数为T&&的函数。一个很常见的场景是矩阵运算,如果我们自己实现一个矩阵类,那么+运算符是必不可少的。但+运算符的参数如何设计则是一个大问题。我们可能会设计成这样:

    Matrix operator+(const Matrix& lhs, const Matrix& rhs) {
        Matrix sum = ...        // Sum all elements in lhs and rhs here.
        return sum;
    }
    

    参数类型设为const Matrix&避免了参数拷贝,返回局部变量也很合理,可以借助编译器RVO优化避免创建临时变量(关于RVO优化的详细知识,参考文末的《Copy elision》)。一切都很完美,但唯一美中不足的是需要创建一个额外的Matrix对象。当然,这是由于返回值类型是非引用类型,按值返回可以避免对操作数造成污染,合情合理。

    不过,考虑一种情况,如果其中某一个操作数是右值,这意味着调用者不会再去使用它,我们便可以复用它的内存空间,不必再创建新的Matrix对象。就像下面这样:

    Matrix operator+(Matrix&& lhs, const Matrix& rhs) {
        lhs = ...               // Sum all elements in lhs and rhs and assign to lhs.
        return std::move(lhs);
    }
    

    该函数用于左操作数是右值的情况,我们把std::move(lhs)返回,这是一个右值,它会触发返回对象的移动构造函数,从而避免拷贝构造。

    这里有个非常令人迷惑的地方,如果把return std::move(lhs)改成return lhs会怎样?答案是,会导致返回对象的拷贝构造。虽然std::move的返回值类型与lhs的类型是完全一样的,都是Matrix&&,但根据前面的预备知识,这里的std::move(lhs)是右值,而lhs是左值,因为std::move(lhs)是个临时对象,而lhs是个显式声明的变量。

    现在,operator+有了两个重载的实现,前者适用于一般情况,后者适用于左操作数为临时对象的情况,使用效果如下:

    Matrix sum1 = m1 + m2;                  // 调用第一种实现
    Matrix sum2 = Matrix::Identity() + m2;  // 调用第二种实现
    Matrix sum3 = std::move(m1) + m2;       // 调用第二种实现
    

    接下来,我们可以想得更远一点,能不能把两个函数合并成一个函数呢?毕竟为了这样简单的目的重载函数有些麻烦。不过,矩阵加法不太适合这种用法,我们换一个例子。

    比如我们想要实现一个创建智能指针的工厂方法,输入某个类T的构造函数所需的参数,返回T的智能指针。如何以最低的代价、最高的通用性来实现这个功能?C++标准库给出了如下答案:

    template<typename T, typename... Args>
    std::unique_ptr<T> make_unique(Args&&... args)
    {
        return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
    }
    

    这里,T是需要构造的模板类,ArgsT的构造函数所需的参数的模板类型。问题又来了,使用new T(std::forward<Args>(args)...)和直接使用new T(args...)的区别是什么?答案是,前者会完美转发args的实际类型(保留其lvalue和rvalue性质),而后者始终按照lvalue看待。结果很明显,如果T的构造函数提供了支持lvalue和rvalue的多个重载,那么使用std::forward的方式会避免额外的内存拷贝。

    最后,总结一下。对于函数的设计者来说,

    • 如果你想要复用参数,请将函数参数设为右值引用,并在函数中使用std::move处理参数后再返回。
    • 如果你想要完美转发参数,请将函数参数设为通用引用,并使用std::forward<T>处理待转发的参数。

    不要滥用通用引用

    通用引用很强大,但强大的东西往往也会带来弊端。本节介绍使用通用引用导致出错的情况,虽然不常发生,但了解这些情况可以帮助我们及早规避这些错误。

    比如下面这个例子,向数组中添加字符串。你可能会想到使用通用引用来避免字符串拷贝:

    std::vector<std::string> names;
    
    template<typename T>
    void add(T&& name) {
        names.emplace_back(std::forward<T>(name));
    }
    

    这样的确能达到目的,当实参是右值时,emplace_back会调用移动构造函数,当实参是string literal时,emplace_back会直接在vector内部原地构造string。现在,出于某种需求,我们想要重载add函数,支持添加指定索引的name,比如:

    std::string nameFromIdx(int idx);           // return name corresponding to idx
    
    void add(int idx) {
        names.emplace_back(nameFromIdx(idx));
    }
    

    此时,如果我们用非int的数值类型作为参数,本来期望会调用第二个重载,但实际调用的却是第一个:

    long idx = 10l;
    add(idx);                                   // invoke T&& overload
    

    这是因为通用引用的匹配范围实在是太大了,long类型的参数对于int idx重载来说不是准确匹配,而对于T&&的重载来说却是准确匹配。

    对此问题,作者的观点是,避免重载通用引用。如果必须重载,有一些比较复杂的方案可以解决这个问题,比如tag dispatch设计模式。本文不再详细介绍这些解决方案,在我看来,这是治标不治本的事情。

    这一问题的出现,其根本原因是我们滥用了通用引用。add函数只是用来把string添加到数组里面,本质上,它只应该接收string类型的参数,即使是int idx的重载,也是为了换一种方式传入string参数。而我们为了节约性能,直接把参数扩大到了可以接收任意类型的地步,这使得函数签名失去了对函数使用者的约束,是一种严重的设计失误。所以,我本人对该问题的看法是,不要尝试为了性能而滥用通用引用。通用引用,由于其模板特性,只应该用在适合使用模板的地方。

    通用引用的内部机制——引用折叠(reference collapsing)

    本节继续讨论通用引用。事实上,“通用引用”并不是C++官方概念,而是Effective C++系列作者Scott Meyers自己发明的概念,是为了方便读者理解而起的名字。那为什么官方不叫这个名字呢?因为它本质上其实就是右值引用,只不过在引用折叠的作用下,展示出了通用引用的效果。

    我们来看看什么是引用折叠。通常,我们把左值引用绑定到左值上,把右值引用绑定到右值上,都是把引用绑定到值上。那如果把引用绑定到另一个引用上会怎样呢?比如:

    typedef int&  lref;
    typedef int&& rref;
    int n;
    lref&  r1 = n;                              // r1 is a lvalue reference to lvalue reference, type of r1 is int&
    lref&& r2 = n;                              // r2 is a rvalue reference to lvalue reference, type of r2 is int&
    rref&  r3 = n;                              // r3 is a lvalue reference to rvalue reference, type of r3 is int&
    rref&& r4 = 1;                              // r4 is a rvalue reference to rvalue reference, type of r4 is int&&
    

    r1r4这四个变量都是引用绑定到引用,可以总结出一个规律:

    • 右值引用绑定到右值引用,结果还是右值引用;
    • 其它情况结果都是左值引用。

    知道了这个规律,再来看通用引用,就可以发现,如果T被推导为左值引用,那么T&&就相当于把右值引用绑定到左值引用,结果是左值引用;如果T被推导为右值引用,那么T&&就相当于把右值引用绑定到右值引用,结果是右值引用。

    可是为什么T会被推导为左值引用呢?书中没有给出答案。我的解释是,当实参为左值引用时,T只能被推导为左值引用,因为其它类型都不合法。但当实参为右值引用时,T被推导为非引用类型或者右值引用都合法,编译器选择了形式更为简单的前者。

    别对move期待过高

    move的出现,为C++代码提供了一种更低代价的构造和拷贝机制。但C++语法不是万能的,move是否真的能够提高性能,其实更取决于类的实现。

    一个良好实现的类,通常需要手动实现这五种内存控制函数:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。如果直接采用编译器自动生成的版本,就很难达到最大的性能节约。以C++ STL容器类为例,std::vector是一个良好实现的类,下面的代码就可以体现出拷贝构造和移动构造的性能差异:

    std::vector<int> v(100000000, 0);               // Create a vector with 100000000 elements
    std::vector<int> v1 = v;                        // Copy construct v1 by v, 0.185 seconds used.
    std::vector<int> v2 = std::move(v);             // Move construct v2 by v, 6.748e-06 seconds used.
    

    其中,拷贝构造和移动构造的耗时是在我的Core i5笔记本上测得的。而且,数组长度越长,v1的拷贝构造用时越长,但v2的移动构造用时不变。这是因为std::vector的拷贝构造函数需要依次拷贝每一个元素,因此是O(n)的时间复杂度。而std::vector的移动构造函数只需要把旧vectordata指针拷贝给新vector即可,因此是O(1)的时间复杂度。

    但并非所有的容器类都能从move中受益,看看下面这个例子:

    std::array<int, 100000> arr;                    // Create an array with 100000 elements
    std::array<int, 100000> arr1 = arr;             // Copy construct arr1 by arr, 0.155 seconds used.
    std::array<int, 100000> arr2 = std::move(arr);  // Move construct arr2 by arr, 0.148 seconds used.
    

    首先声明,这里测的耗时放缩了一定的比例,因为std::array的长度受栈空间限制,不能声明的太大。所以上面这段代码需要循环执行很多次才能得到比较稳定的耗时结果。从结果中可以看到,拷贝构造和移动构造用时接近。这是因为std::array的数据是在栈上申请的临时空间,无法把一个array的数据通过指针赋值给另一个array,只能逐元素拷贝。所以无论是拷贝构造还是移动构造,用时都随着元素增多而增加,时间复杂度是O(n)。但事情到这里还没完,虽然时间复杂度没区别,但单个元素的拷贝,move仍然可能快于copy,看下面这种情况:

    class MyVector {                                                // A customized vector class
        vector<int> data;
    
    public:
        MyVector() {
            data = vector<int>(100, 0);
        }
    };
    
    std::array<MyVector, 10000> my_array;                           // Create an array with 10000 elements
    std::array<MyVector, 10000> my_array1 = my_array;               // Copy construct my_array1 by my_array, 0.0018 seconds used.
    std::array<MyVector, 10000> my_array2 = std::move(my_array);    // Move construct my_array2 by my_array, 0.0001 seconds used.
    

    与前一个例子的区别是,这里array中存的是自定义的数据类型MyVectorMyVector虽然没有显式定义移动构造函数,但编译器为它生成了一个。所以array的移动构造函数就会调用每一个元素的移动构造函数,从而在构造每个元素时节约一部分时间。

    这几个案例告诉我们,move并不是万能的。软件开发是一个上下游协作的过程。每一个类的移动构造都可能依赖于上游代码库,这没关系,我们只要保证在我们这一层做了最大的优化,那么下游用户就可以放心使用我们的类。所以,作为类的开发者,应该使自己的类尽量提供拷贝构造和移动构造这两种方式,并在移动构造中体现出应有的移动语义,比如拷贝指针而不是拷贝数据。

    参考资料

    Copy constructors cppreference
    Copy assignment operator cppreference
    Move constructors cppreference
    Move assignment operator cppreference
    Value categories cppreference
    Return forwarding reference parameters - Best practice stackoverflow
    C++ Core Guidelines: The Rules for in, out, in-out, consume, and forward Function Parameter
    C++模板进阶指南:SFINAE
    Copy elision cppreference

    相关文章

      网友评论

        本文标题:右值引用那些事儿

        本文链接:https://www.haomeiwen.com/subject/msselctx.html