国内某头部私募(类似九坤投资)的风控分析师接手了一份 C++ 工具,功能是吃下一个 510300.SH 成交 CSV、把它聚合成五分钟 bar 序列。工具在 happy path 上工作。第一次某行格式异常的 CSV 把 parse_double 送进了 throw std::runtime_error("bad price"),进程就漏掉了那个已经打开的 FILE*。这是一个长跑的后台守护;六周后这个进程握着 480 个打开的文件描述符,内核拒绝再开第 481 个。组里 senior 在群里贴出的修复只有六行:把 std::fopen / std::fclose 换成一个迷你类,构造函数开、析构函数关。那个类背后的想法——资源获取即初始化(Resource Acquisition Is Initialization, RAII)——是你将工作的每一个 C++ 代码库里、每一个资源的拥有者:内存、锁、套接字、GPU 缓冲区、数据库连接,概莫能外。本课先把表达 RAII 所需的类机制搭起来,然后让 RAII 这个习语本身成为承重话题。本课结束时你会写完一个 FileHandle 类,看着裸 fopen 对照版本在被注入的异常下漏掉描述符,并确认 RAII 版本在不改调用代码的前提下零泄漏。RAII 在国内文献中也写作「构造获取-析构释放」,二者择一并在课内保持一致——本课使用「资源获取即初始化」对齐英文原意。
struct、class 与成员下划线约定
C++ 里 struct 与 class 两个关键字只在一件事上有区别:默认访问权限。struct 成员默认 public,class 成员默认 private。没有别的语义差别。当类型只是一组无不变式的纯数据(三个一起走的 double 当作一个点)就用 struct;当类型对外强制某种不变式而外界不该有能力打破时就用 class。本模块遵循的约定:成员变量尾下划线(price_、fp_)以区别于局部变量与构造函数参数。这是工程风格,不是语言规则,但下面你看到的每个成员都带下划线。
class Quote {
public:
Quote(double bid, double ask) : bid_(bid), ask_(ask) {}
double mid() const { return 0.5 * (bid_ + ask_); }
private:
double bid_;
double ask_;
};
public: 块是类型的对外接口;private: 块装数据与辅助函数。protected: 是为继承存在的,本模块不用——这里的类都不参与继承,因为 RAII 是类层级的想法,不是继承层级的想法。
成员函数在类体里声明,要么就在类体里定义(头文件内、隐式 inline),要么用 ReturnType ClassName::method(args) 语法在 .cpp 里类外定义。const 限定的成员函数——上面那个 double mid() const——承诺不修改对象;编译器替你执行;一个接收 const Quote& q 的函数只能在 q 上调 q.mid(),任何会改 q 的操作都过不了编译。把访问器标 const,正是第 2 课承诺的 const 正确性能跨函数边界传递的支撑。
static 成员是类级的而不是实例级:一份存储被所有实例共用。static int count_; 如果你在构造函数里递增它,就能记录到目前为止构造过多少 Quote 对象。对缓存与实例计数有用;现在先搁。
成员初始化列表
C++ 类语法里最有后果的一片,是构造函数参数列与函数体之间的成员初始化列表(member initializer list):
class Order {
public:
Order(std::int64_t id, const std::string& sym, double px) : id_(id), symbol_(sym), price_(px) {}
private:
const std::int64_t id_;
const std::string symbol_;
double price_;
};
: id_(id), symbol_(sym), price_(px) 这一段把每一个成员直接用对应的构造函数实参初始化。另一种写法——在函数体里赋值,Order(...) { id_ = id; symbol_ = sym; price_ = px; }——会先把 id_、symbol_、price_ 默认构造,然后再赋值覆盖。对 int64_t、double 这种内建类型,浪费的只是几个周期;对 std::string,浪费的是一次默认构造加一次堆分配。但对 const 成员(你无法对 const 赋值)、引用成员(你无法重绑引用),以及没有默认构造函数的类型的成员(在函数体里赋值的写法根本不能编译),初始化列表就不是可选,而是强制。规则:永远优先初始化列表。构造函数体留给那些必须在成员就位之后才发生的副作用;成员的值本身来自列表。
初始化列表的次序按声明次序走,不按你写列表的次序。id_ 声明在 symbol_ 之前、symbol_ 在 price_ 之前,意味着 id_ 最先被初始化,即便你的列表先写了 price_(px) 再写 id_(id)。编译器在二者不一致时会警告;听警告的。
析构函数、被合成的默认与五法则
析构函数在对象寿命终结时运行:栈对象所在作用域返回时,或者 delete 被调到一个堆对象上时。~ClassName() {} 声明一个。编译器会合成一个默认析构函数,按声明的逆序销毁每个成员——对绝大多数类型这个默认是正确的。你只在对象拥有某种「按成员逐个销毁」释放不掉的资源时,才自己写析构函数:一个裸 FILE*、一个 new 出来的裸缓冲区、一个 OS 句柄。
一个类上可能存在六个特殊成员函数:
- 默认构造函数(
ClassName()) - 析构函数(
~ClassName()) - 拷贝构造函数(
ClassName(const ClassName&)) - 拷贝赋值(
ClassName& operator=(const ClassName&)) - 移动构造函数(
ClassName(ClassName&&)) - 移动赋值(
ClassName& operator=(ClassName&&))
三法则(C++11 之前):如果你自己写了析构函数、拷贝构造函数或拷贝赋值中的任何一个,你几乎一定要把三个都写——因为你需要其中一个的原因(拥有某个资源)意味着另外两个的合成版本是错的。五法则(C++11 起)把移动构造与移动赋值加到同一集合里。本课只把这个规则当作一个该认识的名字来教;移动那一侧的机制归模块 3.4.2。
=default 与 =delete 让你明确告诉编译器哪些特殊成员存在。MyClass(const MyClass&) = default; 让编译器给你合成版本。MyClass(const MyClass&) = delete; 告诉编译器这个类不可拷贝,任何尝试拷贝的代码会在编译期失败、给出清楚的错。NonCopyable 原型:
class NonCopyable {
public:
NonCopyable() = default;
~NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
};
任何继承自或含有 NonCopyable 成员的类,自身也变得不可拷贝——在拷贝没有意义(线程、互斥、文件句柄)时有用。
RAII:那个承重的习语
资源获取即初始化只有一句话:把每一种资源——内存、文件句柄、套接字、锁、GPU 缓冲区——都包进一个类里,构造函数获取资源、析构函数释放资源。然后依赖语言的作用域退出与栈展开规则,让每一种资源都被正确释放,即便途中有异常、提前返回、纠缠的控制流。C++ 把裸资源句柄暴露给你,正是为了让这件事可行;习语把句柄塞进一个析构函数无法被忘记的所有者里。
工作例是 FileHandle,包裹 <cstdio> 里的 std::FILE*:
#include <cstdio>
#include <stdexcept>
class FileHandle {
private:
std::FILE* fp_;
public:
FileHandle(const char* path, const char* mode) : fp_(std::fopen(path, mode)) { if (fp_ == nullptr) throw std::runtime_error("fopen failed"); }
~FileHandle() { if (fp_ != nullptr) std::fclose(fp_); }
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
std::FILE* get() const { return fp_; }
};
构造函数接收路径与模式,在初始化列表里调 std::fopen,若打开失败(fp_ 是 nullptr)就抛异常。析构函数在 fp_ 非空时关闭文件——这同时覆盖了「构造函数中途抛了」(此时对象根本没构造完,析构函数也不会跑)与正常路径两种情形。拷贝构造与拷贝赋值都 =delete:两个 FileHandle 共享同一个底层 FILE* 会让同一个描述符被 fclose 两次,这是 undefined behaviour。移动构造与移动赋值故意还没声明;删除拷贝会隐式禁用移动,而模块 3.4.2 里的生产版本会在此之上加 unique_ptr 风格的移动语义。
调用代码:
void write_three_lines(const char* path) {
FileHandle f(path, "w");
std::fprintf(f.get(), "line 1\n");
std::fprintf(f.get(), "line 2\n");
std::fprintf(f.get(), "line 3\n");
} // destructor closes here
f 是一个栈上的 FileHandle;write_three_lines 一返回——无论正常返回,还是某次 fprintf 抛出的异常向外传播——f 的析构函数都会跑、关闭文件。close 无从可忘,因为语言替你关了。
裸版本,在异常下漏掉
对照裸版本,在开关之间故意注入一次失败:
void write_three_lines_raw(const char* path) {
std::FILE* fp = std::fopen(path, "w");
if (fp == nullptr) throw std::runtime_error("fopen failed");
std::fprintf(fp, "line 1\n");
if (random_failure()) throw std::runtime_error("injected failure");
std::fprintf(fp, "line 2\n");
std::fprintf(fp, "line 3\n");
std::fclose(fp); // skipped if throw above
}
当 random_failure() 返回 true,函数抛异常,fp 永不被关闭——打开的文件描述符就漏了。在 Linux 上你可以用 lsof -p <pid> 在调用前后对比确认:裸版本每一次失败调用让描述符计数加一;RAII 版本的计数保持平稳。异常向上传播;栈展开;语言会在每一帧展开时,跑掉那一帧里每一个已经完整构造的自动对象的析构函数。RAII 骑在这个保证上。
工作例文件路径:/tmp/spy_trades_510300.csv(Linux / macOS)或学员本地路径(Windows / WSL)。文件名以 510300 后缀保持与 L2 / L3 锚定标的一致;内容是三行 CSV(timestamp,price,size):20240315093001,4.20,1000 / 20240315093002,4.21,500 / 20240315093003,4.19,800。同一组数据在 RAII 版本与裸 fopen 版本中被写入与读取,便于学员对照。
同一套逻辑可以推广到每一种资源。std::lock_guard<std::mutex> 是一个迷你 RAII 类,构造函数调 mutex.lock()、析构函数调 mutex.unlock():即便临界区抛了,锁也被释放。std::unique_ptr<Order> 是一个堆上 Order 的 RAII 类:析构函数调 delete。在没有析构函数的语言家族里出现的几乎每一种防御性编码模式(Java 的 try-finally、C 的 "goto cleanup"),在 C++ 里都被一个析构函数取代。
Formula Explorer
resource_safety = constructor_acquires + destructor_releases通往智能指针的前瞻
「这个类拥有一块堆分配,析构函数负责释放它」的生产形式,是 <memory> 里的 std::unique_ptr<T>,在模块 3.4.2 的第 1 课讲。你在这里看手写形式,是为了让智能指针机制在下个模块出场时,只是你已经感觉熟悉的某个模式的精炼化,而不是新的玄学。两者的关系:std::unique_ptr<FILE, decltype(&std::fclose)> 就是你刚写的 FileHandle 的「模板化、带 allocator」推广——同一个「构造获取、析构释放」的想法,deleter 由类型系统承载。
阅读清单:《C++ Primer》第 5 版 中译 第 7 章(类)、第 13 章(拷贝控制);《Effective C++》中译 条款 13(以对象管理资源)作为 RAII 的原始论述;zh.cppreference.com 上 std::unique_ptr / std::fopen / std::fclose 的页面;《C++ Core Guidelines》中译 R.1(首选 RAII 管理资源)。行业一句:国内私募 quant 桌的 C++ 代码 review 中「裸 new 必须有对应 RAII 持有者」是几乎所有 codebase 的硬性规则,理由就是本课展示的——一旦出现 throw 或多 return 路径,裸资源就会漏。本课的 FileHandle 是这一规则的最小例。
通往下一课
你现在能声明一个带成员变量、成员函数与访问说明符的类;用成员初始化列表写构造函数;写一个释放所拥有资源的析构函数;用 =default / =delete 明确指明哪些特殊成员存在;并按名字识别三/五法则。你在单文件单类上应用了 RAII。第 5 课把这同一个类拆到 C++ 常规工程布局里——include/pricing.hpp 放声明、src/pricing.cpp 放定义、tests/test_pricing.cpp 放 doctest 用例——并加上接下来每一个 C++ 模块都会依赖的构建系统与单元测试循环。你在这里写的类,在那里就成了「被测单元」。
练习
Exercise
扩展本课的 FileHandle 类以支持读取。(a) 加一个重载构造函数 FileHandle(const char* path),以读模式("r")打开文件,失败时抛异常。(b) 加一个成员函数 std::string read_line(),把一行(到 \n 或 EOF 为止)读进 std::string 并返回;EOF 时返回空串。(c) 写一个测试,打开 write_three_lines 产出的文件,用 read_line() 读三行,确认它们等于 "line 1"、"line 2"、"line 3"。(d) 演示在 EOF 之后再调一次 read_line() 返回空串、不抛异常。(e) 尝试拷贝一个 FileHandle(FileHandle b = a;),报告你拿到的精确编译错信息。
提示
<cstdio> 里的 std::fgets 读进一个定长 char[1024] 缓冲,然后从缓冲构造 std::string;返回前先剥掉末尾的 \n。EOF 由 std::fgets 返回 nullptr 标示。提示
error: use of deleted function 'FileHandle::FileHandle(const FileHandle&)';clang 的措辞是 call to deleted constructor of 'FileHandle'。两种信息都指名了被删除的拷贝构造函数。