国内某头部私募(类似鸣熙资产)C++ 团队的第二个 sprint,组长把一个迷你定价库交给你独立负责。上一任留给你一份 600 行的 main.cpp,能编、能跑、打印三个数,零测试。下个 sprint 的任务清单里包括加上一个 put-call parity 的健全性检查、把库挂到一个策略二进制里、并通过一次把「单文件 C++ 工程」列为 P2 反模式的 code review。明天之前你要把这 600 行铺成一个正经工程——头文件放公开接口、源文件放实现、用真正的断言框架做的测试二进制、一个产出两个 target 并把测试注册到 CTest 的顶层 CMake 构建、外加一个 .clang-format 让代码库在团队多人提交下保持一致。本课是模块的 capstone。你在第 2 课写的 Black-Scholes 函数就是被测单元;本课其余的一切,都是真实 C++ 代码库给那一个单元搭起来、让七个工程师能围着它出货的脚手架。
翻译单元、单一定义原则与包含保护
翻译单元(translation unit)是一份 .cpp 文件,加上它所有的 #include 指令经预处理器拖进来的全部内容。编译器为每一份 .cpp 产出恰好一份 .o。链接器把若干 .o 文件——连同库目标和 C++ 标准库——拼成最终的可执行文件,沿途把所有跨文件符号引用都解析掉。
头文件(.hpp 或 .h——本模块用 .hpp 标记 C++ 头,语言对两种扩展名一视同仁)承载声明(declaration):类定义、函数声明、constexpr 常量,以及任何必须在每一处调用点可见的 inline 或模板定义。源文件(.cpp)承载非内联定义(non-inline definition):自由函数体、类外的成员函数体。
拆分的理由是单一定义原则(One Definition Rule, ODR):每一个非内联函数、每一个非模板类,在整个程序里必须恰好被定义一次。两份 .cpp 都定义了 double black_scholes_call(double, double, double, double, double) { ... },链接器就会报 multiple definition of 'black_scholes_call'。声明可以重复(这正是头文件可以被多个 .cpp 安全包含的原因),非内联定义不可以。
包含保护(include guard)防止同一个头文件在同一个翻译单元里被处理两次——那本身就会让头文件里定义的任何类违反 ODR。两种等效形式:
#pragma once
放在头文件顶端,是本模块目标编译器(gcc / clang / MSVC)都支持的日常形式。经典形式则可移植到曾经发售过的每一种工具链:
#ifndef PRICING_HPP
#define PRICING_HPP
// ... 头文件正文 ...
#endif // PRICING_HPP
新代码选 #pragma once;只有当你撞上某条不支持它的工具链时再退回经典形式。前向声明(forward declaration)是另一种 ODR 友好的工具:当头文件只需通过指针或引用提及某个类型、并不需要类型的完整定义时,就写 class Order;,不要 #include "order.hpp"。这能打破头文件包含环,缩短构建时间。
自由函数定义上的 inline 关键字,把它从 ODR 的「整个程序只能定义一次」规则里豁免出来(每个翻译单元都可以有自己的一份拷贝,链接器选一份用)。这就是头文件里能合法地放函数定义的原因。类体内定义的成员函数隐式带 inline。
工程布局
C++ 工程的常规形状:
pricing-cpp/
├── CMakeLists.txt
├── include/
│ └── pricing.hpp
├── src/
│ └── pricing.cpp
└── tests/
└── test_pricing.cpp
头文件——公开接口——把函数声明在一个 pricing 命名空间里:
#pragma once
namespace pricing { double black_scholes_call(double S, double K, double r, double sigma, double T); }
源文件给出函数体,#include <cmath> 拿数学基元、#include "pricing.hpp" 让声明与定义对上号:
#include <cmath>
#include "pricing.hpp"
namespace pricing { double black_scholes_call(double S, double K, double r, double sigma, double T) { const double d1 = (std::log(S / K) + (r + 0.5 * sigma * sigma) * T) / (sigma * std::sqrt(T)); const double d2 = d1 - sigma * std::sqrt(T); const double Nd1 = 0.5 * std::erfc(-d1 / std::sqrt(2.0)); const double Nd2 = 0.5 * std::erfc(-d2 / std::sqrt(2.0)); return S * Nd1 - K * std::exp(-r * T) * Nd2; } }
把函数体拽到独立翻译单元,意味着实现一改只会让 pricing.cpp 重编——不是让每一个测试文件、每一个调用方都重编。这种隔离正是工程布局给你买到的东西。
顶层 CMakeLists.txt
第 1 课的 CMakeLists.txt 声明一个可执行 target。这里要声明一个库 target、一个可执行测试 target、二者之间的依赖,并把测试注册到 CTest:
cmake_minimum_required(VERSION 3.20)
project(pricing-cpp CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(pricing src/pricing.cpp)
target_include_directories(pricing PUBLIC include)
target_compile_options(pricing PRIVATE -Wall -Wextra)
add_executable(test_pricing tests/test_pricing.cpp)
target_link_libraries(test_pricing PRIVATE pricing)
target_include_directories(test_pricing PRIVATE include)
enable_testing()
add_test(NAME test_pricing COMMAND test_pricing)
逐行讲。add_library(pricing src/pricing.cpp) 声明一个叫 pricing 的库 target,由一个源文件构成;默认是静态库(Linux 上 .a,Windows 上 .lib)。target_include_directories(pricing PUBLIC include) 告诉 CMake include/ 是 pricing 的公开接口——任何 link 到 pricing 的 target 自动把 include/ 加到自己的 include 路径里。add_executable(test_pricing tests/test_pricing.cpp) 声明测试二进制。target_link_libraries(test_pricing PRIVATE pricing) 把可执行连到库上;PRIVATE 表示依赖不向上传播。target_include_directories(test_pricing PRIVATE include) 让测试二进制看得见头文件,用于 #include "pricing.hpp"。enable_testing() 在本工程激活 CTest。add_test(NAME test_pricing COMMAND test_pricing) 把测试二进制注册为一个 CTest 用例。
构建与运行:
cmake -S . -B build -G Ninja
cmake --build build
ctest --test-dir build --output-on-failure
第三条命令跑掉每一个已注册的测试,并对失败的打印细节。--output-on-failure 是日常 flag;不加它 CTest 只显示通过 / 失败计数。
用 doctest 写单元测试
doctest 是一个单头文件、无构建系统依赖的测试框架。把 doctest.h 拖进 include/(或者用 CMake 的 FetchContent 拉取——在此提一句作为生产路径,完整配置归 3.6.5),在 tests/test_pricing.cpp 里写:
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"
#include "pricing.hpp"
TEST_CASE("black_scholes_call: deep ITM") {
const double actual = pricing::black_scholes_call(4.20, 3.80, 0.024, 0.18, 0.5);
// reference price from Python scipy.stats.norm: 0.4286
CHECK(actual == doctest::Approx(0.4286).epsilon(1e-6));
}
TEST_CASE("black_scholes_call: ATM") {
const double actual = pricing::black_scholes_call(4.20, 4.20, 0.024, 0.18, 0.5);
// reference price: 0.1607
CHECK(actual == doctest::Approx(0.1607).epsilon(1e-6));
}
TEST_CASE("black_scholes_call: deep OTM") {
const double actual = pricing::black_scholes_call(4.20, 4.80, 0.024, 0.18, 0.5);
// reference price: 0.0303
CHECK(actual == doctest::Approx(0.0303).epsilon(1e-6));
}
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN 恰好放一次在顶端,告诉 doctest 给测试二进制生成一个 main;不加,你就得自己写。TEST_CASE("description") { ... } 声明一个测试;内部的一切在自己的作用域里运行。CHECK(expr) 记录失败并继续;近邻 REQUIRE(expr) 记录失败并停掉当前 TEST_CASE。
浮点近似比较的习语是 actual == doctest::Approx(expected).epsilon(1e-6)——matcher 在 |actual - expected| <= epsilon * max(|actual|, |expected|) 时通过。对接近零的值,用 .margin(1e-12) 改成绝对容差。参考价由 Python scipy.stats.norm 手算并嵌在注释里以便复核。
工作例参数取值与 L2 完全一致:S = 4.20, sigma = 0.18, r = 0.024, T = 0.5;K 分别取 3.80 / 4.20 / 4.80。这正是 510300.SH 沪深 300 ETF 期权定价库的最小工程形态,与 L2 / L3 / L4 的 510300 锚定一致。
看一个测试变红
重建,跑 ctest --test-dir build --output-on-failure,你看见三个绿 CHECK。现在故意把 src/pricing.cpp 里的公式弄坏——把 (r + 0.5 * sigma * sigma) 改成 (r + sigma * sigma)——重建、运行,你会看到类似:
tests/test_pricing.cpp:6: ERROR: CHECK( actual == doctest::Approx(0.4286).epsilon(1e-6) ) is NOT correct!
values: CHECK( 0.4521 == Approx( 0.4286 ) )
实际价被推高,是因为弄坏的公式让 d1 膨胀,Nd1 跟着变高,看涨期权变得更值钱。三个用例里有两到三个失败(ATM 那个错得最戏剧化,因为价格在平值附近对 d1 最敏感)。把公式改回去,重建、重跑,看着绿色回来。这就是本模块后面每一课都默认你能驱动的循环:改代码、构建、跑测试、读失败、修。
Formula Explorer
test_loop = edit + build + ctest + read_failure + fixclang-format 与日常工具一巡
clang-format 跑 clang-format -i 对工程树里的文件就地格式化。工程根的 .clang-format 文件钉住风格。最小四行:
BasedOnStyle: Google
IndentWidth: 4
ColumnLimit: 100
DerivePointerAlignment: false
BasedOnStyle: Google 是开源 C++ 生态里最常见的起点。IndentWidth: 4 覆盖 Google 默认的 2。ColumnLimit: 100 比传统的 80 宽,适配现代宽屏显示器,而不损可读性。DerivePointerAlignment: false 让 int* 留在类型侧,而不让 clang-format 根据已有代码自动派生(那会随着文件演变而来回翻)。
跑 clang-format -i include/pricing.hpp src/pricing.cpp tests/test_pricing.cpp,每一个文件都被重写成符合规则的形态。生产代码库在 CI 里跑 clang-format --dry-run --Werror,任何未格式化的文件都让 build 失败。.clang-format 在多数桌内是必备文件,强制 CI 检查;本课配置以 Google style 为底是 GCC / clang 社区最常见的默认。
两个邻居只报名字不配置。clang-tidy 是静态分析的兄弟:基于规则的潜在 bug 检测、现代化建议、超出格式化范围的代码风格执行。完整配置(.clang-tidy 文件、自定义检查、CI 集成)归 3.6.2 Git & Code Quality。valgrind 是 AddressSanitizer 在你无法重建二进制时的堆检查替代——比 ASan 慢,但对任意预编译可执行都管用。valgrind --leak-check=full ./build/test_pricing 能在没有 -fsanitize=address 编译过的生产二进制上抓到第 3 课里那种泄漏。
阅读清单:《C++ Primer》第 5 版 中译 第 19.7 节(链接指示与多翻译单元);zh.cppreference.com 上「One Definition Rule」与「inline 函数」的页面;cmake.org 中文文档的「step-by-step tutorial」前 5 步;doctest GitHub README(仅英文)和 onqtam.com 上的 doctest 备忘单——doctest 中文资料较少,多看英文 README;国内 GitLab / Gitee 镜像可加速 clone doctest 源码到本地。行业一句:国内私募 / 自营 quant C++ 项目常见的工程形态就是「include/ + src/ + tests/ + 顶层 CMakeLists.txt」,测试框架 Catch2 / doctest / GTest 三选一都见过——本课选用 doctest 是因为单头文件、无外部依赖,便于学员复现。
通往下一模块
你现在能铺出一个多文件 C++ 工程、写出能产生库与测试二进制的 CMakeLists.txt、通过 CTest 跑单元测试,并用 .clang-format 规则让代码库保持格式一致。C++ 轨道后续每一个模块都默认这条基线:模块 3.4.2(Templates & Modern C++)在你现有的工程布局之上引入 std::unique_ptr、模板、lambda 与 auto;3.4.3(Memory & Performance)掀开堆分配器、缓存布局与 perf 测量的盖子;3.4.4(Concurrency & Networking)加上 std::thread、std::atomic 与 Boost.Asio;3.4.5(Trading Systems in C++)把它们拼成一个迷你撮合簿模拟器。工具链、语言核心、内存模型、RAII 习语、工程形态——这些都成了你的。接下来四个模块就站在它们之上。
练习
Exercise
拿本课的 pricing-cpp 工程。(a) 加一个第四个测试用例 TEST_CASE("black_scholes_call: put via parity"),通过 black_scholes_call 算出 call 价、用 parity 公式 P = C - S + K * exp(-r * T) 算出 put 价、再用 C = P + S - K * exp(-r * T) 反算 call,CHECK 反算值与原 call 在 doctest::Approx(0.0).margin(1e-12) 内一致,以验证 put-call parity。(b) 故意把 src/pricing.cpp 里的 (r + 0.5 * sigma * sigma) 改成 (r + sigma * sigma),重建,跑 ctest --output-on-failure,报告哪些测试用例失败、大约偏差多少。(c) 把公式改回来、重跑、确认绿。(d) 跑 clang-format -i include/pricing.hpp src/pricing.cpp tests/test_pricing.cpp,观察是否有任何文件被重新格式化(对本课提供的代码,多数会是空操作)。
提示
pricing::black_scholes_call 拿到 C;算 P = C - S + K * std::exp(-r * T);再验证 (P + S - K * std::exp(-r * T)) == doctest::Approx(C).margin(1e-12)。这一恒等应当精确成立,因为两侧用的是同一个 std::exp 调用。提示
d1 推高了 0.5 * sigma^2 * T;平值附近这把价格挪动 S 的百分之几。预期 ATM 用例的绝对偏差最大;深度虚值用例的相对偏差大但绝对数小。