深入理解C++11 3.3 右值引用:移动语义和完美转发

首先,本章很长,也较难理解,建议读者有大段连续的时间看这个。。。

3.3.1 指针成员与拷贝构造

关于拷贝构造函数的调用时间,可以看这篇文章
如果类中包含了指针,需要小心处理,下面是一段有问题的代码

class C {
public:
    C():i(new int(0)){
        cout << "none argument constructor called" << endl;
    }
    
    ~C(){
       cout << "destructor called" << endl;
        delete i;
    }
    
    int* i;
};

int main(){
    C c1;
    C c2 = c1;
    
    cout << *c1.i << endl;
    cout << *c2.i << endl;
    
    return 0;
}

XCode代码执行输出

none argument constructor called
0
0
destructor called
destructor called
CppTest(50956,0x1000ad5c0) malloc: *** error for object 0x10070c510: pointer being freed was not allocated
CppTest(50956,0x1000ad5c0) malloc: *** set a breakpoint in malloc_error_break to debug

原因是编译期会默认为类创建拷贝构造函数,而默认的拷贝构造函数只是简单的赋值,对类C,系统默认生成的拷贝构造函数如

C(const C& c):i(c.i){
}

导致c1和c2的i值一样,即指向同一片地址,当c1析构之后,c2.i就成为了一个“悬挂指针”(dangling pointer),不再指向有效的内存了,如果对悬挂指针再次进行delete就会出现严重的错误。
以上系统生成的默认拷贝构造函数做的是浅拷贝(shallow copy),为了解决这个问题,通常是用户自定义拷贝构造函数实现深拷贝(deep copy),修正如下

class C {
public:
    C():i(new int(0)){
        cout << "none argument constructor called" << endl;
    }
    
    //增加此拷贝构造函数,根据传入的c,new一个新的int给i变量
    C(const C& c) :i(new int(*c.i)){
        
    }
    
    ~C(){
       cout << "destructor called" << endl;
        delete i;
    }
    
    int* i;
};

执行代码后如下

none argument constructor called
0
0
destructor called
destructor called
Program ended with exit code: 0

3.3.2 移动语义

拷贝函数中为指针成员分配新的内存再进行内容拷贝的方法在C++中几乎被视为不可违背的,不过有些时候却是不必要的。如下代码:

//这是一个成员包含指针的类
class HasPtrMem {
public:
    HasPtrMem() : d(new int(0)) {
        cout << "Construct:" << ++n_cstr << endl;
    }
    
    HasPtrMem(const HasPtrMem& h) {
        cout << "Copy construct:" << ++n_cptr << endl;
    }
    
    ~HasPtrMem() {
        cout << "Destruct:" << ++n_dstr << endl;
    }
    
private:
    int* d;
    static int n_cstr;
    static int n_dstr;
    static int n_cptr;
};

int HasPtrMem::n_cstr = 0;
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_cptr = 0;

HasPtrMem GetTemp() {
    return HasPtrMem();//①
}

int main(){
    HasPtrMem m = GetTemp();//②
    
    return 0;
}

这里我没用Xcode编译运行,因为在Build Setting里增加-fno-elide-constructors编译器依然还是优化了,所以根据教材用命令行执行

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

会在cpp文件下生成一个a.out文件,在命令行执行./a.out输出

Construct:1
Copy construct:1
Destruct:1
Copy construct:2
Destruct:2
Destruct:3

构造函数被调用1次,是在①处,第一次调用拷贝构造函数是在GetTemp return的时候,将①生成的变量拷贝构造出一个临时值,来当做GetTemp的返回,第二次拷贝构造函数是在②处。同时就有了于此对应的三次析构函数的调用。例子里用的是一个int类型的指针,而如果该指针指向的是非常大的堆内存数据的话,那没拷贝过程就会非常耗时,而且由于整个行为是透明且正确的,分析问题时也不易察觉。

在C++中,我们可以通过移动构造函数解决此问题,修改代码如下:

//这是一个成员包含指针的类
class HasPtrMem {
public:
    HasPtrMem() : d(new int(0)) {
        cout << "Construct:" << ++n_cstr << endl;
    }
    
    HasPtrMem(const HasPtrMem& h) {
        cout << "Copy construct:" << ++n_cptr << endl;
    }
    
    HasPtrMem(HasPtrMem&& h):d(h.d) {
        h.d = nullptr; //③注意对之前的h赋空指针
        cout << "Move construct:" << ++n_mvtr << endl;
    }
    
    ~HasPtrMem() {
        cout << "Destruct:" << ++n_dstr << endl;
    }
    
private:
    int* d;
    static int n_cstr;
    static int n_dstr;
    static int n_cptr;
    static int n_mvtr;
};

int HasPtrMem::n_cstr = 0;
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_cptr = 0;
int HasPtrMem::n_mvtr = 0;

HasPtrMem GetTemp() {
    return HasPtrMem();
}

int main(){
    HasPtrMem m = GetTemp();
    
    return 0;
}

输出

Construct:1
Move construct:1
Destruct:1
Move construct:2
Destruct:2
Destruct:3

这里通过指针赋值的方式,将d的内存直接偷了过来,避免了拷贝构造函数的调用。注意③,这里需要对原来的d进行赋空值,因为在移动构造函数完成之后,临时对象会立即被析构,如果不改变d,那临时对象被析构时,因为偷来的d和原本的d指向同一块内存,会被释放,成为悬挂指针,会造成错误。

为什么不用函数参数里带个指针或者引用当返回结果呢?不是性能的问题,而是代码编写效率及可读性不好,如:

string *a;
int c = 1
int &b = c;
Calculate(GetTemp(),b);//最后一个参数用于返回结果

最后说明一下移动构造函数被调用的时机:一旦用到的是临时变量,那么移动语义就可以得到执行。下一节讲下C++的值是如何分类的。未完待续,后面还有4节。。。

3.3.3 左值、右值与右值引用

关于左值(lvalue)和右值(rvalue)的判别方法:

  • 在赋值表达式中,出现在等号左边的是“左值”,等号右边的是“右值”,如a = b + c;中,a是左值,而b+c是右值;
  • 可以取地址的、有名字的是左值,反之是右值,对于a = b + c;&a是允许的操作,&(b+c)是不允许的操作,所以a是左值,b+c是右值。

而在C++11中右值是由两个概念构成的,一个是将亡值(xvalue, eXpriring Value),另个一个则是纯右值(prvalue, Pure Rvalue)。
其中纯右值包括:

  • 非引用返回的函数返回的临时变量值
  • 运算表达式,如1+3产生的临时变量值
  • 不跟对象关联的字面量,如2、’c‘、true
  • 类型转换函数的返回值
  • lamda表达式

将亡值贼是C++11新增的跟右值引用相关的表达式,包括:

  • 返回右值引用T&&的函数返回值
  • std::move的返回值
  • 转换为T&&的类型转换函数的返回值

而剩余的,可以标识函数、对象的值都属于左值。在C++11的程序中,所有的值必属于左值、将亡值、纯右值三者之一。

在C++11中,右值引用就是对一个右值进行引用的类型。由于右值不具有名字,我们也只能通过引用的方式找到它的存在。通常我们只能是从右值表达式获得其引用。比如:

T&& a = ReturnRvalue();①

右值引用和左值引用都是引用类型,都必须立即进行初始化。引用类型本身并不拥有绑定对象的内存,只是该对象的一个别名。左值引用是具名变量值的别名,右值引用则是匿名变量的别名。

在上面①的例子中,ReturnRvalue函数返回的右值在表达式语句结束后,其生命也就终结了,而通过右值引用的声明,该右值又“重获新生”,其生命期将于右值引用类型a的生命期一样。只要a还“活着”,该右值临时量将会一直“存活”下去。
所以相比于一下语句:

T b = ReturnRvalue();

①的声明方式会少一次对象的析构和一次对象构造。因为a是右值引用,直接绑定了ReturnRvalue()返回的临时量,而b是由临时值构造的,而临时量在表达式结束后会析构因而会多一次析构和构造的开销。
注意,能够声明右值引用a的前提是ReturnRvalue返回的是一个右值。通常右值引用是不能够绑定到任何左值的,如下代码会导致编译无法通过:

int c;
int &&d = c;

有的时候,我们可能不知道一个类型是否是引用类型,以及是左值引用还是右值引用。标准库<type_traits>头文件中提供了3个类模板:is_rvalue_reference、is_lvalue_reference和is_reference,比如:

cout << is_rvalue_reference<string &&>::value;

3.3.4 std::move 强制转化为右值

C++11中,<utility>中提供了函数std::move,功能是将一个左值强制转化为右值引用,继而我们可以通过右值引用使用该值,用于移动语义。std::move基本等同于一个类型转换:

static_cast<T&&>(lvalue);

被转化的左值,其生命期并没有随着左右值的转化而改变。下面是一个正确使用std::move的例子

class HugeMem {
public:
    HugeMem(int size): sz(size>0 ? size: 1) {
        c = new int[size];
    }
    
    ~HugeMem() {
        delete [] c;
    }
    
    HugeMem(HugeMem&& h) : sz(h.sz), c(h.c) {
        h.c = nullptr;
    }
    
    int* c;
    int sz;
};

class Moveable {
public:
    Moveable(): i(new int[3]), h(1024) {}
    
    ~Moveable() {
        delete [] i;     
    }
    
    Moveable(Moveable&& m) : i(m.i), h(move(m.h)) { //使用move将m.h转为右值引用,继而调用HugeMem的移动构造函数
        m.i = nullptr;
    }
    
    int *i;
    HugeMem h;
};

Moveable getTemp() {
    Moveable tmp = Moveable();
    cout << hex << "Huge Mem from " << __func__ << "@" << tmp.h.c << endl;
    return tmp;
}

int main(){
    Moveable a(getTemp());//因为getTemp()返回的是右值,所以会调用Moveable的移动构造函数
    cout << hex << "Huge Mem from " << __func__ << "@" << a.h.c << endl;
    return 0;
}

输出

Huge Mem from getTemp@0x104002000
Huge Mem from main@0x104002000

需要注意的是,在编写移动构造函数的时候,应该总是使用std::move转换拥有形如堆内存、文件句柄的等资源的成员为右值,这样一来,如果成员支持移动构造的话,就可以实现其移动语义,即使成员没有移动构造函数,也会调用拷贝构造,因为不会引起大的问题。

3.3.5 移动语义的一些其他问题

移动语义一定是要改变临时变量的值(这里有以为,需要解决,目前没看出哪里一定要改变,先这么硬背吧)。如声明:

Moveable(const Moveale &&);//这个对应3.3.4的例子,如果这样声明移动构造函数会报错
image.png

而如果是将3.3.4的例子中的Moveable getTemp()改为const Moveable getTemp(),再执行命令

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

注意上面的改动在Xcode中是可以运行的,可以正确调用到移动构造函数,但是通过命令行会提示

copy constructor is implicitly deleted because 'Moveable' has a user-declared move constructor

可见Moveable a(getTemp());实际是要调用Moveable的拷贝构造函数。报错原因显示声明了移动构造函数,编译器就不会为类生成默认的拷贝构造函数了,所以提示没有显示声明拷贝构造函数。

在C++11中,拷贝/移动改造函数有以下3个版本:

  • T Object(T&)
  • T Object(const T&)
  • T Object(T&&)

其中常量左值引用的版本是一个拷贝构造函数版本,右值引用参数的是一个移动构造函数版本。默认情况下,编译器会为程序员隐式地生成一个移动构造函数,但是如果声明了一自定义的拷贝构造函数、拷贝赋值函数、移动构造函数、析构函数中的一个或者多个,编译器都不会再生成默认版本。所以在C++11中,拷贝构造函数、拷贝赋值函数、移动构造函数和移动赋值函数必须同时提供,或者同时不提供,只声明其中一种的话,类都仅能实现一种语义。

只实现一种语义在类的编写中也是非常常见的,比如如果只实现移动语义,则表明该类型的变量拥有的资源只能被移动,不能被拷贝,那么这样的资源必须是唯一的,如智能指针、文件流。

在<type_traits>里,可以通过一些辅助的模板类来判断一个类型是否是可以移动的,如:

  • is_move_constructible
  • is_trivially_move_constructible
  • is_nothrow_move_constructible

使用方法都是使用value成员,如

cout << is_move_constructible<UnknowTYpe>::value;

有了移动语义,可以实现高性能的置换函数,如:

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

如果T是可以移动的,则不会有资源的释放和申请,如果T不可移动但是可以拷贝,则和普通声明一样了。

要注意的是,尽量不要编写会抛出异常的移动构造函数,因为有可能移动没完成,会导致一些指针成为悬挂指针,通过添加noexcept关键字,可以保证移动构造函数抛出异常直接终止程序。

3.3.6 完美转发

完美转发(perfect forwarding),是指在模板函数中,完全依照模板的参数类型讲参数传递给模板中调用的另外一个函数,如:

template <typename T>
void IamForwarding(T t) {
    IrunCodeActually(t);
}

这是一个参数透传的实现,但是因为使用最基本类型转发,会在传参的时候产生一次额外的临时对象拷贝,因为只能说是转发,但不完美。所以通常需要的是一个引用类型餐护士,不会有拷贝的开销。其次需要考虑函数对类型的接受能力,因为目标函数可能需要既接受左值引用,又接受右值引用,如果转发函数只能接受其中的一部分,也不完美。

对应代码

typedef const A T;
typedef T& TR;
TR& v = 1;

在C++11中引入了一条所谓“引用折叠”的新语言规则,规则如下

TR的类型定义 声明v的类型 v的实际类型
T& TR A&
T& TR& A&
T& TR&& A&
T&& TR A&&
T&& TR& A&
T&& TR&& A&&

规则就是一单定义中出现了左值引用,引用折叠总是优先将其折叠为左值引用。前三行TR定义为T&,则v世界类型为A&,第五行的v的类型为TR&,则v的实际类型也为A&,其他则为右值引用。于是我们把转发函数改为:

template <typename T>
void IamForwarding(T&& t) {
    IrunCodeActually(static_cast<T&&>(t));
}

对于传入的左值引用

void IamForwarding(X& && t) {
    IrunCodeActually(static_cast<X& &&>(t));
}

折叠后是

void IamForwarding(X& t) {
    IrunCodeActually(static_cast<X&>(t));
}

对于右值引用

void IamForwarding(X&& && t) {
    IrunCodeActually(static_cast<X&& &&>(t));
}

折叠后是

void IamForwarding(X&& t) {
    IrunCodeActually(static_cast<X&&>(t));
}

此处的static_cast类似std::move的作用,将左值转换为右值引用。不过在C++11中,用于完美转发的函数不叫move,叫forward,所以也可以这么写

void IamForwarding(X&& t) {
    IrunCodeActually(forward(t));
}

move和forward实现差别不大,但是为了不同用途,有了不同命名。
下面是完美转发的例子:

void run(int && m) { cout << "rvalue ref" << endl; }
void run(int & m) { cout << "lvalue ref" << endl; }
void run(const int && m) { cout << "const rvalue ref" << endl; }
void run(const int & m) { cout << "const lvalue ref" << endl; }

template <typename T>
void perfectForward(T&& t) {
    run(forward<T>(t));
}

int main(){
    int a;
    int b;
    const int c = 1;
    const int d = 0;
    
    perfectForward(a);
    perfectForward(move(b));
    perfectForward(c);
    perfectForward(move(d));
    
    return 0;
}

输出

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

推荐阅读更多精彩内容