前言
在本文中将依次讨论左值和右值、左值引用和右值引用、移动语义和完美转发。
值类别
每个表达式只属于三种基本值类别中的一种:纯右值 (prvalue)、亡值 (xvalue)、左值 (lvalue)。
三种基本值类型又组成了两种混合类型:泛左值(glvalue)(包括左值和亡值)和右值(rvalue)(包括纯右值和亡值),即我们普遍意义上的左值和右值。具体的定义可以参见[1],相当之具体且繁复。除非考据党,一般而言不需要这么严谨,只需要知道,左值是指表达式结束后依然存在的持久化对象(可做取地址操作),右值是指表达式结束时就不再存在的临时对象(不可取地址),左值通常是具名变量或对象,而右值通常不具名。
左值引用和右值引用
左值引用
左值引用就是我们通常所指的引用&
,给变量(左值)取一个别名的操作。
int a = 1; int& lref_a = a; int& lref_b = 1;
|
注意,(左值)引用不是对象,所以不存在引用的数组,不存在指向引用的指针,不存在引用的引用(的定义),如下定义是错误的:
int& a[3]; int&* p; int& &r;
int a = 1; int& lref_a = a; int& lref_lref_a = lref_a;
|
右值引用
右值引用自C++11引入,使用声明符&&
,如给匿名变量或函数返回值(右值)取别名的操作。
int&& rref_a = 1; int b = 1; int rref_b = b;
|
class A { public: A() { printf("constructor\n"); } A(const A& a) { printf("copy constructor\n"); } A(A&& a) { printf("move constructor\n"); } A& operator=(A& a) { printf("copy assignment\n"); return *this; } A& operator=(A&& a) noexcept { printf("move assignment\n"); return *this; } ~A() { printf("Destructor\n"); } };
A getA() { return A(); } A getA_() { A a = A(); return a; } void acceptA(A a){} int main() { getA(); acceptA(getA()); A a = getA(); A&& rref_a = getA(); return 0; }
|
上面例子中,有几个有意思的点:
- 关于作为函数返回值的临时变量的构建问题,C++标准允许一种(编译器)实现省略创建一个只是为了初始化另一个同类型对象的临时对象。但如何省略在不同的编译器的不同优化等级下可能表现不同,我做了如下测试:
- 在VS2019(MSVC) Debug模式(
/Od
)下,getA()
函数中返回A
对象时不会重新构建一个临时对象(右值),因为A()
的返回值就是一个临时对象(右值),会将其直接返回【一次构造,无析构】;而getA_
函数中局部对象a
(左值)的定义会调用构造函数,返回时会重新构建一个临时对象用于返回(优先调用移动构造函数,若只自定义了拷贝构造函数,则会调用拷贝构造函数,关于移动语义后续详细讨论),并将局部对象a
析构【两次构造,一次析构】;
- 在VS2019(MSVC) Release模式(
/O2
)下,getA_
函数会被进一步优化,只需要一次构造,省去了移动构造和析构【一次构造,无析构】;
- 用 g++ 编译时,无论采用何种优化等级,
getA
和getA_
函数中都只会调用一次构造函数,重新构建临时变量的操作都会被优化掉【一次构造,无析构】;
- 但是 g++ 编译时可以添加
-fno-elide-constructors
选项来禁用上述优化,强制g++在所有初始化情况下调用拷贝/移动构造函数。所以在getA_
函数中,A()
调用一次构造函数,用其返回的临时变量初始化a
会调用一次移动构造函数,然后临时变量析构,用a
初始化返回的临时变量时又会调用一次移动构造函数,返回后a
析构【三次构造,二次析构】;
- 同样的优化也会发生在函数入参处,如上例函数调用[2]处,
getA()
返回的临时变量成为acceptA
函数的参数时,并不会产生构造和析构成本,因为没有必要;
- 函数调用[1]返回的右值无人接收,则该语句结束后,返回的了临时变量会被析构;
- 函数调用[2]返回的右值被
acceptA
函数接收,被用于初始化形参a
,其生命周期得以延续至acceptA
函数结束;
- 函数调用[3]返回的右值被用于初始化
main
函数中的局部变量a
,临时变量被左值接收,其生命周期得以延续至main
函数结束。注意这里是初始化而不是赋值,不会调用赋值操作符,不开启-fno-elide-constructors
选项的情况下,也不会产生额外的构造和析构操作;
- 函数调用[4]返回的右值被用来初始化右值引用
rref_a
,因而临时变量有了名字,其生命周期也得以延续至main
函数结束。同样的,这里是初始化而不是赋值;
从上面的分析中可以看出,右值引用rref_a
和左值a
的表现近乎一致,所以实质上右值引用也是一个左值,它可以取地址,而且是具名的。
常量左值引用
常量左值引用可以算是一个“万能”的引用类型,它可以绑定非常量左值、常量左值、右值,而且在绑定右值的时候,常量左值引用还可以像右值引用一样将右值的生命期延长,但缺点是只能读不能改。
const int& k_lref_a = 1; int b = 1; const int& k_lref_b = b; const int c = 1; const int& k_lref_c = c;
|
引用折叠
虽然上面谈到无法直接定义引用的引用,但通过模板或 typedef 中的类型操作还是可以构成引用的引用,此时适用*引用折叠(reference collapsing)*规则:右值引用的右值引用折叠成右值引用,所有其他组合均折叠成左值引用:
typedef int& lref; typedef int&& rref; int n; lref& r1 = n; lref&& r2 = n; rref& r3 = n; rref&& r4 = 1;
|
移动语义
为什么需要移动语义?
C++11 的一个重要改进就是引入了移动语义(Move Semantic),通过移动语义,可以避免拷贝构造中不必要的深拷贝操作,同时解决仅通过浅拷贝无法约束的所有权共享问题。移动语义的实现主要通过移动构造函数和移动赋值操作符。下面考虑一个自定义string类的场景:
#include <vector> #include <iostream> #include <cstring>
class MyString { public: static size_t cnt_; static size_t copy_cnt_; static size_t copy_assign_cnt_;
public: MyString(const char* cstr = nullptr) { if (cstr) { size_ = strlen(cstr); data_ = new char[size_ + 1]; memcpy(data_, cstr, size_); } else { size_ = 0; data_ = new char[1]; *data_ = '\0'; } cnt_++; }
MyString(const MyString& str) { size_ = str.size_; data_ = new char[size_ + 1]; memcpy(data_, str.data_, size_); copy_cnt_++; }
MyString& operator=(const MyString& str) { if (this == &str) { return *this; } delete[] data_; size_ = str.size_; data_ = new char[size_ + 1]; memcpy(data_, str.data_, size_); copy_assign_cnt_++; return *this; }
~MyString() { size_ = 0; delete[] data_; }
void Print() { printf("%s\n", data_); }
private: char* data_; size_t size_; };
size_t MyString::cnt_ = 0; size_t MyString::copy_cnt_ = 0; size_t MyString::copy_assign_cnt_ = 0;
void DoSomething(MyString str) { str = MyString("hello string"); str.Print(); }
int main() { std::vector<MyString> vecStr; vecStr.reserve(10); for (int i = 0; i < 10; ++i) { vecStr.push_back(MyString("hello world")); }
std::cout << "constructor time: " << MyString::cnt_ << std::endl; std::cout << "copy constructor time: " << MyString::copy_cnt_ << std::endl; std::cout << "copy assignment time: " << MyString::copy_assign_cnt_ << std::endl;
return 0; }
|
拷贝构造函数和拷贝赋值操作符的参数const MyString& str
是一个常量左值引用,是可以绑定右值的。
上述代码在VS 2019 Release环境下执行,发现当执行DoSomething
函数时,编译器会进行优化,将传参时的拷贝构造操作省去;而执行push_back
操作时,编译器则没有把拷贝构造操作给优化掉,甚至如果不执行reserve
操作,拷贝构造函数的调用次数将远不止10次(涉及资源的动态分配)。
MyString("hello world")
返回的是一个临时变量(右值),它除了被用来压进vector,并没有其他用途。其完全可以被直接拿来用,没必要再深拷贝一遍,即浪费资源又影响执行效率。还可以看到,编译器的优化固然是好的,但我们不能太过依赖它,编译器优化不能解决所有的问题。
那么,可以通过将拷贝构造函数中的深拷贝换成浅拷贝(仅拷贝char*指针的值)来解决效率问题嘛?打咩!且不论这会导致资源所有权的混乱,当临时变量的生命周期结束时,其中的资源被释放,会导致拷贝生成的对象也不可用。这是很蠢的想法。
而移动语义可以很好地解决这种问题:
#include <vector> #include <iostream> #include <cstring>
class MyString { public: static size_t cnt_; static size_t copy_cnt_; static size_t copy_assign_cnt_; static size_t move_cnt_; static size_t move_assign_cnt_;
public: MyString(const char* cstr = nullptr) { if (cstr) { size_ = strlen(cstr); data_ = new char[size_ + 1]; memcpy(data_, cstr, size_); } else { size_ = 0; data_ = new char[1]; *data_ = '\0'; } cnt_++; }
MyString(const MyString& str) { size_ = str.size_; data_ = new char[size_ + 1]; memcpy(data_, str.data_, size_); copy_cnt_++; }
MyString(MyString&& str) noexcept : data_(str.data_), size_(str.size_) { str.data_ = nullptr; str.size_ = 0; move_cnt_++; }
MyString& operator=(const MyString& str) { if (this == &str) { return *this; } delete[] data_; size_ = str.size_; data_ = new char[size_ + 1]; memcpy(data_, str.data_, size_); copy_assign_cnt_++; return *this; }
MyString& operator=(MyString&& str) noexcept { if (this == &str) { return *this; } delete[] data_; size_ = str.size_; data_ = str.data_; str.data_ = nullptr; str.size_ = 0; move_assign_cnt_++; return *this; }
~MyString() { size_ = 0; delete[] data_; }
void Print() { printf("%s\n", data_); }
private: char* data_; size_t size_; };
size_t MyString::cnt_ = 0; size_t MyString::copy_cnt_ = 0; size_t MyString::copy_assign_cnt_ = 0; size_t MyString::move_cnt_ = 0; size_t MyString::move_assign_cnt_ = 0;
void DoSomething(MyString str) { str = MyString("hello string"); str.Print(); }
int main() { std::vector<MyString> vecStr; vecStr.reserve(10); for (int i = 0; i < 10; ++i) { vecStr.push_back(MyString("hello world")); }
std::cout << "constructor time: " << MyString::cnt_ << std::endl; std::cout << "copy constructor time: " << MyString::copy_cnt_ << std::endl; std::cout << "copy assignment time: " << MyString::copy_assign_cnt_ << std::endl; std::cout << "move constructor time: " << MyString::move_cnt_ << std::endl; std::cout << "move assignment time: " << MyString::move_assign_cnt_ << std::endl;
return 0; }
|
从上述实现中可以看出,移动语义的核心在于资源所有权的转移,而不是从新开辟资源并拷贝。在C++11之前,也可以通过仅拷贝指针并令原指针置0的方式实现类似移动语义的操作。但是没有语法约束移动语义和拷贝语义的使用,而右值引用(仅可绑定右值)的出现给予了移动语义的正当性,即当传递右值时,可以优先调用移动构造函数(若实现)而非拷贝构造函数;而传递左值时,只可以调用拷贝构造函数,不可以调用移动构造函数。
std::move
所以,移动语义也是一种浅拷贝,但明确指定了资源所有权的转移(而不是共享),并且用了右值引用的语法对使用场景做了约束(仅右值才可移动)。
... int main() { std::vector<MyString> vecStr; vecStr.reserve(10); for (int i = 0; i < 10; ++i) { MyString tmp("hello"); vecStr.push_back(tmp); }
std::cout << "constructor time: " << MyString::cnt_ << std::endl; std::cout << "copy constructor time: " << MyString::copy_cnt_ << std::endl; std::cout << "copy assignment time: " << MyString::copy_assign_cnt_ << std::endl; std::cout << "move constructor time: " << MyString::move_cnt_ << std::endl; std::cout << "move assignment time: " << MyString::move_assign_cnt_ << std::endl;
return 0; }
|
从上例可有看出,传递左值时,调用了拷贝构造函数,即便这个左值只是个局部变量。我们希望它是可以被移动的,C++11给出的解决方案是提供了std::move
方法,可以将一个左值转换成右值,从而使用移动语义。
... int main() { std::vector<MyString> vecStr; vecStr.reserve(10); for (int i = 0; i < 10; ++i) { MyString tmp("hello"); vecStr.push_back(std::move(tmp)); }
std::cout << "constructor time: " << MyString::cnt_ << std::endl; std::cout << "copy constructor time: " << MyString::copy_cnt_ << std::endl; std::cout << "copy assignment time: " << MyString::copy_assign_cnt_ << std::endl; std::cout << "move constructor time: " << MyString::move_cnt_ << std::endl; std::cout << "move assignment time: " << MyString::move_assign_cnt_ << std::endl;
return 0; }
|
关于std::move
,还有几点需要注意:
- 通过
std::move
完成移动语义后,原左值对象并不会马上析构,而是待到其生命周期结束时才析构,此时该左值对象已不持有资源,继续使用可能会出现未定义行为;
- 如果没有提供移动构造函数,只提供了拷贝构造函数,
std::move
会失效但是不会发生错误,编译器找不到移动构造函数就去寻找拷贝构造函数;
std::move
只会对持有资源的对象产生实质的作用,对基本类型如int
或char[10]
使用std::move
虽不致出错,但没有意义,该拷贝还是会拷贝。
push_back和emplace_back
这里还想提一下push_back
和emplace_back
的区别,push_back
向容器尾部添加元素时,首先会创建这个元素,然后再将这个元素拷贝或者移动到容器中(如果是拷贝的话,事后会自行销毁先前创建的这个元素);而 emplace_back
在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程。
其实push_back(std::move(tmp))
和emplace_back(std::move(tmp))
之间,甚至push_back(MyString("hello world"))
和emplace_back(MyString("hello world"))
之间并没有区别,因为已经在emplace_back
外显示构建了元素对象,emplace_back
是无法省去拷贝或移动的过程的,只有下面这种调用方式才会在emplace_back
内直接构建元素对象,并放到容器尾部。
int main() { std::vector<MyString> vecStr; vecStr.reserve(10); for (int i = 0; i < 10; ++i) { vecStr.emplace_back("hello world"); }
std::cout << "constructor time: " << MyString::cnt_ << std::endl; std::cout << "copy constructor time: " << MyString::copy_cnt_ << std::endl; std::cout << "copy assignment time: " << MyString::copy_assign_cnt_ << std::endl; std::cout << "move constructor time: " << MyString::move_cnt_ << std::endl; std::cout << "move assignment time: " << MyString::move_assign_cnt_ << std::endl; return 0; }
|
swap函数
利用移动语义也可以方便的实现高性能的swap函数:
template <typename T> void swap(T& a, T& b) { T tmp(std::move(a)); a = std::move(b); b = std::move(tmp); }
|
若T类型是可以移动的(存在移动构造函数),则通过移动语义可以实现高效的置换;若T类型不可移动,也不会出错,就像普通的swap函数一样,通过深拷贝完成交换。
通用引用
通用引用(universal reference)是Scott Meyers在C++ and Beyond 2012演讲中自创的一个词,用来特指一种引用的类型。这种引用在源代码中(T&&
)看起来像右值引用,但是它也可以表现的左值引用(即T&
)的行为。它们的双重性质允许它们绑定右值(就像右值引用那样)和左值(就像左值引用那样)。而且,它们可以绑定const或者非const对象,可以绑定volatile和非volatile对象,还可以绑定const和volatile同时作用的对象。它们实际上可以绑定任何东西。构成通用引用有两个条件:
- 必须精确满足
T&&
这种形式(即使加上const也不行)
- 类型T必须是通过推断得到的(最常见的就是模板函数参数或者auto类型)
template<typename T> void f(T&& param);
template <typename T> class Test { public: Test(Test&& rhs); private: T a; }; void f(Test&& param);
template<typename T> void f(std::vector<T>&& param);
template<typename T> void f(const T&& param);
|
关于类型推断及通用类型究竟是表现为左值引用还是右值引用,需要结合前面提到的引用折叠来看:
#include <iostream> #include <string> #include <type_traits>
template <typename T> void f(T&& param) { if (std::is_same<std::string, T>::value) { std::cout << "string" << std::endl; } else if (std::is_same<std::string&, T>::value) { std::cout << "string&" << std::endl; } else if (std::is_same<std::string&&, T>::value) { std::cout << "string&&" << std::endl; } else if (std::is_same<int, T>::value) { std::cout << "int" << std::endl; } else if (std::is_same<int&, T>::value) { std::cout << "int&" << std::endl; } else if (std::is_same<int&&, T>::value) { std::cout << "int&&" << std::endl; } else { std::cout << "unkown" << std::endl; } }
int main() { int a = 1; int&& rref_a = 1; std::string str = "hello";
f(1); f(a); f(rref_a); f(std::string("hello")); f(str); f(std::move(str));
return 0; }
|
从上述例子中可以看出,传入左值,就表现为左值引用;传入右值,就表现为右值引用。不愧为通用引用。
完美转发
所谓转发,即通过一个函数将参数转交给另一个函数进行处理,若转发的过程中,参数的原有特性(如const属性,左值右值)不发生改变,则称为完美转发。
#include <iostream> void process(int& i) { std::cout << "process(int&):" << i << std::endl; } void process(int&& i) { std::cout << "process(int&&):" << i << std::endl; }
void myforward(int&& i) { std::cout << "myforward(int&&):" << i << std::endl; process(i); }
int main() { int a = 0;
process(a); process(1); process(std::move(a)); myforward(2); myforward(std::move(a)); }
|
上面便是一个非完美转发的例子,参数在转发的过程中其特性发生了改变,C++11提供了一个模板函数std::forward<T>()
来解决这个问题。尝试修改myforward
函数的实现:
void myforward(int&& i) { std::cout << "myforward(int&&):" << i << std::endl; process(std::forward<int>(i)); } ... myforward(2);
|
上面的例子仍然不是完美转发,因为myforward
函数不接受左值输入。完美转发需要通用引用和**std::forward
**配合使用来实现。
#include <iostream> void process(int& i) { std::cout << "process(int&):" << i << std::endl; } void process(int&& i) { std::cout << "process(int&&):" << i << std::endl; } void process(const int& i) { std::cout << "process(const int&):" << i << std::endl; } void process(const int&& i) { std::cout << "process(const int&&):" << i << std::endl; }
template <typename T> void perfectForward(T&& i) { process(std::forward<T>(i)); }
template <typename T> void nonPerfectForward(T&& i) { process(i); }
int main() { int a = 0; int b = 1; const int c = 2; const int d = 3;
nonPerfectForward(a); nonPerfectForward(std::move(b)); nonPerfectForward(c); nonPerfectForward(std::move(d));
std::cout << std::endl; perfectForward(a); perfectForward(std::move(b)); perfectForward(c); perfectForward(std::move(d));
return 0; }
|
一种典型的多参数函数完美转发的例子如下:
#include <string> #include <iostream> void f(int a, std::string b, bool c) { std::cout << "a: " << a << std::endl; std::cout << "b: " << b << std::endl; std::cout << "c: " << c << std::endl; }
template <typename... Args> void forward(Args&&... args) { f(std::forward<Args>(args)...); }
int main() { forward(10, "hello", true); return 0; }
|
其中...
出现了三次:
typename... Args
表示模板参数打包(template parameter pack),表示模板类型参数个数可变;
Args&&... args
表示函数参数打包(function parameter pack),表示函数参数个数可变;
std::forward<Args>(args)...
表示参数解包(pack expansion),表示将参数展开为逗号分割的参数列表;
参考
[1] 值类别
[2] [c++11]我理解的右值引用、移动语义和完美转发
[3] 通用引用、引用折叠与完美转发问题