1.左值和右值
在C++中,每个表达式或者是左值,或者是右值。
- 左值(lvalue):可以出现在赋值表达式左侧的值,例如变量名
a
、数据成员a.m
、解引用表达式*p
等。左值可以被赋值和取地址。 - 右值(rvalue):只能出现在赋值表达式右侧的值,例如字面值
42
、算术表达式a+b
、临时对象Point(3,4)
、返回值是值类型的函数调用等。右值不能被赋值和取地址。
例如:
int a;
int* p = &a; // OK, a is lvalue
*p = 42; // OK, *p is lvalue
p = &42; // error, 42 is rvalue
a + 1 = *p; // error, a + 1 is rvalue
注:实际上C++标准定义了纯右值(prvalue)、将亡值(xvalue)和左值三种类型,纯右值和将亡值统称为右值,详见Value categories - cppreference。
2.左值引用和右值引用
C++的引用(reference)是一种类型,可以看作对象的别名。引用在本质上和指针一样,都是对象的地址(指针和引用的区别详见《C++程序设计原理与实践》笔记 第17章 17.9节)。
C++提供了两种类型的引用:
- 左值引用(lvalue reference):使用
&
表示,T&
是T
类型的左值引用。左值引用是最常用的引用类型,可用于在函数调用中实现传引用(pass-by-reference)语义。 - 右值引用(rvalue reference):使用
&&
表示,T&&
是T
类型的右值引用。右值引用是C++11引入的,用于实现移动语义(见第3节)。
注:左值和右值是表达式的一种属性/分类,而左值引用和右值引用是两种不同的类型,二者是完全不同的概念,但是存在一定的联系:
- 左值引用必须使用左值初始化(即左值引用只能绑定到左值),一个例外是
const
左值引用可以使用右值初始化;右值引用必须使用右值初始化。 - 如果函数的返回类型是左值引用,则函数调用表达式是左值;如果函数的返回类型是右值引用或者不是引用,则函数调用表达式是右值。
- 左值引用和右值引用本身都是左值(这意味着右值引用可以被赋值和取地址)。
例如:
int a;
int& lr = a;
int* p = &lr; // OK, lr is lvalue
lr = 42; // OK, lr is lvalue
int& lr2 = 42; // error, lvalue reference can't bind to rvalue
const int& clr = 42; // OK, const lvalue reference bind to rvalue
const int* cp = &clr; // OK, clr is lvalue
int&& rr = a + 1;
p = &rr; // OK, rr is lvalue
++rr; // OK, rr is lvalue
int&& rr2 = a; // error, rvalue reference can't bind to lvalue
int&& rr3 = rr; // error, rvalue reference can't bind to lvalue
int&& rr4 = std::move(a); // OK, std::move(a) is rvalue
其中,std::move()
函数将左值转换为右值引用,详见3.3节。
3.移动语义
为了在特定情况下避免不必要的拷贝,C++11引入了移动语义。在介绍移动语义之前,下面通过一个vector
的例子说明什么情况下存在不必要的拷贝,之后介绍如何实现移动语义。
3.1 拥有资源的类
一个类可能会获取资源,例如自由存储(使用new
创建的对象或数组)、文件、锁、线程、套接字等,这样的类通常具有指向资源的指针成员。
标准库vector
是一个典型的拥有资源的类的例子。例如:
vector<double> age = {0.33, 22.0, 27.2, 54.2};
下图是(简化的)age
示意图:
其中,存储元素的数组是使用new
在自由存储上分配的,age
对象本身仅保存了元素个数和指向该数组的指针。
拥有资源的类通常需要拷贝构造函数、拷贝赋值运算符和析构函数,以确保
- 当对象被拷贝时,资源被正确拷贝。
- 当对象被销毁时,资源被正确释放。
否则可能会导致内存泄露、重复释放等问题,因为拷贝的默认含义是“拷贝所有数据成员”(即浅拷贝)。关于这一点,详见《C++程序设计原理与实践》笔记 第18章 18.3.1和18.3.2节,这里不再详细介绍。
simple_vector.h给出了一个简化的vector
实现,并且定义了拷贝构造函数和拷贝赋值运算符。
然而,在某些情况下会存在不必要的拷贝。下面借用《C++程序设计原理与实践》第18章中的例子:
vector<double> fill(istream& is) {
vector<double> res;
for (double x; is >> x;) res.push_back(x);
return res;
}
void use() {
vector<double> vec = fill(cin);
// ... use vec ...
}
由于函数fill()
的返回类型是值类型,因此return res;
会发生拷贝。假设res
有10万个元素,则将其拷贝到vec
的代价是很高的。但实际上,use()
永远不会使用res
,因为res
在函数fill()
返回后就会被销毁,因此从res
到vec
的拷贝就是不必要的——可以设法让vec
直接复用res
的资源。
为了解决这一问题,C++11引入了移动语义(move semantics):通过“窃取”资源,直接将res
的资源移动(move)到vec
,如下图所示:
移动之后,vec
将引用res
的元素,而res
将被置空(换句话说,移动 = “窃取”资源 = 浅拷贝+置空原指针)。
总之,移动语义是为了解决由即将被销毁的对象初始化或赋给其他对象时发生不必要的拷贝,通过“窃取”资源(移动)来避免拷贝。
3.2 移动构造函数和移动赋值
为了在C++中表达移动语义,需要定义移动构造函数(move constructor)和移动赋值(move assignment)运算符:
T(T&& v); // move constructor
T& operator=(T&& v); // move assignment
移动构造函数和移动赋值运算符的参数都是右值引用,因为右值正是前面提到的“即将被销毁的对象”。
当使用一个右值初始化一个相同类型的对象时,移动构造函数将被调用。 包括:
- 初始化:
T a = std::move(b);
或T a(std::move(b));
,其中b
是T
类型 - 函数参数传递:
f(std::move(a))
,其中a
和函数参数都是T
类型 - 函数返回值:
return a;
,其中函数返回值是T
类型,且T
有移动构造函数
注:C++17引入了拷贝消除(copy elision)/返回值优化(return value optimization, RVO)特性:如果初始值是纯右值(prvalue),则移动构造函数调用会被优化掉。
当对象出现在赋值表达式左侧,并且右侧是一个相同类型的右值时,移动赋值运算符将被调用。
simple_vector.cpp为简化的vector
定义了移动构造函数和移动赋值运算符。再次考虑前面的例子,在fill()
返回时移动构造函数将被隐式调用(fill()
和use()
的代码均不需要修改)。
3.3 std::move
前面提到,右值引用不能绑定到左值,因此左值不能被移动。但是,标准库头文件<utility>提供了std::move()
函数,作用是将参数“当作”右值,从而可以将其赋给右值引用。
如果a
是一个左值,则std::move(a)
是一个右值,这意味着该对象“可移动”(可能被窃取资源),因此不能再使用。例如:
std::vector<int> a = {1, 2, 3};
std::vector<int> b = std::move(a);
std::cout << a.size() << ' ' << b.size() << std::endl;
将输出 “0 3”。
注:
- 在上面的例子中,移动并不是发生在
std::move(a)
,而是b
的移动构造函数。 - 如果一个左值出现在
return
语句中,则它是可移动的(move-eligible),因此不需要显式使用std::move()
。例如3.1节中的fill()
函数。
3.4 示例
下面是一个测试示例:
#include <iostream>
class C {
public:
C() {}
C(const C& c) { std::cout << "copy constructor\n"; }
C(C&& c) { std::cout << "move constructor\n"; }
C& operator=(const C& c) { std::cout << "copy assignment\n"; return *this; }
C& operator=(C&& c) { std::cout << "move assignment\n"; return *this; }
};
C f() {
C c;
return c;
}
int main() {
C a = f();
C b = a;
a = C();
b = a;
b = std::move(a);
return 0;
}
输出如下:
move constructor
move constructor
copy constructor
move assignment
copy assignment
move assignment
return c;
调用移动构造函数(将局部变量c
移动到返回值临时对象),因为函数f()
的返回类型C
不是引用类型,且C
有移动构造函数C a = f();
调用移动构造函数(将返回值临时对象移动到a
),因为f()
是一个右值C b = a;
调用拷贝构造函数,因为a
是一个左值a = C();
调用移动赋值,因为C()
是一个右值b = a;
调用拷贝赋值,因为a
是一个左值b = std::move(a);
调用移动赋值,因为std::move(a)
是一个右值
注:
- 前两个移动构造函数调用可能会被编译器的拷贝消除特性优化掉(除非编译器使用的C++标准版本小于C++17且指定了
-fno-elide-constructors
选项),从而c
和a
的地址是一样的,整个语句只有一次默认构造函数调用。 - 从C++17开始编译器会强制进行拷贝消除。
4.总结
C++的值语义是万恶之源。
参考
- Move constructor - cppreference
- Move assignment - cppreference
- 【C++深陷】之“左值与右值”
- 【C++深陷】之“对象移动”
- Understanding lvalues, rvalues and their references
- 《C++程序设计原理与实践》笔记 第18章