本文代码主要来源:LearnCPP 第十五章内容
引入:智能指针中的重复拷贝
发现简单的例子都被 Copy elision 优化掉了 qaq 从复杂一点的来吧。
C++中一个常用的操作是通过 new
函数来动态分配一个对象,然而,一个问题是容易忘记将分配的内存回收。就算在函数结束时调用了 delete
函数,也可能出现函数中间就返回或者异常的情况。在这个背景下,我们希望有一种结构可以在离开运行环境时自动回收内存。这时候,我们可以自然地想到 C++ 中的类 —— 它会在离开运行环境时自动调用析构函数。如果用一个类来包装指针,则可以将回收函数放在析构函数中,它将在超出范围时自动调用。简单的代码如下:
1 |
|
然而,这么写是有很大问题的。例如:当希望用拷贝构造函数创建一个新的指针时,得到的将是一个指向 res1 中创建的实例的指针。当离开运行环境时,会进行两次 delete操作,而只分配了一份空间,会导致函数出错。
1 | int main() |
考虑一个常见的例子,我们将指针作为参数传入函数,会发生什么呢?调用的函数会通过拷贝构造函数创建一个指向原来实例的指针,而在退出函数时,会进行 delete 操作,main
函数中指针指向的实例也被回收了!
解决这个问题最简单的方法是重写拷贝构造函数和拷贝赋值函数,进行深拷贝 [Deep Copy]。代码如下:
1 | template<class T> |
但是,深拷贝的代价是昂贵的,在一些情况下,我们并不希望进行拷贝。如
1 | Auto_ptr<Resource> generateResource() |
这段代码在关闭了 Copy elision 优化 [g++编译命令中增加 -fno-elide-constructors
] 后的输出为:
- Resource acquired
- Resource acquired
- Resource destroyed
- Resource acquired
- Resource destroyed
- Resource destroyed
咦~明明应该只在 generateResource()
函数中创建过一个 Resource。分析一下:
- 创建指针 mainres 时并没有创建 Resource,此时指针是一个空指针。
- 进入
generateResource()
函数,创建了一个 Resource,同时创建了一个指针 res 指向这个 Resource。出现第一个Resource acquired
- 返回时,通过拷贝构造函数,在
main()
函数中创建了一个临时指针。由于使用深拷贝,需要创建一个新的 Resource,将函数返回的指针指向的 Resource 复制过来。出现第二个Resource acquired
- 离开函数,函数中创建的指针释放,调用了
delete()
函数。出现第一个Resource destroyed
- 进入赋值语句,进行拷贝赋值,创建一个新的 Resource进行拷贝。出现第三个
Resource acquired
- 赋值语句结束,临时变量释放。出现第二个
Resource destroyed
-
main()
函数结束,mainres 回收。出现第三个Resource destroyed
// 如果使用 Copy elision 优化,赋值语句的两个输出会被优化,即第5,6。详见谷歌(
但我们希望看到的,是只进行一次 Resource 的创建:它只发生在调用函数中。事实上,通过深拷贝进行多测对象创建已经偏理了我们使用指针的本意。
在这里,我们并不需要通过拷贝构造函数和拷贝赋值函数复制指针指向的内容,而只需要将指针指向内容的所有权从原来的指针转移到目标对象。这就是移动语义背后的核心思想。 移动语义 [Move semantics] 意味着类将转移对象的所有权,而不是进行复制。我们希望在指针类中加入这样的构造/赋值函数,并在合适的时候调用。
1 | // A copy constructor that implements move semantics |
通过上面的分析,我们知道了想要实现一个会自动释放内存的指针,需要在必要的时候进行深拷贝,同时希望可以在合适的时候通过移动来优化构造/赋值函数。那么一个自然的问题出现了:我们应该如何判断什么时候进行拷贝,什么时候进行移动。
右值引用
C++11 及之后将表达式的值类别属性分为:prvalue, xvalue, and lvalue。但这与理解右值引用与移动语义关联不大,因此这里不介绍了。(其实是我还没看懂)详情参见:Value categories
左值 lvalue 与右值 rvalue
左值与右值指的是表达式的属性。 C ++中的每个表达式都有两个属性:一个类型(用于类型检查)和值类别 [Value categories](用于某些类型的语法检查,如赋值语句的左端需要是“可以修改的变量”)。在 C++ 03及更早版本中,左值和右值是仅有的两个可用值类别。
直观的,左值一开始的定义是 “适合位于赋值表达式左侧的值”,然而后来增加了 const 关键字,左值分成了两个子类别:可修改的左值与不可修改的左值。
而右值,一个简单的定义就是:不是左值的任意表达式。如常量 (1),临时变量 (x+1),匿名对象 (Function(a,b)) 等。右值的生存期只有它所在的表达式,当离开表达式时,右值将被回收。同时右值也不能被赋值:由于生存期有限,这是没有意义的。
左值引用
引用:引用变量是一个别名,也就是说,它是某个已存在变量的另一个名字。一旦把引用初始化为某个变量,就可以使用该引用名称或变量名称来指向变量。引用必须在创建时被初始化,一旦被初始化为一个对象,就不能被指向到另一个对象。[C++引用]
在C++11之前,只存在左值引用,也即引用。左值引用又分为可修改的引用与不可修改的引用。可修改的左值引用只能被可修改的左值初始化。不可修改的左值引用可以被任意左值,右值初始化,但不能通过引用修改对象的值。
1 | int main() { |
右值引用
在 C++11之后,新增了右值引用。表达方式是两个 &&。右值引用只能被右值初始化,同样分为可修改的右值引用与不可修改的右值引用。右值引用有两个作用:首先,右值引用可以延长右值的生存期,可以在表达式之外被使用;此外,可修改的右值引用允许修改右值。一个简单的例子:
1 | int main() |
和左值引用一样,右值引用的主要作用是作为函数参数。需要注意的是,右值引用表示对一个右值的引用,但它本身作为一个命名变量,是一个左值。就如前面所说的,表达式有两个属性,右值引用的类型属性为 “右值引用”,它的值类别属性则为左值。
1 | void fun(const int &lref) // l-value arguments will select this function |
同样的道理,如果在右值引用的函数中加入 fun(rref)
再次调用函数,此时将会进入左值引用的函数。
回到一开始的问题,可以发现,C++中对于左值引用与右值引用的自动区别,完成了判断 “什么时候进行拷贝,什么时候进行移动” 的任务:
- 当通过左值进行构造/赋值时,由于进行构造的左值在之后还可能被使用,我们应该用深拷贝建立一个新的对象。
- 当通过右值进行构造/赋值时,进行构造的右值正常会在表达式结束之后就回收,因此我们可以直接将指针指向这个右值,无需重新创建对象。
移动语义
移动构造函数与移动拷贝函数
通过上面的分析,我们可以通过左值引用与右值引用重载构造函数与赋值函数。当使用左值引用时,不允许修改左值。当使用右值引用时,允许修改。
1 | template<class T> |
此时进行之前的任务,只会进行一次分配与回收:
- Resource acquired
- Resource destroyed
需要注意的是,有一次构造函数发生在函数值返回时,按对左右值的分析,此时返回的应该是左值。但C ++规范有一条特殊的规则,规定从函数按值返回的自动对象 [automatic objects,可以理解为非静态的局部变量] 即使是左值也可以作为右值处理。这是有道理的,因为返回值将在函数结束时销毁。
上面的移动操作仍然是发生在停止了 Copy elision 的情况下。若使用 Copy elision,编译器可以通过完全省去副本,避免了进行复制或移动的需要。
//需要注意的是,对于自定义的类,需要自己定义移动构造函数与移动拷贝函数,否则仍然是进行拷贝操作。
std::move
除了对编译器自动识别出来的右值使用移动语义之外,有时候可能也希望对左值进行移动:你知道你的程序在对象进行下一次赋值之前不会使用,此时可以进行移动。以交换函数为例:
1 | template<class T> |
交换函数一般在两个变量间进行,但是进行三次的拷贝实际上是没有必要的:我们更希望的是将变量 a
移动到临时变量,将变量 b
移动到变量 a
,将临时变量移动到变量 b
完成交换。但是函数中 a
和 b
都被认为是左值,不能进行移动构造和移动赋值。有没有方法将左值转化为右值呢?
在标准库 <utility>
中,提供了 std::move()
函数。它将一个左值转化为右值,从而可以调用移动构造函数与移动赋值函数。
1 | template<class T> |
此外,类似排序函数等算法也可以通过 std::move
提高效率。
其他优化
在定义了右值引用与移动语义之后,很多常见的操作都可以进行优化。
如 vector
中的 push_back()
函数,在 C++11之后,若希望向容器中加入一个右值, push_back()
会创建变量后调用移动构造函数而无需赋值。在有了右值的定义后,一个更方便的方法是直接在容器中构造,于是出现了 emplace_back()
函数。
参考资料:
最后编辑:2020-08-03