国内某私募 CSI 300 ETF 期权桌的风险分析师在翻夜间对账日志:四十笔 510300 期权报价的隐含波动率(IV)显示为整齐的 -1.0。这不是市场信号,而是上一代 IV 求解器在「未收敛」时使用的 sentinel value。当下游的偏斜模型把 -1.0 一起平均进去,报告的偏斜被肉眼可见地拖偏,早会因此浪费了三十分钟去追一个根本不存在的数字。修复是六行的改动:求解器的返回类型从 double 改为 std::optional<double>,sentinel -1.0 改为 std::nullopt,下游消费者代码从 if (price != -1.0) { ... } 改为 if (auto iv = solve(...)) { use(*iv); }。类型系统在编译期防住失败模式,而不是早上 7 点半的会议室。C++17 词汇类型——std::optional / std::variant / std::string_view / std::span——存在的意义就是让这类正确性变便宜。本课把它们走一遍,并以一个把前三课全部串起来的 capstone 收束本模块。
std::optional<T>:「有值或空」
(本课每一个 Fenced cpp 代码块都是 gate 会按字节核对的精确形式。)
<optional> 中的 std::optional<T> 编码「这个值可能在、也可能不在」。它替换掉的传统写法全都易出问题:sentinel 值(return -1.0)会与合法价格冲突并悄悄污染下游数学;额外的 bool found 出参很容易忘检查;异常重且强迫每个调用者写 try / catch。std::optional<double> 把这三件事一并解决:类型本身编码「在不在」;if (opt) { use(*opt); } 是标准访问范式;opt.has_value() 是显式形式;opt.value() 在空时抛 std::bad_optional_access(慎用);opt.value_or(default) 空时返回默认(放心用)。std::nullopt 是「空」状态的字面量。
std::optional<double> implied_vol_from_price(double market_price, double S, double K, double r, double T) {
double sigma = 0.20; // initial guess
for (int i = 0; i < 50; ++i) {
const double price = black_scholes_call(S, K, r, sigma, T);
const double diff = price - market_price;
if (std::abs(diff) < 1e-8) return sigma;
const double vega = black_scholes_vega(S, K, r, sigma, T);
if (vega < 1e-12) return std::nullopt; // ill-conditioned
sigma -= diff / vega;
if (sigma <= 0.0 || sigma > 5.0) return std::nullopt; // diverged
}
return std::nullopt; // did not converge
}
三个被命名的失败模式、三处显式的 std::nullopt 返回:病态(vega 太小不能除)、发散(牛顿步长越出可行域)、未收敛(50 次迭代不足以收敛到 1e-8)。调用方读起来就是 if (auto iv = implied_vol_from_price(...)) { record(*iv); } else { mark_dirty_tick(); }——失败分支不可能被忘记,因为类型系统强制你先检查再解引用。
std::optional 用作「确实可能没有答案」的函数返回类型;用作「确实可选的」函数参数;不要用它表示「可能为空的指针」——那是裸指针与 unique_ptr 的领地。除了构造与访问器之外,接口很小:reset() 清空,emplace(args...) 原地构造内容,operator-> 与 operator* 在调用方已检查后访问内容。
std::variant<Ts...>:闭集多态
<variant> 中的 std::variant<Ts...> 是类型安全的标签联合(tagged union):一个值同时恰好持有给定类型集合中的一个。using OrderResult = std::variant<Filled, Rejected, Cancelled>; 的意思是「一个 OrderResult 是这三者之一」。构造决定备选:OrderResult r = Filled{...}; 构造 Filled;r = Rejected{...}; 切换备选,会先调用 Filled 的析构函数。访问通过 std::visit:
struct EuropeanCall { double K; };
struct EuropeanPut { double K; };
struct DigitalCall { double K; double payout; };
using Payoff = std::variant<EuropeanCall, EuropeanPut, DigitalCall>;
double apply(const Payoff& p, double S) {
return std::visit([S](const auto& payoff) -> double {
using T = std::decay_t<decltype(payoff)>;
if constexpr (std::is_same_v<T, EuropeanCall>) {
return std::max(S - payoff.K, 0.0);
} else if constexpr (std::is_same_v<T, EuropeanPut>) {
return std::max(payoff.K - S, 0.0);
} else { // DigitalCall
return S > payoff.K ? payoff.payout : 0.0;
}
}, p);
}
std::visit 接收一个可调用对象和一个 variant,在运行期根据当前活动备选分派。标准写法是泛型 lambda——编译器为每个备选实例化一次 operator()。using T = std::decay_t<decltype(payoff)>; 让你能在 lambda 内用 if constexpr (std::is_same_v<T, EuropeanCall>) { ... } 按类型在编译期分支。备选写法 std::get<EuropeanCall>(p) 返回引用但在备选非 EuropeanCall 时抛 std::bad_variant_access;std::get_if<EuropeanCall>(&p) 返回指针(不活动时为 nullptr)——免异常形式。规则:**std::visit 优先;std::get / std::get_if 仅在 visit 实在别扭时才用。**
std::variant 是多态的闭集形式——当类型集合在编译期固定时,它是虚函数继承树的替代。性能权衡:variant 就地存储(大小为 max(sizeof(Ts)...) + tag,无堆分配);通过 unique_ptr<Base> 的虚函数分派要在堆上分配派生对象。对订单结果、市场事件类型、校准结果、期权 payoff——这些备选在编译期已知的场景——variant 通常是正确选择。unique_ptr<VirtualBase> 留给真正开放的集合(运行时从 .so 文件加载新备选的插件架构)。
std::string_view 与 std::span
「函数参数需要读字符串但不持有」过去是三重载问题:void log(const std::string& msg) 强迫每个 const char* 调用者做一次分配;void log(const char* msg) 拒绝 std::string;按字符串类型模板化又把二进制膨胀。<string_view> 中的 void log(std::string_view msg) 同时接受两者,零拷贝。string_view 是一个 (pointer, length) 二元组——64-bit 系统上 16 字节,零分配。接口是 std::string 的只读子集(size()、empty()、front()、back()、data()、substr()、比较运算符、range-based for)。
void log(std::string_view msg) {
std::cout << msg << '\n';
}
log("hello"); // zero copy from string literal
std::string s = "world";
log(s); // zero copy from std::string
// DANGER: do NOT do this.
std::string_view dangling = std::string("oops").substr(0, 4);
// dangling now points into a destroyed temporary -> undefined behavior on use.
规则严格、失败模式无声:string_view 不拥有那些字符。底层缓冲先死,view 就悬空。作为函数参数随意用;作为局部变量在「源对象显然存活」时用;不要在没有书面生命周期所有者的情况下把它存为类成员。上面的悬空示例就是经典 UB:临时 std::string("oops") 在分号处死亡;dangling 指向已释放内存;下一次读是未定义行为。
std::span<T>(C++20)是同样的「非拥有的连续区间视图」对任意 T 的推广。C++17 codebase 里的标准替代是 gsl::span(来自微软 Guidelines Support Library)或一个小型自写版本;二者与 C++20 形式接口兼容,将来团队升级标准时的迁移路径是机械化的。本课在语法上使用 std::span,并以脚注说明 C++17-only 项目用 backport。
STL 算法巡礼
基于迭代器的算法——从 1990 年代起 C++ 标准库一直提供的那一批——至今仍是日常的主力。心智模型:所有算法接收一对迭代器 (first, last),作用于 [first, last)。你每周都会接触的成员:
// std::sort with a lambda comparator (descending)
std::sort(v.begin(), v.end(), [](double a, double b) { return a > b; });
// std::find_if with a lambda predicate
const auto target = 4.20;
auto it = std::find_if(v.begin(), v.end(),
[target](double x) { return std::abs(x - target) < 0.01; });
// std::accumulate from <numeric>; note the 0.0 init to keep double accumulator
const double sum = std::accumulate(v.begin(), v.end(), 0.0);
const double mean = sum / static_cast<double>(v.size());
// std::transform writing log-returns into a new vector
std::vector<double> log_returns(v.size() - 1);
std::transform(v.begin() + 1, v.end(), v.begin(),
log_returns.begin(),
[](double curr, double prev) { return std::log(curr / prev); });
两条规则贯穿本节。算法优于裸 for 循环:std::sort(v.begin(), v.end(), comp) 表达「想要什么」(一个有序区间),由实现挑选「怎么做」(intro-sort、小区间切换插入排序);手写的 for 循环把意图藏在索引算术里,让编译器能优化的余地更少。把累加器类型与迭代器值类型对齐:对 vector<double> 做 std::accumulate(v.begin(), v.end(), 0) 会因为 init 实参类型决定累加器类型而悄悄截断为 int。修复是写 0.0;课堂教训是「把 init 的类型写得像写迭代器对一样小心」。C++20 ranges(std::ranges::sort(v)、| 管道语法)是下一代人体工学改进,本课点名后延后到 3.4.3。
异常安全与 capstone
进 capstone 前先把三种异常安全保证命名清楚。基本保证(basic):异常被抛出后程序处于有效状态——无泄漏、不变量未破——但操作的效果未指定。强保证(strong):异常被抛出后程序处于操作之前的状态——commit-or-rollback。不抛保证(no-throw / noexcept):操作永不抛。两条硬规则:析构函数不得抛(在另一个异常引发的栈展开过程中,析构再抛会调用 std::terminate);**存入 std::vector 的任何类型,其 move 操作首选 noexcept**——L3 的回看,正是 no-throw move 让 vector 在 reallocate 时移动而非拷贝。
capstone 把 L2 的 Pricer 重写为 ModernPricer,用上 L4 的每一个词汇类型:
class ModernPricer {
public:
std::optional<double> price(std::span<const double> paths,
const Payoff& payoff,
double discount) const {
if (paths.empty()) return std::nullopt;
const double sum = std::accumulate(
paths.begin(), paths.end(), 0.0,
[&payoff](double acc, double S) {
return acc + apply(payoff, S);
});
return discount * (sum / static_cast<double>(paths.size()));
}
};
返回 std::optional<double>(空输入不是错误,而是「没有答案」)。接收 std::span<const double>(对调用方持有的任何 vector 或数组做零拷贝视图)。通过 std::visit 在 std::variant<EuropeanCall, EuropeanPut, DigitalCall> 上分派 payoff(闭集多态,无堆)。用 std::accumulate 加捕获 lambda 算均值(基于迭代器的形式,0.0 的 init 把累加器保持在 double)。
客户端 main 用 CSI 300 ETF 锚定行权价 K = 4.30 构造三种 payoff,由 L1 的 MonteCarloPath<double> 以 seed = 42 生成 10000 条路径,把它们包成 std::span<const double>,依次定价并打印:
int main() {
constexpr double S = 4.20, K = 4.30, r = 0.024, T_years = 0.5;
const double discount = std::exp(-r * T_years);
auto raw_paths = generate_paths(S, K, r, 0.18, T_years, 10000, 42);
std::span<const double> paths{raw_paths.data(), raw_paths.size()};
ModernPricer pricer;
std::vector<Payoff> payoffs = {
EuropeanCall{K}, EuropeanPut{K}, DigitalCall{K, 1.0}
};
for (const auto& p : payoffs) {
std::cout << std::fixed << std::setprecision(4)
<< pricer.price(paths, p, discount).value_or(0.0 / 0.0) << '\n';
}
return 0;
}
value_or(0.0 / 0.0) 用 NaN 作为可见的「失败」哨兵——本课唯一可接受 sentinel 值的位置,因为 std::optional 在两行之前已经把失败显式化了。打印出的三个数——call、put、digital——让你能把 C - P 与 L2 推导的 put-call parity 期望值 S - K * std::exp(-r * T) 做核对。
Formula Explorer
ModernPricer = optional + variant + visit + span + accumulateExercise
Extend the capstone ModernPricer from this lesson. (a) Add a new payoff alternative BarrierCall { double K; double barrier; } to the Payoff variant and extend apply(...) so that the barrier-knock-out rule is enforced: the payoff is std::max(S - K, 0.0) only when the path's running maximum is strictly less than barrier, else 0.0. (b) Because apply(...) now needs access to the full path (not just the terminal price), change the signature of apply(...) to double apply(const Payoff& p, std::span<const double> path) and update ModernPricer::price accordingly — the European call / put / digital arms still use only the last element. (c) Add a unit test using doctest (#include <doctest/doctest.h>) that constructs a 5-element path {1.0, 1.05, 1.10, 1.08, 1.02} and a BarrierCall{K=1.0, barrier=1.09} and confirms the payoff is 0.0 (because the running max reaches 1.10, breaching 1.09) — with CHECK(...) exactly. (d) State in one sentence why std::variant with a closed set of payoff types is preferable to a virtual base class hierarchy (class PayoffBase { virtual double apply(...) const = 0; }; plus std::unique_ptr<PayoffBase>) when the set of payoff types is fixed and known at compile time.
提示
path.back()。barrier 规则用严格不等号。提示
std::visit 是否能内联、类型集合在编译期闭合 vs 运行时开放。你现在在哪、本 track 接下来去哪
你现在能写出泛型、callable 驱动、资源安全、词汇类型化的 C++17——把 2026 年生产 C++ 与 3.4.1 训练的 C-with-classes 内核区分开的那个现代方言。本 subject 的后三个模块继续:3.4.3 Memory & Performance(自定义分配器、cache 布局、SIMD,以及把 noexcept 与 if constexpr 当性能杠杆使用);3.4.4 Concurrency & Networking(std::thread、原子、无锁队列、kernel-bypass I/O);3.4.5 Trading Systems in C++(策略框架、订单簿、FIX handler,是偏特化、CRTP、policy-based design 发力的地方)。本课的 ModernPricer capstone 将是接下来每一个模块用来衡量改进幅度的基准产物。