C++ 深入理解右值引用与移动语义

一、值类别

1.1 值类别是什么

在 C++ 中,表达式是由一个或多个操作数(operands)和零个或多个运算符(operators)组成的语法单元,每个表达式都会生成一个值,每个值有两种属性:类型与值类别。类型有intdouble等内置类型,也有用户自定义类型。值类别是指按是否可标识与是否可移动两个独立属性对值进行分类。 C++ 语言及其工具和规则的许多方面都需要正确理解这些值类别以及对它们的引用。 这些方面包括获取值的地址、复制值、移动值、将值转发给另一函数。

1.2 C++ 98/03

C++ 98/03依据是否可标识将值类别分为左值与右值两种。这里的左与右是指是否可出现在赋值运算符的左边与右边。但这只是最简单字面意思,实际区分左值与右值是通过是否可标识

左值(lvalue):一个指向特定内存的具有标识的值(具名对象),它有一个相对稳定的内存地址,并有较长生命周期。命名的变量或常量,返回左值引用的函数都是左值。

1
2
3
4
5
6
7
8
9
10
11
12
13
int global_var = 2025; // 左值

int &get_val() // 左值,返回引用类型
{
return global_var;
}

int main()
{
int x = 10; // 左值

return 0;
}

右值(rvalue):一个不指向特定内存的没有标识的值(匿名对象),或一个字面量(不包括字符串字面量)。它的生命周期较短,通常是临时性的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int get_val() // 右值,函数返回时会将global_var拷贝给一个临时变量
{
return global_var;
}


int main()
{
2025; // 字面量,右值
3.14; // 字面量, 右值
char * p = "hello"; // "hello" 是一个字符串字面量,它有特定内存地址,是一个左值

return 0;
}

说明:前置++或前置–返回的是一个左值,它将当前对象自增或自减后返回,所以可以对它取地址。后置++或后置–返回的是一个右值,它先将当前对象的值赋值给一个临时对象,然后自增或自减后返回临时对象,所以不能对它取地址。

1.3 C++11

1.3.1 左值、将亡值、纯右值

C++ 11为支持移动语义完美转发,依据是否可标识与是否可移动两个独立属性将值类别分为以下三种基本类别之一:左值(lvalue)将亡值(xvalue)以及纯右值(prvalue)。其中左值与纯右值定义类似于C++11之前的定义。

左值(lvalue):一个可标识,但不可移动的值。

特征:

  • 有特定的内存地址的可标识的值,可以取地址。
  • 可以出现在赋值语句的左边或右边。
1
2
3
4
5
6
7
8
9
10
11
12
13
struct A{}

int x = 5; // x是左值,因为它有标识符并且可以被取地址的变量
int *ptr = &x; // ptr是一个左值,因为它有标识符并且可以被取地址的变量
const int y = 3; // y为左值,但被const修饰,不能修改
char* c_ptr = "hello"; // 左值,指向字符串第一个字符的地址

++x; // 左值
--x; // 左值
int y = x; // 左值
*ptr = 2025; // 左值,解引用访问x
A a; // 左值
"hello"; //左值, 存储在静态区只读数据段中

将亡值(xvalue):一个可标识,可移动的值,顾名思义该对象即将消亡,可以将该对象资源移动走。

特征:

  • 一个可标识符的值,它代表一个对象或者具体的内存位置,可以取地址。
  • 可以出现在赋值语句的左边或右边。
  • 对象资源即将被析构。

将亡值可以是以前的某个左值。由于复制开销很高,决定移动此左值。一旦为将亡值,则应承诺后续不再使用它,因为它的资源可能被移动走了。 下面演示如何将左值转换为将亡值。

1
2
3
struct A { ... };
A a; // a is an lvalue...
static_cast<A&&>(a); // ...but this expression is an xvalue.

上述代码将左值强制转换为一个将亡值。 该值仍可通过其左值名称进行标识,但作为将亡值,它现在可以移动了。也可以直接使用std::move将一个左值转换为一个将亡值,内部实现也是通过static_cast,其代码可读性更高。

纯右值(prvalue):不可标识,但可移动的值。

特征:

  • 只能在赋值语句的右边。
  • 通常是临时的,短暂存在,不具备持久性。

纯右值可以是临时值、匿名对象、字面量(非字符串字面量)。

1
2
3
4
5
6
7
8
9
10
struct A{};
int get_val();

2025; // 纯右值
A(); // 纯右值,一个匿名对象
get_val(); // 纯右值,非引用的函数返回值
int a = 3, b = 4; // 左值
a + b; // 纯右值
a++; // 纯右值
a > b; // 纯右值

1.3.2 泛左值与右值

在此之上,C++ 11 还引申出了两种集合值类别:泛左值(glvalue)右值(rvalue)。现在将所有值类别汇总,它们关系图如下:

泛左值(glvalue,全称为 generalized lvalue):一个可标识的值,它是包含左值(lvalue)和将亡值(xvalue)的超集。

右值(rvalue):一个可移动的值,它是包含将亡值(xvalue)和纯右值(prvalue)的超集。

说明:后文如没有特别指出,左值指的是lvalue,右值指的是rvalue。

二、左值引用与右值引用

2.1 左值引用

左值引用根据是否带const分为非常量左值引用与常量左值引用两种。

非常量左值引用:使用 & 符号表示,它可以引用一个左值。

1
2
3
int x = 10;
int &lref = x; // lref是x的左值引用
lref = 20; // 修改x的值

常量左值引用:在非常量左值引用基础上加上const限定符,它即可引用一个左值也可引用一个右值,但不允许修改引用的对象。

1
2
3
4
5
6
7
class A{};

int i = 10; // 左值
const int &l_ref1 = i; // 引用一个左值
const A &l_ref2 = A(); // 引用一个纯右值
const int &l_ref3 = 2; // 2为字面量,一个纯右值,本来只是一个符号,不占内存空间。常量左值引用变量l_ref3在定义时,编译器产生了一个临时变量,l_ref3实际引用的是临时变量,并且该临时变量生命周期被延长了。
const int &l_ref4 = std::move(i); // 引用一个将亡值

2.2 右值引用

右值引用:使用 && 符号表示,它可以引用一个右值。

1
2
3
4
5
6
int &&r_ref1 = 10; // 10为字面量,一个纯右值,本来只是一个符号,不占内存空间,右值引用变量r_ref1在定义时,编译器产生了一个临时变量,r_ref1实际引用的是临时变量,并且该临时变量生命周期被延长了。
i = 100;

int a = 1; // 左值
int&& r_ref2 = a; // 错误,a为左值,右值引用不能引用左值
int&& r_ref3 = std::move(a); // 正确,std::move(a)返回一个将亡值

2.3 为何需要右值引用

C++11之前,没有直接移动对象方法。传递函数参数和返回值通常使用拷贝构造(或拷贝赋值),在某些情况下,对象拷贝后就立即被销毁了。如果对象较大 ,进行不必要的拷贝代价非常高 。 此时,如果是移动而非拷贝对象会大幅度提升性能。

  1. 移动语义: 传统的拷贝构造函数和拷贝赋值在处理大对象时会进行昂贵的深拷贝操作。右值引用允许将资源的所有权从一个对象转移到另一个对象,而不是进行实际的复制,从而提高了性能。

  2. 完美转发: 右值引用是实现完美转发(perfect forwarding)的基础,允许函数接受任意类型的参数并将其传递给其他函数,同时保留原始参数的值类别。

三、移动语义

移动语义:对象之间转移资源的所有权而不进行昂贵的深拷贝。

实现移动语义必须采用某种方式,让编译器知道什么时候拷贝对象,什么时候该移动对象资源。而右值引用表明:1. 所引用的对象将要被销毁 2. 该对象没有其他人继续使用。这两个特性意味着使用右值引用的代码可以自由地接管所引用的对象的资源

实现移动语义,通常可以向类提供“移动构造函数”,或者提供移动赋值运算符 (operator=)。

3.1 为什么需要移动语义

下面是一个示例,演示对临时对象、即将析构的对象的进行拷贝,会降低程序的效率。为防止编译器优化,更好看出效果。编译时版本不能高于C++ 14,并需加上编译参数-fno-elide-constructors关闭函数返回值优化ROV

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>

class A
{
public:
A() { std::cout << "A()" << std::endl; }

A(const A &) { std::cout << "A(const A&)" << std::endl; }

~A() { std::cout << "~A()" << std::endl; }

void show() { std::cout << "show A" << std::endl; }
};

A make_a()
{
A a;

return a;
}

int main()
{
A obj = make_a();
obj.show();

return 0;
}

输出:

1
2
3
4
5
6
7
A()
A(const A&)
~A()
A(const A&)
~A()
show A
~A()

从程序输出结果来看,当调用make_a函数时,调用类A构造函数构造对象a,输出”A()”。当执行return a时,编译器依据a对象拷贝构造了一个临时对象,实际返回的是该临时对象,所以输出”A(const A&)。函数执行完局部变量a被析构,输出”A()”。参照临时对象拷贝构造obj时输出“A(const A&)”,然后临时对象析构输出“A()”。执行obj.show函数时输出”show A”。然后obj对象析构输出“~A()”。从输出结果可以看出,程序在make_a函数返回和构造obj对象时执行了2次拷贝构造函数,如果A类型对象较大,会有昂贵的拷贝开销。并且看到,花费昂贵代价拷贝的临时对象在拷贝构造完obj对象后,它便立即销毁了,资源得到巨大浪费

3.2 移动构造函数

移动构造函数用于从一个右值对象初始化另一个对象,通过移动其资源来避免不必要的拷贝

类的移动构造函数通常步骤为:

1)定义一个空的构造函数方法,该方法采用一个对类类型的右值引用作为参数
2)在移动构造函数中,将源对象中的类数据成员添加到要构造的对象
3)将源对象的数据成员分配给默认值。 这可以防止析构函数多次释放资源(如内存)

下面有一个用于管理内存缓冲区的 MemoryBlock类,已有构造函数、拷贝构造函数、重载赋值运算符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <iostream>
#include <algorithm>

class MemoryBlock
{
public:

// Simple constructor that initializes the resource.
explicit MemoryBlock(size_t length)
: _length(length)
, _data(new int[length])
{
std::cout << "In MemoryBlock(size_t). length = "
<< _length << "." << std::endl;
}

// Destructor.
~MemoryBlock()
{
std::cout << "In ~MemoryBlock(). length = "
<< _length << ".";

if (_data != nullptr)
{
std::cout << " Deleting resource.";
// Delete the resource.
delete[] _data;
}

std::cout << std::endl;
}

// Copy constructor.
MemoryBlock(const MemoryBlock& other)
: _length(other._length)
, _data(new int[other._length])
{
std::cout << "In MemoryBlock(const MemoryBlock&). length = "
<< other._length << ". Copying resource." << std::endl;

std::copy(other._data, other._data + _length, _data);
}

// Copy assignment operator.
MemoryBlock& operator=(const MemoryBlock& other)
{
std::cout << "In operator=(const MemoryBlock&). length = "
<< other._length << ". Copying resource." << std::endl;

if (this != &other)
{
// Free the existing resource.
delete[] _data;

_length = other._length;
_data = new int[_length];
std::copy(other._data, other._data + _length, _data);
}
return *this;
}

// Retrieves the length of the data resource.
size_t Length() const
{
return _length;
}

private:
size_t _length; // The length of the resource.
int* _data; // The resource.
};

MemoryBlock类加上移动构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
MemoryBlock(MemoryBlock&& other) noexcept
: _data(nullptr)
, _length(0)
{
// Copy the data pointer and its length from the
// source object.
_data = other._data;
_length = other._length;

// Release the data pointer from the source object so that
// the destructor does not free the memory multiple times.
other._data = nullptr;
other._length = 0;
}

3.3 移动赋值

重载移动赋值运算符用于从一个右值对象赋值给一个已存在对象,通过移动其资源来避免不必要的拷贝

1)定义一个空的赋值运算符,该运算符采用一个对类类型的右值引用作为参数并返回一个对类类型的引用
2)在移动赋值运算符中,如果尝试将对象赋给自身,则添加不执行运算的条件语句
3)在条件语句中,将数据成员从源对象转移到要构造的对象,释放原构造对象资源
4)返回对当前对象的引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
MemoryBlock& operator=(MemoryBlock&& other) noexcept
{
if (this != &other)
{
// Free the existing resource.
delete[] _data;

// Copy the data pointer and its length from the
// source object.
_data = other._data;
_length = other._length;

// Release the data pointer from the source object so that
// the destructor does not free the memory multiple times.
other._data = nullptr;
other._length = 0;
}

return *this;
}

如果为类同时提供了移动构造函数和移动赋值运算符,则可以编写移动构造函数来调用移动赋值运算符,从而消除冗余代码。

1
2
3
4
5
6
MemoryBlock(MemoryBlock&& other) noexcept
: _data(nullptr)
, _length(0)
{
*this = std::move(other);
}

注意:移动构造函数或移动赋值的参数千万不能设置成const类型的右值引用,因为资源无法转移而导致移动语义失效。

现在让我们为3.1节中示例增加移动构造与移动赋值观察下效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>

class A
{
public:
A() { std::cout << "A()" << std::endl; }

A(const A &) { std::cout << "A(const A&)" << std::endl; }

A(A &&) noexcept { std::cout << "A(A &&)" << std::endl; }

A &operator=(A &&) noexcept
{
std::cout << "operator=(A&&){" << std::endl;

return *this;
}

~A() { std::cout << "~A()" << std::endl; }

void show() { std::cout << "show A" << std::endl; }
};

A make_a()
{
A a;

return a;
}

int main()
{
A obj = make_a();
obj.show();

return 0;
}

输出:

1
2
3
4
5
6
7
A()
A(A &&)
~A()
A(A &&)
~A()
show A
~A()

可以看到,程序在make_a函数返回和构造obj对象时调用的是移动构造函数而不是拷贝构造函数,程序运行效率大幅提高。

3.4 异常安全性

移动语义“ 窃取” 资源,而不是分配资源。不建议在移动构造与移动赋值中抛出异常。因为移动资源过程中如果抛出异常,此时对象内资源可能一部分转移走了,另一部分没有转移,将导致源对象与目标对象都不完整情况,带来的后果是无法预料的。当编写一个不抛出异常的函数可用noexcept关键字说明,它告诉编译器和调用者:此函数不会抛出异常,如果真的抛出则编译器会直接调用 std::terminate() 终止程序。

许多STL容器(如 std::vector)在扩容或重新分配元素时,会判断移动构造函数是否为 noexcept。如果移动构造是noexcept,则调用。否则会退回到使用拷贝构造。

注意:必须在类头文件声明和定义中 (如 果定义在类外的话) 都指定noexcept

参考

谢丙堃 <<现代C++语言核心特性解析>>

microsoft C++ 语言文档