文章目录
C++: 对象和资源生命周期 C++: 对象和资源生命周期 C++: 对象和资源生命周期
这是学习 A Tour of C++, by Bjarne Stroustrup: Chapter 6 “Essential Operations” 当中 6.1 ~ 6.3 节的读书笔记。
本篇笔记的内容可以分为 2 个部分:
- copy 和 move
- 资源所有权与 RAII
copy 和 move
在以下 5 种情况下,一个对象可以被 copy 或者 move:
- 初始化:X b = a
- 赋值:b = a
- 函数传参
- 函数返回
- 异常对象传递
设计一个类型时,必须明确“复制它”和“移动它”分别意味着什么
copy
复制的默认行为是逐成员赋值
如果一个类通过指针间接拥有资源,默认的逐成员复制通常是错的。因为这样的复制会导致多个对象的指针成员指向同一资源,从而导致:
- 修改一个对象会影响另一个对象
- 两个析构函数可能释放同一块内存
- 一个对象析构后另一个对象变成悬空状态
从而引入了深复制的概念:复制持有资源的对象时,不只是复制“指针值”,而是连同指针所指向的资源也复制一份新的;与之相对的浅复制指的就是对于持有资源的对象,复制时只复制其指针值。
深复制的例子如下。注意这里为了演示目的,手动构造了 Vector 类。如果使用的是 std::vector,则不属于资源持有对象,则无需考虑深复制的问题。
struct Vector {
double* elem; // 指向动态分配内存的 built-in array
int sz; // 元素个数
}
Vector::vector(const vector& a) : // copy constructor
elem{ new double[a.sz] }, sz{a.sz} { // 手动 new 构造 built-in array
for (int i = 0; i < sz; i++) {
elem[i] = a.elem[i]; // 逐元素复制
}
}
另外,如果一些资源要求 “唯一拥有”,则持有这些资源的类无法复制。例子包括 std::thread、std::unique_ptr,以及要求 “唯一拥有文件句柄” 的类。对于这样的类,要禁止拷贝构造函数和拷贝赋值
File(const File&) = delete;
File& operator=(const File&) = delete;
move
有时候我们不想复制资源(大容器复制的代价太高),只想把资源从一个对象转交给另一个对象,具体情况包括:
- 函数返回值初始化新对象或者赋值已有对象
- 这种情况下,没法返回局部对象的引用,因为局部对象在函数外就被销毁了,引用就无效了
- 所以要从资源转移的角度进行思考
- 比如以下这种情况,可以用 move 来优化
Vector make_vector(int n) {
Vector result{n};
return result; // 未经优化时,这里会直接复制,有较大的开销
}
Vector v = make_vector(100); // 这里使用函数返回值来初始化新对象
v = make_vector(200); // 这里使用函数返回值赋值给已有对象
在赋值的时候,道理类似。如果 Vector 有移动赋值,那么在类似以下这种函数中:
void f(const Vector& x, const Vector& y, const Vector& z) {
Vector r;
r = x + y = z;
}
- 引入 move 并不会简化中间的计算过程,也无法减少临时容器的使用(这部分内容不必深究,如果要深究就得研究寄存器了)
- 每次 operator+ 计算出一个结果,这个结果需要由临时容器通过返回值转移到外部对象上。
- 在没有移动操作时,这个转移需要通过复制来实现;
- 引入移动后,最后这步转移则不复制,直接转移对底层数组的所有权。
- 明确不再使用某个对象时,使用
std::move转移资源
y = std::move(x);
- 把 x 的资源交给 y,之后不要再把 x 当作原来的完整值来使用。
- std::move() 把对象转成右值引用,表示“这个对象的资源可以直接被拿走”
移动构造函数的实现:
Vector::Vector(Vector&& a)
: elem{a.elem}, // 直接把 a.elem 拿走
sz{a.sz}
{
a.elem = nullptr;
a.sz = 0;
}
在移动之后,move-from 的源对象处于一种 有效但状态未指定 的状态:
- 必须能安全析构
- 可以重新赋值
- 不再拥有原来的资源
- 其值由移动构造函数确定(要么自定义,要么依赖编译器生成的)
何时手写特殊成员函数
rule of thumb:如果类的资源都由成员类型负责管理,则不要手写特殊成员函数,使用编译器生成的即可。所谓 类的资源都由成员类型负责管,指的是类不持有 裸资源,比如指针、句柄。
特殊成员函数指的是以下6个函数:
- 默认构造函数
- 拷贝构造函数
- 移动构造函数
- 拷贝赋值运算符
- 移动赋值运算符
- 析构函数
struct User {
std::string name;
std::vector<int> values;
};
这样一个类当中:
std::string封装了字符存储的所有权、生命周期和复制/移动语义;std::vector封装了动态数组的所有权、生命周期和复制/移动语义
因此不要(闲得蛋疼)手写构造函数,直接采取编译器生成的构造函数、析构函数和运算符即可:
User() = default;
User(const User&) = default;
User& operator=(const User&) = default;
User& operator=(User&&) = default;
User(User&&) = default;
~User() = default;
反之,如果存在“裸资源”,比如指针、裸文件句柄、裸锁,则不能依赖默认复制和析构,因为它们只会复制地址,不会正确管理资源。
struct User {
int* p;
}
此时要么改用 std::unique_ptr 替代指针,要么自己定义特殊成员函数(并且把默认的函数=delete)
资源所有权和 RAII
把前面的 copy/move 从内存管理推广到更广阔的资源。这些资源除内存外,还包括 fd、socket、锁、线程、数据连接。资源必须先获取,之后必须释放。在 C++ 当中,应该优先考虑使用 RAII 的 Resource handle,最次才是 Garbage Collection。
Resource Handle 和 RAII
使用 Resource handle 通常比使用 built-in 指针更好(资源句柄…不得不说,把 handle 翻译成“句柄”,这个翻译真的太垃圾了)。这和通过 RAII 消除代码当中的 new 和 delete 是一个道理,使用 resource handle 更安全,可以减少资源泄露。资源管理不仅要考虑安全,还要考虑高效(不用的资源即使释放,避免浪费)。
resource handle 的例子包括:
- std::vector、std::string、std::map 等之于内存
- std::thread 之于操作系统系统线程
- std::fstream 之于文件
- std::lock_guard 和 std::unique_lock 之于锁
- std::unique_ptr 和 std::shared_ptr 之于一般的对象
基于 resource handle 使用 RAII 的好处:
- 释放时机确定:离开作用域就释放
- 不需要后台 Garbage Collection 扫描
- 异常发生时也能通过析构自动清理
Garbage collection
一种自动内存管理机制
假设程序不断创建对象,其中指针的方向是:root -> A -> B -> C
假设 root 不再指向 A,那么 A、B、C 都无法从程序中的有效变量访问到,它们就叫“垃圾”
Garbage Collection 会:
- 从一组根对象开始扫描
- 找出所有仍然可达的对象
- 剩下不可达的对象就是垃圾,对这些垃圾占用的内存进行回收
Garbage Collection 只能处理内存资源,不能处理其它资源。因此优先使用 RAII。