zoukankan      html  css  js  c++  java
  • C++11 右值引用详解

    一、左值和右值

    左值与右值是C++中表达式的属性,在C++11中,每个表达式有两个属性:类型(type,除去引用特性,用于类型检查)和值类型(value category,用于语法检查,比如一个表达式结果是否能被赋值)。值类型包括3个基本类型:lvalueprvaluexrvalue。后两者又统称为rvaluelvalue我们称为左值,可以将左值看成是一个可以获取地址的量,它可以用来标识一个对象或函数。rvalue称为右值,最简单的解释是所有不是左值的量就是右值。

    通常情况下,判断某值是左值还是右值有两种方法:

    1、可位于赋值号(=)左侧的表达式就是左值;反之,只能位于赋值号右侧的表达式就是右值。例如,

    int v1 = 5;
    5 = v1;       // 错误,5不能为左值
    /**
     * 变量 a 就是一个左值,而字面量 5 就是一个右值。
     * C++ 中的左值也可以当做右值使用,如下:
     */
    int v2 = 10;  // b 被声明为一个左值
    v1 = v2;      // a、b 都是左值,而 b 可以当作右值使用

    2、有名称的、可以获取到存储地址的表达式为左值,反之为右值。以上面定义的变量 v1、v2 为例,v1 和 v2 是变量名,且通过 &v1 和 &v2 可以获得他们的存储地址,因此 v1 和 v2 都是左值;反之,字面量 5、10,它们既没有名称,也无法获取其存储地址(字面量通常存储在寄存器中,或者和代码存储在一起),因此 5、10 都是右值。

    注意:以上 2 种判定方法只适用于大部分场景,而非全部场景,更多内容请参考cppreference

    二、左值引用

    左值引用在C++11之前被简称为引用,语法简单并可以被 const 修饰,对于 non-const 引用,只能通过 non-const 左值来初始化。例如,

    int v       = 20;
    int &rv1    = v;    // non-const 引用可以被 non-const 左值初始化
    const int y = 10;
    int &rv2    = y;    // 非法。non-const 引用不能被 const 左值初始化
    int &rv3    = 10;   // 非法。non-const 引用不能被右值初始化

    而 const 引用的限制就很少了:

    int v       = 10;
    const int x = 20;
    ​
    const int &rv1 = v;     // const 引用可以被 non-const 左值初始化
    const int &rv2 = x;     // const 引用可以被 const 左值初始化
    const int &rv3 = 9;     // const 引用可以被右值初始化

    三、右值引用

    C++11之前,右值被认为是无用资源,所以在c++11中引入右值引用,就是为了重用右值。定义右值引用需要使用&&

    int &&rrv = 200;

    右值引用一定不能被左值所初始化,只能使用右值初始化:

    int v = 20;           // 左值
    int &&rrv1 = v;       // 非法。右值引用无法被左值初始化
    const int &&rrv2 = v; // 非法。右值引用无法被左值初始化

    原因:右值引用的目的是为了延长用来初始化的对象生命周期,对于左值,其生命周期与其作用域有关,则没必要延长。若延长,则会出现下面的情况。

    int v = 20;         // 左值
    int &&rv = v * 2;   // v*2 的结果是个右值,rv延长其生命周期
    int y = rv + 2;     // 因此 rv 可被重用,y = 42
    rv = 100;           // 一旦初始化一个右值引用变量,该变量就成为左值并可以被赋值

    重要:初始化之后的右值引用将变成一个左值,如果是 non-const 还可以被赋值。

    右值引用还可以用作函数参数被传递,如下:

    // 接收左值
    void fun(int &lref)
    {
        std::cout << "l-value reference" << std::endl;
    }
    ​
    // 接收右值
    void fun(int &&rref)
    {
        std::cout << "r-value reference" << std::endl;
    }
    ​
    int main()
    {
        int v = 10;
        fun(v);     // output: l-value reference
        fun(10);    // output: r-value reference
        
        return 0;
    }

    可以看到,函数参数要区分开右值引用与左值引用,这是两个不同的重载版本。另外,如果你定义了下面的函数:

    void fun(const int &clref)
    {
        std::cout << "l-value const reference" << std::endl;
    }

    则其不仅可以接收左值,而且可以接收右值(前提是你没有提供接收右值引用的重载版本)。

    四、左右值引用使用场景

    引用类型可以引用的值类型^^^^^^使用场景
      非常量左值 常量左值 非常量右值 常量右值  
    非常量左值引用 Y N N N
    常量左值引用 Y Y Y Y 常用于类中构建拷贝构造函数
    非常量右值引用 N N Y N 移动语义、完美转发
    常量右值引用 N N Y Y 无实际用途

    五、移动语义和完美转发

    1)概念

    • 移动语义:将内存的所有权从一个对象转移到另外一个对象,高效的移动用来替换效率低下的复制,对象的移动语义需要实现移动构造函数(move constructor)和移动赋值运算符(move assignment operator)。

    • 完美转发:定义一个函数模板,该函数模板可以接收任意类型参数,然后将参数转发给其它目标函数,且保证目标函数接受的参数其类型与传递给模板函数的类型相同。

    2)移动语义详解

    有了右值引用的概念,就可以理解移动语义了。前面讲到,一个对象的移动语义的实现是通过移动构造函数与移动赋值运算符来实现的。所以,为了理解移动语义,我们从一个对象出发,下面创建一个动态数组类:

    template <typename T>
    class DynamicArray
    {
    public:
        explicit DynamicArray(int size) : m_size(size), m_array(new T[size])
        {
            std::cout << "Constructor: dynamic array is created!" << std::endl;
        }
        
        virtual ~DynamicArray()
        {
            delete[] m_array;
            std::cout << "Destructor: dynamic array is destroyed!" << std::endl;
        }
        
        // 拷贝构造函数
        DynamicArray(const DynamicArray& rhs) : m_size(rhs.m_size)
        {
            m_array = new T[m_size];
            
            for (int i = 0; i < m_size; ++i)
            {
                m_array[i] = rhs.m_array[i];
            }
            std::cout << "Copy constructor: dynamic array is created!" << std::endl;
        }
        
        // 重载赋值运算符
        DynamicArray& operator = (const DynamicArray &rhs)
        {
            std::cout << "Copy assignment operator is called." << std::endl;
            
            if (&rhs == this)
            {
                return *this;
            }
            delete[] m_array;
    ​
            m_size = rhs.m_size;
            m_array = new T[m_size];
            
            for (int i = 0; i < m_size; ++i)
            {
                m_array[i] = rhs.m_array[i];
            }
    ​
            return *this;
        }
        
        // 重载索引运算符
        T& operator [] (int index)
        {
            // 不进行边界检查
            return m_array[index];
        }
        
        const T& operator [] (int index) const
        {
            return m_array[index];
        }
        
        int Size() const 
        {
            return m_size;
        }
    ​
    private:
        T  *m_array;
        int m_size;
    };

    我们通过在堆上动态分配内存来实现动态数组类,类中实现拷贝构造函数、赋值运算符重载以及索引操作符重载。现假设定义一个生产动态数组的工厂函数:

    // 生成 int 动态数组的工厂函数
    DynamicArray<int> ArrayFactor(int size)
    {
        DynamicArray<int> arr(size);
        return arr;
    }

    然后使用以下代码进行测试:

    int main()
    {
        DynamicArray<int> arr = ArrayFactor(10);
        return 0;
    }

    输出:

    Constructor: dynamic array is created!
    Copy constructor: dynamic array is created!
    Destructor: dynamic array is destroyed!
    Destructor: dynamic array is destroyed!

    现在,让我们来解读一下这个输出。首先,调用ArrayFactor()函数,内部创建了一个动态数组,所以普通构造函数被调用。然后将这个动态数组返回,但是这个对象是函数内部的,函数外是无法获得的,所以要生成一个临时对象,然后用这个动态数组初始化,函数最终返回的是临时对象。很明显这个动态数组即将消亡,所以其是右值,那么在构建临时对象时,会调用拷贝构造函数(没有右值的版本,但是右值可以传递给const左值引用参数)。但是问题又来了,因为返回的这个临时对象又拿去初始化另外一个对象arr,当然调用也是拷贝构造函数。调用两次拷贝构造函数完全没有必要,编译器也会这么想,所以将其优化:直接拿函数内部创建的动态数组去初始化arr。因此仅有一次拷贝构造函数被调用,但是一旦完成arr的创建,那个动态数组对象就被析构了。最后arr离开其作用域被析构。不难发现尽管编译器做了优化,但是还是导致对象被创建了两次,函数内部创建的动态数组仅仅是一个中间对象,用完后就被析构了,有没有可能直接将其申请的空间直接转移到arr,那么资源得以重用,实际上只用申请一份内存。但是问题的关键是复制构造函数执行的是复制,不是转移,无法实现这样的功能。此时,则需要移动构造函数:

    // 移动构造函数
    DynamicArray::DynamicArray(DynamicArray &&rhs) 
        : m_size(rhs.m_size), m_array(rhs.m_array)
    {
        rhs.m_size = 0;
        rhs.m_array = nullptr;
        std::cout << "Move constructor: dynamic array is moved!" << std::endl;
    }
    ​
    // 移动赋值运算符
    DynamicArray& DynamicArray::operator = (DynamicArray &&rhs)
    {
        std::cout << "Move assignment operator is called." << std::endl;
        
        if(&rhs == this)
        {
            return *this;
        }
        delete[] m_array;
        
        m_size  = rhs.m_size;
        m_array = rhs.m_array;
        
        rhs.m_size  = 0;
        rhs.m_array = nullptr;
        
        return *this;
    }

    上面是移动构造函数与移动赋值操作符的实现,相比复制构造函数与复制赋值操作符,前者没有再分配内存,而是实现内存所有权转移。那么使用相同的测试代码,其结果是:

    Constructor: dynamic array is created!
    Move constructor: dynamic array is moved!
    Destructor: dynamic array is destroyed!
    Destructor: dynamic array is destroyed!

    可以看到,在拷贝时调用的是移动构造函数,那么函数内部申请的动态数组直接被转移到arr。从而减少了一份相同内存的申请与释放。注意析构函数被调用两次,这是因为尽管内部进行了内存转移,但是临时对象依然存在,只不过第一次析构函数析构的是一个nullptr,这不会对程序有影响。其实通过这个例子,我们也可以看到,一旦已经自己创建了拷贝构造函数与重载赋值运算符后,编译器不会创建默认的移动构造函数和移动赋值运算符,这点要注意。最好能将其手动实现。

    总结:这就是移动语义,用移动而不是复制来避免无必要的资源浪费,从而提升程序的运行效率。其实在C++11中,STL的容器都实现了移动构造函数与移动赋值运算符,这将大大优化了STL容器的使用效率。

    3)std::move

    通过对移动语义技巧的讲解,我们知道对象的移动语义是依靠移动构造函数和移动赋值操作符实现的。但是前提是传入的必须是右值,但是有时候你需要将一个左值也进行移动语义(因为你已经知道这个左值后面不再使用),那么就必须提供一个机制来将左值转化为右值。在C++中,std::move就是专为此而生,看下面的例子:

    std::vector<int> v1 = {1, 2, 3, 4};
    std::vector<int> v2 = v1;            // 通过运算符重载进行拷贝,v2 是 v1 的副本
    std::vector<int> v3 = std::move(v1); // 通过移动构造函数移动语义,v3 与 v1 交换, v1 为空, v3 = {1, 2, 3, 4}

    可以看到,通过std::move可以将v1转化为右值,从激发v3的移动构造函数,实现移动语义。

    C++中利用std::move实现移动语义的一个典型函数是std::swap(实现两个对象的交换)。C++11之前,std::swap的实现如下:

    template <typename T>
    void swap(T &a, T &b)
    {
        T tmp(a);  // 调用复制构造函数
        a = b;     // 复制赋值运算符
        b = tmp;   // 复制赋值运算符
    }

    从上面的实现可以看出:共进行了3次复制。如果类型T比较占内存,那么交换的代价是非常昂贵的。但是利用移动语义,却可以更加高效地交换两个对象:

    template <typename T>
    void swap(T& a, T& b)
    {
        T tmp(std::move(a));    // 调用移动构造函数
        a = std::move(b);       // 调用移动赋值运算符
        b = std::move(tmp);     // 调用移动赋值运算符
    }

    仅通过三次移动,实现两个对象的交换,由于没有复制,效率更高。

    此时,你可能会想,std::move函数内部到底是怎么实现的。其实std::move函数并不“移动”,它仅仅进行了类型转换。下面给出一个简化版本的std::move

    template <typename T>
    typename remove_reference<T>::type&& move(T &&param)
    {
        using ReturnType = typename remove_reference<T>::type&&;
        return static_cast<ReturnType>(param);
    }

    代码很短,但是估计很难懂。首先看一下函数的返回类型,remove_reference在头文件中,remove_reference<T>有一个成员type,是T去除引用后的类型,所以remove_reference<T>::type&&一定是右值引用,对于返回类型为右值的函数其返回值是一个右值(准确地说是xvalue)。所以,知道了std::move函数的返回值是一个右值。然后,我们看一下函数的参数,使用的是通用引用类型(&&),意味者其可以接收左值,也可以接收右值。其推导规则如下:如果实参是左值,推导后的形参是左值引用,如果是右值,推导出来的是右值引用。但是不管怎么推导,ReturnType的类型一定是右值引用,最后std::move函数只是简单地调用static_cast将参数转化为右值引用。所以,std::move什么也没有做,只是告诉编译器将传入的参数无条件地转化为一个右值。所以,当你使用std::move作用于一个对象时,你只是告诉编译器这个对象要转化为右值,然后就有资格进行移动语义了。

    下面举一个由于误用std::move而无效的例子。假如你在设计一个标注类,其构造函数接收一个std::string类型参数作为标注文本,你不希望它被修改,所以标注为const,然后将其复制给其的一个数据成员,你可能会使用移动语义,现假设的移动语义设计如下:

    class Annotation
    {
    public:
        explicit Annotation(const std::string& text) : 
        m_text (std::move(text)) {}
    ​
        const std::string& getText() const 
        {
            return m_text;
        }
        
    private:
        std::string m_text;
    };

    当使用以下代码进行测试时:

    int main()
    {
        std::string text("hello");
        Annotation ant(text);
    ​
        std::cout << ant.getText() << std::endl;  // output: hello
        std::cout << text << std::endl;           // output: hello 不是空,移动语义没有实现
    return 0;
    }

    我们会发现移动语义并没有被实现,这是为什么呢?首先,从直观上看,假如你移动语义成功了,那么text会发生改变,这会违反其const属性。所以,这样不大可能成功。其实,std::move函数会在推导形参时会保持形参的const属性,所以其最终返回的是一个const右值引用类型,那么m_text(std::move(text))到底会调用什么构造函数呢?我们知道std::string的内部有两个构造函数可能会被匹配:

    class string
    {
        // ...
        string(const string &rhs);   // 拷贝构造函数
        string(string &&rhs);        // 移动构造函数
    };

    那么到底会匹配哪个呢?肯定的是移动构造函数不会被匹配,因为不接受const对象,则拷贝构造函数会被匹配,因为前面讲过const左值引用可以接收右值,const右值更可以。所以,我们其实调用了复制构造函数,那么移动语义当然无法实现。所以,如果你想进行移动语义,则不要把std::move引用在const对象上。

    4)std::forward和完美转发

    完美转发就是创建一个函数,该函数可以接收任意类型的参数,然后将这些参数按原来的类型转发给目标函数,完美转发的实现要依靠std::forward函数。下面就定义了这样一个函数:

    // 目标函数
    void foo(const std::string &str);   // 接收左值
    void foo(std::string &&str);        // 接收右值
    ​
    template <typename T>
    void Wrapper(T &&param)
    {
        foo(std::forward<T>(param));  // 完美转发
    }

    首先要有一点要明确,不论传入Wrapper的参数是左值还是右值,一旦传入之后,param一定是左值,然后我们来具体分析这个函数:

    • 当一个为std::string类型的右值传递给Wrapper时,T被推导为std::stringparam为右值引用类型,但是一旦传入后,param就变成了左值,所以直接转发给foo函数,将丢失param的右值属性,那么std::forward就确保传入foo的值还是一个右值;

    • 当类型为const std::string的左值传递给Wrapper时,T被推导为const std::string&paramconst左值引用类型,传入后,param仍为const左值类型,所以直接转发给foo函数,没有问题,此时应用std::forward函数可以看成什么也没有做;

    • 当类型为std::string的左值传递给Wrapper时,T被推导为std::string&param为左值引用类型,传入后,param仍为左值类型,所以直接转发给foo函数,没有问题,此时应用std::forward函数可以看成什么也没有做;

    综上所述,Wrapper函数可以实现完美转发,其关键点在于使用了std::forward函数确保传入的右值依然转发为右值,而对左值传入不做处理。

    那么,std::forward到底怎么实现的,如下:

    template <typename T>
    T&& forward(typename remove_reference<T>::type &param)
    {
        return static_cast<T &&>(param);
    }

    代码依然与std::move一样简洁,我们结合Wrapper来看,如果传入Wrapper函数中的是std::string左值,那么推导出Tstd::string&,则将调用std::foward<std::string&>,根据std::foward的实现,其实例化为:

    std::string& && forward(typename remove_reference<std::string&>::type &param)
    {
        return static_cast<std::string& &&>(param);
    }

    连续出现3个&符号有点奇怪,我们知道C++不允许引用的引用,那么其实编译器这里进行是引用折叠(reference collapsing,大致就是后面的引用消掉),因此,变成:

    std::string& forward(std::string &param)
    {
        return static_cast<std::string &>(param);
    }

    上面的代码就很清晰了,一个左值引用的参数,然后还是返回左值引用,此时的std::foward就是什么也没有做,因为传入与返回完全一样。

    那么如果传入Wrapper函数中的是std::string右值,那么推导出Tstd::string,那么将调用std::foward<std::string>,根据std::foward的实现,其实例化为:

    std::string&& forward(typename remove_reference<std::string>::type &param)
    {
        return static_cast<std::string &&>(param);
    }

    简化成:

    std::string&& forward(std::string &param)
    {
        return static_cast<std::string &&>(param);
    }

    参数依然是左值引用(这点是一致的,因为前面说过传入std::forward中的实参一直是左值),但是返回的是右值引用,此时的std::foward就是将一个左值转化了右值,这样保证传入目标函数的实参是右值!

    综上,可以看到std::foward函数是有条件地将传入的参数转化为右值,而std::move无条件地将参数转化为右值,这是两者的区别。但是本质上,两者什么没有做,最多就是进行了一次类型转换。

    六、Reference

    1、cppreference C++参考手册

    2、Modern C++ 学习笔记:C++右值引用

    3、C语言中文网:C++右值引用

  • 相关阅读:
    JavaScript 浮点数处理
    从输入URL到浏览器显示页面发生了什么
    hadoop 编译任意版本的eclipse 插件
    Stm32F103面向对象编程之GPIO
    flume 1.7 安装与使用
    HIVE分组排序问题
    MapReduce实现共同朋友问题
    hadoop项目实战--ETL--(三)实现mysql表到HIVE表的全量导入与增量导入
    hadoop项目实战--ETL--(二)实现自动向mysql中添加数据
    hadoop项目实战--ETL--(一)项目分析
  • 原文地址:https://www.cnblogs.com/horacle/p/15330660.html
Copyright © 2011-2022 走看看