国内某私募 CSI 300 ETF 期权桌的资深 C++ engineer 在审一份六年前写就的策略库——它要进 live engine。他贴在每一份源码上的 PR review 评论只有一行:「这里裸 new——改成 std::make_unique。」这份库是 C++03 风格写的,delete 散布在异常处理路径上,等一个错位的 throw 就足以把整个下午开出去的文件描述符全部泄漏。改造很机械,diff 很大:每一处裸 new T(args) 改成 std::make_unique<T>(args);每一个具备所有权的 T* 改成 std::unique_ptr<T>;每一处手写的 ~Class() { delete data_; } 都塌缩为 = default。最终上生产的版本长度减半,在同一份故障注入测试套件下零泄漏。3.4.1 介绍了五法则、并把 unique_ptr 标注为「生产版本,3.4.2 讲」。本课就是它。
std::unique_ptr 与 std::make_unique
(本课每一个 Fenced cpp 代码块都是 gate 会按字节核对的精确形式。)
<memory> 中的 std::unique_ptr<T> 是一个包裹 T* 的类模板:析构函数调用 delete;拷贝操作被 =delete——正是 3.4.1 L4 里 FileHandle 的同款模式;移动操作转移指针:源指针置 nullptr、目标指针接管旧值。接口:p->member 和 *p 解引用,p.get() 返回裸 T* 给老 API,p.release() 返回裸 T* 并把 p 置 nullptr(转出所有权),p.reset(new_ptr) 销毁旧对象并接管新对象,p = std::move(other) 转入所有权。类本身零开销——默认 deleter 下 sizeof(std::unique_ptr<T>) == sizeof(T*)。
工厂函数 std::make_unique<T>(args...) 是标准构造方式:
// Bad: raw
Buffer* b1 = new Buffer(10000);
// ... must remember to call delete b1; on every code path
delete b1;
// Good: smart pointer
auto b2 = std::make_unique<Buffer>(10000);
// destructor runs automatically at scope exit
为什么 std::make_unique<T>(args...) 比 std::unique_ptr<T>(new T(args...)) 更可取?异常安全。在像 f(std::unique_ptr<T>(new T(args)), std::unique_ptr<U>(new U(args))) 这样的调用里,四个子表达式(new T(args)、unique_ptr<T> 构造、new U(args)、unique_ptr<U> 构造)的求值顺序在 C++17 之前未指定。若编译器选了 new T → new U → unique_ptr<T> → unique_ptr<U>,而 new U 抛了,那个刚分配的 T 就泄漏——它没有被任何 unique_ptr 接管,栈展开器看不到它。std::make_unique<T>(args...) 把「分配」与「包裹」做成原子动作:没有窗口。C++17 收紧了求值顺序、让该泄漏更难发生,但纪律保留下来,因为工厂写法同时也更短、更清晰。
自定义 deleter 是一段话的逃生口。std::unique_ptr<T, Deleter> 允许你提供 delete 之外的释放方式。本模块的标志性例子:3.4.1 整整一个 FileHandle 类被一行替掉。
using FilePtr = std::unique_ptr<std::FILE, decltype(&std::fclose)>;
FilePtr open_read(const char* path) {
FilePtr file(std::fopen(path, "r"), &std::fclose);
if (!file) throw std::runtime_error("fopen failed");
return file;
}
decltype(&std::fclose) 把 deleter 的类型指明为「std::fclose 的函数指针类型」;&std::fclose 把实际函数传进去。结果 unique_ptr 的析构函数会调用 fclose 而非 delete。手写的 FileHandle 类——构造函数、析构函数、删除的拷贝、成员——全部消失,由标准库的机制替换。
std::shared_ptr 与 std::weak_ptr
std::shared_ptr<T> 是共享所有权的对应物。一个独立分配的「控制块」里保存原子引用计数;shared_ptr 的每一次拷贝把计数加 1;最后一个销毁的负责调用析构函数并释放资源。std::make_shared<T>(args...) 把对象分配与控制块分配合成一次——比 std::shared_ptr<T>(new T(args...)) 便宜(一次堆分配而不是两次),并且基于与 make_unique 相同的理由具有异常安全性。
热路径上你会碰到的代价:每一次 shared_ptr 拷贝都是引用计数的原子自增;每一次销毁都是原子自减加上对零的分支。原子操作并不免费——x86 上是带 lock 前缀的指令,ARM 上需要 acquire-release 屏障,在紧循环里可测量。能过 review 的规则:**unique_ptr 优先,除非所有权确实共享。** 多数生产代码 80% 以上是 unique_ptr;shared_ptr 出现在资源确实由多个所有者持有、且释放顺序任意的场景——缓存、观察者模式、某些线程池工作队列。在国内 quant 桌的 codebase 中 shared_ptr 的占比通常不到 20%,主要场景是策略对象热插拔(研究模块持一份用于回测、引擎持一份用于实盘)。
std::weak_ptr<T> 是非拥有的观察者:持有指向控制块的指针但不计入引用计数。lock() 在资源仍存活时返回一个 shared_ptr,已销毁则返回空 shared_ptr。其标志性用途是切断观察者模式里的所有权环:
struct Child;
struct Parent {
std::shared_ptr<Child> child;
};
struct Child {
std::weak_ptr<Parent> parent; // weak_ptr, not shared_ptr, to break the cycle
};
auto p = std::make_shared<Parent>();
auto c = std::make_shared<Child>();
p->child = c;
c->parent = p;
若 Child::parent 是 std::shared_ptr<Parent>,则 p 与 c 构成引用环:p 拥有 c、c 拥有 p,二者计数都到不了零,双双泄漏。std::weak_ptr 在不放弃观察能力的前提下打破环。顺带:std::auto_ptr——C++11 之前那个易泄漏的 unique_ptr 前身——已在 C++17 移除,不要用。
值类别与右值引用
为了理解移动语义,先把值类别命名清楚。左值(lvalue)是有名字、有地址的值——int x = 1; f(x); 中的 x 以左值传入。右值(rvalue)是临时的、即将消亡的值——f(1) 中的 1 以右值传入。C++17 把右值再分两类:纯右值(prvalue, pure rvalue)是字面量、x + 1 这种表达式的结果、或赋值前的 std::make_unique<T>(...) 结果;将亡值(xvalue, eXpiring value)是「有名字但正在被移动走」的对象,例如 std::move(x) 的结果。日常带在身上的口诀:左值 = 「我有名字」;纯右值 = 「我是真正的临时值」;将亡值 = 「我有名字,但正在被移走,按可移动处理」。
右值引用 T&& 是只能绑定到右值的引用类型。void f(int& x) 接受 int x = 1; f(x); 但拒绝 f(1);;void f(int&& x) 接受 f(1); 但拒绝 f(x);。正是这种不对称让一个类可以分别处理「我正从一个拷贝构造」(拷贝构造函数,接 const T&)和「我正从一个被移走的临时构造」(移动构造函数,接 T&&)。std::move(x) 本质上就是 static_cast<T&&>(x)——它什么也不移动;它告诉重载决议「把 x 当作右值处理,于是移动重载胜出」。std::move(x) 之后,x 处于有效但未指定状态——你可以销毁它或赋新值;读其值的行为是实现相关的。
五法则的实现
完整的五法则示例。Buffer 拥有一个 double*,指向在堆上分配的 n_ 个 double 数组:
class Buffer {
public:
explicit Buffer(std::size_t n) : data_(new double[n]), n_(n) {}
~Buffer() { delete[] data_; }
Buffer(const Buffer& other) : data_(new double[other.n_]), n_(other.n_) {
std::copy(other.data_, other.data_ + other.n_, data_);
}
Buffer& operator=(const Buffer& other) {
Buffer tmp(other);
std::swap(data_, tmp.data_);
std::swap(n_, tmp.n_);
return *this;
}
Buffer(Buffer&& other) noexcept : data_(other.data_), n_(other.n_) {
other.data_ = nullptr;
other.n_ = 0;
}
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
n_ = other.n_;
other.data_ = nullptr;
other.n_ = 0;
}
return *this;
}
double* data() { return data_; }
std::size_t size() const { return n_; }
private:
double* data_;
std::size_t n_;
};
自定义析构函数释放数组。拷贝构造深拷贝。拷贝赋值采用copy-and-swap 习语:先构造 other 的本地拷贝,再与 *this 交换——异常安全,因为若拷贝阶段抛异常,*this 完好。移动构造偷走指针并将源置空。移动赋值释放旧资源、偷走新资源、置空源,并加自赋值守卫。两个移动操作上的 noexcept 是承重的:std::vector<Buffer> 在 reallocate 时仅当移动构造函数 noexcept 才会移动已有元素;如果你的 move 可能抛,vector 无法保证 push_back 触发 reallocate 时的强异常安全,于是退化为拷贝——而对 Buffer 这种里面是大数组的类,每一次「move 静悄悄变成 deep copy」会让吞吐损失 n_ 倍,是经典的「我丢了一个数量级」性能悬崖。
完美转发
模板内部的 T&& 与模板外部的 T&& 含义不同。它是通用引用(universal reference,亦称 forwarding reference):通过引用折叠规则,绑定左值时折叠为 T&、绑定右值时折叠为 T&&。转发工厂模式:
void take_lvalue(const std::string& s) { std::cout << "lvalue: " << s << '\n'; }
void take_rvalue(std::string&& s) { std::cout << "rvalue: " << s << '\n'; }
template<typename T>
void forwarder(T&& x) {
take_lvalue(x); // x is always an lvalue inside the body
take_rvalue(std::move(x)); // unconditional cast to rvalue
}
template<typename T>
void perfect_forwarder(T&& x) {
// forwards as lvalue if called with lvalue, rvalue if called with rvalue:
take_lvalue(std::forward<T>(x));
}
std::forward<T>(x) 是条件强制转换:若 T 推导为 int&(用左值调用),forward 返回 int&;若 T 推导为 int(用右值调用),forward 返回 int&&。与 std::move 的对比:std::move 是无条件的;std::forward 取决于被推导的模板类型。其标志性用途是完美转发工厂——一个透传任意构造参数的 std::make_unique 包装:
template<typename T, typename... Args>
std::unique_ptr<T> make_unique_clone(Args&&... args) {
return std::make_unique<T>(std::forward<Args>(args)...);
}
通用引用几乎出现在 modern C++ 里每一个 factory 函数中。std::vector::push_back 也是在调用点用同样的机制区分拷贝与移动。策略框架里「以任意参数构造 payoff 并传入 pricer 构造」的写法,正好对应这个模板。
收束
在 2026 年的生产 C++ 里,你几乎不再手写五法则。你把资源包在 std::unique_ptr(或一个内部以 unique_ptr 持有资源的类)里,让编译器合成的移动操作替你工作。上文这个 Buffer 是你职业生涯里唯一一次亲手实现完整五法则——你在打基础时写一次,是为了让你日后阅读标准库实现时认得出 std::unique_ptr<double[]> 在替你做什么。然后你就用标准库,往前走。
Formula Explorer
cost_move = O(1) + 0 * n; cost_copy = O(n)Exercise
Take the Buffer class from this lesson. (a) Construct a std::vector<Buffer> and push_back five Buffer(10000) instances; observe (e.g. by printing vec.capacity() before each push_back) that the vector reallocates and that the existing Buffer elements get moved, not copied. (b) Remove the noexcept from the move constructor and the move assignment operator. Recompile and re-run the same loop. Confirm that the existing elements now get copied instead of moved — the cost balloons by a factor of n_ (10000) per element. State in one sentence why noexcept on the move operations is what enables std::vector to move on reallocation. (c) Implement std::unique_ptr<Buffer> clone_buffer(const Buffer& src) that returns a deep copy of src wrapped in a unique_ptr; the entire body should be return std::make_unique<Buffer>(src); — confirm it works because Buffer has a copy constructor.
提示
std::cerr << "move\n"; / std::cerr << "copy\n";,观察 vector 越过 capacity 时的输出轨迹。提示
std::make_unique<Buffer>(src) 能工作,是因为 make_unique 完美转发 src(一个左值)到 Buffer 的 const Buffer& 构造函数——正是拷贝构造。下一课
你现在已经掌握函数模板、类模板、lambda、std::function、constexpr、智能指针、移动语义与完美转发。本模块最后一课是抛光层:C++17 词汇类型——std::optional / std::variant / std::string_view / std::span——与基于迭代器的 STL 算法。capstone 把 L2 的 Pricer 重构为 ModernPricer:返回 std::optional<double>,接受 std::span<const double> 的输入路径,通过 std::visit 在 payoff 备选 std::variant 上分派,并用 std::accumulate 加 lambda 计算均值。