C++ 踩坑:特殊成员函数 - Rule of Five
上一篇介绍了几种特殊的成员函数以及 Rule of Three. 从 C++11 之后, 由于引入了移动语义, 因此又引入了两个特殊的成员函数, 移动构造函数和移动赋值操作符. 因此原来的Rule of Three 将演变成Rule of Five
拷贝的代价
之前, 我们定义了类 SocketManager 来管理对象 Socket 对象的生命周期. SocketManager 类定义如下:
Socket 在构造函数中创建, 在析构函数中释放. 拷贝构造函数和赋值操作符中实现了深拷贝. 但是某些场景下, 拷贝对象并不是一个很好的方案. 这会创建, 拷贝, 销毁临时对象, 有一定的代价. 比如:
- 从函数返回的对象
- 某些算法, 比如
swap() - 动态分配的容器
以一个 SocketManagers 的 vector 容器为例:
我们期望通过构造函数创建一个(临时)的 SocketManager 对象, 然后通过拷贝构造函数将其插入到 vector 中. 在第二次插入时, vector 为了存储两个新的对象, 重新分配内存, 然后将两个对象拷贝进去, 这就造成了第二次的拷贝构造(注: 第二次插入时, 产生了两次拷贝构造, 一次拷贝原容器对象, 一次拷贝新创建的临时对象). 这种操作有一定的代价, 因为每次拷贝需要释放/重新分配内存并复制数据.
这里我们使用了类 SocketManager 的默认构造函数. 我们将在后续探讨原因.
对于这个例子, 我故意忽略了对象的放置; 当内存被分配时时, 仍然发生拷贝.
资源窃取和右值引用
C++98 支持复制. 当临时对象被创建时, 例如上述例子中, 它们被拷贝(通过拷贝构造函数). 在很多场景中, 拷贝是不必要开销, 临时对象即将超出作用域且不能再使用其中的资源.
如果我们只带走临时对象资源的所有权将是更好的选择, 对象不再担心资源的释放, 我们也不用担心资源的重新分配. 这个概念被称为资源窃取.
针对这些场景, C++11引入了右值引用的概念.
C++程序员对引用是很熟悉的(希望如此). 引用允许你可以创建现有对象的别名. 既然它们引用具名对象, 现在应当称之为左值引用. 利用左值引用, C++ 支持高效地输入, 输入-输出参数.
右值引用可以被显式地绑定到一个右值上. 右值是一个无名对象 (unnamed object), 换句话说, 是临时对象. (点击 这里 获取更多关于左值和右值表达式的细节讨论)
不要混淆右值引用, 既然它是一个具名对象, 那么它实际上是一个左值表达式! 这也是为什么调用 byRef(rval) 实际上是调用了 byRef(int&) 的原因.
右值引用, 就它本身来说不是特别有用 (实际更类似左值引用), 可以被用于重载函数. 根据参数类型是左值还是右值来选择不同的重载版本, 从而表现出不同的行为.
这里 (总是) 有些难搞. 编译器只会为可修改的右值重载右值引用; 对于常量的右值, 编译器总是更偏向常量左值引用 (为了向后兼容). 这意味着为重载 const T&& 的函数没有实际的应用.
当我们能够区分左值和右值对象后, 我们就可以重载构造函数 (接着就是赋值操作符) 来支持资源窃取.
移动构造
移动构造函数是类构造函数的一个重载版本, 接受右值引用作为入参. 编译器可以判定被使用源对象在不久将会跳出作用域, 因此我们可以窃取源对象的资源来构造新的对象. 基本的操作就是转移源对象所有属性的所有权, 让源对象保持着 "空" 状态.
显然, "空" 状态对于不同的类是不同; 但是基本的前提是, 将对象置于这种状态下, 它的析构函数不会失败或者抛出异常.
让我们先看看原生的解决方案, 来理解这个原则.
注意移动构造函数的参数是非const - 我们需要修改入参. 我们的移动构造函数需要窃取源对象的资源, 然后将其置于默认状态.
移动构造函数 "主张" 被提供右值的资源. 通过将设置右值 pSocket 为 nullptr, 当右值对象超出作用域时, 它的析构函数将不会做任何操作.
参考之前的文章, 来看看内存映射, 有助于理清发生了什么. 首先, 我们获取了赋值符号右侧资源的所有权.
然后我们将赋值符号右侧置为 "空" 状态.
当赋值符号右侧 (临时) 对象超出作用域时, 它的析构函数将被调用, 但是不做任何事 (删除 nullptr 没有任何影响). 我们成功窃取了临时对象的资源.
回顾一下代码, 可以看出移动构造函数所做的事情只是简单的交换了一下源对象和处于默认状态的对象的元素. 我们早有执行这些操作的两个函数 - 默认构造函数和 swap() 函数.
重写移动构造函数, 如下所示. 这个方案更加优雅.
这里我们使用了 C++11 的特性 - 级联构造函数 (cascading constructors) (注: 委托构造函数?). 在交换"空"状态的临时对象之前, 我们首先做的事是在移动构造函数中调用默认构造函数.
注意, 为了执行移动构造, 必须尽可能为对象定义一个"空"状态. 一条好的经验:如果你不能为类定义一个默认构造函数, 那它就不应该支持移动语义.
备注: 移动构造函数有必要定义为 noexcept. 这是 C++11 用来标记函数异常修饰符的一个关键字. 在这个例子中, 它保证了移动构造函数不会抛出异常. 这对于容器类是个必要条件 如果移动构造函数可能会抛出异常, 那么容器通常会拷贝对象而非移动对象 (注: 如果类的移动构造函数没有标记为 noexcept, 在容器发生重分配时, 将不会调用移动构造函数, 而是调用拷贝构造函数).
现在, 回到最初的例子, SocketManager 的 vector. 当 SocketManager 有了移动构造函数, 对象会被移动而非拷贝. (注意: 我们没有修改 vector 的代码, 它会一直试图移动类 SocketManager, 但是如果类不具备移动构造函数, 编译器将选择次佳方案 - 将右值对象绑定到一个 const SocketManager& 上; 即拷贝构造函数).
如果容器发生很多插入操作, 代码的效率将大大提高.
如果你想为派生类包含移动语义, 有些事情需要被考虑到. 如果你想在派生类中使用基类的移动语义, 你必须显式调用它; 否则, 拷贝构造函数将被调用.
这里的 std::move 实际上不会发生任何移动; 它只是将左值转换为一个右值. 这强制编译器使用对象的移动构造函数 (如果定义的话) 而不是拷贝构造函数.
移动赋值
有时, 我们希望显式得将资源得所有权从一个对象转移到另一个对象上. 这可以通过使用 std::move 来完成.
在这个例子中, std::move 强制编译器为赋值操作符选择一个右值重载版本.
当赋值符号右侧对象被移动赋值后, 该对象将处于"空"状态, 所以注意: 当且仅当左值对象将立马被丢弃时才使用 std::move - 要么这个对象将不再被使用, 或者将超出作用域 (标准库将其记为 x-value).
现在赋值操作将为右值引用重载. 首先, 让我们先看看原始的方案.
赋值操作必须自赋值检查. 虽然这在手写的代码中几乎遇不到, 但是某些算法 (比如 std::sort) 可能发生自赋值.
再次回顾一下上述代码, 可知, 像移动构造函数一样, 移动赋值也交换了赋值符号右侧对象和"空"对象.
消除移动赋值操作
实际上, 移动赋值操作是没有必要的!
之前定义的赋值操作在被调用之前, 拷贝了赋值符号右侧的对象. 这通常会调用常规的拷贝构造函数. 然而, std::move 将导致编译器为符号右侧对象调用移动构造函数.
通过移动构造参数, 源对象 (mgr2) 将处于一个空状态 (根据要求) (注: 这里应该是 mrg1).
在赋值操作内部, 符号左右两侧的对象只简单地被交换了.
当赋值操作函数退出时, 临时对象(入参)将被删除, 从而便于清理符号左侧所拥有的资源.
再来看看内存映射视图, 这能够帮助解释发生的"魔幻"操作. 最初的状态:
首先, 移动构造函被调用来窃取符号右侧对象(mgr1)的资源, 然后将其置于空状态.
赋值操作符的行为是交换符号左右两侧的对象 - 这个例子中是 mgr2 和 rhs.
当临时对象(rhs)超出作用域时, 它删除了资源(资源最初是由 mrg2 所拥有的).
你的资源管理策略
之前所说的 "The Rule of The Big Five" 现在将退化为 "The Rule of The Big Four (and a half):
- 拷贝构造函数
- 赋值操作符
- 移动构造函数
- 交换函数
通过仔细地编程, 我们已经尽可能限制资源分配/释放内存次数.
"The Rule of The Big Four (and a half)" 指的是, 如果你编写了上述其中一个函数, 你必须为其他函数定义一个策略. 这不是意味着你必须实现其他函数. 实际上, 你必须为你创建的类提供一套资源管理策略. 你的策略可以是以下其中之一:
- 利用编译器提供的函数版本. 换句话说, 你不为这个类进行资源管理.
- 编写你自己的拷贝函数来执行深拷贝, 但是不提供移动语义.
- 编写你自己的移动函数, 但不支持拷贝.
- 对该类禁用拷贝和移动语义, 因为允许这些操作对它们来说没有意义 (注: 比如单例).
禁止移动和拷贝比较直接, 这里有两种方式来实现:
- 将对应的函数声明为私有. (C++98 风格)
- 使用
=delete标记
结论 (至此)
资源管理 - 利用 C++ RAII/RDID 机制 - 是编写高效, 可维护, 无漏洞代码的关键. 为你系统中的每个类型定义你自己的拷贝和移动策略是系统设计的关键一部分. The Rule of The Big Five 应该作为拷贝/移动策略的座右铭. 你可以忽略它, 但这样做会使你陷入危险状态.
最后, 对移动和拷贝策略的验证也带来了两条良好的实践.
- 一个类应该只管理最多一个资源.
- 通过使用现有的类型来简化你的资源管理类, 比如智能指针.
最后一点很重要, 这将引出这系列最后一篇文章 - "The Rule of Zero". 我们将在下次介绍.