30 变换式编程
把程序看成输入到输出的一系列数据变换,以组合、不可变中间值和显式错误通道降低状态负担。
学习目标
- 能解释一条数据路径如何把输入、变换、组合、中间值和错误连接起来
- 能修改一段处理代码,使每个纯函数只接收一个值并返回下一个值,不偷偷改共享状态
- 能回答:一个中间步骤失败时,首差在哪里、接收者如何看到错误、怎样从原始输入重放?
为什么只看最后结果不够
想象一张厨房订单单:先读出原料,再清洗、称量、组合,最后才端出成品。若每个厨师都把东西塞回同一个篮子,最后端出来的菜即使偶尔正确,也没人知道是哪一步改坏了材料。
本章解决的是“怎样把一件大工作拆成一串可检查的小变化”。没有这条路径,错误会被藏到最后一行,重试会重复改变状态,修复者只能凭猜测重写整个流程。
先把输入和输出写在纸上,再逐段观察中间结果。每一次改动只应回答一个问题:哪一个值变了、谁接收它、如果不能继续,失败从哪条路离开?
本章对象与验收合同
本页依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020 年 4 月,ISBN 9787121384356 的公开中文目录,独立重构 30 变换式编程、提示49:编程讲的是代码,而程序谈的是数据 与 提示50:不要囤积状态,传递下去。示例、代码、图示、实验和练习均为本课程重新设计,不复制原书正文、插图或练习答案。
验收对象是一行订单数据:raw = { sku: "A-17", quantity: 2, price: "19.90" }。基线输出必须包含标准化后的数量、金额和版本;边界输入要明确拒绝;单故障输入只让一个步骤返回失败。每次实验保存原始输入、当前节点、实际首差、拒绝理由和恢复动作。
先猜一猜:如果金额解析失败,应该让最后的订单状态看起来成功,还是沿错误通道返回一个可复核的原因?打开实验台,推进到组合节点,再注入绕过函数边界的故障。
第 1 / 5 步:输入 已留下证据。
常见误区与回退
五个节点:让值经过可复查的路径
30 变换式编程:把大步骤拆成小变化
<Term def="把程序看成一串从输入值到输出值的变换,并让每段变换可以单独检查和组合。">变换式编程</Term>不是把代码机械地拆成更多函数,而是让每个函数的输入、输出和拒绝条件都能说清楚。先画出订单数据要经过的节点,再决定哪里允许改变状态;没有证据的隐式改变就停在边界。
这条思路的位置在“原始输入”和“最终副作用”之间:解析、规范化、计算和组合先产出值,真正的写入放在已验证的边界。这样一个金额规则变化只需要重放受影响的变换,而不必猜测整个流程共享了什么。
提示49:编程讲的是代码,而程序谈的是数据
提示49的可操作版本是:先描述数据从哪里来、经过什么变换、交给谁,再选择语法和框架。<Term def="沿着处理步骤传递的输入、输出和失败信息;它让每个节点的责任可以被观察。">数据流</Term>把“代码看起来很整齐”换成可验证的问题:这一段收到什么,产出什么,下游是否接受?
对订单例子,数据流可以是 raw → normalized → subtotal → Result。如果 price 不能解析,就走 Error(reason),而不是把一个不明来源的零继续传给结算。读者应能用同一输入重放每一段,并在第一处差异停止。
提示50:不要囤积状态,传递下去
“传递下去”不是把一个越来越大的对象塞进所有函数,而是传递有名字、可验证的中间值。<Term def="不依赖隐藏外部状态、同样输入会产生同样输出的函数;它适合单独测试和组合。">纯函数</Term>让改变集中在返回值,调用者不必猜函数是否偷偷修改了别处。
如果某一步必须写数据库或发送通知,应把它放在数据流末端的明确边界,并让前面的纯函数先完成解析和业务判断。纯函数不能消除所有副作用,却能把副作用从推理路径中隔离出来。
中间值:让每段结果都能被看见
<Term def="一个变换完成后传给下一段的命名结果;它可以是成功值,也可以携带失败原因。">中间值</Term>是调试和恢复的停点。它不等于“临时变量越多越好”,而是让每段输出有稳定形状,例如 NormalizedOrder、Subtotal 或 Error。
保存中间值还有一个复核作用:当结果不对时,比较相邻节点就能定位首差;恢复时从原始输入重放到首差,而不是直接把最后的金额改成正确数字。每个中间值都应注明生产者和接收者,避免再次变成共享抽屉。
错误:为失败保留一条路
<Term def="专门传递失败状态、节点和原因的路径;它让下游明确知道为何不能继续。">错误通道</Term>与成功值一样是数据流的一部分。它可以返回“价格格式不合法”“数量超出库存”或“版本冲突”,但不能用一个静默的空值让下游自行猜测。
错误通道的位置决定了恢复动作:解析错误回到输入修正,业务拒绝回到调用者,外部写入失败进入重试或人工处理。只要拒绝理由、原始输入和节点仍在,系统就能重放;若错误被吞掉,最后的成功文本没有修复价值。
读图:从输入走到错误或输出
图中的五个节点处理同一行订单数据。上方的数据带显示每次变换产出的新值;下方的合同显示正常路径和故障路径的差异。重点不是节点数量,而是每个节点都有明确接收者,失败不跳过中间值。
逐步观察:每段变换都留下可复查的中间结果。
1. 保留输入,先建立可重放基线
输入:先保留原始值
把原始订单行保存为不可变输入,记录版本和来源。此时只做读取与格式检查,不更新库存或发送通知;验证点是同一输入可以再次进入第一节点。
代码对照:返回值比共享对象更容易复核
先把解析和规范化写成没有隐藏状态的变换。代码只展示一段可运行的逻辑;完整应用还要在外部边界处理持久化、重试和权限。
type RawOrder = { sku: string; quantity: number; price: string };
type NormalizedOrder = { sku: string; quantity: number; price: number };
type Result<T> =
| { ok: true; value: T }
| { ok: false; node: string; reason: string };
function normalize(input: RawOrder): Result<NormalizedOrder> {
const price = Number(input.price);
if (!Number.isFinite(price) || input.quantity <= 0)
return { ok: false, node: "normalize", reason: "invalid-order" };
return {
ok: true,
value: { sku: input.sku, quantity: input.quantity, price },
};
}normalize 不修改 input,成功和失败都有明确形状。测试可以单独确认数量边界与价格解析,而不需要先搭建结算服务;错误接收者也能知道首差发生在哪个节点。
再把变换组合起来。组合函数只把成功值交给下一段;失败沿原路返回,并保留已经知道的节点和原因。
type Subtotal = { sku: string; amount: number };
function subtotal(order: NormalizedOrder): Result<Subtotal> {
const amount = order.quantity * order.price;
return amount > 0
? { ok: true, value: { sku: order.sku, amount } }
: { ok: false, node: "subtotal", reason: "non-positive-total" };
}
function priceOrder(input: RawOrder): Result<Subtotal> {
const normalized = normalize(input);
if (!normalized.ok) return normalized;
return subtotal(normalized.value);
}priceOrder 仍然没有写库存、账本或通知。真正的副作用应读取 Result,只对 ok: true 的值提交,并在失败时记录原始输入和错误通道;这样重试不会把“未验证”的中间值带到外部世界。
正常、边界与单故障矩阵
| 样本 | 只改变的变量 | 预期 | 必存证据 |
|---|---|---|---|
| 正常 | 合法价格与正数量 | 变换逐段成功,最终只提交一次副作用 | 输入、五节点结果与写入次数 |
| 边界 | 价格不可解析或数量恰好为零 | 在规范化节点返回拒绝,不进入组合 | 节点、原因、原始输入 |
| 单故障 | 只注入组合阶段的共享状态写入 | 首差立刻可见,停止后续写入并从输入重放 | 首差、中间值、恢复动作 |
三类样本共享代码版本、订单身份和起始状态。若同时更换解析规则、存储和重试次数,就无法知道哪一个条件造成了差异;应恢复基线,再只注入一个故障。
可重放记录
transform_record:
unit: tpp20-topic-30-transforming-programming
object: order-line-A-17
input: { sku: A-17, quantity: 2, price: "19.90" }
expected_path: input -> transform -> compose -> value -> error-or-output
injected_change: shared-state-write-at-compose
first_difference: compose-boundary
rejected_effect: inventory-and-ledger-write
recovery: restore-original-input-and-replay-to-first-difference
proof: intermediate-values-and-side-effect-count独立复核者只需要这份记录、同一输入和验收命题,就能比较第一处不同。若重放结果不一致,先检查输入版本和中间值,再检查外部写入;不要用最终状态覆盖失败日志。
跨团队迁移:保留数据语义,替换实现
迁移到消息队列、批处理或 AI 辅助开发时,可以替换传输方式、函数语言和存储引擎,但不能删掉输入身份、节点责任、错误通道和恢复证据。自动化工具可以生成管道或测试,却不能替人决定一个副作用是否已经被验证。
实践上,先把每个节点的输入、输出和拒绝条件写成测试,再把纯函数组合嵌入框架。对于异步任务,保存中间值和输入版本;对于失败重试,验证同一输入不会重复写入;对于生成式补丁,要求工具同时给出正常、边界和一条可复现的失败样例。
本章回顾
- 变换把大工作拆成可单独检查的输入到输出。
- 数据流让每个节点的接收者和产出物可见。
- 纯函数把推理留在返回值,不隐藏共享状态。
- 中间值是定位首差和恢复重放的停点。
- 错误通道让失败成为可传递、可复核的结果。
可验证练习
练习
本组练习围绕 30 变换式编程、提示49:编程讲的是代码,而程序谈的是数据 与 提示50:不要囤积状态,传递下去 展开。每题都要写出输入、首差、接收者和恢复证据。
问题 1:改 Demo 代码。 修改 priceOrder,让数量为零时返回 normalize 的拒绝结果,并写一个断言证明 subtotal 没有被调用。
问题 2:定位首差。 组合函数偷偷修改共享对象,但最终金额仍然正确。你会把首差记在哪里?还要保存什么?
问题 3:设计错误通道。 价格无法解析时,调用者需要看到哪些字段,才能决定修正输入、拒绝订单或安全重试?
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 变换式编程
把一件大工作拆成许多段“收到一个值、交出下一个值”的处理,每段都能单独检查。
- 数据流
数据从输入经过多个节点到达输出的路线;每个节点都应写清接收者和产出物。
- 纯函数
同样输入会给出同样输出、不会偷偷改变外部东西的函数,所以容易单独测试。
- 中间值
某一段处理完成后留下的命名结果;它是找错和从头重放时的停点。
- 错误通道
专门传递失败节点和原因的路径,让下游知道不能继续以及该如何恢复。
来源与改写范围
- 作者与英文版页面:核对 20 周年版书名、版本与 Topic 30 目录坐标。
- 中文公开目录:核对“30 变换式编程”、提示49与提示50的中文目录坐标。
- 书目与版本记录:辅助核对译者、出版社、出版时间与 ISBN。
本页是基于公开目录的 independent rewrite;五节点模型、TypeScript 示例、专属数据流图、故障实验和练习均为本课程重新设计,不声称提供原书全文或原书答案。