← 返回模块
3.4.2.3beta 可读 · 未来付费校验通过内容版本 2026-05-27

智能指针与移动语义

3.4.2 · 模板与现代 C++ · 编程

国内某私募 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_ptrstd::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* 并把 pnullptr(转出所有权),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 Tnew Uunique_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_ptrstd::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_ptrshared_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::parentstd::shared_ptr<Parent>,则 pc 构成引用环:p 拥有 cc 拥有 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.

提示
想在运行期观测 move 与 copy,可在移动构造与拷贝构造里各加一行 std::cerr << "move\n"; / std::cerr << "copy\n";,观察 vector 越过 capacity 时的输出轨迹。
提示
(c) 中 std::make_unique<Buffer>(src) 能工作,是因为 make_unique 完美转发 src(一个左值)到 Bufferconst Buffer& 构造函数——正是拷贝构造。

下一课

你现在已经掌握函数模板、类模板、lambda、std::functionconstexpr、智能指针、移动语义与完美转发。本模块最后一课是抛光层: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 计算均值。