30 变换式编程

把程序看成输入到输出的一系列数据变换,以组合、不可变中间值和显式错误通道降低状态负担。

学习目标

  • 能解释一条数据路径如何把输入、变换、组合、中间值和错误连接起来
  • 能修改一段处理代码,使每个纯函数只接收一个值并返回下一个值,不偷偷改共享状态
  • 能回答:一个中间步骤失败时,首差在哪里、接收者如何看到错误、怎样从原始输入重放?

为什么只看最后结果不够

想象一张厨房订单单:先读出原料,再清洗、称量、组合,最后才端出成品。若每个厨师都把东西塞回同一个篮子,最后端出来的菜即使偶尔正确,也没人知道是哪一步改坏了材料。

本章解决的是“怎样把一件大工作拆成一串可检查的小变化”。没有这条路径,错误会被藏到最后一行,重试会重复改变状态,修复者只能凭猜测重写整个流程。

先把输入和输出写在纸上,再逐段观察中间结果。每一次改动只应回答一个问题:哪一个值变了、谁接收它、如果不能继续,失败从哪条路离开?

本章对象与验收合同

本页依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020 年 4 月,ISBN 9787121384356 的公开中文目录,独立重构 30 变换式编程提示49:编程讲的是代码,而程序谈的是数据提示50:不要囤积状态,传递下去。示例、代码、图示、实验和练习均为本课程重新设计,不复制原书正文、插图或练习答案。

验收对象是一行订单数据:raw = { sku: "A-17", quantity: 2, price: "19.90" }。基线输出必须包含标准化后的数量、金额和版本;边界输入要明确拒绝;单故障输入只让一个步骤返回失败。每次实验保存原始输入、当前节点、实际首差、拒绝理由和恢复动作。

先猜一猜:如果金额解析失败,应该让最后的订单状态看起来成功,还是沿错误通道返回一个可复核的原因?打开实验台,推进到组合节点,再注入绕过函数边界的故障。

Topic 30 · 数据变换实验台
变换式编程:让数据经过一条可复查的路径每个节点接收一个值,产出下一个值;失败沿显式通道离开主链数据流raw: 订单行normalizedsubtotalResultError(reason)1输入保留原始数据订单行 + 版本只读入,不急着改状态2变换逐段清洗与计算normalize → price一个函数只做一件事3组合串起纯函数输入 → 输出组合不藏副作用4中间值保存每段结果Result / next input失败也有可读形状5错误显式错误通道reject + reason拒绝不能静默丢失验收合同:同一输入得到同一结果,错误也必须可被接收者看见记录每段输入、输出、拒绝理由和下游接收者,才能定位第一处变化先预测首差,再推进节点;不要把全部状态堆进最后一个变量当前观察点:输入 · 保留原始数据

第 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>是调试和恢复的停点。它不等于“临时变量越多越好”,而是让每段输出有稳定形状,例如 NormalizedOrderSubtotalError

保存中间值还有一个复核作用:当结果不对时,比较相邻节点就能定位首差;恢复时从原始输入重放到首差,而不是直接把最后的金额改成正确数字。每个中间值都应注明生产者和接收者,避免再次变成共享抽屉。

错误:为失败保留一条路

<Term def="专门传递失败状态、节点和原因的路径;它让下游明确知道为何不能继续。">错误通道</Term>与成功值一样是数据流的一部分。它可以返回“价格格式不合法”“数量超出库存”或“版本冲突”,但不能用一个静默的空值让下游自行猜测。

错误通道的位置决定了恢复动作:解析错误回到输入修正,业务拒绝回到调用者,外部写入失败进入重试或人工处理。只要拒绝理由、原始输入和节点仍在,系统就能重放;若错误被吞掉,最后的成功文本没有修复价值。

读图:从输入走到错误或输出

图中的五个节点处理同一行订单数据。上方的数据带显示每次变换产出的新值;下方的合同显示正常路径和故障路径的差异。重点不是节点数量,而是每个节点都有明确接收者,失败不跳过中间值。

变换式编程:让数据经过一条可复查的路径每个节点接收一个值,产出下一个值;失败沿显式通道离开主链数据流raw: 订单行normalizedsubtotalResultError(reason)1输入保留原始数据订单行 + 版本只读入,不急着改状态2变换逐段清洗与计算normalize → price一个函数只做一件事3组合串起纯函数输入 → 输出组合不藏副作用4中间值保存每段结果Result / next input失败也有可读形状5错误显式错误通道reject + reason拒绝不能静默丢失验收合同:同一输入得到同一结果,错误也必须可被接收者看见记录每段输入、输出、拒绝理由和下游接收者,才能定位第一处变化先预测首差,再推进节点;不要把全部状态堆进最后一个变量当前观察点:输入 · 保留原始数据

逐步观察:每段变换都留下可复查的中间结果。

把程序拆成值到值的变换,状态负担就能落在具体节点,而不是藏在一条长流程里。
分步1 / 3

1. 保留输入,先建立可重放基线

变换式编程:让数据经过一条可复查的路径每个节点接收一个值,产出下一个值;失败沿显式通道离开主链数据流raw: 订单行normalizedsubtotalResultError(reason)1输入保留原始数据订单行 + 版本只读入,不急着改状态2变换逐段清洗与计算normalize → price一个函数只做一件事3组合串起纯函数输入 → 输出组合不藏副作用4中间值保存每段结果Result / next input失败也有可读形状5错误显式错误通道reject + reason拒绝不能静默丢失验收合同:同一输入得到同一结果,错误也必须可被接收者看见记录每段输入、输出、拒绝理由和下游接收者,才能定位第一处变化先预测首差,再推进节点;不要把全部状态堆进最后一个变量当前观察点:输入 · 保留原始数据

输入:先保留原始值

把程序拆成值到值的变换,状态负担就能落在具体节点,而不是藏在一条长流程里。

把原始订单行保存为不可变输入,记录版本和来源。此时只做读取与格式检查,不更新库存或发送通知;验证点是同一输入可以再次进入第一节点。

代码对照:返回值比共享对象更容易复核

先把解析和规范化写成没有隐藏状态的变换。代码只展示一段可运行的逻辑;完整应用还要在外部边界处理持久化、重试和权限。

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:设计错误通道。 价格无法解析时,调用者需要看到哪些字段,才能决定修正输入、拒绝订单或安全重试?

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

变换式编程

把一件大工作拆成许多段“收到一个值、交出下一个值”的处理,每段都能单独检查。

数据流

数据从输入经过多个节点到达输出的路线;每个节点都应写清接收者和产出物。

纯函数

同样输入会给出同样输出、不会偷偷改变外部东西的函数,所以容易单独测试。

中间值

某一段处理完成后留下的命名结果;它是找错和从头重放时的停点。

错误通道

专门传递失败节点和原因的路径,让下游知道不能继续以及该如何恢复。

来源与改写范围

本页是基于公开目录的 independent rewrite;五节点模型、TypeScript 示例、专属数据流图、故障实验和练习均为本课程重新设计,不声称提供原书全文或原书答案。

前后导航

讨论

评论区加载中…