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

Lambda 表达式、函数工具与 constexpr

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

国内某私募衍生品桌的研究主管要在不动 Monte Carlo 引擎的前提下,对同一笔 CSI 300 ETF(510300.SH)4.30 行权价的欧式 call 跑三种 payoff——call、put、二元 digital。C++98 时代的答案是一棵 PayoffBase 指针继承树;C++11 之后的答案变成一行 lambda:把它直接传进 pricer。如今该桌的定价代码是 pricer.price(path, [K](double S){ return std::max(S - K, 0.0); }),写出这行的研究员从头到尾没碰过引擎源码。Lambda、std::functionconstexpr 共同构成了 modern C++ 的「函数式层」——让你在调用点参数化行为,而不必把外层调用方也整个模板化。上一课留下的 MonteCarloPath<double> 与模板机制能承载它;本课给出与之配对的 callable。

Lambda 表达式

(本课每一个 Fenced cpp 代码块都是 gate 会按字节核对的精确形式。)

lambda 表达式生成一个无名「闭包对象」(closure object)——值的类型由编译器现场合成,带一个 operator() 用于执行 lambda 体。完整语法是 [capture](params) -> return { body }。心智模型:lambda 是一个无名类类型的值,类里有 operator(),编译器替你写出这个类。闭包对象可拷贝、可存储、可像普通值一样传递——这正是它能作为回调目标的根本原因。

捕获列表是 lambda 与普通函数的关键差异,它列出 lambda 体内需要看到的「外层作用域变量」。[x] 按值捕获——闭包持有 x 在构造点的拷贝;外层 x 后续被改不会影响 lambda 视角。[&x] 按引用捕获——闭包持有引用;改动是共享的,并且​​闭包不得比 x 活得更久​​。这是承重的安全规则:[&] 捕获叠加一个生命周期错误,是 modern C++ 里最经典的 use-after-free 来源。

万能形式 [=](全部按值捕获)与 [&](全部按引用捕获)能少打几个字符,代价是可读性。Code reviewer 在一眼内看不出 lambda 实际依赖哪些外层名字,而 [&] 与生命周期错误的组合在所有使用 C++ 的公司都至少炸过一次生产环境。能过 review 的团队规则:​​显式列出每一个捕获,除非有书面理由不这么做。​ 真要写 [&],配一行注释说明为什么。

const double K = 4.30;
auto call_payoff = [K](double S) { return std::max(S - K, 0.0); };
auto put_payoff  = [K](double S) { return std::max(K - S, 0.0); };

(params) 是参数列表,与函数完全一致。-> return 是后置返回类型——通常可被推导,因此通常省略;当推导有歧义或你想强制一个具体类型时再写。{ body } 是函数体。

mutable 与合成的 operator()

默认情况下闭包类型上合成的 operator()const,这意味着按值捕获的成员在 lambda 体内是只读的。[x](double v) { x = v; } 不能通过编译,因为 x 等价于闭包对象的 const 成员。加上 mutable 去掉这个 const

int call_count = 0;
auto invoke_and_count = [call_count](double S) mutable -> double {
    ++call_count;
    return S * 2.0;
};

现在 lambda 每次调用都修改它自己那份 call_count 的拷贝;外层 call_count 仍然是 0(lambda 持有的是独立拷贝)。mutable 在「有状态的算法」之外并不常见——滚动均值、计数器、需要跨调用累积状态的逻辑。出现频率低到当你在 review 里看到它时应该问一句:这份状态是不是应该有一个被命名的显式类承载?

std::function 与类型擦除的代价

lambda 的类型是该 lambda 独有的,你既无法命名也无法把两个不同的 lambda 放进同一个 std::vector<functional> 里的 std::function<R(Args...)> 就是解决这个问题的​​类型擦除​​(type erasure)可调用包装:它能装下任何匹配签名的可调用对象——普通函数、成员函数、lambda、有 operator() 的函数对象。编译器在存储点合成一个小适配器,把 operator() 转发到里面装的那位。

std::vector<std::function<double(double)>> payoffs;
payoffs.push_back(call_payoff);
payoffs.push_back(put_payoff);
for (const auto& f : payoffs) {
    std::cout << f(4.20) << '\n';
}

它给你的能力是真的:异构 callable 放进同一个容器;callable 跨 ABI 边界传递(.so 库 API 不能有模板参数——模板需要源码可见,.so 没有);callable 作为类成员,不需要把外层类按 callable 类型模板化。代价同样是真的:当捕获大小超过小缓冲优化阈值(多数实现约 16 字节),std::function 会做堆分配;调用走函数指针间接跳转,编译器无法内联穿透。

桌面规则:​​热路径上的 callable 用模板参数​​(callable 类型编译期已知,编译器能内联);​**std::function 用在类型擦除值回报的地方​**​——配置代码、回调注册、插件 API、任何需要异构容器或穿越 ABI 边界的位置。

构造一个 Pricer

worked example 把上面这些拼到一起:Pricer 类在构造时存下一个 std::function<double(double)> payoff,并把它应用在 L1 的 MonteCarloPath<double> 的每一个终止价格上,再用给定的折现因子折现均值:

class Pricer {
  public:
    explicit Pricer(std::function<double(double)> payoff) : payoff_(std::move(payoff)) {}
    double price(const MonteCarloPath<double>& path, double discount) const {
        double sum = 0.0;
        for (std::size_t i = 0; i < path.size(); ++i) {
            sum += payoff_(path.sample(i));
        }
        return discount * (sum / static_cast<double>(path.size()));
    }
  private:
    std::function<double(double)> payoff_;
};

MonteCarloPath<double>::sample(std::size_t) 是底层 vector 的一行索引访问器;从 L1 直接复用即可。)构造函数按值接收 payoff 并把它 move 到成员里——「constructor 拥有该参数」时的标准 sink 模式。price(...) 的循环把存下的 payoff 应用到每一个 path 样本,最终返回折现后的算术平均。

客户端构造两个 pricer——一个绑 call payoff lambda,一个绑 put——在 L1 产出的同一份 MonteCarloPath<double> 上分别运行,并打印 put-call parity 的健康检查 C - PS - K * std::exp(-r * T) 的差。在 CSI 300 ETF 锚定参数上(S = 4.20K = 4.30r = 0.024T = 0.5),折现后的理论差是 4.20 - 4.30 * std::exp(-0.024 * 0.5);当 N = 10000 时 Monte Carlo 的 C - P 应在 Monte Carlo 误差范围内与之匹配。

constexpr:编译期值与编译期函数

constexpr 是 C++ 中表示「编译期可求值」的关键字。作用于变量,要求编译器在编译期把值算出来;作用于函数,要求编译器在​​给定编译期实参时​​在编译期对函数求值——同一个函数面对运行期实参时仍按普通函数在运行期工作。

constexpr double kPi = 3.14159265358979;
constexpr double square(double x) { return x * x; }
constexpr double k_pi_squared = square(kPi);  // evaluated at compile time

kPi 是编译期常量——非常适合数学常数、数组尺寸、模板实参,以及任何你想断言「这个值编译期已定且不可改」的地方。squareconstexpr 函数——用 constexpr 实参调用得到 constexpr 结果;用运行期 double 调用得到运行期结果。C++17 把 constexpr 的能力扩展到:局部变量、多个 return、循环、ifswitch——操作性结论是「大部分合理的算术与控制流都能 constexpr」。值得记住的边界:constexpr 函数在 C++17 里不能调用非 constexpr 函数、不能 throw,标准库里的函数只有声明里写了 constexpr 才算。std::exp 在 C++17 里​​不是​ constexpr(它要等到 C++26 才变成),下面的练习正好踩这种一行的惊喜。

if constexpr:编译期分支

if constexpr 是 C++17 的编译期分支。在模板内部,它按一个常量表达式——通常是类型萃取——在编译期挑出唯一一支。​​被丢弃的那一支不会被针对当前模板实参做完整编译​​(只会被解析),所以你可以在分支里写出对其它类型本身不合法的代码:

template<typename T>
std::string describe(T value) {
    if constexpr (std::is_integral_v<T>) {
        return "integral: " + std::to_string(value);
    } else if constexpr (std::is_floating_point_v<T>) {
        return "floating-point: " + std::to_string(value);
    } else {
        return "other";
    }
}

这是「在少量备选间分支」时 SFINAE 模式的现代替代。L1 的全特化是更重的工具:当某个类型需要​​完全不同​​的实现时再用。能在同一函数体内用分支表达的差异,就交给 if constexpr。SFINAE 本身(std::enable_if)与 C++20 Concepts 都留到 3.4.5。

折叠表达式(fold expression)值得单独一段,让你在 review 时能认出来。可变参数模板(variadic template,template<typename... Args>)声明一个参数包——可变数量的类型。折叠表达式 (args + ...)(一元右折叠)或 (... + args)(一元左折叠)以指定运算符收拢参数包。template<typename... Args> auto sum(Args... args) { return (args + ...); } 返回所有实参之和;编译器在编译期展开包。可变模板与参数包机制的深入处理留到 3.4.5——那里的策略与 handler 框架大量依赖它们。

Formula Explorer

C - P = S - K * exp(-r * T)

Exercise

Take the Pricer class from this lesson. (a) Add an overload template<typename Payoff> double price_templated(const MonteCarloPath<double>& path, double discount, Payoff payoff) const that accepts the payoff as a template parameter instead of a std::function. (b) Construct two Pricer instances and price the same MonteCarloPath<double> with the call payoff using both price(...) and price_templated(...); confirm the two answers are byte-identical (==, not Approx). (c) Compile both call sites with -O2 -S and find the payoff_(...) call in the price assembly output and the inlined payoff body in the price_templated assembly output. State in one sentence what type erasure cost you in the price version. (d) Write a constexpr function constexpr double discount_factor(double r, double T) that returns std::exp(-r * T) — observe what error the compiler gives and explain in one sentence why std::exp is not constexpr in C++17 (it became constexpr only in C++26).

提示
(a) 把 Payoff 放在第三个参数位上,让调用点能完成类型推导。(c) g++ -O2 -S -fno-asynchronous-unwind-tables main.cpp 生成可读的 main.s
提示
(d) 编译器会报「call to non-constexpr function std::exp」之类的错。C++17 标准并没有把 <cmath> 的函数标 constexpr,更晚的标准才补上。

下一课

上面的 pricer 把 payoff 存为 std::function,接受类型擦除作为「泛型存储」的代价。同一种范式——把资源包在类里、靠语言的值语义管理其生命周期——正是 C++ 管理动态分配对象的方式。下一课把这个模式推到极致:std::unique_ptrstd::shared_ptr 替掉裸 new / delete,移动语义让所有权可在不拷贝的前提下转移,五法则成为你这辈子写一次就反复倚赖的脚手架。