打开导航 打开导航 Open menu
量化交易系统开发(C++)/ 期权策略与风控 / 行业研究分析
量化交易系统開發(C++)/ 期权策略与风控 / 行业研究分析
C++ trading systems / options strategy & risk controls / equity research
adrian@adrianxv.cn
文章目录

C++: 对象和资源生命周期 C++: 对象和资源生命周期 C++: 对象和资源生命周期

这是学习 A Tour of C++, by Bjarne Stroustrup: Chapter 6 “Essential Operations” 当中 6.1 ~ 6.3 节的读书笔记。

本篇笔记的内容可以分为 2 个部分:

  1. copy 和 move
  2. 资源所有权与 RAII

copy 和 move

在以下 5 种情况下,一个对象可以被 copy 或者 move:

  1. 初始化:X b = a
  2. 赋值:b = a
  3. 函数传参
  4. 函数返回
  5. 异常对象传递

设计一个类型时,必须明确“复制它”和“移动它”分别意味着什么

copy

复制的默认行为是逐成员赋值

如果一个类通过指针间接拥有资源,默认的逐成员复制通常是错的。因为这样的复制会导致多个对象的指针成员指向同一资源,从而导致:

  1. 修改一个对象会影响另一个对象
  2. 两个析构函数可能释放同一块内存
  3. 一个对象析构后另一个对象变成悬空状态

从而引入了深复制的概念:复制持有资源的对象时,不只是复制“指针值”,而是连同指针所指向的资源也复制一份新的;与之相对的浅复制指的就是对于持有资源的对象,复制时只复制其指针值。

深复制的例子如下。注意这里为了演示目的,手动构造了 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

有时候我们不想复制资源(大容器复制的代价太高),只想把资源从一个对象转交给另一个对象,具体情况包括:

  1. 函数返回值初始化新对象或者赋值已有对象
  • 这种情况下,没法返回局部对象的引用,因为局部对象在函数外就被销毁了,引用就无效了
  • 所以要从资源转移的角度进行思考
  • 比如以下这种情况,可以用 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+ 计算出一个结果,这个结果需要由临时容器通过返回值转移到外部对象上。
  • 在没有移动操作时,这个转移需要通过复制来实现;
  • 引入移动后,最后这步转移则不复制,直接转移对底层数组的所有权。
  1. 明确不再使用某个对象时,使用 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 的源对象处于一种 有效但状态未指定 的状态:

  1. 必须能安全析构
  2. 可以重新赋值
  3. 不再拥有原来的资源
  4. 其值由移动构造函数确定(要么自定义,要么依赖编译器生成的)

何时手写特殊成员函数

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 的例子包括:

  1. std::vector、std::string、std::map 等之于内存
  2. std::thread 之于操作系统系统线程
  3. std::fstream 之于文件
  4. std::lock_guard 和 std::unique_lock 之于锁
  5. std::unique_ptr 和 std::shared_ptr 之于一般的对象

基于 resource handle 使用 RAII 的好处:

  1. 释放时机确定:离开作用域就释放
  2. 不需要后台 Garbage Collection 扫描
  3. 异常发生时也能通过析构自动清理

Garbage collection

一种自动内存管理机制

假设程序不断创建对象,其中指针的方向是:root -> A -> B -> C

假设 root 不再指向 A,那么 A、B、C 都无法从程序中的有效变量访问到,它们就叫“垃圾”

Garbage Collection 会:

  1. 从一组根对象开始扫描
  2. 找出所有仍然可达的对象
  3. 剩下不可达的对象就是垃圾,对这些垃圾占用的内存进行回收

Garbage Collection 只能处理内存资源,不能处理其它资源。因此优先使用 RAII。