前言

在本文中将依次讨论左值和右值、左值引用和右值引用、移动语义和完美转发。

值类别

每个表达式只属于三种基本值类别中的一种:纯右值 (prvalue)亡值 (xvalue)左值 (lvalue)

三种基本值类型又组成了两种混合类型:泛左值(glvalue)(包括左值和亡值)和右值(rvalue)(包括纯右值和亡值),即我们普遍意义上的左值和右值。具体的定义可以参见[1],相当之具体且繁复。除非考据党,一般而言不需要这么严谨,只需要知道,左值是指表达式结束后依然存在的持久化对象(可做取地址操作),右值是指表达式结束时就不再存在的临时对象(不可取地址),左值通常是具名变量或对象,而右值通常不具名。

左值引用和右值引用

左值引用

左值引用就是我们通常所指的引用&,给变量(左值)取一个别名的操作。

int a = 1;			// a 为左值
int& lref_a = a; // 左值引用lref_a是a的别名,修改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(); // [1]
acceptA(getA()); // [2]
A a = getA(); // [3]
A&& rref_a = getA(); // [4]

return 0;
}

上面例子中,有几个有意思的点:

  • 关于作为函数返回值的临时变量的构建问题,C++标准允许一种(编译器)实现省略创建一个只是为了初始化另一个同类型对象的临时对象。但如何省略在不同的编译器的不同优化等级下可能表现不同,我做了如下测试:
    • 在VS2019(MSVC) Debug模式(/Od)下,getA()函数中返回A对象时不会重新构建一个临时对象(右值),因为A()的返回值就是一个临时对象(右值),会将其直接返回【一次构造,无析构】;而getA_函数中局部对象a(左值)的定义会调用构造函数,返回时会重新构建一个临时对象用于返回(优先调用移动构造函数,若只自定义了拷贝构造函数,则会调用拷贝构造函数,关于移动语义后续详细讨论),并将局部对象a析构【两次构造,一次析构】;
    • 在VS2019(MSVC) Release模式(/O2)下,getA_函数会被进一步优化,只需要一次构造,省去了移动构造和析构【一次构造,无析构】;
    • 用 g++ 编译时,无论采用何种优化等级,getAgetA_函数中都只会调用一次构造函数,重新构建临时变量的操作都会被优化掉【一次构造,无析构】;
    • 但是 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; // 绑定non-const左值
const int c = 1;
const int& k_lref_c = c; // 绑定const左值

引用折叠

虽然上面谈到无法直接定义引用的引用,但通过模板或 typedef 中的类型操作还是可以构成引用的引用,此时适用*引用折叠(reference collapsing)*规则:右值引用的右值引用折叠成右值引用,所有其他组合均折叠成左值引用:

typedef int&  lref;
typedef int&& rref;
int n;

lref& r1 = n; // r1 的类型是 int&
lref&& r2 = n; // r2 的类型是 int&
rref& r3 = n; // r3 的类型是 int&
rref&& r4 = 1; // r4 的类型是 int&&

移动语义

为什么需要移动语义?

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) { // 认同测试,避免自我赋值,参见 条款11
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) {
// DoSomething(MyString("hello world")); // [1]
vecStr.push_back(MyString("hello world")); // [2]
}

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) { // 认同测试,避免自我赋值,参见 条款11
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) { // 认同测试,避免自我赋值,参见 条款11
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"));
//DoSomething(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;
}
// 输出如下:
// constructor time: 10
// copy constructor time: 10
// copy assignment time: 0
// move constructor time: 0
// move assignment time: 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;
}
// 输出如下:
// constructor time: 10
// copy constructor time: 0
// copy assignment time: 0
// move constructor time: 10
// move assignment time: 0

关于std::move,还有几点需要注意:

  • 通过std::move完成移动语义后,原左值对象并不会马上析构,而是待到其生命周期结束时才析构,此时该左值对象已不持有资源,继续使用可能会出现未定义行为;
  • 如果没有提供移动构造函数,只提供了拷贝构造函数,std::move会失效但是不会发生错误,编译器找不到移动构造函数就去寻找拷贝构造函数;
  • std::move只会对持有资源的对象产生实质的作用,对基本类型如intchar[10]使用std::move虽不致出错,但没有意义,该拷贝还是会拷贝。

push_back和emplace_back

这里还想提一下push_backemplace_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.push_back("hello world");
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;
}
// 输出如下:
// constructor time: 10
// copy constructor time: 0
// copy assignment time: 0
// move constructor time: 0
// move assignment time: 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); //T类型需要推导,为&&通用引用

template <typename T>
class Test {
public:
Test(Test&& rhs); // 虽然是类模板,但Test是一个特定的类型,不需要类型推导,所以&&为右值引用
private:
T a;
};
void f(Test&& param); // 右值引用

template<typename T>
void f(std::vector<T>&& param); // 在调用这个函数之前,std::vector<T>中的推断类型已经确定了
// 所以调用f函数的时候没有类型推断了,所以是 右值引用

template<typename T>
void f(const T&& param); // 右值引用 有 const 限定

关于类型推断及通用类型究竟是表现为左值引用还是右值引用,需要结合前面提到的引用折叠来看:

#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); // 传入右值,推断为int,int&& 右值引用
f(a); // 传入左值,推断为int&,int& && 折叠为左值引用
f(rref_a); // 传入右值引用(实为左值),推断为int&,int& && 折叠为左值引用

f(std::string("hello"));// 传入右值,推断为std::string,std::string&& 右值引用
f(str); // 传入左值,推断为std::string&,std::string& && 折叠为左值引用
f(std::move(str)); // 传入右值,推断为std::string,std::string&& 右值引用

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(int&):0
process(1); // 传入右值 打印 process(int&&):1
process(std::move(a)); // 传入右值 打印 process(int&&):0
myforward(2); // 传入右值,通过myforward函数转发给process函数 右值变成了右值引用(实为左值) 打印 process(int&):2
myforward(std::move(a)); // 同上 打印 process(int&):0
// myforward(a); // 错误用法,右值引用不接受左值
}

上面便是一个非完美转发的例子,参数在转发的过程中其特性发生了改变,C++11提供了一个模板函数std::forward<T>()来解决这个问题。尝试修改myforward函数的实现:

void myforward(int&& i) {
std::cout << "myforward(int&&):" << i << std::endl;
//process(i); // 转发
process(std::forward<int>(i)); // 转发
}
...
myforward(2); // 打印 process(int&&):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); // process(int&):0
nonPerfectForward(std::move(b)); // process(int&):1
nonPerfectForward(c); // process(const int&):2
nonPerfectForward(std::move(d)); // process(const int&):3

std::cout << std::endl;
perfectForward(a); // process(int&):0
perfectForward(std::move(b)); // process(int&&):1
perfectForward(c); // process(const int&):2
perfectForward(std::move(d)); // process(const int&&):3

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] 通用引用、引用折叠与完美转发问题