我们关注现代C++中比较难以理解的概念:右值引用,移动语义,完美转发等, 这些概念都是C++11之后才提出的,目的是进一步压榨程序的运行期效率,避免某些非必要的临时变量的拷贝构造和析构过程。这些语法是完全针对底层实现的,并不是针对于上层的语义优化,不是为了让程序变得更易读的语法糖。

左值和右值 (1)

在C++中,表达式由一个或多个运算对象通过运算符组成,对表达式求值得到一个结果。 字面量和变量是最简单的表达式,它们的结果就是字面量和变量的值。 一个表达式至少具有如下两个属性:

  • 类型:描述计算产生的值的静态类型
  • 值类别:描述值是如何产生的,以及表达式的行为如何被影响

从语法上检查一个表达式能否给另一个表达式赋值,既需要判断类型之间能否进行转换,还需要判断值类别是否满足要求。在本文中我们不讨论类型问题,重点关注表达式的值类别。

在C语言和C++的早期语法中,值类别被简单分为左值和右值。 简单地说,在一个合法的赋值语句中,等号左边的就是左值表达式,等号右边的就是右值表达式。 在赋值过程中,右值表达式不会被改变,而左值表达式会因为赋值而改变。

1
lvalue = rvalue;

左值和右值的区别本质上在于它们发挥了不同的角色:

  • 对于左值,我们需要使用它的身份(内存地址);
  • 对于右值,我们需要使用它的值(内容)。

表达式能否被赋值等价于表达式能不能作为左值,显然只有表达式在内存中有对应的存储地址时,赋值才是有意义的,当然这个条件是不充分的,它还需要允许被修改。 在这个原始的定义下:

  • 字面量常量表达式只能作为右值,因为字面量常量仅仅有值,并没有存储或内存地址的概念,它实际上可能直接被直接写入指令中,或者存放在不可修改的数据区。
  • 对于变量直接构成的表达式,通常既可以作为左值也可以作为右值,但是只读变量构成的表达式只能作为右值,原因很简单:它是只读而不可修改的。
  • 如果一个表达式可以作为左值,通常它也可以作为右值,反之不成立。

例如下面的赋值语句是不合法的,虽然两侧类型一致,但是出现在左侧的表达式并不能作为左值。

1
7 = 5 + 2;

引用 (1)

引用——指针的语法糖

C语言中的指针是非常核心但是非常困难的概念,C++提供了引用作为指针常量的语法糖,在使用时省去了繁琐的指针操作和指针判空操作。 例如下面使用指针实现的函数

1
2
3
4
5
6
7
8
9
void add(int *p, int n) {
if (p != nullptr) { *p = *p + n; }
}

void test1() {
int s = 10;
add(&s, 5);
std::cout << "s = " << s; // s = 15
}

改成引用就会简洁很多

1
2
3
4
5
6
7
void add(int &m, int n) { m = m + n; }

void test1() {
int s = 10;
add(s, 5);
std::cout << "s = " << s; // s = 15
}

直接省去了取地址、解引用以及指针判空的操作。

当然引用说到底只是C++提供的语法糖,实质上还是通过指针常量进行实现的,并且这里的指针常量必须使用变量的地址进行初始化。 上面的代码等价于

1
2
3
4
5
6
7
8
void add(int *const pm, int n) { *pm = *pm + n; }

void test1() {
int s = 10;
int *const pm = &s; // pm != nullptr
add(pm, 5);
std::cout << "s = " << s; // s = 15
}

如果引用的对象是不可变的(或者即使它本身可变,我们也可以加上不可变限制:不允许通过引用修改它,只允许通过引用读取它), 那么对指针的类型又加上了一个const约束,变成了指向只读对象的指针常量,例如下面的代码

1
2
3
4
5
6
7
8
void show(const int &m) {
std::cout << "m = " << m << std::endl;
}

void test1() {
int s = 10;
show(s);
}

等价于

1
2
3
4
5
6
7
8
9
void show(const int *const p) {
std::cout << "m = " << *p << std::endl;
}

void test1() {
int s = 10;
const int *const p = &s;
show(p);
}

引用类型和绑定的变量类型必须保持一致,下面的语句是不合法的

1
2
3
double val = 3.14;
int &m = val; // compile error
std::cout << m;

在C++11之后,在语法上又提出了右值引用等更多的概念,与之相对,我们把最初的这些引用称为左值引用。

将亡值

我们需要引入一个特殊的概念——将亡值。

函数的调用过程

C++程序的主要工作就是一次次的函数调用,在函数调用的开始涉及到:

  • 冻结并记录函数执行中的寄存器状态
  • 创建函数栈帧
  • 传递参数(赋值语义,可能涉及到某些参数的创建)
  • 立即创建局部变量(或者预留内存空间,在后续的语句中创建局部变量)

在函数调用的结束涉及到:

  • 保存返回值(临时保存到寄存器,或者某个临时内存空间,反正是在当前的函数栈帧之外)
  • 销毁当前的函数栈帧(包括对所有的局部对象执行析构)
  • 恢复上一层函数执行中的寄存器状态
  • 上一层函数从寄存器或临时内存空间中获取返回值

这两个过程中由于栈帧的创建和销毁,可能会产生很多临时对象,造成很多不必要的浪费,这个问题对于C语言并不明显,因为无非是多进行了一两次的内存复制。

但是对C++来说,这里的问题就可大可小了:C++面向对象的语法保证,对自定义类型的对象在生命周期的开始和结束时自动调用构造和析构函数,如果这两个过程的消耗非常大(例如在堆内存中申请和释放空间),那么对临时对象的构造和析构,就会对计算效率产生很大的影响。 C++恰恰又是一个对性能有极致要求的底层语言,当然不能被这个问题绑架,于是C++11在语法上定义了将亡值的概念,允许程序利用这些将亡值的“遗体”。

将亡值通常会在函数传参和函数返回的过程中产生,下面以一个缓冲区类的使用为例,分别进行分析。 接下来的实验,我们将忽略编译器进行的所有优化,包括在-O0条件下编译器也可能自动实施的某些优化,我们采用如下的编译命令

1
clang++ -O0 -fno-elide-constructors main.cpp

示例——Buffer缓存类

考虑下面的例子

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
#include <cstdlib>
#include <cstring>
#include <iostream>

#define SHOW 1

#ifdef SHOW
#define PRINT_INFO(info) std::cout << info;
#else
#define PRINT_INFO(info)
#endif

class Buffer {
public:
explicit Buffer(size_t size) {
PRINT_INFO("call constructor\n");
allocate(size);
}

Buffer(const Buffer &ob) {
PRINT_INFO("call copy constructor\n");
allocate(ob.m_buf_size);
memcpy(m_buf, ob.m_buf, sizeof(int) * ob.m_buf_size);
}

Buffer &operator=(const Buffer &ob) {
PRINT_INFO("call copy operator\n");
if (this == &ob) { return *this; }

deallocate();
allocate(ob.m_buf_size);
memcpy(m_buf, ob.m_buf, sizeof(int) * ob.m_buf_size);

return *this;
}

// Buffer(Buffer &&ob) noexcept;
// Buffer &operator=(Buffer &&ob) noexcept;

~Buffer() {
PRINT_INFO("call destructor\n");
deallocate();
}

int &at(size_t index) { return m_buf[index]; }

int at(size_t index) const { return m_buf[index]; }

size_t size() const { return m_buf_size; }

private:
size_t m_buf_size;
int *m_buf;

void allocate(size_t size) {
m_buf_size = size;
m_buf = static_cast<int *>(malloc(sizeof(int) * size));
}

void deallocate() {
if (m_buf != nullptr) {
free(m_buf);
m_buf = nullptr;
}
}
};

这段代码定义了一个简单的int类型的缓冲区类,并且提供了对元素的访问的获取长度的方法。

函数传参时的将亡值

我们考虑下面的函数调用

1
2
3
4
5
6
7
8
9
10
void ProcessBuf(Buffer buf) {
for (int i = 0; i < buf.size(); i++) { buf.at(i) = 2 * i; }

for (int i = 0; i < buf.size(); i++) { std::cout << buf.at(i) << " "; }
std::cout << "\n";
}

void test2() {
ProcessBuf(Buffer{5});
}

ProcessBuf函数对Buffer对象进行了修改和输出,test2函数调用了ProcessBuf函数,并给它传递了一个临时对象Buffer{5}

程序执行结果如下(编译时必须关掉返回值优化,否则结果不一样)

1
2
3
4
5
call constructor        // 在test2函数中,构造临时对象Buffer{5}
call copy constructor // 给ProcessBuf函数传参的过程,拷贝构造参数对象buf
0 2 4 6 8
call destructor // ProcessBuf函数调用结束,参数对象buf自动析构
call destructor // 在test2函数中,析构临时对象Buffer{5}

但是实际上我们只需要一个就够了,我们在调用侧构造了一个临时对象,能不能将其直接作为函数参数进行废物利用呢?

如果我们将ProcessBuf函数的参数类型改为Buffer &

1
2
3
4
5
6
void ProcessBuf(Buffer buf) { // compile error
for (int i = 0; i < buf.size(); i++) { buf.at(i) = 2 * i; }

for (int i = 0; i < buf.size(); i++) { std::cout << buf.at(i) << " "; }
std::cout << "\n";
}

这会直接导致编译报错

1
2
error C2664: 'void ProcessBuf(Buffer &)': cannot convert argument 1 from 'Buffer' to 'Buffer &'
note: A non-const reference may only be bound to an lvalue

这是因为函数的参数也是当前函数的局部变量,而传参过程是具有赋值语义的,实际上就是说下面的语句是不合法的

1
Buffer &buf = Buffer{5}; // compile error

原因是语法上只允许引用绑定在一个左值上,而临时对象是不能取地址的,不能作为左值。

为什么临时对象不能被取地址?这还是出于运行效率的考虑:

  • 对于某些特别小的临时对象(例如基本数据类型),程序会选择直接将数据存储在寄存器中,而不会写入内存再读取;
  • 只有对于那些在寄存器中放不下的大对象,程序才不得不把临时对象存储在临时内存中,用于临时的中转。(临时内存空间是在当前函数栈之外的)

函数返回时的将亡值

我们考虑下面的函数调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Buffer GetBuf(size_t n, int value) {
Buffer buf{n};

for (int i = 0; i < buf.size(); i++) { buf.at(i) = value; }

return buf;
}

void test3() {
Buffer ret = GetBuf(4, 1);

for (int i = 0; i < ret.size(); i++) { std::cout << ret.at(i) << " "; }
std::cout << "\n";
}

GetBuf函数创建并初始化了一个Buffer对象,然后将其返回。 test3函数调用了GetBuf函数获取了一个Buffer对象,然后输出其中的内容。

程序执行结果如下(编译时必须关掉返回值优化,否则结果不一样)

1
2
3
4
5
6
7
call constructor        // 构造局部变量buf
call copy constructor // 基于buf,拷贝构造一个临时对象作为返回值
call destructor // GetBuf函数调用结束,局部变量buf自动析构
call copy constructor // 基于返回值,拷贝构造局部变量ret
call destructor // 临时对象自动析构(在构造ret之后,临时对象立刻消亡)
1 1 1 1
call destructor // test3函数调用结束,局部变量ret自动析构

我们发现这里居然发生了三次构造和析构,产生了很多的额外计算开销。

考虑到只用于返回的临时对象的存在,程序大致相对于下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void GetBuf(Buffer *tmp_p, size_t n, int value) {
Buffer buf{n};

for (int i = 0; i < buf.size(); i++) { buf.at(i) = value; }

tmp_p->Buffer::Buffer(buf);
return;
}

void test3() {
Buffer *tmp_p = (Buffer *)malloc(sizeof(Buffer));
GetBuf(tmp_p, 4, 1);
Buffer ret = *tmp_p;
tmp_p->Buffer::~Buffer();
free(tmp_p);

for (int i = 0; i < ret.size(); i++) { std::cout << ret.at(i) << " "; }
std::cout << "\n";
}

程序执行结果同上(这次不需要关闭返回值优化,结果不会受到影响)

1
2
3
4
5
6
7
call constructor        // 构造局部变量buf
call copy constructor // 基于buf,在tmp_p指向的内存,手动拷贝构造一个临时对象
call destructor // GetBuf函数调用结束,局部变量buf自动析构
call copy constructor // 基于tmp_p指向的临时对象,拷贝构造局部变量ret
call destructor // 手动析构tmp_p指向的临时对象
1 1 1 1
call destructor // test3函数调用结束,局部变量ret自动析构

这样的做法显然过于繁琐了。

回到原来的函数,我们显然不能把GetBuf函数的返回值类型改为Buffer &,因为这样做其实是返回了一个局部变量的引用或指针,而这个局部变量在使用时已经被销毁了。那如果我们将test1函数中的接收部分改为引用呢?

1
2
3
4
5
6
void test2() {
Buffer &ret = GetBuf(4, 1); // compile error

for (int i = 0; i < ret.size(); i++) { std::cout << ret.at(i) << " "; }
std::cout << "\n";
}

还是会导致编译报错

1
2
error C2440: 'initializing': cannot convert from 'Buffer' to 'Buffer &'
note: A non-const reference may only be bound to an lvalue

编译器给出的理由和上面一样,非const的左值引用不能绑定在一个仅仅作为返回值的临时对象上,后者不能被取地址,不能作为左值。 即使我们尝试进行强制转换,在语法上仍然是不允许的

1
Buffer &ret = static_cast<Buffer &>(GetBuf(4, 1)); // compile error

引用 (2)

右值引用

C++11之后引入了右值引用的语法,这是完全针对底层实现的,为了进一步压榨运行期的效率,避免某些非必要的临时变量的拷贝和析构。这些语法不是针对于上层的语义优化,并不是为了让程序变得更易读的指针语法糖。

右值引用包括如下两个情况:

  1. 右值引用绑定一个字面值常量
  2. 右值引用绑定一个将亡值对象

这里字面值常量和将亡值对象都是通常意义下的右值。

右值引用绑定字面值常量的情况比较简单,编译器其实就是定义了一个普通变量来接收字面值常量

1
2
3
int &&r = 5;   // 右值引用绑定字面值常量
// 等效于
int tmp_r = 5; // tmp_r就是个普通的int变量而已,并不存在什么引用

加上const修饰也是一样的

1
2
3
const int &&r = 5;
// 等效于
const int tmp_r = 5;

这表明:右值引用绑定一个字面值常量时,相当于给这个字面值常量提供了生命周期,此时压根没有什么引用或指针,就是在实现时定义了一个变量来接收字面值常量而已。

右值引用并不要求左右两侧的类型严格一致,只要右侧类型可以隐式转换为左侧类型即可

1
2
3
const int &&r = 3.14;
// 等效于
const int tmp_r = 3.14;

右值引用并不能像普通的引用一样绑定一个正常的变量,语法上直接禁止了这种行为

1
2
3
int a = 2;
int &l = a; // ok
int &&r = a; // compile error

右值引用只能用于绑定特殊的将亡值对象,用于延续它们的生命周期,并完全接管这些临时对象, 我们先讨论如何利用那些函数调用过程中的将亡值,在后文中我们还会介绍如何手动创建将亡值(让变量提前衰亡,使用右值引用进行“夺舍”)

我们可以将函数参数的类型改为右值引用(这种做法其实并不好,见下文中的移动语义部分)

1
2
3
4
5
6
7
8
void ProcessBuf(Buffer &&buf) {
for (int i = 0; i < buf.size(); i++) { buf.at(i) = 2 * i; }

for (int i = 0; i < buf.size(); i++) { std::cout << buf.at(i) << " "; }
std::cout << "\n";
}

void test2() { ProcessBuf(Buffer{5}); }

此时程序运行结果如下(我们仍然关闭了返回值优化)

1
2
3
call constructor    // 在test2函数中,构造临时对象Buffer{5},并被右值引用buf接管
0 2 4 6 8
call destructor // ProcessBuf函数调用结束,buf接管的对象被自动析构

这里省略了一次构造析构,因为我们在传参过程中用右值引用接收并接管了临时对象

1
Buffer &&buf = Buffer{5};

我们也可以将函数的返回值使用右值引用接收

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Buffer GetBuf(size_t n, int value) {
Buffer buf{n};

for (int i = 0; i < buf.size(); i++) { buf.at(i) = value; }

return buf;
}

void test3() {
Buffer &&ret = GetBuf(4, 1);

for (int i = 0; i < ret.size(); i++) { std::cout << ret.at(i) << " "; }
std::cout << "\n";
}

此时程序运行结果如下(我们仍然关闭了返回值优化)

1
2
3
4
5
call constructor        // 构造局部变量buf
call copy constructor // 基于buf,拷贝构造一个临时对象作为返回值
call destructor // GetBuf函数调用结束,局部变量buf自动析构
1 1 1 1
call destructor // ret接管了返回的临时对象,直到test3函数调用结束后,自动析构

这里也省略了一个对象的构造析构,因为我们用右值引用接收并接管了函数的返回值。

小结一下,右值引用绑定将亡值对象的主要作用就是延长将亡值对象的生命周期:

  • 在正常情况下,将亡值对象的生命周期非常短暂,短暂到创建和销毁只是一瞬间,我们无法在语法层面对其进行任何操作。它的使命就是传递参数或返回值,然后就地销毁。对它无法进行任何操作,所以才将其归结为右值。
  • 我们使用右值引用绑定这将亡值对象,手动延续了它的生命周期(称为续命引用更加合适),它的生命周期从“将亡”变成了与右值引用共存亡;
  • 原本这个将亡值对象是匿名的,我们使用右值引用完全接管了它,此后可以对其进行正常的读写等,从这个角度来说,右值引用本身是可以作为左值的(如果没有const修饰)。

const引用

C++为了贯彻“引用就是别名”的设计目标,除了让引用绑定通常意义下的左值(例如变量)之外, 还允许const引用绑定通常意义下的右值(例如字面值常量),反正const引用不会被修改,只能作为右值。

const引用只能作为右值,右值引用却可以作为左值,这样的命名实在是滑稽。

我们显然无法对字面值常量取地址,为了达成上述目的,const引用在底层实现上必须要分成两种情况进行讨论:

  1. 绑定变量,作为只读变量的引用,在底层实现中基于指针常量实现;
  2. 绑定字面值常量,将亡值对象等右值,同样延续了将亡值的生命周期,在底层实现中可以理解为基于右值引用实现。需要说明的是,const引用绑定右值的实现很可能不是基于右值引用的,这主要是历史原因:const引用出现的时间比右值引用早很多,但是这不影响我们的理解。

绑定变量例如

1
2
3
int a = 5;          // a是一个变量
const int &r2 = a; // r2是变量a的只读引用
std::cout << r2;

实际相当于

1
2
3
int a = 5;
const int *const ptr_r2 = &a; // ptr_r2是指向变量a的只读的指针常量
std::cout << *ptr_r2;

绑定字面值常量例如

1
2
const int &r1 = 8;  // r1是字面值常量的只读引用
std::cout << r1;

实际相当于

1
2
const int &&tmp_r1 = 8;   // tmp_r1是使用字面值常量初始化的只读变量
std::cout << tmp_r1;

对于通常的引用,我们要求引用类型和绑定的变量类型必须保持一致,但是对于const引用可以放松这个要求, 只要右侧表达式的类型可以隐式转换为左侧类型即可(当然编译器可能会给出警告)

1
2
3
double val = 3.14;
const int &m = val;
std::cout << m; // 3

编译器的做法和使用const引用直接绑定一个字面量的处理类似

1
2
3
double val = 3.14;
const int &&m = val;
std::cout << m; // 3

左值和右值 (2)

前面我们已经给出了从C语言继承来的原始的左值和右值的定义,但是在现代C++标准中(C++11、C++14、C++17), 为了适配C++11才提出的右值引用和移动语义等,以及与C++17规定的返回值优化等兼容,左值和右值的相关概念一直在调整。(C++是真的离谱,这么基础的概念在语法上还是一直扯不清楚)

最新的分类标准如下:

  • 基本概念
    • 纯右值(prvalue)
    • 将亡值(xvalue)
    • 左值(lvalue)
  • 组合概念
    • 右值(rvalue):纯右值和将亡值统称为右值;
    • 泛左值(glvalue):左值和将亡值统称为泛左值。

想把上面几个基本概念说清楚是一项非常繁琐复杂的工作,具体的讨论可以参考这篇博客:C++17 的值类别。 我并没有兴趣去做C++语言律师,下面只是给出最典型的例子:

  • 纯右值:例如绝大多数的字面值常量表达式
  • 将亡值:std::move()得到的表达式是将亡值,还有上文中的那些仅仅用于返回值或传参的临时对象
  • 左值:例如一个变量组成的表达式

补充:字面值常量在绝大部分情况下都是纯右值,但有一个例外——字符串字面值是左值,可以取地址的,下面的语句是合法的

1
2
std::cout << &("abc") << std::endl;
const char *p_char = "abc";

补充

函数的只读参数类型

我们通常会使用const引用作为函数中只读参数的类型,因为它可以兼容更多方式的函数调用,例如

1
2
3
4
5
6
7
8
9
10
11
12
struct Demo {};

void func(const Demo &arg) {
// ...
}

void test() {
func(Demo{});

Demo a;
func(a);
}

如果使用普通的传值方式,也可以做到这一点,但是正如前文中讨论的,在传值过程中会发生额外的拷贝(其实也未必,因为编译器很可能自动进行了优化)

1
2
3
4
5
6
7
8
9
10
11
12
struct Demo {};

void func(Demo arg) {
// ...
}

void test() {
func(Demo{});

Demo a;
func(a);
}

如果改成非const的左值引用则无法做到(并且失去了只读参数的语义),此时第一种调用方式是编译错误

1
2
3
4
5
6
7
8
9
10
11
12
struct Demo {};

void func(Demo &arg) {
// ...
}

void test() {
func(Demo{}); // compile error

Demo a;
func(a);
}

如果改成右值引用也无法做到,此时第二种调用方式是编译错误

1
2
3
4
5
6
7
8
9
10
11
12
struct Demo {};

void func(Demo &&arg) {
// ...
}

void test() {
func(Demo{});

Demo a;
func(a); // compile error
}

小结:

  • 对于复杂类型的只读参数,const引用是最优选择,但是对于简单类型的参数(例如基本类型),直接使用值传递也是很好的选择;
  • 如果我们希望只允许使用某一种只读参数,例如只允许传入字面量,那么右值引用就是更合适的选择;
  • 如果函数需要的是读写参数,那么对参数类型的选择通常有非const的左值引用和指针两种选择,除了使用风格不同,两者之间并没有明显的优劣。

补充:这里讨论的是非模板的普通函数,对于模板函数的参数类型设计过于复杂,不在本文的讨论范围之内。