美文网首页
c++11 新特性之右值

c++11 新特性之右值

作者: 阳_逍 | 来源:发表于2017-04-17 17:57 被阅读0次

    "C++11 标准新特性之 右值"

    什么是右值, 用来做什么的, T &&是什么鬼?

    第一节 教你分分钟学会什么是右值

    今天我们聊一聊c++11中引入的一个新概念 - 右值. 在C++98的年代里,其实我们也有这个概念,只不过当时没有明确的給抽象出这么一个词出来。那么你可能要问了,何为右值? 我能给出的最简单的答案应该就是“不可以出现在等号左边的值都是右值!”

    怎么理解这句话呢,比如

    /*1-1*/
    
    //! b 和 c 都是 int 型
    int a = b + c;  
    

    其中 b + c 就是右值, 因为我们不能写出 b + c= ... 这样的代码。 这个解释简单吗?

    如果觉得不过瘾,我们再举个稍微复杂点的例子.

    /*1-2*/
    
    class A{};
    
    A func(){
        return A();
    }
    
    //!  变量a是左值,因为它可以放到等号左边
    //!  func 返回的是个临时对象,我们无法写出 func()=...的代码
    //!  所以 func() 是右值
    A a = func();
    
    

    给出我们可以意会的定义之后,我们来看看C++中对右值的定义。
    C++中的右值由两个概念组成,一个称为xvalue, 另外一个叫prvalue。 xvalue(eXpiring Value)呢,通常是指生命周期很快就要结束的值,比如1-2中的func()的返回值这种临时对象, prvalue呢就是我们的第一个例子(b+c),翻译成中文叫纯右值(Pure Rvalue)。

    第二节 右值用来做什么?

    右值的设计依我看来,主要目的有两个

    第一呢,用来实现move语义

    第二个目的则是用于完美转发.

    好吧,说人话!

    在我解释move语义之前呢,我们看一个非常简单的例子,可以运行的代码哦~。

    为了确保你可以跟上我的节奏,首先你要准备好一个使着顺手的文本编辑器,我用的是VIM。 你可以用emac, 或者其他随便吧,只要能敲代码,任何工具都可以。 其次你要确保电脑上安装了gcc, 或者clang 也可以。 因为我们要写真正的c++11标准的代码。 我不建议你使用高度集成的开发IDE, 这些工具或许在你开发项目的时候可以提升效率,但是在你学习语言的阶段,因为它们帮你做了太多的工作,不利于你学习和掌握最基本的知识。

    好了,让我们开始准备撸起袖子,敲代码吧!
    先建立一个文件夹吧,专门用来存放我们的测试代码,就叫Project吧。在Project 下新建两个文件 build.shRValue.cpp.

    build.sh 的内容如下所示

    #!/bin/sh
    
    g++ ./RValue.cpp -o RValue && {
        ./RValue
    }
    

    脚本中首先我们用g++编译RValue.cpp(我们一会就会写), 通过-o
    给编译出的可执行文件起个好听点的名字,如果不指定的话,默认叫a.out。 如果编译成功的话, 我们让脚本自动执行RValue。 等会我们编写完RValue.cpp之后,就可以通过运行./build.sh 编译并查看运行结果了。简单吧
    哦,对了,运行之前不要忘了修改./build.sh文件的属性,给它添加一个可执行的属性, 否则系统不知道它可以运行

    chmod +x ./build.sh
    

    终于该写我们的主角了,真是不容易。需要休息一会,喝个咖啡神马的



    歇了十分钟,开始撸代码

    RValue.cpp 的内容如下所示:

    #include <iostream>
    using namespace std;
    
    class MyString{
    public:
        //! 构造函数, 初始化的时候給_ptr分配空间
        MyString()
            :_ptr(new char[10]){
        }
    
        //! 拷贝构造函数
        MyString(const MyString &s)
            :_ptr(s._ptr){
        }
    
        //! 析构函数中,我们小心的清除的_ptr的内存
        ~MyString(){
            delete[] _ptr;
        }
    private:
        char *_ptr;
    };
    
    int main(){
        MyString s1;
        MyString s2(s1);
        return 0;
    }
    

    代码写完了,试试我们的脚本吧,执行./build.sh吧,看看运行顺利不。

    Ohh, My God!! 程序崩溃了, 我们拿到了这样的提示信息

        error for object 0x7fc533c02870: pointer being freed 
        was not allocated
    

    不要慌~ 我们来看看为什么crash了。 MyString中有个char *_ptr的成员变量, 在MyString对象销毁的时候,同时也会调用delete[] _ptr释放内存。 但是我们发现, 执行 MyString s2(s1) 时,其实会调用到MyString的拷贝构造函数,这时会把s1的_ptr赋值給s2的_ptr, 也就是说s1的_ptr其实和s2的_ptr指向了同一块内存空间。 s2 在生命周期结束时会释放一次自己的_ptr, s1同样也会释放自己的_ptr, 二这两个对象的_ptr又是同一块内存空间,这意味着这块空间会被释放两次,第二次释放触发了crash。

    如果你尝试着删除MyString的拷贝构造函数,然后编译运行会发现,仍然有这个错误。 这是因为默认情况下,编译器会为我们生成一个拷贝构造函数,默认的拷贝构造就是按位拷贝,和我们实现是一样的。这就是通常说到的浅拷贝.

    知道crash原因之后,我们尝试着修复这个bug, 修改MyString的拷贝构造函数。

    #include <iostream>
    using namespace std;
    
    class MyString{
    public:
        MyString()
            :_ptr(new char[10]){
        }
    
        //! 浅拷贝改为深拷贝
        //! 为_ptr分配新的内存,然后把s._ptr的内容拷贝到新的内存空间
        MyString(const MyString &s)
            :_ptr(new char[10]){
            memcpy(_ptr, s._ptr, 10);
        }
    
        ~MyString(){
            delete[] _ptr;
            _ptr = nullptr;
        }
    private:
        char *_ptr;
    };
    
    int main(){
        MyString s1;
        MyString s2(s1);
        return 0;
    }
    
    

    新的版本中,如同代码注释中加的那样,我们把浅拷贝的方式改成了深拷贝,然后编译运行,一切都是那么的平静~. 没有消息就是最好的消息,不是吗?

    接下来,我们要看看各个函数都被执行了几次,代码里加一些计数器来帮助我们统计这些数据。

    #include <iostream>
    using namespace std;
    
    class MyString{
    public:
        MyString()
            :_ptr(new char[10]){
                cout<<__func__<<": "<<++n_c<<endl;
        }
    
        MyString(const MyString &s)
            :_ptr(new char[10]){
            memcpy(_ptr, s._ptr, 10);
                cout<<__func__<<": "<<++n_cp<<endl;
        }
    
        ~MyString(){
            cout<<__func__<<": "<<++n_d<<endl;
            delete[] _ptr;
            _ptr = nullptr;
        }
    private:
        char *_ptr;
    
        static int n_c;
        static int n_cp;
        static int n_d;
    };
    
    int MyString::n_c = 0;
    int MyString::n_cp = 0;
    int MyString::n_d = 0;
    
    MyString temp_string(){
        return MyString();
    }
    
    int main(){
        MyString a = temp_string();
        return 0;
    }
    

    上面的代码中,我们使用了n_c来统计构造函数的调用次数, n_cp统计拷贝构造的次数, n_d统计析构函数的调用次数。

    新的代码逻辑更是简单的不要不要的, 就通过调用 temp_string()创建了一个
    MyString 对象

    为了阻止编译器做优化,便于我们查看最原始的c++运作情况,我们需要修改一下编译脚本build.sh, 添加一个
    -fno-elide-constructors 编译选项。

    #!/bin/sh
    
    g++ ./RValue.cpp -fno-elide-constructors -o RValue && {
    ./RValue
    }
    

    代码输出结果: 对照输出,想想为什么?

    MyString: 1
    MyString: 1
    ~MyString: 1
    MyString: 2
    ~MyString: 2
    ~MyString: 3 
    

    整个过程分解为三个步骤:

    • 第一步 temp_string函数中使用构造函数,构造出一个MyString对象
    • 第二步 将这个对象通过拷贝构造构造出一个临时对象作为返回值
    • 第三步 把临时对象通过拷贝构造函数构造出对象a。

    整个过程中涉及到3个对象,所以我们看到三次构造三次析构函数的调用。 对于内存小的对象来说,这样的工作方式勉强可以接受,但是对于比较耗时的构造来说,这种方式就会带来非常大的效率问题。

    那么,有没有一种方式可以让临时对象产生时,不进行耗时的内存分配或者拷贝操作呢, 因为临时对象的出现本身就是对程序员透明的,除了带来性能问题,对与程序员来说,并没有其他比较直观的感知。我们能不能定义一种构造函数,让通过临时对象构造的时候调用的它,于是但凡是在这个构造函数中的耗时操作,我们都可以改成浅拷贝这种快捷的方式。

    抛出这个问题之后,聪明的你一定想到了,之前咱们讨论过右值的问题,这个临时对象不就是右值吗,那么我们是不是可以定一个右值引用构造函数呢?
    为了区别于左值引用, c++11中使用T && 的方式来表示右值引用。 所以,在刚才的例子里,我们可以愉快的加一个右值引用构造函数了。

    #include <iostream>
    using namespace std;
    
    class MyString{
    public:
        MyString()
            :_ptr(new char[10]){
                cout<<__func__<<":<()> "<<++n_c<<endl;
        }
    
        MyString(const MyString &s)
            :_ptr(new char[10]){
            memcpy(_ptr, s._ptr, 10);
                cout<<__func__<<":<&> "<<++n_cp<<endl;
        }
    
        //! 新加的右值构造函数
        MyString(MyString&& s)
            :_ptr(s._ptr){
                cout<<__func__<<":<&&> "<<++n_rc<<endl;
                _ptr = nullptr;
        }
    
        ~MyString(){
            cout<<__func__<<": "<<++n_d<<endl;
            delete[] _ptr;
            _ptr = nullptr;
        }
    private:
        char *_ptr;
    
        static int n_c;
        static int n_cp;
        static int n_d;
        static int n_rc;
    };
    
    int MyString::n_c = 0;
    int MyString::n_cp = 0;
    int MyString::n_d = 0;
    int MyString::n_rc = 0;
    
    MyString temp_string(){
        return MyString();
    }
    
    int main(){
        MyString a = temp_string();
        return 0;
    }
    
    

    为了更加清晰的看到各种构造函数调用,我们在log内容上加以区分。执行结果如下所示:

    MyString:<()> 1
    MyString:<&&> 1
    ~MyString: 1
    MyString:<&&> 2
    ~MyString: 2
    ~MyString: 3  
    

    各位看官自己根据运行结果分析调用过程吧,如果你认真读了前面部分的内容,对于这个结果应该不会感到意外。

     MyString(MyString&& s)
            :_ptr(s._ptr){
                cout<<__func__<<":<&&> "<<++n_rc<<endl;
                _ptr = nullptr;
      }
    

    在右值构造函数中,我们做了浅拷贝,就如同把s的资源移动到了_ptr, 所以就有了move的概念。进一步的,我们不仅可以让临时对象,即真正的右值调用到这个函数,c++11标准库中还提供了一个std::move提供一个move语意.
    看个例子就一目了然了。

    int main(){
        /* MyString a = temp_string(); */
        MyString a;
        MyString b(std::move(a));
        return 0;
    }
    

    我们稍微修改了下main函数, 可以看到a本身是个左值,但是我想人为的通过
    移动构造来构造b,这样不需要额外的申请10个字节的内存空间,于是我们借助了
    std::move(a)返回一个右值,进而会触发b对象的右值引用构造。 运行结果如下所示:

    MyString:<()> 1
    MyString:<&&> 1
    ~MyString: 1
    ~MyString: 2
    

    讲到这里,基本上右值引用move语义的关系就明朗了。 在下一篇中,我们聊一聊C++中令人兴奋的完美转发,不要错过这个话题,你不会失望的!

    相关文章

      网友评论

          本文标题:c++11 新特性之右值

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