首页 > 代码库 > C++11:右值引用

C++11:右值引用

右值引用导言

右值引用(及其支持的Move语意和完美转发)是C++ 11加入的最重大语言特性之一,这点从该特性的提案在C++ - State of the Evolution列表上高居榜首也可以看得出来。从实践角度讲,它能够完美解决C++ 中长久以来为人所诟病的临时对象效率问题。从语言本身讲,它健全了C++中的引用类型在左值右值方面的缺陷。从库设计者的角度讲,它给库设计者又带来了一把利器。从库使用者的角度讲,不动一兵一卒便可以获得“免费的”效率提升…

问题提出

设想这样一段代码

std::vector<int> readFile()
{
    std::vector<int> retv;
    
    // fill retv
    return retv;
}
 
int main()
{
    ...
    std::vector<int> v = readFile();
    ...
}

这段代码低效的地方在于那个返回的临时对象。整个vector得被拷贝一遍,仅仅是为了传递其中的一组int,当v被构造完毕之后,这个临时对象便烟消云散。这是公然的浪费。 原则上讲,这里有两份浪费:一,retv(retv在readFile()结束之后便烟消云散);二,返回的临时对象(返回的临时变量在v拷贝构造完毕之后也随即香消玉殒)。不过呢,对于上面的简单代码来说,大部分编译器都已经能够做到优化掉这两个对象,直接把那个retv创建到接受返回值的对象,即v中去。

按照C++鬼才Andrei Alexandrescu的说法,只要readFile()改成这样:

readFile()
{
    if(/* err condition */) return std::vector<int>();
    if(/* yet another err condition */) return std::vector<int>(1, 0);
    std::vector<int> retv;
    // fill retv
    return retv;
}

出现这种情况,编译器一般都会乖乖放弃优化。但对编译器来说这还不是最郁闷的一种情况,最郁闷的是:

std::vector<int> v;
v = readFile(); // assignment, not copy construction

这下由拷贝构造,变成了拷贝赋值。编译器只能缴械投降。

新特性的目的

右值引用 (Rvalue Referene) 是 C++ 新标准 (C++11, 11 代表 2011 年 ) 中引入的新特性 , 它实现了转移语义 (Move Sementics) 和完美转发 (Perfect Forwarding)。
它的主要目的有两个方面:

  1. 消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率。
  2. 能够更简洁明确地定义泛型函数。

左值与右值的定义

见书《c++ 11新特性解析与应用》左值、右值与右值引用(Page 75)

转移语义的定义

右值引用是用来支持移动语义的。移动语义可以将资源 ( 堆,系统对象等 ) 从一个对象移动到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,能够大幅度提高 C++ 应用程序的性能。临时对象的维护 ( 创建和销毁 ) 对性能有严重影响。
移动语义是和拷贝语义相对的,可以类比文件的剪切与拷贝,当我们将文件从一个目录拷贝到另一个目录时,速度比剪切慢很多。
通过移动语义,临时对象中的资源能够移动其它的对象里。
在现有的 C++ 机制中,我们可以定义拷贝构造函数和赋值函数。要实现移动语义,需要定义移动构造函数,还可以定义移动赋值操作符。对于右值的拷贝和赋值会调用移动构造函数和移动赋值操作符。如果移动构造函数和移动拷贝操作符没有定义,那么就遵循现有的机制,拷贝构造函数和赋值操作符会被调用。 普通的函数和操作符也可以利用右值引用操作符实现移动语义。
举个具体的例子,std::string的拷贝构造函数会做两件事情:

1、根据源std::string对象的大小分配一段大小适当的缓冲区。

2、将源std::string中的字符串拷贝过来。

// just for illustrating the idea, not the actual implementation
string::string(const string& o)
{
    this->buffer_ = new buffer[o.length() + 1];
    copy(o.begin(), o.end(), buffer_);
}

但是假设我们知道o是一个临时对象(比如是一个函数的返回值),即o不会再被其它地方用到,o的生命期会在它所处的full expression的结尾结束的话,我们便可以将o里面的资源偷过来:

string::string(string&& o)
{
    // since o is a temporary, we can safely steal its resources without causing any problem
    this->buffer_ = o.buffer_;
    o.buffer_ = nullptr;
}

想想看,如果存在这样一个move constructor(转移式构造函数)的话,所有源对象为临时对象的拷贝构造行为都可以简化为移动式(move)构造。对于上面的string例子来说,move和copy construction之间的效率差是节省了一次O(n)的分配操作,一次O(n)的拷贝操作,一次O(1)的析构操作(被拷贝的那个临时对象的析构)。这里的效率提升是显而易见且显著的。

我们可以实现一个含有move语义的string类

class MyString {
private:
    char*   p_data_;
    size_t  len_;
 
private:
    void InitData(const char *s)
    {
        p_data_ = new char[len_ + 1];
        memcpy(p_data_, s, len_);
        p_data_[len_] = ‘\0‘;
    }
 
public:
    MyString()
    {
        p_data_ = NULL;
        len_ = 0;
    }
 
    MyString(MyString&& str)
    {
            std::cout << "Move Constructor is called! source: " << str.p_data_ << std::endl;
        len_ = str.len_;
        p_data_ = str.p_data_;
        str.len_ = 0;
        str.p_data_ = NULL;
    }
 
 
    MyString(const char* p)
    {
        len_ = strlen (p);
        InitData(p);
    }
 
    MyString(const MyString& str)
    {
        len_ = str.len_;
        InitData(str.p_data_);
        std::cout << "Copy Constructor is called! source: " << str.p_data_ << std::endl;
    }
 
    MyString& operator = (const MyString& str)
    {
        if (this != &str) {
            len_ = str.len_;
            InitData(str.p_data_);
        }
        std::cout << "Copy Assignment is called! source: " << str.p_data_ << std::endl;
        return *this;
    }
 
    MyString& operator=(MyString&& str)
    {
        std::cout << "Move Assignment is called! source: " << str.p_data_ << std::endl;
        if (this != &str) {
            len_ = str.len_;
            p_data_ = str.p_data_;
            str.len_ = 0;
            str.p_data_ = NULL;
        }
        return *this;
    }
 
    virtual ~MyString()
    {
        if (p_data_) delete[] p_data_;
    }
};
 
int main()
    MyString a; 
    a = MyString("Hello"); 
    std::vector<MyString> vec; 
    vec.push_back(MyString("World"));
    return 0;
}
 
我们的程序运行结果为 :
Move Assignment is called! source: Hello
Move Constructor is called! source: World

有了右值引用和移动语义,我们在设计和实现类时,对于需要动态申请大量资源的类,应该设计移动构造函数和移动赋值函数,以提高应用程序的效率

对移动语义仍有疑问的同学可参考《c++11新特性解析与应用》移动语义(Page 69)

标准库函数 std::move

既然编译器只对右值引用才能调用移动构造函数和移动赋值函数,而所有命名对象都只能是左值引用,如果已知一个命名对象不再被使用而想对它调用移动构造函数和移动赋值函数,也就是把一个左值引用当做右值引用来使用,怎么做呢?标准库提供了函数 std::move,这个函数以非常简单的方式将左值引用转换为右值引用。
std::move在提高 swap 函数的的性能上非常有帮助,一般来说,swap函数的通用定义如下:

template <class T> swap(T& a, T& b)
{
    T tmp(a);   // copy a to tmp
    a = b;      // copy b to a
    b = tmp;    // copy tmp to b
}

有了 std::move,swap 函数的定义变为 :

template<typename T>
void swap(T& a, T& b)
{
    T t(std::move(a));  //a为空,t占有a的初始数据
    a = std::move(b);   //b为空,a占有b的初始数据
    b = std::move(t);   //t为空,b占有a的初始数据
}

通过 std::move,一个简单的 swap 函数就避免了 3 次不必要的拷贝操作。

若有不理解的同学可以参考《c++11 新特性解析与应用》std::move:强制转化为右值(Page 80)

完美转发

在泛型编码中经常出现的一个问题是:如何将一组参数原封不动地转发给另一个函数。
注意,这里所谓“原封不动”就是指,如果参数是左值,那么转发给的那个函数也要接受到一个左值,如果参数是右值,那么后者要接受到一个右值;同理,如果参数是const的,那么转发给的那个函数也要接受到一个const的值,如果是non-const的,那么后者也要接受到一个non-const的值。
总之一句话:保持参数的左值/右值、const/non-const属性不变。
在实现转发的方案里,有七种需要我们了解,本文会为你一一介绍,这七种转发里,只有第七种能实现完美转发。

方案1.非常量左值引用转发

何谓非常量左值引用呢?形如int& a;我们就把a叫做非常量左值引用。这里的左值引用就是我们平时使用的&。
这个解决方案的实例代码如下:

template<class A1, class A2, class A3>
void f(A1 & a1, A2 & a2, A3 & a3)
{
    return g(a1, a2, a3);
}
 
void g(int a1, int a2, int a3){
 
}

转发失败原因:这个转发不能传入非常量右值,即下面的代码是无法通过编译的。

int main()
{
    f(1, 2, 3);
}

但是这种解决方案并不是一无是处的,对于那些只可能传入左值的场景来说,比如Boost库中的Iterator,这种应用还是能够看到的。只是它并不是一个完美的转发方案。

方案2.常量左值引用转发

何谓常量左值引用呢?形如const int& a;我们就把a叫做常量左值引用。
这个解决方案的实例代码如下:

template<class A1, class A2, class A3>
void f(A1 const & a1, A2 const & a2, A3 const & a3)
{
    return g(a1, a2, a3);
}
 
void g(int& a1, int& a2, int& a3)
{
}

转发失败原因:上面的函数f虽然可以接受任何参数列表,但是当函数g接受非常量左值引用变量时,函数f是无法将常量左值引用参数传递给非常量左值引用的。
这个解决方案一般用于拷贝构造函数,因为拷贝构造函数一般传递的都是形如const A&的参数,但也不排除有的拷贝构造函数比较变态,不传递const A&的参数。

方案3.非常量左值引用+常量左值引用转发

这个解决方案的实例代码如下:

template<class A1>
void f(A1& a1)
{
    return g(a1);
}
 
template<class A1>
void f(A1 const & a1)
{
    return g(a1);
}

转发失败原因:上面的实现的确可以满足所有的参数都能传递了,并且当函数g接受非常量参数时,编译器也能找到最佳的匹配模板函数,即第一个。当然这个前提是所有的编译器达成共识,认定第一个模板函数。除此之外,还有一个重要的问题,当函数的参数有三个的时候,我们不得不像下面这样来实现我们的函数f:

template<class A1, class A2, class A3>
void f(A1 const & a1, A2 const & a2, A3 const & a3)
{
    return g(a1, a2, a3);
}
 
template<class A1, class A2, class A3>
void f(A1 & a1, A2 const & a2, A3 const & a3)
{
    return g(a1, a2, a3);
}
 
template<class A1, class A2, class A3>
void f(A1 const & a1, A2 & a2, A3 const & a3)
{
    return g(a1, a2, a3);
}
 
template<class A1, class A2, class A3>
void f(A1 & a1, A2 & a2, A3 const & a3)
{
    return g(a1, a2, a3);
}
 
template<class A1, class A2, class A3>
void f(A1 const & a1, A2 const & a2, A3 & a3)
{
    return g(a1, a2, a3);
}
 
template<class A1, class A2, class A3>
void f(A1 & a1, A2 const & a2, A3 & a3)
{
    return g(a1, a2, a3);
}
 
template<class A1, class A2, class A3>
void f(A1 const & a1, A2 & a2, A3 & a3)
{
    return g(a1, a2, a3);
}
 
template<class A1, class A2, class A3>
void f(A1 & a1, A2 & a2, A3 & a3)
{
    return g(a1, a2, a3);
}

这种方案相当于对函数f进行了重载。由于使用了常量和非常量两种形式的重载,当参数的个数N较大时,需要重载的函数会呈指数级增长(2的N次方),因此这种方案实际上是不可取的。

方案4.常量左值引用+const_cast转发

const_cast的作用是什么呢?它可以去除常量的的const属性,这个转换可以解决方案2里遇到的问题。
这个解决方案的实例代码如下:

template<class A1, class A2, class A3>
void f(A1 const & a1, A2 const & a2, A3 const & a3)
{
    return g(const_cast<A1 &>(a1), const_cast<A2 &>(a2), const_cast<A3 &>(a3));
}

转发失败原因:很显然,去除了const属性,我们就能修改原来的常量了,这样的转发会造成对常量的修改。

方案5.非常量左值引用+修改的参数推到规则转发

这里所谓的“修改参数推倒”是指修改C++ 的现有标准。在模板编程里,有一种参数推倒的说法。当你传递int类型的参数时,编译器会为你找到最佳匹配的模板函数,然后再把参数传递给这个模板函数。在方案1里,导致我们失败的事情就是无法传递非常量右值,但是如果修改C++ 标准,我们就能够将非常量右值推倒成常量右值。

template<class A1>
void f(A1 & a1)
{
    std::cout << 1 << std::endl;
}
 
void f(long const &)
{
    std::cout << 2 << std::endl;
}
 
int main()
{
    f(5);              // 在既有参数推倒规则,会打印2;修改参数推倒规则后,会打印1
    int const n(5);
    f(n);              // 这种情况好一点,都会打印1
}

看出来对现有代码的破坏了吗?注意注释。

方案6.右值引用转发

template<class A1, class A2, class A3>
void f(A1 && a1, A2 && a2, A3 && a3)
{
    return g(a1, a2, a3);
}

转发失败原因:函数g无法接收左值,因为不能将一个左值传递给一个右值引用。另外,当传递非常量右值时也会存在问题,因为此时a1、a2、a3本身是左值,这样当F的参数是非常量左值引用时,我们就可以来修改传入的非常量右值了,而右值是不能被修改的。

方案7.右值引用+修改的参数推到规则转发

你可能疑惑,不是说过修改参数推倒规则后会导致对既有代码的破坏吗?是的,不过那是对左值参数推倒规则的修改,我们这里要修改的是针对右值引用推倒规则的修改。首先,要理解参数推倒规则,我们要理解引用折叠规则

序号规则
1T& + & = T& 
2T& + && = T& 
3T&& + & = T& 
4T或T&& + && = T&& 
引用叠加规则示例程序
#include <iostream>
using namespace std;
 
typedef int&  LRINT;
typedef int&& RRINT;
 
int main()
{
    int     a = 10;
 
    // 左值引用
    LRINT   b = a;      // 单纯:&
    LRINT&  c = a;      // 叠加:&  +  &   不能写做:LRINT& c = 10;     可见c是左值引用
 
    // 右值引用
    RRINT    d = 10;    // 单纯:&&
    RRINT&&  e = 10;    // 叠加:&& + &&   不能写做:RRINT&& e = a;     可见e是右值引用
    LRINT&&  f = a;     // 叠加:&  + &&   不能写做:LRINT&& f = 10;    可见f是左值引用
    RRINT&   g = a;     // 叠加:&& +  &   不能写做:RRINT&  g = 10;    可见g是左值引用
 
    system("pause");
    return 0;
}

理解了引用叠加规则后,让我们来看看修改后的针对右值引用模板参数推倒规则。若函数模板的模板参数为A,模板函数的形参为A&&,则可分为两种情况讨论:
1、如果实参是左值,那么T就被推导为A&(其中A为实参的类型),于是T&& = A& &&,而A& &&则退化为A&(理解为:左值引用的右值引用仍然是左值引用)。
2、如果实参是右值,那么T就被推导为A,于是T&& = A&&(右值引用)。
应用了新的参数推导规则后,我们来看下面的代码:

template<class A>
void f(A&& a)
{
    return g(static_cast<A&&>(a));
}

当传给f一个左值(类型为T)时,由于模板是一个引用类型,因此它被隐式转换为左值引用类型T&,根据推导规则1,模板参数A被推导为T&。这样,在f内部调用g(static_cast<A &&>(a))时,static_cast<A &&>(a)等同于static_cast<T& &&>(a),根据引用折叠规则第2点,即为static_cast<T&>(a),这样转发给g的还是一个左值。
当传给f一个右值(类型为T)时,由于模板是一个引用类型,因此它被隐式装换为右值引用类型T&&,根据推导规则2,模板参数A被推导为T。这样,在f内部调用g(static_cast<A &&>(a))时,static_cast<A &&>(a)等同于static_cast<T&&>(a),这样转发给g的还是一个右值(不具名右值引用是右值)。

可见,使用该方案后,左值和右值都能正确地进行转发,并且不会带来其他问题。
另外c++11 为了转发方便,提供了一个函数模板forward,因此上面代码可以简写为:

template<class A>
void f(A&& a)
{
    return g(std::forward<A>(a));
}

对完美转发有疑问的同学可参考《c++11 新特性解析与应用》完美转发(Page 85)

C++11:右值引用