C++ 踩坑:特殊成员函数 - Rule of Three
上一篇讲了几种特殊函数的编译器隐式生成要素和规则, 这篇主要详细说说Rule of Three (三大律). 文章主要参考自 The Rule of The Big Three (and a half)
Rule of Three
Rule of Three 指的是, 假如你声明了拷贝构造, 拷贝赋值或者析构函数中的任意一个, 你应该同时声明这三个函数.
为了实现拷贝-交换语义, 资源管理类应当实现交换函数来执行类成员的交换. 所以这个定律也扩展成了 Rule of Three (and a half)
通常, 接管拷贝操作大多数因为需要执行一些资源管理. 这意味着, (1)其中一个拷贝操作对资源进行管理, 意味着另一个拷贝操作也需要对资源进行管理; (2)析构函数也应当参与其中(通常是释放资源). 典型的需要管理的资源是内存(还有文件, socket, mutex 等). 结合拷贝和移动操作之间的关系, C++11 也不会保证为具有自定义的析构函数的类生成移动函数. 因此, 当且以下三个条件成立时, 才会为类生成移动操作:
- 没有声明拷贝操作
- 没有声明移动操作
- 没有声明析构函数
示例
析构函数
类 SocketManager 负责管理类 Socket 的生命周期. 对象 Socket 在 SocketManager 的构造函数中分配内存, 在析构函数中释放内存. 类图及实现如下所示.
SocketManager 对象的内存分布如下所示. 当 mgr 超出作用域时, 析构函数被自动调用, delete 指针 pSocket, 调用 Socket 的析构函数, 从而释放 Socket 所占用的内存.
拷贝构造函数
拷贝构造函数通常被编译器"不可见"地调用. 以下是调用拷贝构造函数常见的四种场景.
- 显式拷贝构造. 将一个已知对象当作入参来构造新的同类型对象. 但更常见的是下面一种构造方式.
- 对象初始化. 注意区分初始化和赋值, 虽然用的都是
=符号
- 值传递. 当函数入参是值传递的方式, 则会调用拷贝构造函数生成副本. 值传递会造成额外的构造和内存分配, 所以通常函数参数最好使用引用传递.
-
函数返回值. 如果函数返回一个值时, 在函数返回时, 会调用拷贝构造函数生成一个副本. 有两种例外情况:
- 如果函数直接返回的是
return的对象, 编译器会进行 返回值优化(Named Return Value, NRV), 返回一个正常构造函数生成的对象, 而不是拷贝构造函数. (注: 返回值优化在 C++1x, C++2x 规则又有更新). 类似函数NRV_make_SocketManager所示. - 如果对象定义了移动构造函数, 编译器会优先调用移动构造函数.
- 如果函数直接返回的是
举个例子, 函数 peramble 入参为值传递的 SocketManager 类型.
编译器为 Socket 对象提供了默认拷贝构造函数来执行 逐成员 (member-wise) 初始化. 新对象中的每个数据成员都会被初始化为源对象相同的值 (注: 浅拷贝). 下图展示了函数调用前后的内存布局. 因为是逐成员拷贝, 因此函数入参 mgr 中的 pSocket 值和源对象 socketMgr 中指向的值是一样的.
当函数 peramble 返回时, 对象 mgr 被销毁, 析构函数被调用, 该对象所持资源被释放. 然而, 当 main() 函数返回时, socketMgr 也超出了作用域, 该类的析构函数再次被调用. 问题出现了, 所持的 pSocket 又被释放了一次, 异常抛出.
为解决这一问题, 我们应当提供自定义的拷贝构造函数来重载编译器默认生成的. 拷贝构造函数应当采用深拷贝, 不是简单的拷贝指针值, 而是拷贝指针所指的对象. 注意传入 const SocketManager 的引用, 并且在构造数据成员时注意指针非空检查.
该拷贝构造函数执行如下:
- 为新对象
SocketManager分配了内存; - 通过初始化列表初始化非空指针;
- 为新资源 (Socket) 分配内存;
- 源对象 Socket 被赋值到了目标对象
赋值操作符
C++ 的设计目标之一, 用户自定义的类型应该和内置类型表现一致. 两个对象之间也可以像基本类型一样赋值. 如果未自定义赋值操作符, 编译器将生成默认赋值操作符, 逐成员赋值. 例如,
由前述可知, mrg2 是由 mrg1 拷贝构造初始化的. 关键在于 mrg3, 虽然语义上和 mrg2 相同, 但编译器产生的代码不同. mrg3 首先通过默认构造函数构造出来, 然后将 mrg 赋值给它. 这就造成了之前遇到的问题, 默认的赋值操作是浅拷贝, 而我们期望的是深拷贝. 因此我们需要实现一个正确的拷贝语义.
赋值操作符的行为和拷贝构造的行为非常类似.虽然可以参考交换拷贝构造函数的写法, 但是会出现重复代码. 这里提供了一个比较好的实践. 通过 Copy-Swap Idiom, 复制-交换技术, 来提高代码的复用率. 首先, 需要实现一个 swap() 函数, 这里利用了标准库里的 std::swap 来交换类的属性.
看一下实际赋值过程的内存状态
首先构造了两个对象, 在发生赋值操作之前的内存状态.
开始赋值, 拷贝构造了一个中间对象 temp.
调用 swap() 函数, 交换中间对象和 *this 对象的数据.
退出赋值函数时, 由于中间对象超出了作用域, 因此自动析构, 资源被回收. 此时释放的资源也就是原先 mgr2 的资源. 因此, 不会产生内存泄漏问题.
但是, 这个过程中有个小问题就是, 假如在拷贝构造 temp 对象时失败, 那么会抛出异常. 这可能导致对象处于一个非法的状态. 为了避免这个问题, 其中一种方式就是让拷贝构造发生在赋值操作调用之前. 重载的赋值操作符可以通过传值的方式来代替原先传引用的方式. 当赋值操作符被调用时, 首先右值被拷贝. 这个操作发生在发生 operator= 调用之前. 所以, 即使发生了异常, operator= 不会被调用, 赋值左值并不会受到影响.
参考资料
- Amazon.com: Effective Modern C++: 42 Specific Ways to Improve Your Use of C++11 and C++14: 4708364244318: Meyers, Scott: Books
- Special members - C++ Tutorials (cplusplus.com)
- The Rule of The Big Three (and a half) – Resource Management in C++ - Sticky Bits - Powered by FeabhasSticky Bits – Powered by Feabhas