国内某私募的 C++ 研究桌周一例会:新入职的研究员上线了 mean_double、mean_float、mean_long_double 三份函数——同一个九行的均值计算被复制了三次,只是浮点精度不同。Senior C++ engineer 的 review 意见只有一句话:「这里要写成模板。」周五新人交回的版本里,三份代码合成了一个 template<typename T> T mean_of(const std::vector<T>&):编译产物更小、bug 更少,并且上季度生产环境里那个 int 溢出 bug 只需要在一个地方修。模板是 C++ 的主要代码复用工具,而 3.4.1 留给你的状态正是 senior engineer 拒掉的那种「单态、复制粘贴」的写法。本课把这件事补齐。
函数模板:最小单元
最小的模板是「函数模板」——以类型为参数的函数家族配方。语法是声明一个类型参数,然后像普通类型一样在签名和函数体中使用它。(本课每一个 Fenced cpp 代码块都是 gate 会按字节核对的精确形式。)
template<typename T>
T min_of(T a, T b) {
return a < b ? a : b;
}
调用点上,编译器会把每个实参与参数列表比对,推导出 T 的类型。min_of(1, 2) 推导出 T = int,实例化出 int min_of<int>(int, int);min_of(1.0, 2.0) 推导出 T = double,实例化出 double min_of<double>(double, double)。每一次实例化在编译产物里都是独立的函数;模板定义是配方,实例化是蛋糕。
当实参之间无法统一时,推导失败。min_of(1, 2.0)——一个 int、一个 double——没有任何单一的 T 同时满足两者,编译器会报出类似 no matching function for call to min_of(int, double) 的错误。两种修复方式:在调用点把实参类型统一(min_of(1.0, 2.0)),或者给出显式模板实参(min_of<double>(1, 2.0)),意思是「就用 double,能转就转」。显式模板实参也是你「强制选某个实例化」时的工具——min_of<long>(1, 2) 会实例化出 long 版本,即使 int 本来就能用。
完整的推导算法——顶层 cv 限定符剥离、引用折叠、数组退化为指针——在 cppreference 上有详细描述;日常你需要的就是上面的操作性规则。参数列表里 class 与 typename 可互换(template<class T> 与 template<typename T> 等价);现代风格首选 typename。
类模板与类型别名
类模板把一个类按类型参数化。语法仍然是 template<typename T> 前缀挂在类定义前:
template<typename T>
class MonteCarloPath {
public:
explicit MonteCarloPath(std::vector<T> samples) : samples_(std::move(samples)) {}
T mean() const {
T sum = T(0);
for (const T& x : samples_) sum += x;
return sum / static_cast<T>(samples_.size());
}
std::size_t size() const { return samples_.size(); }
private:
std::vector<T> samples_;
};
MonteCarloPath<double> 与 MonteCarloPath<float> 是两个完全独立的类型,它们之间没有隐式转换、没有共同基类、没有共享存储。一个签名为 void summarise(const MonteCarloPath<double>&) 的函数会在编译期拒绝 MonteCarloPath<float> 实参。若要让 summariser 对元素类型也泛化,把它也写成模板:template<typename T> void summarise(const MonteCarloPath<T>&)。编译器在第一次用到时实例化类模板;某个翻译单元若提到 MonteCarloPath<double>,就会把该 T 下的完整类定义(包含每个成员函数体)实例化一遍。
using 形式的类型别名是 typedef 的现代替代,并且与模板友好:
using PriceVector = std::vector<double>;
template<typename T>
using Vec = std::vector<T>;
PriceVector p = {4.20, 4.21, 4.19};
Vec<int> sizes = {1000, 500, 800};
typedef std::vector<double> PriceVector; 对非模板情形仍能工作,但 typedef 无法表达别名模板——语言里没有 typedef template 的语法。using 形式既覆盖非模板别名又覆盖别名模板,且从左到右阅读更顺。新写的代码统一用 using。
auto 与结构化绑定
auto 的三种操作形式覆盖了 90% 的日常用法;结构化绑定(C++17)解决「一次取出多个字段」的需求:
auto x = 1.0; // double
auto& y = container; // reference
const auto& z = compute_price(); // const reference
std::map<std::string, double> prices;
for (const auto& [ticker, price] : prices) {
// ticker is const std::string&; price is const double&
}
auto x = expr; 从初始化表达式推导值类型。auto& y = container; 推导出引用——少写一个 & 就会拷贝 container(包含 std::vector 自带的一次堆分配);引用形式才是「我打算读写原对象」时该用的写法。const auto& z = expensive_to_copy(); 是「我想读这个值,但不要为拷贝付钱」的经典模式——函数按值或按引用返回而你一时不确定时,先写它。
range-based for 继承同样的形式:for (const auto& x : v) 用于读、for (auto& x : v) 用于改、for (auto x : v) 用于逐个拷贝。结构化绑定一次性把 pair、tuple 或三成员 struct 拆开:auto [key, value] = *map.find(k); 绑定为拷贝;auto& [k, v] = *map.find(k); 绑定为引用;const auto& [k, v] = *it; 是只读形式——你在对 std::map 做 range-based for 时第一想到的就是它。
全特化:紧急出口
当模板的泛型实现对某个具体类型不正确时,「全特化」让你只对该类型重写实现,其它实例化保持泛型。min_of 模板用 < 做比较;当某一实参为 NaN 时,IEEE 754 下 < 会无条件返回前者,结果是错的。修复方式是给一个委托给 std::fmin 的全特化——std::fmin 按 IEEE 754 正确处理 NaN:
template<>
double min_of<double>(double a, double b) {
return std::fmin(a, b);
}
template<> 一行是「这是一个全特化,不是新的主模板」的语法标记。签名指出确切的类型(min_of<double>),与主模板的参数与返回类型对齐。从此 min_of(1.0, 2.0) 走特化版本;min_of(1, 2) 与 min_of(1.0f, 2.0f) 仍走泛型。
全特化适用于「某个类型需要完全不同的实现」。偏特化——对一族类型做特化,例如 template<typename T> class Box<T*> { ... };——表达力更强,本课不教,留到 3.4.5 的策略框架;那里的 trading-system C++ 代码大量依赖偏特化。SFINAE(std::enable_if)和 C++20 Concepts 也都延后——它们是「如何约束模板」的工具;本课先教模板本体,约束层是后续话题。if constexpr——让你在单个模板定义内按类型分支——是「不必写完全不同实现」时的更轻方案;下一课会讲。
头文件规则
新手最常踩的、且必须记牢的一条规则:模板写在头文件里。 使用 MonteCarloPath<double> 的翻译单元必须自己实例化完整的类(包括每个成员函数体),而实例化需要模板定义可见。如果把 template<typename T> T mean() const { ... } 放在 monte_carlo_path.cpp,再从 main.cpp 调用 MonteCarloPath<double>::mean(),main.cpp 的编译器看不到定义、无从实例化,链接器最终报 undefined reference to MonteCarloPath<double>::mean()。
教科书会提到的两条「绕路」对日常代码都是死路。「显式实例化」——在 .cpp 末尾写 template class MonteCarloPath<double>;——对一组已知有限的类型可用,但不可扩展。export template 关键字在 C++11 已被语言移除,不要再去查。务实规则:把模板代码放在 .hpp 头里,谁用就 include 谁,让编译器在每个翻译单元里各自实例化。这意味着模板重的代码编译会更慢,因为每个翻译单元都重复实例化一遍——是的,每一个你将会接触的生产 C++ 代码库都是这样工作的。把这部分编译开销切下来的杠杆在 3.4.3。
串起来:模板化的定价器骨架
worked example 复用 3.4.1 的 CSI 300 ETF(510300.SH)欧式 call 锚定参数:S = 4.20、K = 4.30、r = 0.024、sigma = 0.18、T = 0.5,并以 MonteCarloPath<double> 承载 N = 10000 条样本、seed = 42(与 3.3.1 的 Python Monte Carlo 例子一致,便于数值交叉验证):
int main() {
std::vector<double> samples = generate_paths(/* region-specific S, K, r, sigma, T, N=10000, seed=42 */);
MonteCarloPath<double> path(std::move(samples));
std::cout << std::fixed << std::setprecision(4)
<< "mean = " << path.mean() << '\n'
<< "size = " << path.size() << '\n';
return 0;
}
std::move(samples) 把 vector 的堆存储交给 MonteCarloPath 构造函数,而不发生拷贝——你将在第 3 课亲手实现这套移动语义。std::fixed << std::setprecision(4) 是这门模块里所有数值输出 main 都会出现的固定格式化模式。类按 T 参数化,因此当 3.4.3 做内存压测要从 double 切到 float 时,只需在调用点改一行:MonteCarloPath<float> path(...)。
Formula Explorer
(1/N) * sum(payoff(S_T_i))Exercise
Take the MonteCarloPath<T> class template from this lesson. (a) Add a templated member function T variance() const that computes the sample variance using the formula Var = (1/(N-1)) * sum((x_i - mean)^2) for N >= 2. (b) Add a full template specialization template<> double MonteCarloPath<double>::variance() const that uses std::accumulate from <numeric> and a lambda — the implementation is your choice as long as the answer matches the generic version to within 1e-12 on the same input. (c) Instantiate MonteCarloPath<int> and MonteCarloPath<double> from main, call variance() on each, and observe the integer-division surprise in the int version. State in one sentence why this is a case where the generic does not handle int correctly and a full specialization for double (or a different design — like making variance() always return double) would be the production fix.
提示
N-1。先算 mean,再累加平方偏差。T = int 时小心整数除法。提示
double 特化,可用一行 std::accumulate(samples_.begin(), samples_.end(), 0.0, [m](double acc, double x){ return acc + (x-m)*(x-m); }) 收掉循环。下一课
上面这个定价器把 payoff 硬编码在函数体里。真实交易桌会让 payoff 在运行时被参数化——call、put、digital、barrier——并把选定的规则作为可调用对象传入 pricer。下一课引入让这种写法成为惯用法的语言要素:lambda 表达式、其背后的闭包对象、用于做类型擦除存储的 std::function,以及编译期计算所属的 constexpr 家族。