C++11新特性:右值引述和转移构造函数

C++11新特性:右值引用和转移构造函数

右值引用和转移构造函数

问题背景

#include <iostream>
 
using namespace std;
 
vector<int> doubleValues (const vector<int>& v)
{
    vector<int> new_values( v.size() );
    for (auto itr = new_values.begin(), end_itr = new_values.end(); itr != end_itr; ++itr )
    {
        new_values.push_back( 2 * *itr );
    }
    return new_values;
}
 
int main()
{
    vector<int> v;
    for ( int i = 0; i < 100; i++ )
    {
        v.push_back( i );
    }
    v = doubleValues( v );
}

先来分析一下上述代码的运行过程。

    vector<int> v;
    for ( int i = 0; i < 100; i++ )
    {
        v.push_back( i );
    }

以上5行语句在栈上新建了一个vector的实例,并在里面放了100个数。

v = doubleValues( v )
这条语句调用函数doubleValues,函数的参数类型的const reference,常量引用,那么在实参形参结合的时候并不会将v复制一份,而是直接传递引用。所以在函数体内部使用的v就是刚才创建的那个vector的实例。

但是

vector<int> new_values( v.size() );
这条语句新建了一个vector的实例new_values,并且复制了v的所有内容。但这是合理的,因为我们这是要将一个vector中所有的值翻倍,所以我们不应该改变原有的vector的内容。
v = doubleValues( v );

函数执行完之后,new_values中放了翻倍之后的数值,作为函数的返回值返回。但是注意,这个时候doubleValue(v)的调用已经结束。开始执行 = 的语义。

赋值的过程实际上是将返回的vector<int>复制一份放入新的内存空间,然后改变v的地址,让v指向这篇内存空间。总的来说,我们刚才新建的那个vector又被复制了一遍。

但我们其实希望v能直接得到函数中复制好的那个vector。在C++11之前,我们只能通过传递指针来实现这个目的。但是指针用多了非常不爽。我们希望有更简单的方法。这就是我们为什么要引入右值引用和转移构造函数的原因。


左值和右值

在说明左值的定义之前,我们可以先看几个左值的例子。
int a;
a = 1; // here, a is an lvalue
上述的a就是一个左值。
临时变量可以做左值。同样函数的返回值也可以做左值。
int x;
int& getRef () 
{
        return x;
}
 
getRef() = 4;
以上就是函数返回值做左值的例子。

其实左值就是指一个拥有地址的表达式。换句话说,左值指向的是一个稳定的内存空间(即可以是在堆上由用户管理的内存空间,也可以是在栈上,离开了一个block就被销毁的内存空间)。上面第二个例子,getRef返回的就是一个全局变量(建立在堆上),所以可以当做左值使用。

与此相反,右值指向的不是一个稳定的内存空间,而是一个临时的空间。比如说下面的例子:
int x;
int getVal ()
{
    return x;
}
getVal();
这里getVal()得到的就是临时的一个值,没法对它进行赋值。
下面的语句就是错的。
getVal() = 1;//compilation error
所以右值只能够用来给其他的左值赋值。

右值引用

在C++11中,你可以使用const的左值引用来绑定一个右值,比如说:
const int& val = getVal();//right
int& val = getVal();//error

因为左值引用并不是左值,并没有建立一片稳定的内存空间,所以如果不是const的话你就可以对它的内容进行修改,而右值又不能进行赋值,所以就会出错。因此只能用const的左值引用来绑定一个右值。

在C++11中,我们可以显示地使用“右值引用”来绑定一个右值,语法是"&&"。因为指定了是右值引用,所以无论是否const都是正确的。
const string&& name = getName(); // ok
string&& name = getName(); // also ok 

有了这个功能,我们就可以对原来的左值引用的函数进行重载,重载的函数参数使用右值引用。比如下面这个例子:
printReference (const String& str)
{
        cout << str;
}
 
printReference (String&& str)
{
        cout << str;
}
可以这么调用它。
string me( "alex" );
printReference(  me ); // 调用第一函数,参数为左值常量引用
 
printReference( getName() ); 调用第二个函数,参数为右值引用。

好了,现在我们知道C++11可以进行显示的右值引用了。但是我们如果用它来解决一开始那个复制的问题呢?
这就要引入与此相关的另一个新特性,转移构造函数和转移赋值运算符

转移构造函数和转移赋值运算符

假设我们定义了一个ArrayWrapper的类,这个类对数组进行了封装。
class ArrayWrapper
{
    public:
        ArrayWrapper (int n)
            : _p_vals( new int[ n ] )
            , _size( n )
        {}
        // copy constructor
        ArrayWrapper (const ArrayWrapper& other)
            : _p_vals( new int[ other._size  ] )
            , _size( other._size )
        {
            for ( int i = 0; i < _size; ++i )
            {
                _p_vals[ i ] = other._p_vals[ i ];
            }
        }
        ~ArrayWrapper ()
        {
            delete [] _p_vals;
        }
    private:
    int *_p_vals;
    int _size;
};

我们可以看到,这个类的拷贝构造函数显示新建了一片内存空间,然后又对传进来的左值引用进行了复制。
如果传进来的实际参数是一个右值(马上就销毁),我们自然希望能够继续使用这个右值的空间,这样可以节省申请空间和复制的时间。
我们可以使用转移构造函数实现这个功能:
class ArrayWrapper
{
public:
    // default constructor produces a moderately sized array
    ArrayWrapper ()
        : _p_vals( new int[ 64 ] )
        , _size( 64 )
    {}
 
    ArrayWrapper (int n)
        : _p_vals( new int[ n ] )
        , _size( n )
    {}
 
    // move constructor
    ArrayWrapper (ArrayWrapper&& other)
        : _p_vals( other._p_vals  )
        , _size( other._size )
    {
        other._p_vals = NULL;
    }
 
    // copy constructor
    ArrayWrapper (const ArrayWrapper& other)
        : _p_vals( new int[ other._size  ] )
        , _size( other._size )
    {
        for ( int i = 0; i < _size; ++i )
        {
            _p_vals[ i ] = other._p_vals[ i ];
        }
    }
    ~ArrayWrapper ()
    {
        delete [] _p_vals;
    }
 
private:
    int *_p_vals;
    int _size;
};

第一个构造函数就是转移构造函数。它先将other的域复制给自己。尤其是将_p_vals的指针赋值给自己的指针,这个过程相当于int的复制,所以非常快。然后将other里面_p_vals指针置成NULL。这样做有什么用呢?
我们看到,这个类的析构函数是这样的:
~ArrayWrapper ()
    {
        delete [] _p_vals;
    }
它会delete掉_p_vals的内存空间。但是如果调用析构函数的时候_p_vals指向的是NULL,那么就不会delte任何内存空间。
所以假设我们这样使用ArrayWrapper的转移构造函数:
ArrayWrapper *aw = new ArrayWrapper((new ArrayWrapper(5)));
其中
(new ArrayWrapper(5)
获得的实例就是一个右值,我们不妨称为r,当整条语句执行结束的时候就会被销毁,执行析构函数。
所以如果转移构造函数中没有
other._p_vals = NULL;
的话,虽然aw已经获得了r的_p_vals的内存空间,但是之后r就被销毁了,那么r._p_vals的那片内存也被释放了,aw中的_p_vals指向的就是一个不合法的内存空间。所以我们就要防止这片空间被销毁。

(未完)