rvalue 参考


参考〉中谈到,参考是对象的别名,在 C++ 中,「对象」这个名词,不单只是指类的实例,而是指内存中的一块数据,那么可以参考字面常量吗?常量无法使用&取址,例如无法&10,因此以下会编译错误:

int &r = 10; // error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'

不过,加上const的话倒是可以:

const int &r = 10;

常量是内存中临时的数据,无法对常量取址,因此编译器会将以上转换为像是:

const int _n = 10;
const int &r = _n;

实际上,r并不是真的参考至 10,而是 10 被复制给_n,然后r参考至_n,如果不加上const,那么你可能会以为变更了r,就是变更了 10 地址处的值,因此就要求你一定得加上const,不让你改了。

为什么会需要参考至常量?通常跟函数调用相关,这之后文件再来讨论;类似地,以下会编译失败:

int a = 10;
int b = 20;
int &r = a + b; // error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'

这是因为a + b运算出的结果,会是在临时的内存空间中,无法取址;类似地,若想通过编译,必须加上const

int a = 10;
int b = 20;
const int &r = a + b;

不过在 C++ 11 之后,像以上的表达式,可以直接参考了:

int a = 10;
int b = 20;
int &&rr = a + b;

在以上的程序中,int&&是 rvalue 参考(rvalue reference),rr参考了a + b运算结果的空间,相对于以下的程序来说比较有效率:

int a = 10;
int b = 20;
int c = a + b; // 将 a + b 的结果复制给 c

因为不必有将值复制、存储至c的动作,效率上比较好,特别是当 rvalue 表达式会产生庞大对象的时候,复制就会是个成本考量,例如s1s2若是个很长的string,那么s1 + s2的结果还会复制给目标string的话:

string result = s1 + s2;

改用以下会比较经济:

string &&result = s1 + s2;

相对于 rvalue 参考,int&这类参考就被称为 lvalue 参考;只不过,lvalue 或 rvalue 是什么?方才编译错误的消息中,似乎也出现了 lvalue、rvalue 之类的字眼,这些是什么?

lvalue、rvalue 是 C++ 对表达式(expression)的分类方式,一个粗略的判别方式,是看看&可否对表达式取址,若可以的话,表达式是 lvalue,否则是个 rvalue。

若要精确的定义,可以参考〈Value categories〉,该文件中 History 的区域,有谈到表达式分类的历史,最早是从 CPL 开始对表达式区分为左侧模式(left-hand mode)与右侧模式(right-hand mode),左、右是指表达式是在指定的左或右侧,有些表达式只有在指定的左侧才会有意义。

C 语言有类似的分类方式,分为 lvalue 与其他表达式,l 似乎暗示着 left 的首字母,不过实际上,并非以指定的左、右来分类,lvalue 是指可以识别对象的表达式,白话点的说法是,表达式的结果会是个有名称的对象。

到了 C++ 98,非 lvalue 表达式被称为 rvalue,一些 C 中非 lvalue 的表达式成了 lvalue,到了 C++ 11,表达式又被重新分类为〈Value categories〉中的结果。

许多文件取 lvalue、rvalue 的 l、r,将它们分别译为左值、右值,就表达式的分类历史来说,不能说是错,不过严格来说,C++ 中 lvalue、rvalue 的 l、r,并没有左、右的意思,lvalue、rvalue 只是个分类名称。

在〈Value categories〉一开头,可以看到目前的 C++ 标准,将表达式更细分为 glvalue、prvalue、xvalue、lvalue 与 rvalue,g 暗示为 generalized,pr 暗示为 pure,x 暗示为 eXpiring,就涵盖关系而言,使用图来表示会比较清楚:

rvalue 参考

具体来说,哪个表达式属于哪个分类,〈Value categories〉都有举例,当然,容易看到眼花花…

方才谈到,一个粗略的判别方式,是看看&可否对表达式取址,若可以的话,表达式是 lvalue,否则是个 rvalue;另一个白话点的判别方式是,lvalue 表达式的结果会是个有名称的对象,例如a,rvalue 的结果是暂时性存在于内存,例如a + b

那么++ii++呢?在〈递增、递减、指定运算〉中谈过,++i运算结果是递增后的i,也就是++i运算结果是个有名称的对象,因此可以使用 lvalue 参考:

int i = 10;
int &r = ++i; // OK

然而i++运算结果是递增前的i,暂时性存在于内存,若不指定给变量的话就不见了,因此i++是个 rvalue,因此以下会编译失败:

int i = 10;
int &r = i++; // error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'

C++ 11 开始,若想参考i++运算时暂时存在于内存中递增前的i,可以使用 rvalue 参考:

int i = 10;
int &&rr = i++; // OK

哪些是 lvalue,而哪些又是 rvalue,基本上还是以〈Value categories〉的定义为准,不清楚的话就查一下。

使用 rvalue 参考通常是为了效率上的考量,

还有个std::move(定义于utility标头文件)用来实现移动语义(move semantics),例如实现移动构造函数(move constructor),这需要在认识类定义、复制构造函数等之后才能细谈,就现阶段而言,可以从string来稍微认识一下,例如,以下会将s1的数据复制给s2

string s2 = s1;    // s1 是个 string,而这边会复制 s1 的内容给 s2

s1指定给s2后,就不再会用到原本的内容,那么复制就是不必要的成本,若能把s1的内容直接移给s2的话就好了,C++ 11 开始可以这么做:

string s2 = std::move(s1);

这么一来,s1的数据就被移至s2了,在这之后不能立即使用s1来取值,因为数据转移出去了,取值结果是不可预期的,只能销毁s1,或者是重新指定字符串给s1

来看个简单的示范:

#include <iostream> 
#include <string>
using namespace std; 

int main() { 
    string s1 = "abc";
    string s2 = s1;     //  复制 s1 的数据

    cout << s1 << endl; // 显示 "abc"
    cout << s2 << endl; // 显示 "abc"
}

跟移动版本比较一下:

#include <iostream> 
#include <string>
#include <utility>

using namespace std; 

int main() { 
    string s1 = "abc";
    string s2 = std::move(s1);    //  转移 s1 的数据

    // cout << s1 << endl;        // 这时取值结果不可预期
    cout << s2 << endl;           // 显示 "abc"

    s1 = "xyz";                   // OK
    cout << s1 << endl;           // 这时可以取值
}

移动版本之所以能够运作,是因为string的构造函数之一,使用了 rvalue 参考,而std::move的作用,其实是告诉编译器,将指定的 lvalue 当成是 rvalue(某些程度就是一种 cast),以选择定义了 rvalue 参考的构造函数,而构造函数中实现了移动来源数据的演算。

因为 move 这个名称太平凡了,为了避免名称冲突,建议包含std命名空间,也就是使用std::move


展开阅读全文