在C++中,如果一个类获取了资源,则需要定义拷贝构造函数和拷贝赋值运算符以确保资源被正确地拷贝。然而,在某些情况下会存在不必要的拷贝,影响程序性能。为了解决这一问题,C++11引入了移动语义。本文首先介绍C++的左值和右值及其引用,之后介绍移动语义和完美转发及其实现。
在C++中,每个表达式除了具有类型,还有值类别(value category):
a
、数据成员a.m
、下标表达式a[n]
、解引用表达式*p
、返回值是左值引用的函数调用等。左值可以被赋值和取地址。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)和左值(lvalue),纯右值和将亡值统称为右值(rvalue),详见Value categories - cppreference。“右值”只是沿用了C语言中的叫法。
C++的引用(reference)是一种类型,可以看作对象的别名。引用在本质上和指针一样,都是对象的地址(指针和引用的区别详见《C++程序设计原理与实践》笔记 第17章 17.9节)。
C++提供了两种类型的引用:
&
表示,T&
是T
类型的左值引用。左值引用是最常用的引用类型,可用于在函数调用中实现传引用(pass-by-reference)语义。&&
表示,T&&
是T
类型的右值引用。右值引用是C++11引入的,用于实现移动语义(见第3节)。注:左值和右值是表达式的值类别,而左值引用和右值引用是两种不同的类型。二者是完全不同的概念,但是存在一定的联系:
const
左值引用可以使用右值初始化;右值引用必须使用右值初始化。vector::operator[]
),则函数调用表达式是左值;如果函数的返回类型是右值引用(例如std::move()
)或者不是引用(例如vector::size()
),则函数调用表达式是右值。小结:右值引用类型的表达式可能是左值或右值。有名字的右值引用(例如变量和形参)是左值,没有名字的右值引用(例如std::move()
和std::forward()
调用表达式)是右值。
下面是一个使用左值引用和右值引用的示例:
int a;
int& lr = a; // lr is lvalue reference
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 can bind to rvalue
const int* cp = &clr; // OK, clr is lvalue
int&& rr = a + 1; // rr is rvalue reference
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.4节。
如果函数实参是右值,那么重载解析规则会优先选择右值引用版本的重载。例如:
#include
#include
void f(int& x) {
std::cout << "lvalue reference overload f(" << x << ")\n";
}
void f(const int& x) {
std::cout << "lvalue reference to const overload f(" << x << ")\n";
}
void f(int&& x) {
std::cout << "rvalue reference overload f(" << x << ")\n";
}
int main() {
int i = 1;
const int ci = 2;
f(i); // calls f(int&)
f(ci); // calls f(const int&)
f(3); // calls f(int&&)
// would call f(const int&) if f(int&&) overload wasn't provided
f(std::move(i)); // calls f(int&&)
// rvalue reference variables are lvalues when used in expressions
int&& x = 1;
f(x); // calls f(int& x)
f(std::move(x)); // calls f(int&& x)
}
其中
f(3)
调用f(int&&)
,因为3是右值。如果没有定义f(int&&)
,则调用f(const int&)
,因为const
左值引用可以绑定到右值。f(x)
调用f(int&)
,因为x
是左值(尽管其类型是int&&
)。为了在特定情况下避免不必要的拷贝,C++11引入了移动语义。在介绍移动语义之前,下面通过一个vector
的例子说明什么情况下存在不必要的拷贝,之后介绍如何实现移动语义。
一个类可能会获取资源,例如自由存储(使用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()
的返回类型是值类型,因此理论上会发生两次拷贝(res
→返回值临时对象→vec
)。假设res
有10万个元素,则拷贝代价是很高的。但实际上,use()
永远不会使用res
,因为res
在函数fill()
返回后就会被销毁,因此从res
到vec
的拷贝就是不必要的——可以设法让vec
直接复用res
的资源。
为了解决这一问题,C++11引入了移动语义(move semantics):通过“窃取”资源,直接将res
的资源移动(move)到vec
,如下图所示:
移动之后,vec
将引用res
的元素,而res
将被置空(换句话说,移动 = “窃取”资源 = 浅拷贝+置空原指针)。
总之,移动语义是为了解决由即将被销毁的对象初始化或赋给其他对象时发生不必要的拷贝,通过“窃取”资源(移动)来避免拷贝。
注:
(1)传引用参数:
void fill(istream& is, vector<double>& v) {
for (double x; is >> x;) v.push_back(x);
}
void use() {
vector<double> vec;
fill(cin, vec);
// ... use vec ...
}
缺点是不能使用返回值语法,必须先声明变量。
(2)返回new
创建的指针:
vector* fill(istream& is) {
vector<double>* res = new vector<double>;
for (double x; is >> x;) res->push_back(x);
return res;
}
void use() {
vector<double>* vec = fill(cin);
// ... use vec ...
delete vec;
}
缺点是必须记得delete
这个向量。
我们希望使用返回值语法,同时避免拷贝。移动语义可以做到这一点。
为了在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
有移动构造函数注:如果初始值是纯右值(prvalue)(例如T a = T();
、f(T())
、return T();
),则移动构造函数调用可能会被拷贝消除优化掉,详见3.3节。
当对象出现在赋值表达式左侧,并且右侧是一个相同类型的右值时,移动赋值运算符将被调用。
simple_vector.cpp为简化的vector
定义了移动构造函数和移动赋值运算符。
再次考虑前面的例子,在fill()
返回时,vector
的移动构造函数将被隐式调用(fill()
和use()
的代码均不需要修改)。
C++标准支持拷贝消除(copy elision),允许编译器在某些情况下省略拷贝构造函数和移动构造函数的调用,从而提高程序的性能。拷贝消除的规则也随着C++标准版本的更新而不断扩展。
从C++17开始,在下列情况下编译器会强制进行拷贝消除:
return
语句中,操作数是与返回类型相同的纯右值。例如,T f() { return T(); }
T x = T();
在下列情况下,编译器允许但不强制进行拷贝消除:
return
语句中,操作数是与返回类型相同的变量的名字,但不能是函数参数。这一规则称为命名返回值优化(named return value optimization, NRVO)。例如,T f() { T x; return x; }
return
语句时,这一规则称为返回值优化(return value optimization, RVO)。例如,T x = f();
注:上面仅列出了常见情况,完整规则详见Copy elision - cppreference。
当拷贝消除发生时,被省略的拷贝/移动构造函数的源对象(参数)和目标对象(this
)将变成同一个对象。
前面提到,右值引用不能绑定到左值,因此左值不能被移动。但是,标准库头文件std::move()
函数,作用是将参数转换为右值引用,即将参数“当作”右值,使其变成“可移动的”。
实际上,std::move()
仅仅是一个强制类型转换:
template<class T>
typename std::remove_reference<T>::type&& move(T&& t) noexcept {
return static_cast<typename std::remove_reference<T>::type&&>(t);
}
其中std::remove_reference
的作用是移除参数类型中的引用,再加上&&
就变成了右值引用。
详见std::move - cppreference。
虽然std::move()
的返回类型是右值引用,但调用该函数的表达式是一个右值(准确来说是xvalue)。如果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; // prints "0 3"
注:
std::move(a)
,而是b
的移动构造函数,std::move()
本身并不执行任何移动操作。return
语句中,则它是可移动的(move-eligible),因此不需要显式使用std::move()
。例如3.1节中的fill()
函数。const
左值调用std::move()
没有任何效果(见下面的示例)。 参考Beware of using std::move on a const lvalue。下面是一个测试示例:
#include
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() {
std::cout << "C a = f();\n";
C a = f();
std::cout << "\nC b = a;\n";
C b = a;
std::cout << "\na = C();\n";
a = C();
std::cout << "\nb = a;\n";
b = a;
C&& r = std::move(b);
std::cout << "\na = r;\n";
a = r;
std::cout << "\nb = std::move(a);\n";
b = std::move(a);
const C c;
std::cout << "\nb = std::move(c);\n";
b = std::move(c);
return 0;
}
输出如下:
C a = f();
move constructor
move constructor
C b = a;
copy constructor
a = C();
move assignment
b = a;
copy assignment
a = r;
copy assignment
b = std::move(a);
move assignment
b = std::move(c);
copy assignment
return c;
调用移动构造函数(将局部变量c
移动到返回值临时对象),因为函数f()
的返回类型C
不是引用类型,且C
有移动构造函数C a = f();
调用移动构造函数(将返回值临时对象移动到a
),因为f()
是一个右值C b = a;
调用拷贝构造函数,因为a
是一个左值a = C();
调用移动赋值,因为C()
是一个右值,且C
有移动赋值b = a;
调用拷贝赋值,因为a
是一个左值a = r;
调用拷贝赋值,因为r
是一个左值(之前的std::move(b)
对b
没有任何影响)b = std::move(a);
调用移动赋值,因为std::move(a)
是一个右值,且C
有移动赋值b = std::move(c);
调用拷贝赋值,因为c
是const
注:
C a = f();
涉及的两次移动构造函数调用可能会被编译器的拷贝消除特性优化掉,从而c
和a
的地址是一样的,整个语句只有一次默认构造函数调用。C a = f();
调用移动构造函数的次数如下表所示(使用的编译器是GCC 13):C++标准版本 | 编译选项 | 移动构造函数调用次数 |
---|---|---|
C++11 | -fno-elide-constructors |
2 (c →返回值临时对象→a ) |
C++11 | 无 | 0 (&c == &a ) |
C++17 | -fno-elide-constructors |
1 (c →a ) |
C++17 | 无 | 0 (&c == &a ) |
如果C
没有移动构造函数和移动赋值,那么输出会变为
C a = f();
copy constructor
copy constructor
C b = a;
copy constructor
a = C();
copy assignment
b = a;
copy assignment
b = std::move(a);
copy assignment
b = std::move(c);
copy assignment
类似地,C a = f();
涉及的两次拷贝构造函数调用可能会被编译器的拷贝消除特性优化掉:
C++标准版本 | 编译选项 | 拷贝构造函数调用次数 |
---|---|---|
C++11 | -fno-elide-constructors |
2 (c →返回值临时对象→a ) |
C++11 | 无 | 0 (&c == &a ) |
C++17 | -fno-elide-constructors |
1 (c →a ) |
C++17 | 无 | 0 (&c == &a ) |
(由此看来,有了拷贝消除,移动语义似乎变得可有可无了)
除了移动语义,右值引用还有一个重要的用途——实现完美转发(perfect forwarding)。下面首先介绍引用折叠和转发引用的概念,之后介绍std::forward()
函数,并通过一个示例说明如何实现完美转发。
在C++中不存在引用的引用,但是允许通过模板或typedef
间接形成引用的引用。在这种情况下,适用引用折叠(reference collapsing)规则:右值引用的右值引用折叠为右值引用,其他组合都形成左值引用。例如:
typedef int& lref;
typedef int&& rref;
int n;
lref& r1 = n; // type of r1 is int&
lref&& r2 = n; // type of r2 is int&
rref& r3 = n; // type of r3 is int&
rref&& r4 = 1; // type of r4 is int&&
引用折叠是实现转发引用的基础。
详见Reference collapsing - cppreference。
前面提到,&&
表示右值引用,但是有一个例外——转发引用(forwarding reference),也叫万能引用(universal reference)。转发引用有两种形式:
T&&
(不能有const
限定),其中T
是模板参数auto&&
详见Forwarding references - cppreference。
例如:
template<class T>
void f(T&& x) { // x is a forwarding reference
// ...
}
auto&& r = foo(); // r is a forwarding reference
转发引用的作用是根据实参是左值或右值自动匹配为左值引用或右值引用。例如:
int i;
f(i); // argument is lvalue, calls f(int&)
f(0); // argument is rvalue, calls f(int&&)
f(i)
,实参是左值,则T = int&
,形参类型为int& &&
,折叠为int&
。f(0)
,实参是右值,则T = int
,形参类型为int&&
。其中,模板参数T
的推导依赖于特殊的模板参数推导规则,详见Deduction from a function call - cppreference。
下面介绍std::forward()
函数,该函数与转发引用一起实现完美转发。
首先考虑一个例子:
#include
#include
void g(int& x) {
std::cout << "lvalue overload, x = " << x << std::endl;
}
void g(int&& x) {
std::cout << "rvalue overload, x = " << x << std::endl;
}
template<class T>
void f(T&& x) {
g(x);
g(std::forward<T>(x));
g(std::move(x));
}
int main() {
int x = 42;
f(x);
f(123);
return 0;
}
这段程序的输出如下:
lvalue overload, x = 42
lvalue overload, x = 42
rvalue overload, x = 42
lvalue overload, x = 123
rvalue overload, x = 123
rvalue overload, x = 123
其中,函数f()
的形参x
是一个转发引用。虽然x
能够根据实参自动匹配为左值引用或右值引用,但它始终是一个左值(见第2节的小结)。因此无论f()
的实参是左值还是右值,g(x)
调用的都是g(int&)
重载,这与我们的本意不符。我们希望当f()
的实参是左值时调用g(int&)
,实参是右值时调用g(int&&)
,这就要用到函数。
函数std::forward()
定义在标准库头文件
和std::move()
类似,std::forward()
实际上也仅仅是一个强制类型转换:
template<class T>
T&& forward(std::remove_reference_t<T>& t) noexcept {
return static_cast<T&&>(t);
}
template<class T>
T&& forward(std::remove_reference_t<T>&& t) noexcept {
return static_cast<T&&>(t);
}
注意:调用std::forward()
时必须显式指定模板参数,不能依赖模板参数推导。
详见std::forward - cppreference。
回到前面的例子,下面分析f(x)
和f(123)
两次调用中的模板实例化过程(只考虑f()
的第二行)。
对于f(x)
,函数f()
的实例化如下:
T = int&
void f(int& && x) {
g(std::forward<int&>(x));
}
根据引用折叠规则,int& &&
等价于int&
。函数std::forward()
的实例化如下:
T = int&
int& && forward(int& t) noexcept {
return static_cast<int& &&>(t);
}
根据引用折叠规则,上面的代码等价于
T = int&
int& forward(int& t) noexcept {
return static_cast<int&>(t);
}
可以看出,当实参为左值时,std::forward()
将int&
(左值)转换为int&
(左值),相当于什么都不做,因此g(std::forward
调用g(int&)
。
对于f(123)
,函数f()
的实例化如下:
T = int
void f(int&& x) {
g(std::forward<int>(x));
}
函数std::forward()
的实例化如下:
T = int
int&& forward(int&& t) noexcept {
return static_cast<int&&>(t);
}
可以看出,当实参为右值时,std::forward()
将int&&
(左值)转换为int&&
(右值),作用相当于std::move()
,因此g(std::forward
调用g(int&&)
。
总结起来:通过使用转发引用和std::forward()
,函数f()
在调用g()
时保持了实参的值类别,从而实现了完美转发。
与std::forward()
不同,std::move()
无论实参是左值还是右值,都转换为右值引用,调用表达式始终是右值。因此在上面的例子中,g(std::move(x))
调用的都是g(int&&)
重载。
小结:
实参 | 形参x (转发引用) |
std::forward(x) |
std::move(x) |
---|---|---|---|
左值 | 左值引用(左值) | 左值引用(左值) | 右值引用(右值) |
右值 | 右值引用(左值) | 右值引用(右值) | 右值引用(右值) |
标准库头文件make_unique()
函数,用于构造一个T
类型的对象,并返回指向它的unique_ptr
。其中一个重载的定义如下:
template<class T, class... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
其中,args
是转发引用,通过std::forward()
被传递给T
的构造函数。在这里,T
的构造函数接受到的参数的值类别将与make_unique()
函数的实参相同。例如,make_unique
将调用T
的移动构造函数。
类似的函数还有vector
、allocator
等等。
注:使用Python实现类似的功能则简单得多(虽然Python根本不需要unique_ptr
),因为根本不存在值类别的概念:
def make_unique(T, *args):
return unique_ptr(T(*args))
C++的值语义是万恶之源。