函数式编程 00:导读与能力自测
两件商品单价 12.50 元,另一件单价 3.20 元,九折后的应付金额是 25.38 元。计算这个数只需要一个循环。程序扩大后,报价还会受日期、规则版本、外部查询和订单状态影响;字段可能非法,文件可能读取失败,任务可能取消。函数式编程的学习路线从这些具体差异出发:计算怎样保持可预测,输入和状态怎样进入类型,计算之间怎样组合,实际执行由谁管理。
认识纯函数、一等函数、部分应用、柯里化和 memoize,已经掌握了函数式编程的重要基础。这些知识主要解释函数怎样作为值使用,以及调用怎样组织。后续还需要不可变数据与别名、求值策略、代数数据类型、错误模型、组合定律和效果生命周期。Monad 位于组合这一段,不能用“还差 Monad 一个术语”衡量整条路线的完成度。
系列共有 40 篇主线和 8 篇选修。主线前半用 Java 21 建立可运行的例子,第 14 篇补足后续需要的 Scala 3 语法,再研究类型构造器与效果组合。选修讨论 λ 演算、参数性、Optics、程序解释和语言生态,按需求进入,不是理解 Optional 的前置门槛。
先定义能观察的行为
教学案例只处理本地合成订单,不连接生产服务。初版限定 CNY;商品价格须能无损规范化为两位小数,数量为正整数,折扣率在闭区间 [0,1] 内。逐行金额相加后,应用折扣,再以 HALF_UP 舍入到分。日期从调用者传入。金额如何计量、折扣在哪一步生效、什么时候舍入,都是程序契约,不能由“用了 BigDecimal”自动决定。
这些限制让两个实现可以接受相同输入,并比较相同结果。以后把循环换成 fold、把异常换成结构化错误、把模拟查询放入 IO 时,都需要回答原有行为是否保留。有的重构只改变内部结构;有的需求明确改变错误处理或执行策略。先写出差异,才能判断测试失败表示实现错误,还是契约已经变化。
订单报价的结果包含订单号、币种、金额和日期。相同订单、折扣和日期产生相同 Quote,并且原订单不变。这个观察范围暂不包含运行耗时、分配量或 JVM 调度,也没有声称程序能处理任意大输入而永不失败。后续谈引用透明性与性能时,会分别说明这些观察边界。
1 | |
图中没有外部报价服务、缓存和数据库。把这些依赖提前藏进计算函数,会让第一章的等价比较失去确定输入;等相应需求出现后,再明确加入端口和运行边界。
直接实现作为基线
完整代码在第 00 章实验入口。Line 检查商品编码、单价和数量;Order 检查订单号与币种,并用 List.copyOf 保存行列表。记录类里的字段引用不能重新赋值,但这不等于其中任意对象都不可变。本例的 Line 元素使用 String、BigDecimal 和 int;第 02 篇会专门加入嵌套可变元素,检查浅层快照的限制。
计算先用最普通的控制流实现:
1 | |
局部变量 total 在循环中重新赋值。这种内部实现不需要向调用者暴露共享可变状态。判断计算是否纯,不能只搜索源码有没有赋值符号;还要检查函数是否修改外部可见对象、依赖调用顺序、读隐藏环境或执行外部动作。第 01 篇会把时钟和随机数作为对照输入,第 03 篇会检查闭包捕获共享状态的情况。
这段函数对非法折扣抛异常,因此没有宣称它是 Java 类型所包含全部值上的总函数。null、非法数量和超过约定的小数位都需要在边界处理。后续的 Result 模型会把预期输入错误变成明确返回值,程序缺陷仍与业务错误分开。把异常包装起来,也不会自动解决终止性或外部服务失败。
样本的计算过程为:12.50 × 2 + 3.20 × 1 = 28.20;再乘 0.90,得到 25.3800,最终保留两位为 25.38。商品单价从十进制字符串构造,不先经过 double。本系列的样本没有汇率和税款分摊;增加这类要求时需要扩展货币契约,不能直接复用当前舍入规则。
运行与反例
实验调度器读取明确的 JDK 21,在系统临时目录复制源码,执行 javac --release 21 和 java -ea。本机默认 Java 8 不会被改变。其他机器设置已有 JDK 路径即可运行:
1 | |
-ea 启用本章的 Java assert;没有这个参数,计算结果的断言可能不执行。非法输入检查使用显式 AssertionError:操作必须抛出指定异常;如果错误类型不同或根本没抛异常,实验进程失败。返回零退出码只支持本次实际断言,不证明所有输入上的正确性。
本次运行输出的主要结果如下,完整环境、命令、退出码和源文件哈希见第 00 章运行证据:
1 | |
正例还检查零折扣得到 28.20、全额折扣得到 0.00,以及重复计算的 Quote 相等。1.001 需要舍入才能保留两位,因 UNNECESSARY 被拒绝;1.230 仅去掉额外尾零,实验确认它能无损规范化为 1.23。这条规则并非检查原始 scale 是否大于 2。最终折扣结果则明确允许 HALF_UP。输入规范和计算输出采取不同处理,是这个教学契约的选择;第 39 篇另有更严格的文件文本格式校验。
源码、确定输入和被正文引用的证据可以提交;class、依赖缓存、临时草稿和未引用日志留在本地。调度器源码见公共实验 runner,工具位置与依赖组合见版本记录。重新运行时需要检查源哈希,不能拿旧记录替已经改过的源码作保证。
复现与检查分三类入口:批量实验脚本执行主线、选修与旧 Optional 回归;引用及哈希检查核对当前源码与证据,并拒绝未被正文引用的实验文件;隔离全站构建在系统临时目录执行完整 Hexo 构建,再检查生成页面。E07 需要将 GHC 的 bin 目录加入单次命令的 PATH,具体版本见版本记录。第 35 篇的计时会随重跑变化,应以同一次运行的证据解释数字。
五组能力诊断
诊断采用推导和修改任务,不统计认识了多少名词。每组记录“能独立完成”“需查资料”“尚未完成”;没有实际作答,不能把文章已经存在记成学习通过。
| 能力 | 诊断任务 | 对应主线 |
|---|---|---|
| 纯计算与数据 | 将当前日期改成显式输入;检查复制列表后修改原元素是否影响结果 | 01–02、08–09 |
| 函数与求值 | 写出部分应用、柯里化和偏函数的类型;预测 Supplier 与缓存的调用次数 | 03–07 |
| 类型与错误 | 表达缺席、业务拒绝和合法订单状态;说明哪个约束仍需运行时校验 | 10–13、18 |
| 组合 | 实现 Maybe 的 map/flatMap;判断两项校验独立还是依赖前一步结果 | 14–25 |
| 执行管理 | 设计成功、失败、取消时的资源释放断言;重放一个性质测试失败样本 | 26–39 |
第一组不仅要求写出一个“相同输入相同输出”的函数,还要解释输入是否稳定。调用者传入可变列表,函数保存其引用,下一次调用前列表发生变化;看似相同的对象地址不能表示相同数据。只读视图禁止某条修改路径,却可能仍观察到其他别名的修改。掌握不可变数据后,纯函数的输入范围才更容易说清。
第二组可以从三条签名自测:(A,B) -> C 固定 A 后得到 B -> C;柯里化得到 A -> (B -> C);偏函数只在声明域的一部分有定义。前三者分别涉及参数固定、参数组织和输入定义域,不能因为译名接近而混用。延迟一个函数的执行与记住其结果也不同;一个 Supplier 被调用两次,未必只计算一次。
第三组要求说明数据能够表达什么状态。几个 boolean 可以组成业务不允许的组合,和类型能收缩这类表示空间,但无法仅凭 String 判定订单号真实存在。缺席只有“无结果”,错误还包含拒绝原因;把所有错误都改成 empty 会丢失诊断信息。
第四组先写具体 Maybe,再讨论对类型构造器的统一抽象。函数返回普通 B 时,map 就能保留原有上下文;函数返回 Maybe 时,仅用 map 会形成嵌套。flatMap 负责接续这样的函数。Monad 的定律约束这些操作怎样分组,并不允许交换业务步骤;第 21、22 篇分别用环境函数和状态函数解释组合,避免停在物理容器的比喻。
第五组把计算描述与运行能力分开。会写 flatMap 不能证明任务可以取消、深链不会栈溢出或文件总会关闭。实验会使用可控信号记录 acquire/use/release 和任务事件;“打印了关闭日志”仍需要结合计数、退出和失败分支判断,不能替代完整生命周期检查。
Monad 的学习位置
首次阅读按编号推进。已经熟悉函数技巧,可以先完成 00、01–02、10–14 的诊断,再集中阅读 16–20。Semigroup/Monoid 为独立错误累积和 Writer 提供基础;Applicative 研究无需根据前一个结果决定后续计算的组合;Monad 研究依赖结果的接续。它们提供可推导的接口与定律,具体实例仍决定缺席传播、候选顺序和错误处理。
理解 Monad 后继续 Reader、State、Writer、Traverse 和 Transformer,最后进入 IO、资源、并发和流。Reader 展开为环境到结果的函数,State 展开为初态到结果与终态的函数;它们能在没有 IO 的情况下组合。进入效果运行时之后,才增加调度、取消和资源作用域。这个顺序让“组合如何成立”与“程序如何执行”各有可验证的问题。
Java Optional 可以作为 Maybe 风格的具体 API 对照,但 API 相似不能证明 Java 是为了引入 Monad 才增加它。其文档主要定位于可能没有结果的方法返回值。讨论定律时还必须处理 null:map 的 null 结果会转成 empty,flatMap 的 null 结果会被拒绝。第 16 篇给出 map 组合律的具体反例;这个反例不能直接冒充 flatMap 结合律的证明。
完整章节目录
主线按编号推进;选修按问题进入。每篇都链接本章源码、运行证据和本导读,读完后的验收仍以实际完成自测与修改题为准。
纯计算、函数与数据(00–09)
| 编号 | 章节 |
|---|---|
| 00 | 导读与能力自测 |
| 01 | 纯函数引用透明性与总函数 |
| 02 | 不可变数据与别名 |
| 03 | 一等函数高阶函数与闭包 |
| 04 | 部分应用柯里化与偏函数 |
| 05 | 函数组合与管道 |
| 06 | 递归fold-map与filter |
| 07 | 严格求值惰性与按需共享 |
| 08 | memoize与缓存的可观察行为 |
| 09 | 持久化数据结构与结构共享 |
类型、错误与组合抽象(10–18)
| 编号 | 章节 |
|---|---|
| 10 | 和类型与积类型 |
| 11 | 模式匹配与结构折叠 |
| 12 | Maybe与Optional的缺席语义 |
| 13 | Result与异常的失败边界 |
| 14 | Scala桥接与类型类 |
| 15 | Semigroup与Monoid |
| 16 | Functor与映射定律 |
| 17 | Applicative与独立输入组合 |
| 18 | 错误累积与依赖校验 |
Monad 与上下文(19–25)
| 编号 | 章节 |
|---|---|
| 19 | map与join与flatMap |
| 20 | Monad定律与Kleisli组合 |
| 21 | List与Reader的组合 |
| 22 | State与显式状态传递 |
| 23 | Writer与可组合日志 |
| 24 | Traverse与批量校验 |
| 25 | EitherT与OptionT的逐层组合 |
效果、资源与任务(26–30)
| 编号 | 章节 |
|---|---|
| 26 | IO描述与执行边界 |
| 27 | bracket与Resource生命周期 |
| 28 | 异步与有界并行组合 |
| 29 | 取消超时与任务归属 |
| 30 | 效果流背压与资源作用域 |
工程实践与结课(31–39)
| 编号 | 章节 |
|---|---|
| 31 | 性质生成器与缩减反例 |
| 32 | 纯核心与外部端口 |
| 33 | 状态不变量与对象封装 |
| 34 | Java组合与生命周期边界 |
| 35 | 时间分配与保留空间 |
| 36 | 契约回归与渐进重构 |
| 37 | Trampoline与Eval栈安全 |
| 38 | 同一程序与两个解释器 |
| 39 | 本地订单导入综合工程 |
独立选修(E01–E08)
| 编号 | 章节 |
|---|---|
| E01 | λ演算与求值策略 |
| E02 | 参数性与free-theorem |
| E03 | Lens与Prism |
| E04 | Free与Tagless-Final |
| E05 | CPS与Continuation |
| E06 | Contravariant与Profunctor与Comonad |
| E07 | Haskell与JavaScript对照 |
| E08 | ZIO与FRP时间模型 |
已有文章的承接
已有Scala 系列导读按语言机制组织内容;本系列按函数式设计问题组织内容。需要 Scala 语法细节时回到原章节,新实验则保留自己的运行记录。历史状态表中的通过标记属于当时的源码、版本和输入,不代表本系列已经复跑。
函数缓存 memoize与Optional 的正确用法保留原地址。相关反例已实际运行,旧文已定点勘误:memoize 保留错误代码作为反例并补充有界实现;Optional 修正调用和 null 边界,使用建议补上适用范围。λ 演算与领域建模文章继续保留各自系列归属,供选修和综合设计交叉阅读,不通过删除旧入口来制造一个新的目录。
交付状态见单一状态表。正文导航只链接已经存在的章节;实验通过、页面通过和已提交分别记录。主线计划列出的 40 篇与 8 篇选修是目标覆盖范围,不能用若干术语或若干已有文章替代逐章验收。
自测与修改练习
手算:将折扣率改成 0.15,保持相同订单与舍入规则,应付金额是多少?计算式是 28.20 × 0.85 = 23.9700,结果为 23.97。价格 1.005 是否作为合法单价进入计算?它无法无损规范化为两位小数,因此被拒绝;不能因为输出允许舍入就放宽输入规则。
修改:在 Main.java 增加第三行商品,单价 0.05、数量 1,并用断言检查九折结果。先手算得到 28.25 × 0.90 = 25.425,再根据 HALF_UP 写出 25.43;重新运行后检查退出码和源哈希变化。额外增加折扣率恰好为 1 的断言,避免将合法边界误写成非法输入。
边界题:如果报价方法内部调用 LocalDate.now(),订单和折扣两个参数相同就足以保证 Quote 相等吗?日期成为隐藏输入,跨日的两次结果可能不同。保留显式日期输入,测试就能固定这个维度;第 01 篇继续比较显式随机数、异常和共享计数。
参考资料
JDK 21 BigDecimal API支持十进制、舍入和数值比较的 API 规则;本章的币种、输入精度与舍入时点由教学契约另行定义。JDK 21 Optional API支持返回值定位和 null 行为的说明;不据此推断历史引入动机。完整章节依赖与验收要求保存在系列写作计划。


