• C++11的右值引用


             右值引用是C++11         引入的新特性。它解决了两类问题:实现移动语义和完美转发。本文大绝大部分内容,来自于文章:http://kuring.me/post/cpp11_right_reference/

     

    一:左值(lvalue)和右值(rvalue

             最初,在C中,左值和右值的定义如下:左值,是一个可以出现在赋值操作符左边或者右边的表达式;而右值是只能出现在赋值操作符右边的表达式。比如:

    int a = 42;
    int b = 43;
    
    // a and b are both l-values:
    a = b; // ok
    b = a; // ok
    a = a * b; // ok
    
    // a * b is an rvalue:
    int c = a * b; // ok, rvalue on right hand side of assignment
    a * b = 42; // error, rvalue on left hand side of assignment

     

             然而对于当前的C++而言,这种定义已不再正确了。可以用另外一种方法来区分左值和右值:如果一个表达式绑定了一块内存,并且允许使用&取地址操作符得到该内存地址,则该表达式就是左值。非左值的表达式就是右值。

             也就是说:左值是可以取地址,有名字的值,是一个绑定某内存空间的表达式,可以使用&操作符获取内存地址。右值:不能取地址,即非左值的都是右值,没有名字的值,是一个临时值,表达式结束后右值就没有意义了。对于临时对象,它可以存储于寄存器中,所以是没办法用“取地址&”运算符;对于常量,它可能被编码到机器指令的“立即数”中,所以是没办法用“取地址&”运算符;

             如果对左值右值的严格定义感兴趣,可以参考Mikael Kilpeläinen的文章:《ACCU article

     

    二:右值引用

             C++11为了实现移动语义,引入了右值引用的概念。首先看一个C++11之前,没有实现移动语义的函数返回类对象的例子:

    class MyString {
    public: 
        MyString() { 
            _data = nullptr; 
            _len = 0; 
            printf("Constructor is called!
    ");
        } 
        MyString(const char* p) { 
            _len = strlen (p); 
            _init_data(p); 
            cout << "Constructor is called! this->_data: " << (long)_data << endl;
        } 
        MyString(const MyString& str) { 
            _len = str._len; 
            _init_data(str._data); 
               cout << "Copy Constructor is called! src: " << (long)str._data << " dst: " << (long)_data << endl;
        }
        ~MyString() { 
            if (_data)
            {
                cout << "DeConstructor is called! this->_data: " << (long)_data << endl; 
                free(_data);
            }
            else
            {
                std::cout << "DeConstructor is called!" << std::endl; 
            }
        } 
        MyString& operator=(const MyString& str) { 
            if (this != &str) { 
                _len = str._len; 
                _init_data(str._data); 
            } 
               cout << "Copy Assignment is called! src: " << (long)str._data << " dst" << (long)_data << endl; 
            return *this; 
        } 
        operator const char *() const {
            return _data;
        }
    private: 
        char *_data; 
        size_t   _len; 
        void _init_data(const char *s) { 
            _data = new char[_len+1]; 
            memcpy(_data, s, _len); 
            _data[_len] = ''; 
        } 
    }; 
    
    MyString foo()
    {
        MyString middle("123");
        return middle;
    }
    
    int main() { 
        MyString a = foo(); 
        return 1;
    }

             使用的编译命令是:

    g++ -std=c++11 -fno-elide-constructors -g  -o leftright leftright.cpp

             之所以要加上-fno-elide-constructors选项,是因为g++编译器默认情况下会对函数返回类对象的情况作返回值优化处理,这不是我们讨论的重点。

             上述代码运行结果如下:

    Constructor is called! this->_data: 29483024 // foo函数中middle对象的构造函数
    Copy Constructor is called! src: 29483024 dst: 29483056 //foo函数的返回值,也就是临时对象的构造,通过middle对象调用复制构造函数
    DeConstructor is called! this->_data: 29483024 // foo函数中middle对象的析构
    Copy Constructor is called! src: 29483056 dst: 29483024 // a对象构造,通过临时对象调用复制构造函数
    DeConstructor is called! this->_data: 29483056 // 临时对象析构
    DeConstructor is called! this->_data: 29483024 // a对象析构

             在上述例子中,临时对象的构造、复制和析构操作所带来的效率影响一直是C++中为人诟病的问题,临时对象的构造和析构操作均对堆上的内存进行操作,而如果_data的内存过大,势必会非常影响效率。从程序员的角度而言,该临时对象是透明的。而这一问题正是C++11中需要解决的问题。

     

             在C++11中解决该问题的思路为,引入了移动构造函数,移动构造函数的定义如下。

    MyString(MyString &&str) {
        cout << "Move Constructor is called! src: " << (long)str._data << endl;
        _len = str._len;
        _data = str._data;
        str._data = nullptr;
    }

             在移动构造函数中我们窃取了str对象已经申请的内存,将其拿为己用,并将str申请的内存给赋值为nullptr。

             移动构造函数和复制构造函数的不同之处在于移动构造函数的参数使用&&,这就是所谓的右值引用符号。参数不再是const,因为在移动构造函数需要修改右值str的内容。

     

             当构造临时对象时,或用临时对象来构造其他对象的时候,移动语义会被调用。加入上面的代码后,运行结果如下:

    Constructor is called! this->_data: 22872080 // foo函数中middle对象构造
    Move Constructor is called! src: 22872080 //foo函数的返回值,也就是临时对象的构造,通过移动构造函数构造,将middle申请的内存窃取
    DeConstructor is called! // foo函数中middle对象析构
    Move Constructor is called! src: 22872080 // 对象a通过移动构造函数构造,将临时对象的内存窃取
    DeConstructor is called! // 临时对象析构
    DeConstructor is called! this->_data: 22872080 // 对象a析构

             通过输出结果可以看出,整个过程中仅申请了一块内存,这也正好符合我们的要求了。

     

             在C++11中,引入了右值引用的概念,使用&&来表示,如果X是一种类型的话,则X&&表示X的右值引用;而传统的X&表示左值引用。

             右值引用和左值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。而其原因可以理解为是引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。

             右值引用类似于传统的左值引用,不过也有几点不同,最重要的区别在于:在引入了右值引用后,在函数重载时可以根据是左值引用还是右值引用来区分。如果存在重载函数,以左值为实参,则会调用具有左值引用参数的函数,而以右值为实参,则调用具有右值引用参数的函数。

    void fun(MyString &str)
    {
        cout << "left reference" << endl;
    }
    void fun(MyString &&str)
    {
        cout << "right reference" << endl;
    }
    int main() { 
        MyString a("456"); 
        fun(a); // 左值引用,调用void fun(MyString &str)
        fun(foo()); // 右值引用,调用void fun(MyString &&str)
        return 1;
    }

             可以像上面的例子一样重载任何函数,但是在绝大多数情况下,这种重载应该只发生在重载复制构造函数以及复制操作符函数上,用于实现移动语义。

     

             前面已经介绍过了移动构造函数的具体形式和使用情况,这里对移动赋值操作符的定义再说明一下,并将main函数的内容也一起更改,将得到如下输出结果。

    MyString& operator=(MyString&& str) { 
        cout << "Move Operator= is called! src: " << (long)str._data << endl; 
        if (this != &str) { 
            if (_data != nullptr)
            {
                free(_data);
            }
            _len = str._len;
            _data = str._data;
            str._len = 0;
            str._data = nullptr;
        }     
        return *this; 
    }
    
    int main() { 
        MyString b;
        b = foo();
        return 1;
    }

             输出结果如下,整个过程仅申请了一个内存地址:

    Constructor is called! // 对象b构造函数调用
    Constructor is called! this->_data: 14835728 // middle对象构造
    Move Constructor is called! src: 14835728 // 临时对象通过移动构造函数由middle对象构造
    DeConstructor is called! // middle对象析构
    Move Operator= is called! src: 14835728 // 对象b通过移动赋值操作符由临时对象赋值
    DeConstructor is called! // 临时对象析构
    DeConstructor is called! this->_data: 14835728 // 对象b析构函数调用

     

             引入了右值引用之后,引用就可以划分为 const左值引用、非const左值引用、const右值引用和非const右值引用四种类型。其中左值引用的绑定规则和之前是一样的。

             非const左值引用只能绑定到非const左值,不能绑定到const右值、非const右值和const左值。这一点可以通过const关键字的语义来判断。

             const左值引用可以绑定到任何类型,包括const左值、非const左值、const右值和非const右值,属于万能引用类型。其中绑定const右值的规则比较少见,但是语法上是可行的。

             非const右值引用不能绑定到任何左值和const右值,只能绑定非const右值。

             const右值引用类型仅是为了语法的完整性而设计的,比如可以使用const MyString &&right_ref = foo(),但是右值引用类型的引入主要是为了移动语义,而移动语义需要右值引用是可以被修改的,因此const右值引用类型没有实际意义。

     

             通过表格的形式对上文中提到的四种引用类型可以绑定的类型进行总结。

     

             一般情况下,临时对象的生命期非常短,只会持续到完整表达式的结尾。不过,可以将栈上的const左值引用绑定到临时对象,从而将临时对象的生命期延长到和引用的生命期一样长。注意,这种只适用于函数内的局部引用,而如果引用是类对象成员时不起作用。

             右值引用也有同样的作用:

    int main() {
        const MyString &s1 = foo();
        MyString &&s2 = foo();
       
        return 1;
    }
    

      

    三:强制移动语义std::move()

             前文中我们通过右值引用给类增加移动构造函数和移动赋值操作符已经解决了函数返回类对象效率低下的问题。那么还有什么问题没有解决呢?

     

             在之前的C++98中,swap函数的实现形式如下:

    template <class T> 
    void swap ( T& a, T& b )
    {
        T c(a); 
        a=b;
        b=c;
    }

             在该函数中,变量a、b、c均为左值,因此无法直接使用前面的移动语义。

             但是该函数中能够使用移动语义是非常合适的,仅是为了交换两个变量,却要反复申请和释放资源。

             在C++11的标准库中引入了std::move()函数来解决该问题,该函数的作用为将其参数转换为右值。在C++11中的swap函数就可以更改为了:

    template <class T> 
    void swap (T& a, T& b)
    {
        T c(std::move(a)); 
        a=std::move(b); 
        b=std::move(c);
    }

             在使用了move语义以后,swap函数的效率会大大提升。增加一个MyString::display成员函数,并且更改main函数后测试如下:

    void MyString::display()
    {
        if (_data)
        {
            cout << "str is " << _data << "(" << (long)_data << ")" << endl;
        }
        else
        {
            cout << "nothing" << endl;
        }
    }
    
    int main() { 
    
        MyString d("123");
        MyString e("456");
        d.display();
        e.display();
    
        std::swap(d, e);
        d.display();
        e.display();
    
        return 1;
    }

             输出结果如下,通过输出结果可以看出对象交换是成功的:

    Constructor is called! this->_data: 9498640  // 对象d构造
    Constructor is called! this->_data: 9498672  // 对象e构造
    str is 123(9498640)  // 对象d的内容
    str is 456(9498672)  // 对象e的内容
    Move Constructor is called! src: 9498640  // swap函数中的对象c通过移动构造函数构造
    Move Operator= is called! src: 9498672    // swap函数中的对象a通过移动赋值操作符赋值
    Move Operator= is called! src: 9498640    // swap函数中的对象b通过移动赋值操作符赋值
    DeConstructor is called!  // swap函数中的对象c析构
    str is 456(9498672)  // 对象d的内容
    str is 123(9498640)  // 对象e的内容
    DeConstructor is called! this->_data: 9498640  // 对象e析构
    DeConstructor is called! this->_data: 9498672  // 对象d析构

     

             注意,对于那些没有实现移动语义的类型而言(没有重载复制构造函数和赋值操作符的右值引用版本),新的swap的行为与老的swap一样。

     

    四:右值引用和右值的关系

             这个问题就有点绕了,需要开动思考一下右值引用和右值是啥含义了。读者会凭空的认为右值引用肯定是右值,其实不然。我们在之前的例子中添加如下代码,并将main函数进行修改如下:

    void test_rvalue_rref(MyString &&str)
    {
        cout << "tmp object construct start" << endl;
        MyString tmp = str;
        cout << "tmp object construct finish" << endl;
    }
    int main() {
        test_rvalue_rref(foo());
        return 1;
    }

             输出结果

    Constructor is called! this->_data: 28913680
    Move Constructor is called! src: 28913680
    DeConstructor is called!
    tmp object construct start
    Copy Constructor is called! src: 28913680 dst: 28913712 // 可以看到这里调用的是复制构造函数而不是移动构造函数
    tmp object construct finish
    DeConstructor is called! this->_data: 28913712
    DeConstructor is called! this->_data: 28913680

             我想程序运行的结果肯定跟大多数人想到的不一样,“Are you kidding me?不是应该调用移动构造函数吗?为什么调用了复制构造函数?”。关于右值引用和左右值之间的规则是:

             如果右值引用有名字则为左值,如果右值引用没有名字则为右值。

     

             通过规则我们可以发现,在我们的例子中右值引用str是有名字的,因此为左值,tmp的构造会调用复制构造函数。之所以会这样,是因为如果tmp构造的时候调用了移动构造函数,则调用完成后str的申请的内存已经不可用了,如果在该函数中该语句的后面再次使用str变量,则会出现意想不到的问题。鉴于此,我们也就能够理解为什么有名字的右值引用是左值了。如果已经确定在tmp构造语句的后面不需要使用str变量了,可以使用std::move()函数将str变量从左值转换为右值,这样tmp变量的构造就可以使用移动构造函数了。

     

             而如果我们调用的是MyString b = foo()语句,由于foo()函数返回的是临时对象没有名字属于右值,因此b的构造会调用移动构造函数。

     

             该规则非常的重要,要想能够正确使用右值引用,该规则必须要掌握,否则写出来的代码会有一个大坑。

     

    五:完美转发

             在泛型编程中,经常会遇到的一个问题是怎样将一组参数原封不动的转发给另外一个函数。这里的原封不动是指,如果函数是左值,那么转发给的那个函数也要接收一个左值;如果参数是右值,那么转发给的函数也要接收一个右值;如果参数是const的,转发给的函数也要接收一个const参数;如果参数是非const的,转发给的函数也要接收一个非const值。

     

             该问题看上去非常简单,其实不然。看一个例子:

    void fun(int &) { cout << "lvalue ref" << endl; } 
    void fun(int &&) { cout << "rvalue ref" << endl; } 
    void fun(const int &) { cout << "const lvalue ref" << endl; } 
    void fun(const int &&) { cout << "const rvalue ref" << endl; }
    
    template<typename T>
    void PerfectForward(T t) { fun(t); } 
    
    int main()
    {
        PerfectForward(10);           // rvalue ref
        int a;
        PerfectForward(a);            // lvalue ref
        PerfectForward(std::move(a)); // rvalue ref
        const int b = 8;
        PerfectForward(b);            // const lvalue ref
        PerfectForward(std::move(b)); // const rvalue ref
        return 0;
    }

             在上述例子中,我们想达到的目的是PerfectForward模板函数能够完美转发参数t到fun函数中。上述例子中的PerfectForward函数必然不能够达到此目的,因为PerfectForward函数的参数为左值类型,调用的fun函数也必然为void fun(int &)。上述代码的运行结果就是打印5次”lvalue ref”。并且调用PerfectForward之前就产生了一次参数的复制操作,因此这样的转发只能称之为正确转发,而不是完美转发。要想达到完美转发,需要做到像转发函数不存在一样的效率。

     

             在C++11中为了能够解决完美转发问题,引入了更为复杂的规则:引用折叠规则和特殊模板参数推导规则:

             在C++11之前,不允许定义引用的引用:像A& &这样的写法会导致编译错误。而在C++11中,引入了引用折叠规则。该规则如下:

    A& & => A&
    A& && => A&
    A&& & => A&
    A&& && => A&&

             可以看出一旦引用中定义了左值类型,折叠规则总是将其折叠为左值引用。这就是引用折叠规则的全部内容了。另外折叠规则跟变量的const特性是没有关系的。

     

             对于具有右值引用形参的模板函数:

    template<typename T>
    void foo(T&&);

             其模板形参推断的规则与普通的规则有所不同:

             如果使用左值A调用foo,则模板形参T为A&,因此,根据引用折叠规则,foo的参数实际上为A&;

             如果使用右值A调用foo,则T为A,因此,根据引用折叠规则,foo的参数实际上为A&&;

     

             利用上面两条规则,可以解决完美转发的问题:

    void fun(int &) { cout << "lvalue ref" << endl; }
    void fun(int &&) { cout << "rvalue ref" << endl; }
    void fun(const int &) { cout << "const lvalue ref" << endl; }
    void fun(const int &&) { cout << "const rvalue ref" << endl; }
    
    // 利用引用折叠规则代替了原有的不完美转发机制
    template<typename T>
    void PerfectForward(T &&t) { fun(static_cast<T &&>(t)); }
    
    int main()
    {
        PerfectForward(10);           // rvalue ref,折叠后t类型仍然为T &&
        int a;
        PerfectForward(a);            // lvalue ref,折叠后t类型为T &
        PerfectForward(std::move(a)); // rvalue ref,折叠后t类型为T &&
        const int b = 8;
        PerfectForward(b);            // const lvalue ref,折叠后t类型为const T &
        PerfectForward(std::move(b)); // const rvalue ref,折叠后t类型为const T &&
        return 0;
    }

             上述代码的运行结果为:

    rvalue ref
    lvalue ref
    rvalue ref
    const lvalue ref
    const rvalue ref

             使用static_cast进行强制类型转换的原因:之前提过:如果右值引用有名字则为左值,如果右值引用没有名字则为右值。这里的变量t虽然为右值引用,但是是左值。如果我们想继续向fun函数中传递右值,就需要使用static_cast进行强制类型转换了:

    // 参数为左值,引用折叠规则引用前
    template<int &T>
    void PerfectForward(int & &t) { fun(static_cast<int & &>(t)); }
    // 引用折叠规则应用后
    template<int &T>
    void PerfectForward(int &t) { fun(static_cast<int &>(t)); }
    
    // 参数为右值,引用折叠规则引用前
    template<int &&T>
    void PerfectForward(int && &&t) { fun(static_cast<int && &&>(t)); }
    // 引用折叠规则应用后
    template<int &&T>
    void PerfectForward(int &&t) { fun(static_cast<int &&>(t)); }

             因此,static_cast仅是对传递右值时起作用。

     

             其实在C++11中已经为我们封装了std::forward函数来替代我们上文中使用的static_cast类型转换,该例子中使用std::forward函数的版本变为了:

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

             运行结果与上面一样。

     

    六:其他

             当定义移动构造函数以及赋值操作符重载函数时,建议:做到不抛出异常,使用noexcept告诉编译器函数不会抛出异常。

             如果不这么做的话,至少会有一个非常常见的场景中,希望使用移动语义但是实际上却不会:当一个std::vector扩容时,肯定是希望原有元素使用移动语义填充到新申请的内存中。但是只有做到上面两点的情况下,才会使用移动语义。

             当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。除非标准库知道不会抛出异常,否则它会为了处理可能抛出异常这种可能性而做一些额外的工作。一种通知标准库的方法是将构造函数指明为 noexcept。这个关键字是新标准引入的。不抛出异常的移动构造函数和移动赋值运算符都必须标记为noexcept.

     

     

    总结:

             Ok, that's it, the whole story on rvalue references. As you can see, the benefits are considerable. The details are gory. As a C++ professional, you will have to understand these details. Otherwise, you have given up on fully understanding the central tool of your trade. You can take solace, though, in the thought that in your day-to-day programming, you will only have to remember three things about rvalue references:

             By overloading a function like this:

    void foo(X& x); // lvalue reference overload

    void foo(X&& x); // rvalue reference overload

             you can branch at compile time on the condition "is foo being called on an lvalue or an rvalue?" The primary (and for all practical purposes, the only) application of that is to overload the copy constructor and copy assignment operator of a class for the sake of implementing move semantics. If and when you do that, make sure to pay attention to exception handling, and use the new noexcept keyword as much as you can. 

     

             std::move turns its argument into an rvalue.

     

             std::forward allows you to achieve perfect forwarding if you use it exactly as shown in the factory function example in Section 8.

     

    http://kuring.me/post/cpp11_right_reference/

    http://thbecker.net/articles/rvalue_references/section_01.html#section_01

  • 相关阅读:
    C# 日期格式化
    MVVM框架下,WPF实现Datagrid里的全选和选择
    【转】WPF 给DataGridTextColumn统一加上ToolTip
    C# 获取当前月第一天和最后一天 计算两个日期差多少天
    WPF 弹出UserControl
    斐讯Fir302b救砖教程
    mvc 传递匿名对象
    Java HttpGet
    Java xml object 互转
    HttpClientHandler
  • 原文地址:https://www.cnblogs.com/gqtcgq/p/7301223.html
Copyright © 2020-2023  润新知