右值引用与 move 语意

时间:2019-02-19
本文章向大家介绍右值引用与 move 语意,主要包括右值引用与 move 语意使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

右值引用、move 语意

左值与右值 (LValue & RValue)

在 C++ 中将变量分为左值和右值两类。简单地说就是能放在等号左边的就是左值,只能放在等号右边的就是右值。比如下面的例子:

int a  =  1;

在这个例子里 a 是左值,1 是右值。但是左值并不一定就在等号的左边。比如下面的例子:

int b = a;

这个例子里 a 和 b 就都是左值。因为 a 这个值是可以放到等号左边的。

我们还有另外一种方法来判断左值与右值,那就是看能不能取地址。比如上面例子中 &a 是合法的, &1 不合法。所以 a 是左值,b 是右值。 同样道理,++i 是左值,i++ 是右值。

右值不一定就是一个量,也可以是一个表达式。比如:

int  b = a + 1;

这里 a + 1 就是一个右值。

C++11 中又把右值细分为两种类型,分别为 xvalue (expiring value) 和 prvalue (pure rvalue).

prvalue 就是刚才说的那种常规的右值。

xvalue 是在执行完赋值任务后就会结束它的生命周期的值。

比如:

int foo();
int a = foo();

这里函数 foo() 的返回值就是典型的 xvalue。 为什么要 搞个 xvalue 呢。因为 xvalue 具有左值和右值的双重特性。 xvalue 有时会可以取得地址,表现的像个左值。有时又像个右值。大家可以参考下面的图。这个图总结了各种值的关系。
xvalue 和 prvalue 有一个特点,就是在赋值给另一个变量之后,它本身就不再被用到了。所以如果xvalue 或 prvalue 本身是个很大的对象,拥有一些不便于复制的资源。那个这个赋值操作是可以优化的。简单的说我们不需要把它拥有的资源复制一份,只要简单的把所有权移交给赋值的那个变量就好了。

比如下面的代码:

string  foo();
string b = foo();

foo() 是一个函数返回一个 string 对象,在旧的 c++ 标准里,b = foo() 这个操作只能是利用 string 的拷贝构造函数来初始化 b,也就是要把 foo() 返回的临时string 对象里的字符串复制一份给 b。在C++11 标准下,这个代码会将 foo() 返回的临时string 对象里的字符串转移给 b,免去了复制操作,提高了效率。

那么如何实现这种资源的转移呢?这就涉及到了移动构造函数。下一节就讲讲移动构造函数。

移动构造函数

我们知道 C++ 有拷贝构造函数。当一个类的对象被赋值给另一个对象时调用拷贝构造函数。当一个对象拥有大量资源时这种赋值操作的成本非常的高。为了解决这个问题大家搞了很多技术,比如内存共享,写时拷贝等。移动构造函数也是解决某个场景下这个问题的一种方法。

简单的说,如果一个对象是 xvalue 或 prvalue,那么它赋值给另一个对象后本身就没什么用了。所以在这种情况下把它的资源移交给被赋值的对象就可以了,用不着 deep copy。那一个对象什么时候会是 xvalue 呢,最常见的场景就是这个对象是一个函数的局部变量,同时也是这个函数的返回值。函数返回之后,这个变量就终止生命了。

比如我们有个类叫做 MoveClass,有个函数 bar():

MoveClass bar()
{
    MoveClass a;
    return a;
}

那么如果有下面的赋值语句:

MoveClass c = bar();

这时我们最佳的策略是将 bar 函数里的 a 的资源转移给 c。 为了让 MoveClass 具有资源转移的能力,需要在类中定义移动构造函数,下面给了例子:

class MoveClass
{
public:
    MoveClass() :m_a(nullptr)
    {
        m_a = new int[2];
        m_a[0] = 0;
        m_a[1] = 1;
        cout << "Constructor" << endl;
    }
    MoveClass(const MoveClass& b)
    {
        m_a = new int[2];
        m_a[0] = b.m_a[0];
        m_a[1] = b.m_a[1];
        cout << "Copy Constructor" << endl;
    }
    MoveClass(MoveClass&& b)
    {
        m_a = b.m_a;
        //delete b.m_a;
        b.m_a = nullptr;
        cout << "Move Constructor" << endl;
    }
    ~MoveClass()
    {
        delete m_a;
        m_a = nullptr;
        cout << "destructor" << endl;
    }
public:
    int* m_a;
};

下面是个简单的测试代码:

MoveClass c = bar();
MoveClass d = c;

运行的结果是:

Constructor
Move Constructor
destructor
Copy Constructor

可以看到 MoveClass c = bar(); 对应的是移动构造函数,MoveClass d = c; 对应的是拷贝构造函数。因为执行完这条代码后 c 还是有效的,不能把 c 的资源转移给 d。

在写移动构造函数时需要特别注意的是要保证被转移的资源不要被析构函数给释放了。这里是将指针赋值为 nullptr 。对于其他类型的资源,可能会需要其他的办法。

除了这里用到的移动构造函数,赋值操作符也可以用上类似的技巧。比如常规的赋值运算是这样声明的:

MoveClass& operator=(const MoveClass& src);

如果要变成移动赋值,则是这样声明:

MoveClass& operator=(MoveClass&& src);

注意这里的 const 消失了。因为我们要把资源转移过来,那么就要改变 src 的内部状态,所以 src 不能是 const 的。

右值引用

在上一节的代码里出现了个 MoveClass&& b。 这个 b 到底是什么类型呢。这是 C++11 中新引入的一种类型,叫做右值引用。在古代 C++ 中其实也是有引用类型的,那里的引用类型我们称为左值引用。

那么为什么要再搞出一个右值引用呢。我认为主要原因是右值有个特性,就是用完就扔。利用好这个特性,赋值语句就有了优化的可能。为了编译器能够优化,我们需要告诉编译器这里的值具有这种用完就扔的特性。所以就搞了个右值引用。右值引用所代表的的变量可以随便的折腾,反正用完了就没用了。

比如下面的例子:

void temp(QString &a)
{
    cout << "bar(QString &a)" << endl;
}
void temp(QString && a)
{
    cout << "bar(QString &&a)" << endl;
}

int main(int argc, char *argv[])
{
    QString c("123");
    temp(c);
    temp(QString("456"));
}

输出的结果如下:

bar(QString &a)
bar(QString &&a)

可以看到,当我们传进去的是一个普通的变量时,使用的是左值引用,如果传进去的是个右值(xvalue),则编译器自动的去使用右值引用。那么在函数体内我们就能相应的做些优化了。

有的时候,我们手里有个左值,可是我们希望它当作右值来用。这时就要用到 move 了。

std::move

move 的作用就是将一个左值变为一个右值引用。比如下面的代码:

MoveClass a;
MoveClass b(a);
MoveClass c(std::move(a));

这里 b 和 c 都是用 a 来初始化的。但是 b 调用的是拷贝构造函数。c 用的是移动构造函数。当然,初始化 c 之后,我们就不应该再用 a 了。因为 a 的资源已经移交给 c 了。

那么什么时候应该用 move ,这里举两个简单的场景。第一个场景是将类对象存入容器中。比如:

std::string s1 = "apple";
std::vector<std::string> dict;
dict.push_back( std::move(s1) );

这个代码就比 dict.push_back( s1 ) 效率高。当然前提是存完之后 s1 不要再直接使用了。

还有一个用法就是使用 std::unique_ptr 时。我们在转移 unique_ptr 的所有权时是能用到 move 的。