两件商品单价 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
2
3
4
5
Order + discountRate + date
|
直接报价计算
|
Quote(orderId, currency, amount, date)

图中没有外部报价服务、缓存和数据库。把这些依赖提前藏进计算函数,会让第一章的等价比较失去确定输入;等相应需求出现后,再明确加入端口和运行边界。

直接实现作为基线

完整代码在第 00 章实验入口。Line 检查商品编码、单价和数量;Order 检查订单号与币种,并用 List.copyOf 保存行列表。记录类里的字段引用不能重新赋值,但这不等于其中任意对象都不可变。本例的 Line 元素使用 String、BigDecimal 和 int;第 02 篇会专门加入嵌套可变元素,检查浅层快照的限制。

计算先用最普通的控制流实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static Quote quote(Order order, BigDecimal discountRate, LocalDate date) {
Objects.requireNonNull(date);
if (discountRate.signum() < 0
|| discountRate.compareTo(BigDecimal.ONE) > 0) {
throw new IllegalArgumentException("discount outside [0,1]");
}
BigDecimal total = new BigDecimal("0.00");
for (Line line : order.lines()) {
total = total.add(line.unitPrice()
.multiply(BigDecimal.valueOf(line.quantity())));
}
BigDecimal payable = total
.multiply(BigDecimal.ONE.subtract(discountRate))
.setScale(2, RoundingMode.HALF_UP);
return new Quote(order.id(), order.currency(), payable, date);
}

局部变量 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
2
FP_LAB_JAVA_HOME=/path/to/jdk21 \
node examples/functional-programming/run.mjs 00

-ea 启用本章的 Java assert;没有这个参数,计算结果的断言可能不执行。非法输入检查使用显式 AssertionError:操作必须抛出指定异常;如果错误类型不同或根本没抛异常,实验进程失败。返回零退出码只支持本次实际断言,不证明所有输入上的正确性。

本次运行输出的主要结果如下,完整环境、命令、退出码和源文件哈希见第 00 章运行证据:

1
2
3
4
5
6
7
8
9
rejected: zero quantity -> IllegalArgumentException
rejected: negative price -> IllegalArgumentException
rejected: rounding required -> ArithmeticException
rejected: empty order -> IllegalArgumentException
rejected: other currency -> IllegalArgumentException
rejected: discount over one -> IllegalArgumentException
rejected: negative discount -> IllegalArgumentException
baseline=Quote[orderId=O-001, currency=CNY, amount=25.38, date=2026-10-03]
PASS: direct quote, repeated input, zero/full discount, lossless trailing-zero normalization, seven invalid boundaries

正例还检查零折扣得到 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 行为的说明;不据此推断历史引入动机。完整章节依赖与验收要求保存在系列写作计划。