Functional Programming in C++

Ivan Čukić 著,Manning Publications 2019 年出版。 KDE 核心开发者、贝尔格莱德大学数学系讲师 Ivan Čukić 系统讲解如何在 C++ 中运用函数式编程范式提升代码质量。

全书概览

本书是 C++ 函数式编程领域的经典著作。全书共 13 章,从基础概念到高级应用层层递进,覆盖函数对象、柯里化、纯函数、惰性求值、Range、不可变数据结构、代数数据类型、Monad、模板元编程、响应式流、属性测试等核心主题。

核心观点:函数式编程的核心哲学不是”如何实现”,而是”要做什么”——声明式思维替代指令式思维。C++ 作为多范式语言,可以结合模板、lambda、STL 等特性有效运用 FP 思想,写出更简洁、更安全、更易并行的代码。

读者定位:有 2 年以上 C++ 开发经验的中高级开发者。


第1章 函数式编程导论

核心观点:

  • FP 哲学:关注”做什么”而非”怎么做”(声明式 vs 指令式)
  • FP 与 OOP 各有所长,应根据场景选择或组合使用
  • C++ 是多范式语言:过程式、面向对象、函数式、泛型编程可混用
  • FP 与泛型编程在 C++ 中天然契合,两者都推动思维从硬件层面上升到更高抽象
  • 函数提升(function lifting):从操作单个值的函数创建操作值集合的函数
  • 函数组合(function composition):将值通过一系列转换传递,前一个转换的输出是后一个的输入
  • 避免可变状态 → 提升代码正确性 + 消除多线程中对互斥锁的需求
  • 函数式思维 = 思考输入数据 + 思考需要执行哪些转换来得到期望输出

第2章 函数式编程入门

核心观点:

  • 高阶函数是 FP 语言的主要特征:函数可像普通值一样被传递、存储
  • STL 算法(transform、filter、accumulate 等)通过传入不同谓词函数实现通用算法的特化
  • std::accumulate 实现了折叠(folding)概念,不限于数值计算,可用来实现许多标准算法
  • std::stable_partition 保持元素相对顺序,在 UI 场景中比 partition 更合适
  • STL 算法设计上是可组合的,但组合方式不如纯 FP 语言优雅——Range 库可以弥补这一点
  • 标准算法用循环实现不是用手写循环的理由,正如 while/if 用 goto 实现但你不会直接用 goto
  • 尾调用优化(TCO):递归的最后一步是调用自身时,编译器可优化为循环,避免栈溢出
  • 折叠算法是最强大的通用递归模式之一,许多算法都可以用 fold 实现

第3章 函数对象

核心观点:

  • C++ 中可被当作”函数”使用的东西:函数指针、函数对象(重载 operator())、lambda
  • **函数对象(functor)**优于函数指针:可携带状态、可内联、性能更好
  • 自动返回类型推导(auto):避免显式指定返回类型时可能发生的隐式转换或窄化
  • 泛型函数对象:将 operator() 声明为模板,使其能处理多种类型
  • Lambda 是创建函数对象的语法糖,通常比手写类更简洁
  • C++14 的 lambda 支持泛型(auto 参数)和更灵活的捕获方式,可替代大多数手写函数对象
  • Boost.Phoenix 等库可用于创建比 lambda 更简洁的函数对象
  • std::function 很有用,但有虚函数调用级别的性能开销,热路径需谨慎使用

第4章 从旧函数创建新函数

核心观点:

  • 偏函数应用(Partial Application):固定多参数函数的部分参数,得到参数更少的新函数
  • std::bind + 占位符(placeholders)可灵活绑定参数、重排参数顺序、重复使用同一参数
  • 性能注意:std::bind 可能有性能开销,性能关键代码中考虑用 lambda 替代(编译器更容易内联优化)
  • 柯里化(Currying):将多参数函数转化为一系列单参数函数链。每个函数返回下一个函数
    • 柯里化函数 = 所有参数都被偏应用的函数
    • API 设计中使用柯里化函数可提供更高的灵活性
  • 函数组合(Function Composition):将多个函数组合成一个函数,f(g(x)) = (f ∘ g)(x)
  • 函数提升(Function Lifting):将操作单个值的函数提升为操作集合/包装类型的函数
  • FP 让代码更短的真正原因:可组合的函数能用一小部分代码解决复杂问题

第5章 纯函数:避免可变状态

核心观点:

  • 可变状态的问题:状态越多,正确推理程序越难;多线程环境下需要同步,限制并行度
  • 纯函数:输出只依赖输入,不产生副作用,相同输入永远返回相同输出
  • 引用透明性(Referential Transparency):函数调用可以被其返回值替换而不改变程序行为
  • 无副作用编程:不修改变量,而是返回新值
  • 可变状态本身不一定坏——只要不被多个系统组件同时共享就是安全的
  • const 的重要性:
    • 成员函数声明为 const = 承诺不修改对象的任何数据
    • mutable 成员的修改对用户而言必须是原子的(不影响外部可见状态)
    • const 支持编译器优化
  • 逻辑 const 性 vs 内部 const 性:外部不可变 ≠ 内部完全不可变(如缓存)
  • 对临时对象优化成员函数(右值引用重载)
  • 不可变数据结构(第8章)可以解决”修改就要全拷贝”的效率问题

第6章 惰性求值

核心观点:

  • 惰性求值:只在真正需要值的时候才计算,计算后缓存结果
  • 应用场景:
    • 可能不需要的昂贵计算(避免浪费 CPU)
    • 惰性排序:只排序需要的部分
    • UI 中的项目视图:只计算可见部分
    • 递归树剪枝:缓存中间结果,避免重复计算
    • 动态规划本质上是一种惰性求值
  • 记忆化(Memoization):将函数的输入输出对缓存起来,下次相同输入直接返回缓存
    • 纯函数才能安全地记忆化
    • make_memoized 通用包装器可用于基准测试收益
  • 表达式模板(Expression Templates):
    • 延迟表达式计算,构建表达式树,在最终赋值时才实际计算
    • 常用于矩阵库等需要优化复杂表达式的场景
    • 例子:惰性字符串拼接——避免创建大量临时对象
  • Levenshtein 距离(编辑距离)是记忆化优化的典型例子:指数复杂度 → 线性/平方

第7章 Range

核心观点:

  • STL 算法的常见错误源:向算法传递不正确的迭代器,甚至是属于不同容器的迭代器
  • Range 概念:对任何可迭代数据的抽象。可以建模普通容器、输入输出流、数据库查询结果集等
  • Range 将 begin/end 迭代器对封装为单一对象,更安全也更易组合
  • Range Views(视图):不拥有数据,也不修改数据,提供惰性转换视图
    • filter_view、transform_view 等
    • 惰性求值:只有在访问元素时才执行转换
  • Range Actions(动作):修改实际数据(如 sort、reverse)
  • 管道语法(|):链式组合多个 range 转换,声明式数据流
    • 例如:data | filter(pred) | transform(f) | take(10)
  • 无限 Range:用哨兵迭代器表示结束。算法如果能处理无限 range,说明其通用性足够高
  • Range 被纳入 C++20 标准,但 range-v3 等库在此之前就提供相同功能
  • 用 range 转换思维分解程序逻辑 → 高度可复用的组件

第8章 函数式数据结构

核心观点:

  • **不可变数据结构(持久化数据结构)**的核心优化:数据共享(data sharing)
    • 修改数据结构时,大部分节点保持不变并被新旧版本共享
    • 允许多个略有差异的版本共存,内存开销远低于全量拷贝
  • 不可变链表:
    • 在头部增删元素:O(1),共享尾部
    • 在尾部/中间操作:效率较低
  • 位图向量前缀树(Bitmapped Vector Trie):
    • 类似 std::vector 的不可变版本
    • 32 路前缀树,查找/更新/追加均为 O(log₃₂ n) ≈ 近似 O(1)
    • 路径上的节点被复制,其余节点共享
  • 不可变数据结构的优势:
    • 天然线程安全(无修改)
    • 可回溯到任意历史状态(时间旅行调试)
    • 无需锁
  • 其他不可变结构:红黑树(关联容器)等也可修改为数据共享版本
  • 选择合适的结构:不像 std::vector 是万能选择,每种不可变数据结构都有各自的适用场景和局限

第9章 代数数据类型与模式匹配

核心观点:

  • 代数数据类型(ADT):通过组合已有类型创建新类型
    • 积类型(Product Types):struct/class(AND 组合,同时包含 A 和 B)
    • 和类型(Sum Types):variant(OR 组合,要么是 A 要么是 B)
  • 用 ADT 建模程序状态 → 最小化可能的状态数,消除非法状态
  • Sum Type 的实现方式:
    • 继承 + 虚函数 + Visitor 模式:OOP 风格,有运行时开销
    • std::variant:更好的选择,性能更高,但 std::visit 样板代码较多
  • 可选值(std::optional):特殊的 sum type,要么有值要么为空
  • 错误处理:
    • expected<T, E> / Result 类型:要么是成功值 T,要么是错误 E
    • 比异常更明确地表达”可能失败”,跨线程/跨进程更容易传递
    • 异常应该只用于真正”异常”的情况
  • 领域建模:自顶向下设计,用 sum type 精确表达状态机
  • 模式匹配:更优雅地处理 sum type
    • C++ 标准库中通过 std::visit + 重载函数对象实现
    • Mach7 库提供更强大的模式匹配能力
  • Andrei Alexandrescu 2012 年的 “Systematic Error Handling in C++” 演讲推动了 expected 类型在 C++ 社区的普及

第10章 Monad

核心观点:

  • FP 世界也有常用的抽象和模式,Functor 和 Monad 是最重要的两个
  • Functor(函子):
    • 类似集合的结构,知道如何对其内容应用转换函数
    • 核心操作:transform / fmap(包装类型 + 函数 → 新的包装类型)
    • 例子:std::optional、std::vector、std::future 都是 functor
  • Monad(单子):
    • 比 functor 多两个操作:单位元(unit / pure,普通值 → 包装类型)和扁平化(join / bind,嵌套包装 → 单层包装)
    • 核心:组合返回包装类型的函数
    • 解决”函数链中每个函数都返回包装类型,导致嵌套越来越深”的问题
  • Monad 比喻:可以把 monad 想成”盒子”
    • 但不是所有 monad 都能”打开”看里面(如 continuation monad)
    • 通用做法是:告诉盒子对里面的值做什么,而不是直接取出值
  • 常见 Monad 实例:
    • std::optional:处理可能为空的值
    • expected<T, E> / Try:错误处理
    • State Monad:处理状态传递
    • Continuation Monad / Future:异步并发
    • List Monad:非确定性计算
  • Monad 组合(Monad Composition):将多个 monad 堆叠使用(如 future<expected>)
  • Range Comprehension / Monad Comprehension:类似 Haskell 的 do-notation,用类似 for 循环的语法处理 monadic 值

第11章 模板元编程

核心观点:

  • 模板元编程(TMP):在编译时执行的程序,操作对象是类型而非值
  • C++ 模板是图灵完备的——这是偶然发现的(Erwin Unruh 用编译错误打印素数)
  • TMP 是纯函数式语言:所有变量不可变,没有任何形式的可变状态
  • 应用场景:
    • 根据类型特性选择不同的算法实现(编译时分发)
    • 类型操作和转换(type_traits)
    • 静态内省(检查类型是否有某个成员函数/嵌套类型)
    • 编译期分支(constexpr-if,C++17)
  • type_traits 头文件包含大量有用的元函数
  • std::invoke:统一调用所有可调用对象(包括不支持常规函数调用语法的,如成员函数指针)
  • std::apply:将元组展开为函数参数
  • DSL 构建:
    • 模板元编程可用于构建领域特定语言
    • 例子:用 DSL 定义数据记录更新的事务
    • Range 在某种意义上也是 DSL——用管道语法定义范围转换的 AST
  • DSL 写起来麻烦,但能显著简化主程序逻辑

第12章 并发系统的函数式设计

核心观点:

  • 软件最大的问题是复杂度管理,并发系统中复杂度问题更突出
  • 共享可变数据 + 互斥锁 = 扩展性问题 + 扼杀并发
  • 解决共享可变数据的两种思路:
    1. 完全没有可变数据(第5章纯函数)
    2. 有可变数据但从不共享(Actor 模型)
  • Actor 模型:
    • 将软件拆分为独立、隔离的组件(Actor)
    • Actor 之间通过消息通信,不共享状态
    • 人类通过交流达成伟大目标 → Actor 模型的灵感来源
    • 推荐阅读 David West 的 Object Thinking 提升 OOP 设计能力
  • 响应式流(Reactive Streams):
    • 将消息视为数据流
    • 类似 input range 的转换操作(map、filter、flatMap 等)
    • 但不支持排序等需要随机访问全部元素的操作
    • 可以发送特殊消息(如”流结束”),用于高效内存管理
  • Reactive Streams 作为 Monad:
    • 可以像处理普通集合一样处理异步数据流
    • Monad 可以很好地相互堆叠
  • 有状态 Actor:Actor 内部可以有可变状态,但不对外共享
  • 分布式系统:Actor 模型天然适合分布式系统(消息传递 = 网络通信)

第13章 测试与调试

核心观点:

  • 现代编程语言的大多数特性都是为了帮助避免常见编程错误,将错误检测从运行时移到编译时
    • 智能指针 → 内存安全
    • auto → 避免隐式转换
    • std::future → 正确的并发
    • std::optional / std::variant → 更强的类型安全
  • 火星气候轨道器事故:英制/公制单位不匹配导致的著名灾难——类型系统本可以在编译时捕获
  • 纯函数与单元测试:
    • 每个纯函数都是单元测试的理想对象
    • 你确切知道它用什么计算结果,也知道它不修改任何外部状态
    • 唯一的效果就是返回结果
  • 属性测试(Property-Based Testing):
    • 不是测试具体输入输出对,而是测试函数应满足的属性
    • 自动生成大量随机测试用例验证属性
    • Haskell 的 QuickCheck 是最著名的实现,启发了许多语言(包括 C++)的类似项目
    • 记住初始随机种子 → 可复现失败的测试
  • 对比测试(Comparative Testing):将被测实现与已有参考实现对比
  • Fuzzing:用随机的无效输入测试程序是否崩溃/异常
  • Monadic 系统测试:
    • 设计良好的 monadic 系统:将 continuation monad / reactive streams 替换为普通值 / 普通集合,系统仍应正常工作
    • → 可以在测试时开关并发和异步执行
    • → 单线程环境中测试所有逻辑,再开启并发进行集成测试

全书核心思想提炼

1. FP 的本质

声明式思维 > 指令式思维。说”我要什么”,而不是”怎么做”。

2. 纯函数是基石

纯函数 = 可推理 + 可测试 + 可缓存 + 可并行 + 可组合。能纯则纯。

3. 类型系统是盟友

用类型系统表达约束(ADT、可选值、错误类型),让编译器在编译时帮你找 bug。

4. 组合优于继承(也优于手写循环)

函数组合、Range 管道、Monad 组合——构建系统的方式是组合小的、正确的组件。

5. 惰性是强大的优化工具

只在需要时计算,计算后缓存。从记忆化到表达式模板到 Range,都是惰性思想的体现。

6. 不可变数据解决并发难题

要么数据不可变,要么数据不共享。两者选其一,并发就不再可怕。

7. FP 与 OOP 不是对立关系

C++ 多范式的优势在于可以在合适的地方使用合适的范式。对象组织数据和行为,函数式处理转换和数据流。


关键概念索引

概念章节一句话定义
高阶函数Ch2接受函数作为参数或返回函数的函数
折叠(Fold)Ch2用累积器遍历集合,产生单个结果的通用模式
偏应用Ch4固定部分参数,得到参数更少的新函数
柯里化Ch4多参数函数 → 单参数函数链
函数组合Ch4f(g(x)) = (f ∘ g)(x)
纯函数Ch5输出只依赖输入,无副作用
引用透明性Ch5函数调用可被其返回值替换
惰性求值Ch6按需计算 + 缓存结果
记忆化Ch6缓存纯函数的输入输出对
表达式模板Ch6编译期构建表达式树,延迟计算
RangeCh7迭代器对的抽象,支持链式惰性转换
持久化数据结构Ch8修改时数据共享,保留历史版本
位图向量前缀树Ch832路前缀树,近似O(1)的不可变vector
代数数据类型Ch9和类型 + 积类型,精确建模状态
Sum TypeCh9要么A要么B,消除非法状态
std::variantCh9C++17 标准库 sum type
expected<T,E>Ch9显式错误处理类型
模式匹配Ch9根据类型分支处理 sum type
FunctorCh10可被 map/transform 的包装类型
MonadCh10可组合返回包装类型函数的抽象
模板元编程Ch11编译期类型计算,纯函数式
Actor 模型Ch12隔离组件 + 消息传递 + 无共享状态
响应式流Ch12异步数据流的函数式处理
属性测试Ch13随机生成数据验证函数属性
QuickCheckCh13Haskell 开创的属性测试框架

实践建议

  1. 从简单的开始:先用 STL 算法替代手写循环,习惯声明式思维
  2. 多用 const:默认将变量和成员函数声明为 const,培养不可变思维
  3. 拥抱 lambda:用 lambda 替代手写函数对象,但性能关键处注意 std::function 开销
  4. 学习 Range:Range 是 C++ 函数式编程最实用的入口(C++20 ranges / range-v3)
  5. 用类型表达意图:optional 表示可能失败,variant 表示状态机,不要用 bool 标志位
  6. 纯度优先:尽量将业务逻辑写在纯函数中,把副作用隔离在系统边缘
  7. 并发从设计开始:用 Actor 或响应式流思考并发系统,而不是上来就加锁
  8. 测试纯函数:纯函数容易测试,属性测试比手写用例覆盖率更高

与其他书籍的关联

  • Modern C++ Design:Andrei Alexandrescu 的 Policy-Based Design,与本书的模板元编程和泛型编程思想一脉相承;expected 类型的普及也归功于他
  • C++ Concurrency in Action:本书第12章函数式并发设计的更深层实践可参考此书
  • The C++ Programming Language:Stroustrup 的圣经,C++ 标准库和语言特性的完整参考
  • A Tour of C++:C++ 全景导览,阅读本书前建议已熟悉现代 C++ 基础