C++ 左值、右值与右值引用
在我们日常的 C/C++ 语言学习过程中,我们肯定会经常听到诸如“左值”和“右值”这样的名词。不仅如此,在我们编译 C/C++ 应用程序时,编译器偶尔也会在其错误信息中包含与左值、右值相关的错误信息。那么究竟什么是左值和右值呢?是不是同它们的名字一样,可以通过一些“位置”因素来进行判断呢?比如位于某个语法结构中左侧的值就是左值,而位于右边的就是右值?而在本文中,我们将以 C++ 语言为例来介绍在其语言体系中,左值、右值和右值引用的概念和应用。
通常来说,我们确实可以使用一个值在表达式中的位置信息来判断该值的“左右值”类型。比如在下面这段代码中,最后一行代码的表达式中位于“=”左侧的 “sum” 就是一个左值,而右侧的 “x + y” 则是一个右值。除此之外,我们也可以通过另外一种常用的方式来判断,即“可以取地址的、有名字的值就是左值;反之不能够取地址、并且没有名字的值就是右值”。比如在这段代码中,我们可以对变量 “sum” 进行取地址(&sum)操作,而不能够对等号右侧的 “x + y” 进行取地址操作。
int x = 10;
int y = 20;
int sum = x + y;
接下来,我们再深入看一下 C++ 标准对“右值”更进一步的定义。实际上在 C++11 中,右值其实被分为了 “xvalue“ 和 “prvalue“ 两种类型。它们两者分别对应的单词是 “Expiring Value” 和 “Pure Right Value”,而对应的中文名词则可以翻译为”将亡值“和”纯右值“,翻译的名称并不唯一。其中的纯右值则是指我们最常见的那种右值类型,比如函数返回的临时变量值、字面量值以及 Lambda 表达式等等,这些都是无法被取地址的临时值类型。当然,你可以把它们赋值给一个左值,然后再继续使用。
而”将亡值“则是在 C++11 中新引入的跟右值引用相关的表达式类型,这样的表达式通常是要被移动的对象。“右值引用”顾名思义就是对右值的一个引用,通常来说由于右值并不具有实际的名字,所以我们也只能通过引用的方式来关联到它们的存在。在 C++ 中,我们可以使用两个“按位与”符号(&&)来表示一个右值引用类型,比如下面这段代码。这里的变量 “x” 便代表一个整型临时值的右值引用类型。
int &&x = 10;
那么“右值引用”应该用在什么地方呢?一般来说,我们可以通过“右值引用”来完成对一个将亡值的“语义转移”过程。通常来讲,在 C++ 中我们会通过“拷贝构造函数”来完成类对象之间的赋值过程,比如在如下这段代码中,临时值赋值给变量 “x”,然后 “x” 在被赋值给变量 “y” 时便会分别执行两次类 “A” 中我们定义的拷贝构造函数。而在该拷贝构造函数中,便会对类对象内名为 “array” 的数组重新进行内存分配的过程。
#include <iostream>
class A {
public:
A(size_t size): size(size), array((int*) malloc(size)) {
std::cout
<< "constructor called."
<< std::endl;
}
~A() {
free(array);
}
A(const A &a) : size(a.size) {
array = (int*) malloc(a.size);
std::cout
<< "normal copied, memory at: "
<< array
<< std::endl;
}
size_t size;
int *array;
};
int main (int argc, char **argv) {
auto x = A(100);
auto y = x;
return 0;
}
而为了完成“语义转移”的过程,我们则需要使用 C++ 中另外一个名为“移动构造函数”的成员函数类型,代码如下所示。
#include <iostream>
#include <type_traits>
#include <utility>
class A {
public:
A(size_t size): size(size), array((int*) malloc(size)) {
std::cout
<< "constructor called."
<< std::endl;
}
~A() {
free(array);
}
A(A &&a) : array(a.array), size(a.size) {
a.array = nullptr;
std::cout
<< "xvalue copied, memory at: "
<< array
<< std::endl;
}
A(const A &a) : size(a.size) {
array = (int*) malloc(a.size);
std::cout
<< "normal copied, memory at: "
<< array << std::endl;
}
size_t size;
int *array;
};
int main (int argc, char **argv) {
auto getTempA = [](size_t size = 100) -> A {
auto tmp = A(size);
std::cout << "Memory at: " << tmp.array << std::endl;
return tmp;
};
std::cout
<< std::is_rvalue_reference<decltype(getTempA())&&>::value
<< std::endl; // true;
auto x = getTempA(1000);
auto y = x;
return 0;
}
这里我们先从主函数来看。首先我们编写了一个用于返回临时值的 Lambda 函数 “getTempA”,通过该函数可以返回一个类 “A” 的临时值对象,也就是一个右值。接下来我们使用了宏函数 is_rvalue_reference
来判断上述 Lambda 函数返回的值是否是一个右值引用,这里验证了我们的相反,该宏函数返回的是 “true”。再接下来的两行代码,我们分别把之前的右值赋值给了变量 “x”,然后又把左值 “x” 赋值了变量 “y”。然后我们把目光移向重点,也就是我们在类 “A” 内部新增加“移动构造函数”。这里我们单独把这段代码拿出来,如下所示。
...
A(A &&a) : array(a.array), size(a.size) {
a.array = nullptr;
std::cout
<< "xvalue copied, memory at: "
<< array
<< std::endl;
}
...
在这个函数中我们一共做了两件事。第一件事是直接把临时值对象,也就是右值引用对象 “A” 内部的成员变量 “array” 的值直接赋值给了新生成对象内部的 “array” 变量,这使得新对象可以直接使用临时值对象内部已经分配好的这块内存空间,而不需要再像拷贝构造函数一样需要去重新分配内存空间。第二件事是将临时值对象成员变量 “array” 的值置为 nullptr
空指针,这样做的目的是为了防止临时值对象的析构函数在执行时将这块已经分配的内存区域清除,因为这快内存区域实际上已经被新对象中的成员变量 “array” 直接使用了。而我们之前提到的“转移语义”便可以简单地理解为将临时对象内已经分配好的内存区域直接“偷”过来使用这样一个过程。
总的来讲,使用基于右值引用的语义转移,可以使我们在复制具有大块内存空间的对象时可以直接使用原对象已经分配好的内存空间进而省去重新分配内存空间的过程,因此某种程度上来讲,可以在一定条件下提升应用的运行效率。
最后我们来做个总结。在 C++ 中,左值一般是指位于运算符左侧的值,并且该值可以被进行取地址操作;而右值则通常指位于运算符右侧的值,并且该值无法被取地址。而在 C++11 中,右值又被细分为“纯右值”与“将亡值”。比如常见的字面量值,表达式产生的临时变量值等均是纯右值。而“将亡值”则代表资源能够被重新使用的对象,常见的如 std::move
函数的返回值,或者返回值被标记为右值引用(&&)的函数等,对于“纯右值”和“将亡值”在实际编码中我们并不需要进行十分细致的区分,在大多数情况下将其统一看成右值使用即可。
除此之外还要注意的是,我们上述介绍的 C++ 标准特性是一方面,但除此之外,各大编译器厂商还会同时使用名为 “RVO” 和 “NRVO” 的编译器优化技术来对函数的对象返回值类型进行临时值上的优化,因此对于拷贝构造函数的在代码中的实际调用次数,可能在不同的编译器下会有着不同的表现。BTW,类拷贝构造函数的参数使用常量左值引用类型(const T&)是由于“常量引用”是一种全能类型,它即可以接纳左值,也可以接纳右值。
评论 | Comments