c++11 新特性之右值

"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++中令人兴奋的完美转发,不要错过这个话题,你不会失望的!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容