精通Cocos2d-x游戏开发(基础卷)
上QQ阅读APP看书,第一时间看更新

5.7 右值引用

右值引用是一个不容易理解的概念(即使对有C++基础的人而言),右值引用的目的是为了实现“移动语义”和“完美转发”,这两个概念也是不容易理解的。不论冠以何种名词,它们都是为了解决问题的,所以先将问题抛出,看右值引用如何解决问题,才能更好地理解它。将普通的引用定义为Type&,那么右值引用的写法则是Type&&,通过std::move函数,可以将左值转换为右值引用,move可理解为static_cast<T&&>(obj)。

5.7.1 分辨左值和右值

左值和右值是什么?理解为等号左右两边的值并不准确。这里说的右值是指表达式结束后就不存在的一个临时对象(或者称为纯右值会合适一些)。通过是否可取地址操作符以及是否有名字可以判断能否为右值,类似i++和3+4的表达式都取不了地址。

 //编译正确,a是左值,3 + 4 为右值,该表达式返回了一个临时对象
int a = 3 + 4;
//编译错误,3 + 4 即没有名字,也无法取址,为右值
&3 + 4;
//编译错误,a++会返回a的复制(临时变量),并对真正的a执行++操作,该临时变量即没有名字也无法取址,该复制为右值
&a++;
//编译错误,123和true为临时对象,没有名字也不能取址,是右值
&123;
 &true;
//编译正确,虽然该字符串没有名字,但可以取址,是左值
&"Hello World"

C++11有一个定义:“基于安全的原因,具名参数将不被认定为右值,即便是右值引用,必须使用move来获取右值”。右值引用并不等于右值,即使程序员的类型是右值引用,但仍然需要使用move来转换,如下面这个例子:

bool isRV(const A& a) { return false; }
bool isRV(const A&& a) { return true; }
void fun(A&& a)
{
    //false
   isRV(a);
    //true
    isRV(move(a));
}

5.7.2 移动语义

移动是为了消除不必要的对象复制,为提高效率而存在的,下面列举两个问题,返回临时对象以及移动构造。

当从一个函数中返回一个临时对象,并在调用函数处用一个变量来接住这个对象时,会创建两个对象。(如果编译器开启了RVO Return Value Optimization返回值优化,则只有一个,VS的DEBUG模式默认没有开启RVO)第一个是函数内部定义的局部变量,第二个则是在外面用来接住对象的变量。下面定义了一个类A,在A的构造和析构函数打印日志中,可以看到调用了两次构造函数和两次析构函数。

class A
{
public:
    ~A() { cout << "~A()" << endl; }
}
A GetA()
{
    A a;        
    return a;
}
int main()
{
    A a = GetA();
    return 0;
}

执行结果如下:

~A()
~A()

使用移动语义进行优化之后的代码如下,整个过程只创建一个对象。右值引用成功将本该释放的临时变量取了出来,并可以正常使用。在C++11之前,使用const A&也可以将函数内的变量取出,但由于是const引用,所以并不能对变量进行操作,而非const的右值引用则没有该限制。

A&& GetA()
{
    A a;
    return move(a);
}
int main()
{
    A&& a = GetA();
    return 0;
}

执行结果如下:

~A()

上面的代码中,当A是一个巨大的容器时,复制带来的消耗会是非常恐怖的。另外,使用void GetA(A& a)的方式也可以,但是很多时候需要的形式是由函数返回一个对象,从代码的简单易用性来看,右值引用会更友好一些。如图5-1所示为这两种方法的区别。

图5-1 移动构造和拷贝构造

移动构造是为了在使用临时对象来构造新对象时,可以直接使用临时对象已经申请的资源,这在移动对象指向堆内存的指针时,可以节省堆内存的分配和释放。下面用一个简单的字符串类来分析。

class MyStr
{
public:
    MyStr()
   {
        str = NULL;
    }
    MyStr(char* s)
   {
        str = new char[strlen(s) + 1];
        memcpy(str, s, strlen(s) + 1);
    }
    ~MyStr()
    {
        if (NULL != str)
         {
            delete[] str;
        }
    }
    char* str;
};

在main函数中使用它们,MyStr默认会自动生成一个拷贝构造函数,默认的拷贝构造函数使用类型memcpy的方法将类的内容复制过去,这里可以称之为浅拷贝。下面的a=MyStr("123");会调用默认的拷贝构造函数,将str的指针地址复制过去,注意是地址而不是内容。语句执行完毕后,MyStr("123")临时对象会被析构,临时对象和变量a的str都指向同一块内存,当临时变量析构时,a对str的任何访问都会崩溃,所以这不是我们想要的。

MyStr a;
a = MyStr("123");

如果为MyStr添加一个拷贝构造函数,就可以解决这个问题了,在拷贝构造函数中进行深拷贝,将str的内容复制出来,这样的写法在C++中很常见。

MyStr(const MyStr& s)
{
    str = new char[strlen(s.str) + 1];
    memcpy(str, s.str, strlen(s.str) + 1);
}

拷贝构造解决了代码可能的错误,但在执行的时候,对于临时变量MyStr("123"),存在无意义的new和delete,这是资源的浪费,移动构造节省了这种资源的浪费,当MyStr存在移动构造函数时,临时变量会自动调用移动构造函数,而普通的变量还是调用拷贝构造函数。

MyStr(MyStr&& s)
{
  str = s.str;
  s.str = NULL;
}

上面的移动构造是非常经典的写法,理解这种写法,就可以掌握移动构造了。首先s是一个右值引用,其即将被释放,然后在移动构造中做了两个操作,将右值引用的指针取出并保存,最后设置为空,这时就把临时变量中的指针“移动”过来了。下面对比默认拷贝构造、移动构造和拷贝构造的过程,如图5-2所示。

图5-2 对比3种构造方法

看到移动构造的经典写法后,读者是否会想在普通的拷贝构造函数中使用这种写法来移动指针?可以尝试,但需要先把参数类型调整一下,将const MyStr&调整为MyStr&,否则s.str=NULL的操作将无法执行,运行时会发现,根本进不了新的拷贝构造函数,这是因为右值可以用const MyStr&取出,却不能作为MyStr&取出(回顾前一个例子有介绍),没有匹配到该函数,则自动调用默认的移动构造函数,而默认的移动构造函数并没有实现移动Tmp内str指针。

5.7.3 完美转发

完美转发是为了能更简洁明确地定义泛型函数——将一组参数原封不动地传给另一个函数。原封不动指的是参数数值和类型不变,参数的左值右值属性不变,参数的const属性不变。这在泛型函数中是比较普遍的需求。

例如,有一个函数fun1,其可以将参数转发给fun2,按照C++03的写法如下:

template<typename T>
void fun1(T& t)
{
    fun2(t);
}
template<typename T>
void fun1(const T& t)
{
    fun2(t);
}

在执行的时候,转发到fun2的参数类型如下面注释所示。

int a;
const int& b = 1;
fun1(a);    //int&
fun1(b);    //const int&
fun1(2);    //int&

这样的写法存在两个问题,第一,需要重载两个参数类型为const T&和T&的泛型函数,第二,无法保留参数的右值属性。所以将fun1改为下面一个函数即可解决该问题。

template<typename T>
void fun1(T&& t)
{
    fun2(t);
}

再次运行,转发到fun2的参数类型如下面注释所示。

int a;
const int& b = 1;
fun1(a);    //int&
fun1(b);    //const int&
fun1(2);    //int&&

C++11定义的T&&推导规则是,右值实参为右值引用,左值实参为左值引用,参数属性不变。