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章 并发系统的函数式设计
核心观点:
- 软件最大的问题是复杂度管理,并发系统中复杂度问题更突出
- 共享可变数据 + 互斥锁 = 扩展性问题 + 扼杀并发
- 解决共享可变数据的两种思路:
- 完全没有可变数据(第5章纯函数)
- 有可变数据但从不共享(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 | 多参数函数 → 单参数函数链 |
| 函数组合 | Ch4 | f(g(x)) = (f ∘ g)(x) |
| 纯函数 | Ch5 | 输出只依赖输入,无副作用 |
| 引用透明性 | Ch5 | 函数调用可被其返回值替换 |
| 惰性求值 | Ch6 | 按需计算 + 缓存结果 |
| 记忆化 | Ch6 | 缓存纯函数的输入输出对 |
| 表达式模板 | Ch6 | 编译期构建表达式树,延迟计算 |
| Range | Ch7 | 迭代器对的抽象,支持链式惰性转换 |
| 持久化数据结构 | Ch8 | 修改时数据共享,保留历史版本 |
| 位图向量前缀树 | Ch8 | 32路前缀树,近似O(1)的不可变vector |
| 代数数据类型 | Ch9 | 和类型 + 积类型,精确建模状态 |
| Sum Type | Ch9 | 要么A要么B,消除非法状态 |
| std::variant | Ch9 | C++17 标准库 sum type |
| expected<T,E> | Ch9 | 显式错误处理类型 |
| 模式匹配 | Ch9 | 根据类型分支处理 sum type |
| Functor | Ch10 | 可被 map/transform 的包装类型 |
| Monad | Ch10 | 可组合返回包装类型函数的抽象 |
| 模板元编程 | Ch11 | 编译期类型计算,纯函数式 |
| Actor 模型 | Ch12 | 隔离组件 + 消息传递 + 无共享状态 |
| 响应式流 | Ch12 | 异步数据流的函数式处理 |
| 属性测试 | Ch13 | 随机生成数据验证函数属性 |
| QuickCheck | Ch13 | Haskell 开创的属性测试框架 |
实践建议
- 从简单的开始:先用 STL 算法替代手写循环,习惯声明式思维
- 多用 const:默认将变量和成员函数声明为 const,培养不可变思维
- 拥抱 lambda:用 lambda 替代手写函数对象,但性能关键处注意 std::function 开销
- 学习 Range:Range 是 C++ 函数式编程最实用的入口(C++20 ranges / range-v3)
- 用类型表达意图:optional 表示可能失败,variant 表示状态机,不要用 bool 标志位
- 纯度优先:尽量将业务逻辑写在纯函数中,把副作用隔离在系统边缘
- 并发从设计开始:用 Actor 或响应式流思考并发系统,而不是上来就加锁
- 测试纯函数:纯函数容易测试,属性测试比手写用例覆盖率更高
与其他书籍的关联
- 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++ 基础