C++右值引用

c/c++

浏览数:239

2019-3-30

C++右值引用

右值引用应该是C++11引入的一个非常重要的技术,因为它是移动语义(Move semantics)与完美转发(Perfect forwarding)的基石:

  • 移动语义:将内存的所有权从一个对象转移到另外一个对象,高效的移动用来替换效率低下的复制,对象的移动语义需要实现移动构造函数(move constructor)和移动赋值运算符(move asssignment operator)。
  • 完美转发:定义一个函数模板,该函数模板可以接收任意类型参数,然后将参数转发给其它目标函数,且保证目标函数接受的参数其类型与传递给模板函数的类型相同。

左值与右值

在讲解右值引用之前,你必须首先要区分两个概念:左值与右值。但是精确讲解清楚这两个概念并不容易。首先,你要清楚左值与右值是C++中表达式的属性,在C++11中,每个表达式有两个属性:类型(type,除去引用特性,用于类型检查)和值类型(value category,用于语法检查,比如一个表达式结果是否能被赋值)。值类型包括3个基本类型:lvalueprvaluexrvalue。后两者又统称为rvaluelvalue我们称为左值,你可以将左值看成是一个可以获取地址的量,它可以用来标识一个对象或函数。rvalue称为右值,你可以认为所有不是左值的量就是右值,这是最简单的解释。要准确区分出右值中的prvaluexrvalue并不容易:大概前者就是纯粹的右值,比如字面量,后者指的是可以被重用的临时对象。如果你感兴趣,你可以访问cppreference去细究。但是,你只要能够区分开左值与右值就够了。

左值引用

C++11之前就已经有了左值引用,有时候我们简称为引用,其语法很简单:

int x = 20;
int& rx = x;   // 定义引用时必须初始化

但是引用也分为const引用与non-const引用,对于non-const引用,其只能用non-const左值来初始化:

int x = 20;
int& rx1 = x;   // non-const引用可以被non-const左值初始化
const int y = 10;
const int& rx2 = y;  // 非法:non-const引用不能被const左值初始化
int& rx3 = 10;      // 非法:non-const引用不能被右值初始化

但是const引用限制就少了:

int x = 10;
const int cx = 20;
const int& rx1 = x;   // const引用可以被non-const左值初始化
const int& rx2 = cx;  // const引用可以被const左值初始化
const int& rx3 = 9;   // const引用可以被右值初始化

理解上面并不难,因为你只要想着这样初始化不会造成矛盾就好了,特别注意的是const左值引用可以接收右值(这点很重要,后面会说)。

右值引用

C++11以前,右值被认为是无用的资源,所以在C++11中引入了右值引用,就是为了重用右值。定义右值引用需要使用&&

int&& rrx = 200;

右值引用一定不能被左值所初始化,只能用左值初始化:

int x = 20;    // 左值
int&& rrx1 = x;   // 非法:右值引用无法被左值初始化
const int&& rrx2 = x;  // 非法:右值引用无法被左值初始化

那么为什么呢?因为右值引用的目的是为了延长用来初始化对象的生命周期,对于左值,其生命周期与其作用域有关,你没有必要去延长,这是我的理解。既然是延长,那么就出现了下面的情况:

int x = 20;   // 左值
int&& rx = x * 2;  // x*2的结果是一个右值,rx延长其生命周期
int y = rx + 2;   // 因此你可以重用它:42
rx = 100;         // 一旦你初始化一个右值引用变量,该变量就成为了一个左值,可以被赋值

这点很重要,初始化之后的右值引用将变成一个左值,如果是non-const还可以被赋值!

右值引用还可以用于函数参数:

// 接收左值
void fun(int& lref)
{
    cout << "l-value reference\n";
}
// 接收右值
void fun(int&& rref)
{
    cout << "r-value reference\n";
}

int main()
{
    int x = 10;
    fun(x);   // output: l-value reference
    fun(10);  // output: r-value reference
}

可以看到,函数参数要区分开右值引用与左值引用,这是两个不同的重载版本。还有,如果你定义了下面的函数:

void fun(const int& clref)
{
    cout << "l-value const reference\n";
}

但是其实它不仅可以接收左值,而且可以接收右值(如果你没有提供接收右值引用的重载版本)。

移动语义

有了右值引用的概念,就可以理解移动语义了。前面说过,一个对象的移动语义的实现是通过移动构造函数与移动赋值运算符来实现的。所以,为了理解移动语义,我们从一个对象出发,下面创建一个动态数组类:

template <typename T>
class DynamicArray
{
public:
    explicit DynamicArray(int size) :
        m_size{ size }, m_array{ new T[size] }
    {
        cout << "Constructor: dynamic array is created!\n";
    }

    virtual ~DynamicArray()
    {
        delete[] m_array;
        cout << "Destructor: dynamic array is destroyed!\n";
    }

    // 复制构造函数
    DynamicArray(const DynamicArray& rhs) :
        m_size{ rhs.m_size }
    {
        
        m_array = new T[m_size];
        for (int i = 0; i < m_size; ++i)
            m_array[i] = rhs.m_array[i];
        cout << "Copy constructor: dynamic array is created!\n";
    }

    // 复制赋值操作符
    DynamicArray& operator=(const DynamicArray& rhs)
    {
        cout << "Copy assignment operator is called\n";
        if (this == &rhs)
            return *this;

        delete[] m_array;
        
        m_size = rhs.m_size;
        m_array = new T[m_size];
        for (int i = 0; i < m_size; ++i)
            m_array[i] = rhs.m_array[i];

        return *this;
    }

    
    // 索引运算符
    T& operator[](int index)
    {
        // 不进行边界检查
        return m_array[index];
    }

    const T& operator[](int index) const
    {
        return m_array[index];
    }

    int size() const { return m_size; }
private:
    T* m_array;
    int m_size;
};

我们通过在堆上动态分配内存来实现动态数组类,类中实现复制构造函数、复制赋值操作符以及索引操作符。假如我们定义一个生产动态数组的工厂函数:

// 生产int动态数组的工厂函数
DynamicArray<int> arrayFactor(int size)
{
    DynamicArray<int> arr{ size };
    return arr;
}

然后我们用下面的代码进行测试:

int main()
{
    {
        DynamicArray<int> arr = arrayFactor(10);
    }
    return 0;
}

其输出为:

Constructor: dynamic array is created!
Copy constructor: dynamic array is created!
Destructor: dynamic array is destroyed!
Destructor: dynamic array is destroyed!

此时,我们来解读这个输出。首先,你调用arrayFactor函数,内部创建了一个动态数组,所以普通构造函数被调用。然后将这个动态数组返回,但是这个对象是函数内部的,函数外是无法获得的,所以要生成一个临时对象,然后用这个动态数组初始化,函数最终返回的是临时对象。我们知道这个动态数组即将消亡,所以其是右值,那么在构建临时对象时,会调用复制构造函数(没有右值的版本,但是右值可以传递给const左值引用参数)。但是问题又来了,因为你返回的这个临时对象又拿去初始化另外一个对象arr,当然调用也是复制构造函数。调用两次复制构造函数完全没有必要,编译器也会这么想,所以将其优化:直接拿函数内部创建的动态数组去初始化arr。所以仅有一次复制构造函数被调用,但是一旦完成arr的创建,那个动态数组对象就被析构了。最后arr离开其作用域被析构。我们看到编译器尽管做了优化,但是还是导致对象被创建了两次,函数内部创建的动态数组仅仅是一个中间对象,用完后就被析构了,有没有可能直接将其申请的空间直接转移到arr,那么资源得以重用,实际上只用申请一份内存。但是问题的关键是复制构造函数执行的是复制,不是转移,无法实现这样的功能。此时,你需要移动构造函数:

template <typename T>
class DynamicArray
{
public:
        // ...其它省略
    
    // 移动构造函数
    DynamicArray(DynamicArray&& rhs) :
        m_size{ rhs.m_size }, m_array{rhs.m_array}
    {
        rhs.m_size = 0;
        rhs.m_array = nullptr;
        cout << "Move constructor: dynamic array is moved!\n";
    }

    // 移动赋值操作符
    DynamicArray& operator=(DynamicArray&& rhs)
    {
        cout << "Move assignment operator is called\n";
        if (this == &rhs)
            return *this;
        delete[] m_array;
        m_size = rhs.m_size;
        m_array = rhs.m_array;
        rhs.m_size = 0;
        rhs.m_array = nullptr;

        return *this;
    }
};

上面是移动构造函数与移动赋值操作符的实现,相比复制构造函数与复制赋值操作符,前者没有再分配内存,而是实现内存所有权转移。那么测试相同的代码,其结果是:

Constructor: dynamic array is created!
Move constructor: dynamic array is moved!
Destructor: dynamic array is destroyed!
Destructor: dynamic array is destroyed!

可以看到,调用的是移动构造函数,那么函数内部申请的动态数组直接被转移到arr。从而减少了一份相同内存的申请与释放。注意析构函数被调用两次,这是因为尽管内部进行了内存转移,但是临时对象依然存在,只不过第一次析构函数析构的是一个nullptr,这不会对程序有影响。其实通过这个例子,我们也可以看到,一旦你已经自己创建了复制构造函数与复制赋值运算符后,编译器不会创建默认的移动构造函数和移动赋值运算符,这点要注意。最好的话,这个4个函数一旦自己实现一个,就应该养成实现另外3个的习惯。

这就是移动语义,用移动而不是复制来避免无必要的资源浪费,从而提升程序的运行效率。其实在C++11中,STL的容器都实现了移动构造函数与移动赋值运算符,这将大大优化STL容器。

std::move

移动语义前面已经介绍了,我们知道对象的移动语义的实现是依靠移动构造函数和移动赋值操作符。但是前提是你传入的必须是右值,但是有时候你需要将一个左值也进行移动语义(因为你已经知道这个左值后面不再使用),那么就必须提供一个机制来将左值转化为右值。在C++中,std::move就是专为此而生,看下面的例子:

vector<int> v1{1, 2, 3, 4};
vector<int> v2 = v1;             // 此时调用复制构造函数,v2是v1的副本
vector<int> v3 = std::move(v1);  // 此时调用移动构造函数,v3与v1交换:v1为空,v3为{1, 2, 3, 4}

可以看到,我们通过std::movev1转化为右值,从激发v3的移动构造函数,实现移动语义。

C++中利用std::move实现移动语义的一个典型函数是std::swap:实现两个对象的交换。C++11之前,std::swap的实现如下:

template <typename T>
void swap(T& a, T& b)
{
    T tmp{a};  // 调用复制构造函数
    a = b;     // 复制赋值运算符
    b = tmp;     // 复制赋值运算符
}

从上面的实现可以看到:共进行了3次复制。如果类型T比较占内存,那么交换的代价是非常昂贵的。但是利用移动语义,我们可以更加高效地交换两个对象:

template <typename T>
void swap(T& a, T& b)
{
    T temp{std::move(a)};   // 调用移动构造函数
    a = std::move(b);       // 调用移动赋值运算符
    b = std::move(tmp);     // 调用移动赋值运算符
}

仅通过三次移动,实现两个对象的交换,由于没有复制,效率更高!

你可能会想,std::move函数内部到底是怎么实现的。其实std::move函数并不“移动”,它仅仅进行了类型转换。下面给出一个简化版本的std::move:

template <typename T>
typename remove_reference<T>::type&& move(T&& param)
{
    using ReturnType = typename remove_reference<T>::type&&;
    
    return static_cast<ReturnType>(param);
}

代码很短,但是估计很难懂。首先看一下函数的返回类型,remove_reference在头文件<type_traits>中,remove_reference<T>有一个成员type,是T去除引用后的类型,所以remove_reference<T>::type&&一定是右值引用,对于返回类型为右值的函数其返回值是一个右值(准确地说是xvalue)。所以,知道了std::move函数的返回值是一个右值。然后,我们看一下函数的参数,使用的是通用引用类型(&&),意味者其可以接收左值,也可以接收右值。其推导规则如下:如果实参是左值,推导后的形参是左值引用,如果是右值,推导出来的是右值引用(感兴趣的话可以看看reference collapsing)。但是不管怎么推导,ReturnType的类型一定是右值引用,最后std::move函数只是简单地调用static_cast将参数转化为右值引用。所以,std::move什么也没有做,只是告诉编译器将传入的参数无条件地转化为一个右值。所以,当你使用std::move作用于一个对象时,你只是告诉编译器这个对象要转化为右值,然后就有资格进行移动语义了!

下面举一个由于误用std::move而无效的例子。假如你在设计一个标注类,其构造函数接收一个string类型参数作为标注文本,你不希望它被修改,所以标注为const,然后将其复制给其的一个数据成员,你可能会使用移动语义:

class Annotation
{
public:
    explicit Annotation(const string& text):
        m_text {std::move(text)}
    { }
    
    const string& getText() const { return m_text; }
private:
    string m_text;
};

然后你高高兴兴地去测试:

int main()
{
    string text{ "hello" };
    Annotation ant{ text };

    cout << ant.getText() << endl;  // output: hello
    cout << text << endl;           // output: hello 不是空,移动语义没有实现
    
    return 0;
}

你发现移动语义并没有被实现,这是为什么呢?首先,从直观上看,假如你移动语义成功了,那么text会发生改变,这会违反其const属性。所以,你不大可能成功!其实,std::move函数会在推导形参时会保持形参的const属性,所以其最终返回的是一个const右值引用类型,那么m_text{std::move(text)}到底会调用什么构造函数呢?我们知道string的内部有两个构造函数可能会匹配:

class string
{
    // ...
    string(const string& rhs);   // 复制构造函数
    string(string&& rhs);    // 移动构造函数
}

那么到底会匹配哪个呢?肯定的是移动构造函数不会被匹配,因为不接受const对象,复制构造函数会匹配吗?答案是可以,因为前面我们讲过const左值引用可以接收右值,const右值更可以!所以,你其实调用了复制构造函数,那么移动语义当然无法实现。

所以,如果你想接下来进行移动,那不要把std::move引用在const对象上!

std::forward与完美转发

前面已经讲过,完美转发就是创建一个函数,该函数可以接收任意类型的参数,然后将这些参数按原来的类型转发给目标函数,完美转发的实现要依靠std::forward函数。下面就定义了这样一个函数:

// 目标函数
void foo(const string& str);   // 接收左值
void foo(string&& str);        // 接收右值

template <typename T>
void wrapper(T&& param)
{
    foo(std::forward<T>(param));  // 完美转发
}

首先要有一点要明确,不论传入wrapper的参数是左值还是右值,一旦传入之后,param一定是左值,然后我们来具体分析这个函数:

  • 当一个类型为string类型的右值传递给wrapper时,T被推导为stringparam为右值引用类型,但是一旦传入后,param就变成了左值,所以你直接转发给foo函数,将丢失param的右值属性,那么std::forward就确保传入foo的值还是一个右值;
  • 当类型为const string的左值传递给wrapper时,T被推导为const string&param为const左值引用类型,传入后,param仍为const左值类型,所以你直接转发给foo函数,没有问题,此时应用std::forward函数可以看成什么也没有做;
  • 当类型为string的左值传递给wrapper时,T被推导为string&param为左值引用类型,传入后,param仍为左值类型,所以你直接转发给foo函数,没有问题,此时应用std::forward函数可以看成什么也没有做;

所以wrapper函数可以实现完美转发,其关键点在于使用了std::forward函数确保传入的右值依然转发为右值,而对左值传入不做处理。

那么,std::forward到底怎么处理,其实现如下:

template<typename T> 
T&& forward(typename remove_reference<T>::type& param) 
{
    return static_cast<T&&>(param);
}

代码依然与std::move一样简洁,我们结合wrapper来看,如果传入wrapper函数中的是string左值,那么推导出Tstring&,那么将调用std::foward<string&>,根据std::foward的实现,其实例化为:

string& && forward(typename remove_reference<string&>::type& param)
{
    return static_cast<string& &&>(param);
}

连续出现3个&符号有点奇怪,我们知道C++不允许引用的引用,那么其实编译器这里进行是引用折叠(reference collapsing,大致就是后面的引用消掉),因此,变成:

string& forward(string& param)
{
    return static_cast<string&>(param);
}

上面的代码就很清晰了,一个左值引用的参数,然后还是返回左值引用,此时的std::foward就是什么也没有做,因为传入与返回完全一样。

那么如果传入wrapper函数中的是string右值,那么推导出Tstring,那么将调用std::foward<string>,根据std::foward的实现,其实例化为:

string && forward(typename remove_reference<string>::type& param)
{
    return static_cast<string&&>(param);
}

继续简化,变成:

string&& forward(string& param)
{
    return static_cast<string&&>(param);
}

参数依然是左值引用(这点是一致的,因为前面说过传入std:;forward中的实参一直是左值),但是返回的是右值引用,此时的std::foward就是将一个左值转化了右值,这样保证传入目标函数的实参是右值!

综上,可以看到std::foward函数是有条件地将传入的参数转化为右值,而std::move无条件地将参数转化为右值,这是两者的区别。但是本质上,两者什么没有做,最多就是进行了一次类型转换。

讲完了!

References

[1] cpp leraning online.
[2] Marc Gregoire. Professional C++, Third Edition, 2016.
[3] cppreference
[4] 欧长坤(欧龙崎), 高速上手 C++ 11/14.
[5] Scott Meyers. Effective Modern C++, 2014.