在上一篇理解C++中的左值和右值中,我们解释了右值的逻辑:无论如何,C++不允许对右值进行修改。

但是,现代C++(C++11及之后)带来了右值引用:一种可以绑定到右值的新类型,还可以对它进行修改。为什么这样做呢?

我们先复习下右值的一些场景:

1
2
3
4
5
6
7
8
9
10
11
int x = 666;                    // (1)
int y = x + 5; // (2)

std::string s1 = "hello ";
std::string s2 = "world";
std::string s3 = s1 + s2; // (3)

std::string getString() {
return "hello world";
}
std::string s4 = getString(); // (4)

第(1)处,字面常量666是一个右值:它没有特定的内存地址,只是在程序运行时存储在某些临时寄存器上。它只有在被赋值给左值x后才有意义。

第(4)处与第(1)处类似,不同的是这里的右值并不是硬编码的,它来自一个函数的返回值。和第(1)处一样,这个临时对象只有在被赋值给左值S4后才有意义。

第(2)和(3)处更复杂一些:编译器会创建一个临时对象来保存operator +的结果。operator +的结果是一个右值,因此我们也需要将它赋值给变量ys3

右值引用

传统C++中,右值只有被存储在const变量时,才能获取其地址。或者说, 我们只能绑定常量左值到右值。就像下面这样:

1
2
int& x = 666; // Error
const int& x = 666; // OK

第一行是错误的:我们不能用一个右值来初始化左值引用int &

第一行是OK的,当然,因为x是常量,你也无法修改它。

C++11引入了一个新的类型:右值引用,用符号&&来表示。这样一来,右值也可以被修改了。

下面我们就来玩玩这个新玩具:

1
2
3
4
5
std::string   s1     = "Hello ";
std::string s2 = "world";
std::string&& s_rref = s1 + s2; // the result of s1 + s2 is an rvalue
s_rref += ", my friend"; // I can change the temporary string!
std::cout << s_rref << '\n'; // prints "Hello world, my friend"

我们创建了两个字符串s1s2。把它们连接起来,并把结果(是一个临时的字符串,右值)保存到std::string&& s_rref. 现在s_rref是一个右值引用了。我们可以根据我们的需要对这个临时字符串进行修改。如果没有右值引用,这是不可能的。为了更好的区分,我们将传统的C++引用称为左值引用(只有一个&)。

咋一看这个右值引用可能没什么鸟用。但是右值引用引申出了移动语义,一种可以显著提升程序性能的技术。

移动语义

移动语义是指一种基于右值引用来实现的可避免不必要的临时对象拷贝的移动资源的新方法。

在我看来,了解移动语义的最佳方式是构建一个包含动态内存分配的包装类,在资源进出的函数中跟踪它。记住,移动语义并不仅仅适用于类。

看看下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Holder
{
public:
Holder(int size) // 构造函数
{
m_data = new int[size];
m_size = size;
}
~Holder() // 析构函数
{
delete[] m_data;
}
private:
int* m_data;
size_t m_size;
};

在这个简单的类中,它管理一块动态分配的内存。如果需要自己管理内存,我们需要遵守C++中的三法则。它的要求是,假如类有明显定义下列其中一个成员函数,那么程序员必须写入其他两个成员函数到类内,也就是说下列三个成员函数缺一不可。

  • 析构函数
  • 拷贝构造函数
  • 赋值函数

如果都没有定义,编译器将生成这三个成员函数的默认版本。

糟糕的是,如果你的类中有管理动态内存,默认版本还不够用。因为编译器根本不知道你的业务逻辑和需求是啥。

实现拷贝构造函数

我们先来实现三法则中的拷贝构造函数。拷贝构造函数是用来从现有对象创建新对象。例如:

1
2
3
Holder h1(10000); // 构造函数
Holder h2 = h1; // 拷贝构造函数
Holder h3(h1); // 拷贝构造函数 (另一种语法)

它的拷贝构造函数大概长这样:

1
2
3
4
5
6
Holder(const Holder& other)
{
m_data = new int[other.m_size]; // (1)
std::copy(other.m_data, other.m_data + other.m_size, m_data); // (2)
m_size = other.m_size;
}

这里我们从一个现有对象other初始化了一个新的Holder对象:创建了一个新的同样大小的数组(1),再拷贝other.m_datathis.m_data(2)。

实现赋值函数

赋值函数是用另一个现有对象来替换现有对象。例如:

1
2
3
Holder h1(10000);  // 构造函数
Holder h2(60000); // 构造函数
h1 = h2; // 赋值函数

上面的Holder类中的赋值函数大概长这样:

1
2
3
4
5
6
7
8
9
Holder& operator=(const Holder& other) 
{
if(this == &other) return *this; // (1)
delete[] m_data; // (2)
m_data = new int[other.m_size];
std::copy(other.m_data, other.m_data + other.m_size, m_data);
m_size = other.m_size;
return *this; // (3)
}

首先, 避免自己赋值给自己(1)。然后,我们开始替换,先清理掉当前数据(2),再像拷贝构造函数一样可拷贝数据。最后,返回此对象的引用(3)。

拷贝构造函数和赋值函数的关键点都是它们都接受常量(const)引用作为参数,然后复制其中的数据作为己用。输入的是常量,当然不能修改。

Holder的当前实现有什么限制?

Holder类写好了,但是它缺少一些优化。我们看看下面的函数:

1
2
3
4
Holder createHolder(int size)
{
return Holder(size);
}

它按值返回Holder对象。我们知道,如果函数按值返回对象,编译器不得不创建一个临时对象(右值)。现在,假设我们的Holder管理了大量的内存,在当前的设计下,我们将付出昂贵的代价,会触发多次内存分配和拷贝。就像下面这样:

1
2
3
4
int main()
{
Holder h = createHolder(1000);
}

通过createHolder()按值返回的临时对象传递给拷贝构造函数。在当前的类设计下,拷贝构造函数将从,临时对象中拷贝数据到自己的m_data。这里将发生两次昂贵的内存分配:a) 创建临时对象;b) 在拷贝构造函数中。

同样的拷贝流程也会发生在赋值函数中:

1
2
3
4
5
int main()
{
Holder h = createHolder(1000); // 拷贝构造函数
h = createHolder(500); // 赋值函数
}

同样的,这里也将发生两次昂贵的内存分配:a) 创建临时对象;b) 在赋值函数中。

如此多昂贵的拷贝,我们明明已经有这个完整的对象(从createHolder()得到的临时对象)了,但还需要再次拷贝。因为它是临时对象,是右值,我们有没有办法直接从临时对象中偷走数据呢?或者直接把临时对象中分配的数据移动过去?

传统C++不能,但现代C++可以!

不要拷贝,仅仅移动,因为移动总是更划算的。

用右值引用来实现移动语义

让我们的类具有移动语义:添加新版本的拷贝构造函数和赋值函数以从临时对象中偷走数据。”偷走“数据意味着我们要修改数据的持有者,我们怎么来修改一个临时对象呢?用右值引用

三法则变成了五法则,在原先的基础上增加了两个成员函数:

  • 移动构造函数
  • 移动赋值函数

实现移动构造函数

一个标准的移动构造函数实现:

1
2
3
4
5
6
7
Holder(Holder&& other)     // <-- 右值引用
{
m_data = other.m_data; // (1)
m_size = other.m_size;
other.m_data = nullptr; // (2)
other.m_size = 0;
}

它的输入参数是另一个Holder对象的右值引用。这是关键的部分:变成右值引用后,我们就可以修改它啦!然后我们开始偷走数据(1), 再把它置为空(2)。这里没有任何拷贝发生,我们只是移动它,将自己的指针指向别人的资源,再将别人的指针置为空。

第(2)步非常重要,如果不将别人的指针置为nullptr,那么临时对象析构的时候就会释放掉这个资源,偷也白偷了。还记得Holder的析构函数中的delete[] m_data吗?

实现移动赋值函数

移动赋值函数的逻辑一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Holder& operator=(Holder&& other)     // <-- 右值引用 
{
if (this == &other) return *this;

delete[] m_data; // (1)

m_data = other.m_data; // (2)
m_size = other.m_size;

other.m_data = nullptr; // (3)
other.m_size = 0;

return *this;
}

先清空自己的资源(1), 再偷走临时对象的数据(2) , 最后打个标记表示数据我已经偷走了(3)。其他一切都和原始的赋值函数一样。

现在我们有两个新的成员函数,聪明的编译器会检测到它们并判断你是否从临时对象(右值)还是正常对象(左值)创建新对象,它将触发恰当的函数。例如:

1
2
3
4
5
6
7
8
9
int main()
{
Holder h1(1000); // 构造函数
Holder h2(h1); // 拷贝构造函数 (输入的是左值)
Holder h3 = createHolder(2000); // 移动构造函数 (输入的是右值) (1)

h2 = h3; // 赋值函数 (输入的是左值)
h2 = createHolder(500); // 移动赋值函数 (输入的是右值)
}

什么时候移动语义生效

当传递重量级的对象时,移动语义提供了一种更智能的做法。你只要一次创建你的重量级对象,然后把它移动到需要的地方。就像之前说的一样,移动语义并不仅仅关于类。你可以在任何你要改变资源持有者的地方使用它。但是记住,和指针不一样,你不能共享任何东西:如果对象A从对象B中偷走了数据,那么对象B中的数据就不存在了。对临时对象这样处理没啥问题,但你也可以从普通对象中偷走数据,我们将在后面提到。

你的移动构造函数永远都不会被调用!

没错!如果你跑上面的代码片段你会发现移动构造函数并没有被调用(1),原始的构造函数被调用了。这都是因为返回值优化(Return Value Optimization 简写成RVO)。现代的编译器可以检测到你是否按值返回了临时对象,然后使用了某种方法来避免返回过程触发复制。

你也可以告诉编译器不要RVO,GCC编译器使用-fno-elide-constructors.

RVO已经做了优化,为什么我们还要实现移动语义?

RVO仅仅是关于返回值(输出),与函数参数(输入)无关。有许多需要传递可移动对象的时候,可能会调用移动构造函数和移动赋值函数(如果实现了它们)。最重要的一个:标准库。在C++11中,所有算法和容器都支持了移动语义。因此,如果你遵循了五法则的标准库,你将获得一个重要的性能提升。

可以移动左值吗?

可以!使用标准库的工具函数std::move。你可以用它来将左值转换成右值。

我们来看一个偷走左值的例子:

1
2
3
4
5
int main()
{
Holder h1(1000); // h1 是一个左值
Holder h2(h1); // 调用拷贝构造函数 (因为输入的是左值)
}

上面的代码并h2没有偷走h1的数据,因为h2传入的是左值,将调用拷贝构造函数。我们需要强制调用移动构造函数,就像这样:

1
2
3
4
5
int main()
{
Holder h1(1000); // h1 是一个左值
Holder h2(std::move(h1)); // 调用移动构造函数 (因为输入的是右值)
}

这里std::move将左值h1转换成了右值,然后触发了移动构造函数。这样h2就成功从h1那偷走了数据。

需要提醒的是被偷走数据之后,h1的数据指针被置为nullptr(在移动构造函数中的other.m_data = nullptr)。如果还要使用h1,先检测它或者将它移除作用域,以避免Crash。

最后说明 & 可能的改进

RAII

我们在Holder的例子中用到了RAII(Resource Acquisition Is Initialization, 资源获取就是初始化).

RAII是一种C++技术,当你把资源(文件,套接字,数据库连接,内存…)包装成类时可能会用到。在构造函数中初始化资源,在析构函数中清理资源。在这种要求下,只要对象能正确地析构,就不会出现资源泄露问题。更详细的介绍在这里

给你的移动构造函数和移动赋值函数加上noexcept

C++11的关键字noexcept意味着“这个函数永远都不会抛出异常”。它用来做一些优化。有人说移动构造函数和移动赋值函数永远都不要抛出异常。基本法则:永远不要在这两个函数中进行内存分配或调用其他代码,你应该只在这偷数据。更多信息,请参考:[1], [2]

使用copy-and-swap来优化

在所有的拷贝构造/赋值函数中充斥着大量的重复代码,这不太爽。此外,如果在其中分配内存抛出异常,源对象可能会留在错误的状态。copy-and-swap 解决了这两个问题,仅需要在类中添加一个新方法而已。更多信息,请参考:[1], [2]

完美转发

此技术允许你在多个模板和非模板功能上移动数据时不会发生类型转换错误。更多信息,请参考[1],[2]

Reference