C++11右值引用

指针成员和拷贝构造函数

参考:《深入理解C++11:新特性解析与应用》
对于 C++ 程序员来说,如果类中包含了一个指针成员 p 的话,需要小心拷贝构造函数的编写,否则很容易出现严重内存错误。
当使用编译器隐式生成的拷贝构造函数时,用一个对象 a 去构造另一个对象 b,只是把两个指针指向了同一块内存,当对象 a 作用域结束后会调用其析构函数,释放了空间,这时候对象 b 的成员 p 就成了一个“悬挂指针”,其不再指向有效的内存。当对象 b 作用域结束后,去调用析构函数,就会造成严重的内存错误。
这个问题就是 C++ 编程中非常经典的“浅拷贝”问题,为了解决这个问题,就是不能使用编译器自动生成的浅拷贝构造函数,而是用户自定义拷贝构造函数来实现“深拷贝”。
深拷贝构造函数一般需要重新从堆中分配内存,用对象 a 的成员内容初始化新的堆内存并将新分配的内存指针交给对象 b,这样就可以避免浅拷贝带来的内存问题。

移动构造函数

拷贝构造函数中为指针成员分配新的内存再进行内容拷贝的做法在 C++ 中几乎是不可违背的。但是在有些时候,我们不需要这样的拷贝构造语义。如下:

#include <iostream>
#include <bits/stdc++.h>
using namespace std;
class CMyTest
{
public:
    CMyTest() :d(new int(0))
    {
        cout << "Construct:" << ++n_ccnt << endl;
    }
    CMyTest(const CMyTest& t) : d(new int(*t.d))
    {
        cout << "Copy Construct:" << ++n_cpcnt << endl;
    }
    ~CMyTest()
    {
        delete d;
        d = nullptr;
        cout << "Destruct:" << ++n_dcnt << endl;
    }
private:
    int* d;
    static int n_ccnt;
    static int n_dcnt;
    static int n_cpcnt;
};

int CMyTest::n_ccnt = 0;
int CMyTest::n_dcnt = 0;
int CMyTest::n_cpcnt = 0;
CMyTest GetTemp() { return CMyTest(); }

int main()
{
    CMyTest a = GetTemp();
    return 0;
}

为了记录构造函数、拷贝构造、析构函数的调用次数,定义了一些静态变量。主函数中使用 GetTemp() 返回值进行初始化。编译运行该程序,可以看到下面输出:

$ ./mytest
Construct:1
Copy Construct:1
Destruct:1
Copy Construct:2
Destruct:2
Destruct:3

注意:现代编译器大多数都有 RVO 优化,在编译时加上 -fno-elide-constructors 选项可以禁止其优化。
可以看到整个过程:构造函数调用了一次,是在 GetTemp() 函数中调用的,然后又进行一次拷贝构造给返回临时对象,GetTemp() 函数中创建的对象调用析构函数,临时对象拷贝构造 a,然后临时对象析构,接着对象 a 析构。
让人感到不安的是:该类中的成员只有一个 int 类型的指针。如果是一个非常大的内存数据(比如它存储了一个几万行表格中的数据),那么这样拷贝构造的过程代价是相当昂贵的,同时程序的执行速度也令人堪忧,而且对于程序员来说是透明的,不会影响正确性,更不容易察觉(所以现代编译器都加入了优化机制)。
我们会想到:临时对象在析构后会释放资源,而对象 a 在拷贝时,又会分配资源,是否可以在临时对象构造 a 的时候不分配内存,即不使用拷贝构造函数。在 C++11 中,答案是肯定的。
C++11 中使用一种新的方法可以使得对象 a 的 d 成员指向临时对象的堆内存资源,同时保证临时对象不释放所指向的堆内存资源(将临时对象指向 NULL 或其他空间地址),那么在构造完成后,临时对象被析构,a 就从临时对象中“偷”到了临时对象所拥有的堆内存资源。
C++11 中这样的“偷走”临时变量中的资源的构造函数,就被成为移动构造函数,这样的“偷”的行为,则成为移动语义。说白了就是“移为己用”。
接下来,再看代码如何实现移动语义:

#include <iostream>
#include <bits/stdc++.h>
using namespace std;
class CMyTest
{
public:
    CMyTest() :d(new int(0))
    {
        cout << "Construct:" << ++n_ccnt << endl;
    }
    CMyTest(const CMyTest& t) : d(new int(*t.d))
    {
        cout << "Copy Construct:" << ++n_cpcnt << endl;
    }
    CMyTest(CMyTest&& t) : d(t.d)
    {
        t.d = nullptr;
        cout << "Move Construct:" << ++n_mvcnt << endl;
    }
    ~CMyTest()
    {
        delete d;
        d = nullptr;
        cout << "Destruct:" << ++n_dcnt << endl;
    }
    
    int* d;
    static int n_ccnt;
    static int n_dcnt;
    static int n_cpcnt;
    static int n_mvcnt;
};

int CMyTest::n_ccnt = 0;
int CMyTest::n_dcnt = 0;
int CMyTest::n_cpcnt = 0;
int CMyTest::n_mvcnt = 0;
CMyTest GetTemp()
{
    CMyTest t;
    cout << "Resource from" << __func__ << ":" << hex << t.d << endl;
    return t;
}

int main()
{
    CMyTest a = GetTemp();
    cout << "Resource from" << __func__ << ":" << hex << a.d << endl;
    return 0;
}

和拷贝构造函数相比,移动构造函数接受一个所谓的“右值引用”的参数,右值引用现在先理解为对临时变量的引用。在移动构造函数中使用了参数 t 的成员 d 初始化了本对象成员的 d,而 t 的成员 d 随后被置为空指针。这就是所谓的“偷”内存。如果不把 t 的成员 d 指针置为空,还是会出现类似浅拷贝的指针悬挂问题。
在看看效果,编译时,同样加 -fno-elide-constructors,运行结果:

$ ./rightval 
Construct:1
Resource fromGetTemp:0xdafc20
Move Construct:1
Destruct:1
Move Construct:2
Destruct:2
Resource frommain:0xdafc20
Destruct:3

通过运行结果可以看到过程:在 GetTemp() 中构造对象创建了一次构造函数,该对象返回给临时对象调用的不是构造函数,而是移动构造函数,因为该对象也变成一个右值,然后 GetTemp() 创建的对象调用析构,临时对象构造 a 再次调用移动构造函数,临时对象再析构,对象 a 再析构。
如果堆内存是个很大的数据,这样提升的性能是惊人的。或许有人说,为什么要费力添加移动构造函数呢,完全可以选择改变 GetTemp 的接口,比如传个引用或者指针到参数中,效果也不差。从性能方面将,确实可以,甚至会更好;但从使用方便性来说,效果很差。如果函数返回临时值的话,可以在单条语句中完成很多计算,比如我们可以很自然的写出如下语句:

Caculate(GetTemp(), SomeOther(Maybe(), Useful(Value, 2)));

如果使用传引用的方式:

string* a; vector b; //事先声明一些变量用于传递返回值
...
Useful(Value, 2, a); //最后一个参数是指针,用于返回结果
SomeOther(Maybe(), a, b); //最后一个参数是引用,用于返回结果
Cacluate(GetTemp(), b);

两种代码的而可读性存在明显差别,需要在 Caculate 调用之前声明好所有的指针和引用。这无疑是繁琐的工作,函数返回临时变量的好处就是不需要声明变量,也不需要知道生命值。在不影响性能的情况下,就应该以最简单自然的语句完成大量的工作。

左值&右值&右值引用

上面已经解释了移动构造函数的原理,但是最关键的是:移动构造函数何时被触发?之前我们只提到了临时对象,一旦我们用到的是个临时变量,那么移动构造函数就可以被执行。那么,在 C++ 中怎么判断产生了临时对象?如果将其用于移动构造函数?是否只能临时变量才能用于移动构造函数?这需要先了解到 C++ 的“值”是如何分类的。
注意:虽然移动语义在 C++11 中加入标准库,但是在 C++98/03 的语言和库中已经存在相关概念了,比如:

  1. 在某些情况下拷贝构造函数的省略
  2. 智能指针的拷贝(auto_ptr “copy”)
  3. 链表拼接(list::splice)
  4. 容器内的置换(swap on containers)

这些操作都包含了从一个对象到另一个对象的资源转移过程,唯一欠缺的就是统一的语法和语义的支持来使我们可以使用通用的代码移动任意的对象。
在 C 语言中,经常会提到左值(lvalue)、右值(rvalue)这样的称呼。在编译器中,有时也会报出错误信息中包含左值、右值的说法。不过左值和右值不是通过严谨定义而为人所知的,大多数时候右值的定义与其判别方法是一体的。最典型的判别方法是:在赋值表达式中,出现在等号左边的就是“左值”,而在等号右边的成为“右值”。比如:
在 C 语言中,经常会提到左值(lvalue)、右值(rvalue)这样的称呼。在编译器中,有时也会报出错误信息中包含左值、右值的说法。不过左值和右值不是通过严谨定义而为人所知的,大多数时候右值的定义与其判别方法是一体的。最典型的判别方法是:在赋值表达式中,出现在等号左边的就是“左值”,而在等号右边的成为“右值”。比如:

a = b + c;

在这个赋值表达式中,a 就是一个左值,而 b + c 就是一个右值。
这种识别方法在 C++ 中依旧有效,不过 C++ 中还有一个被广泛认同的说法:可以取地址的、有名字的就是左值,反之不能取地址、没有名字的就是右值。那么这个表达式中,&a 是允许的操作,但是 &(b + c) 是不允许的操作,并且不会通过编译。因此 a 是一个左值,(b + c) 是一个右值。
更为细致的,在 C++11 中,右值是由两个概念组成:一个是将亡值(xvalue),另一个是纯右值(prvalue
)。
其中纯右值是 C++98 标准中右值的概念,讲的是用于辨识临时变量和一些不跟对象关联的值。比如非引用返回的函数返回的临时变量值就是一个纯右值。一些运算表达式,如 1 + 3 产生的临时变量值,也是纯右值。不跟对象关联的字面量值,如 2、‘C’、true 也是纯右值。此外,类型转换函数的返回值、Lambda 表达式都是右值。
而将亡值则是 C++11 新增的和右值引用相关得表达式,这样的表达式通常是要被移动的对象(移为他用),比如返回右值引用 T&& 的函数返回值、std::move 的返回值,或者转换为 T&& 的类型转换函数的返回值。而剩余的可以标识函数、对象的值都属于左值。在 C++11 程序中,所有的值比属于左值、将亡值、纯右值三者之一。
在 C++11 中,右值引用就是对一个右值进行引用的类型。因为右值通常没有名字,只能通过引用的方式找到它的存在。通常我们只能从右值表达式获得其引用。比如:

T&& a = ReturnRvalue();

这个表达式,假设 ReturnRvalue 返回一个右值,就声明了一个名为 a 的右值引用,其值等于 ReturnRvalue 函数返回的临时变量的值。
为了区别 C++98 中的引用类型,我们称 C++98 中的引用为“左值引用”。右值引用和左值引用都属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。原因是:引用类型本身并不拥有所绑定对象的内存,只是该对象的一个别名,左值引用是具有变量值的别名,右值引用则是匿名变量的别名。
在上面的例子中,ReturnRvalue 返回的右值在表达式语句结束后,其声明就终结了,而通过右值引用的声明,该右值又“重获新生”,其生命期将于右值引用类型变量 a 的生命期一样。只要 a 还“活着”,该右值临时量将会一直“存活”下去。
相比于如下语句:

T b = ReturnRvalue();

上面的右值引用声明的变量,就会少一次对象的析构和一次构造。因为 a 是右值引用,直接绑定了 ReturnRvalue 返回的临时量,而 b 是由临时值构造而成的,临时量在表达式结束后会析构因而就会多一次析构和构造的开销。
必须指出的是:能够声明右值引用 a 的前提是 ReturnRvalue 返回的是一个右值。通常情况下,右值引用是不能绑定到任何左值的。比如下面的编译是无法通过的:

int c;
int&& d = c; //错误

在 C++98 标准中就出现了左值引用是否可以绑定到右值,如:

T& e = ReturnRvalue(); //错误
const T& f = ReturnRvalue(); //正确

变量 f 正确的原因是:在常量左值引用在 C++98 标准中开始就是个“万能”引用类型。可以接受非常量左值、常量左值、右值对其进行初始化。而且在使用右值对其进行初始化时,常量左值引用还可以像右值引用一样将右值的声明期延长。不过相比于右值引用所引用的右值,常量右值引用的右值在它的“余生”只是可读的。相对的,非常量左值只能接受非常量左值对其进行初始化。
在 C++11 之前,左值和右值对于程序员来说是透明的。不知道什么是左值、右值并不影响写出正确的代码。引用的是左值还是右值通常也不重要。事实上,C++98 通过左值引用来绑定一个右值的情况并不少见,比如:

const bool& judge = true;
const bool judgement = true;

这就是一个使用常量左值引用来绑定右值的例子,虽然不加 & 差别不大,但是从语法上加了 & 直接使用右值并为其续命,而不加的右值在表达式结束后就销毁了。

右值引用与移动语义

在右值引用中,经常会使用到移动语义,比如如下函数:

void AcceptValueRef(Copyable&& s)
{
    Copyable news = std::move(s);
}

std::move 的作用是强制一个左值成为右值,之后会解释。该函数就是用右值来初始化 Copyable 变量 news。同时,使用移动语义的前提是 Copyable 还需要一个以右值引用为参数的移动构造函数,如下:

Copyable(Copyable&& o) { /*实现移动语义*/ }

这样,如果 Copyable 临时对象中包含大块内存的指针,news 变量就可以窃为己用。事实上,右值引用从来就根移动语义紧紧相关。
有趣的是,如果没有声明移动构造函数,只声明常量左值为参数的构造函数会发生什么?下面语句:

Copyable news = std::move(s);

将调用以常量左值引用为参数的拷贝构造函数。这是非常安全的——移动不成功,至少可以拷贝。
为了语义的完整,C++11 中还存在着常量右值引用,通过以下设置一个常量右值引用:

const T&& crvalueref = ReturnRvalue();

但是,右值引用一般主要是为了移动语义,移动语义右值是可以被修改的,常量右值引用再移动语义中没有用武之处。而且如果想要引用右值不可以更改,而使用常量左值引用就够了。所以常量右值引用暂时没有用处,只需要了解概念即可。
如下表中,给出引用类型和其可引用的值类型关系:

引用类型非常量左值常量左值非常量右值常量右值标记
非常量左值引用YNNN
常量右值引用YYYY全能类型,可用于拷贝语义
非常量右值引用NNYN可用于移动语义、完美转发
常量右值引用NNYY暂无用途

有时候可能不知道一个类型是否是引用类型,以及是左值引用还是右值引用(模板中常见)。标准库在 <type_traits> 头文件中提供了 3 个模板类:is_rvalue_referenceis_lvalue_referenceis_reference 可以进行判断。比如:

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

通过模板类的成员 value 就可以打印出 string&& 是否是一个右值引用了。配合类型推导操作符 decltype,甚至还可以对变量的类型进行判断。当搞不清楚引用类型的时候,不妨使用这样的小工具试验一下。
有关 std::move 和 std::forward 的详细讲解在其他篇幅中。

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

code_peak

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值