3.4 一次纯函数重构的全程复盘


3.4 一次纯函数重构的全程复盘

本节摘要:工具学了三节,本节投入实战:把一个混杂全局状态、隐式时序与 IO 的真实业务函数——优惠券核销——按"提纯、拆意图、组管道、收边界"四步完整重构。每一步给出改前改后代码、重构依据与验证方式,最后汇总一次真实的收益盘点。这不是理想化示例,第一版甚至比原版更长——重构的收益从第二步才开始出现。

背景与案发代码

场景是电商系统的优惠券核销:用户下单时使用一张券,系统校验有效期、门槛、状态,通过则记账并返回折后价。原始代码长这样(真实代码脱敏,逻辑保留原貌):

# 重构前:28 行,五种关注点搅在一起 _last_seq = 0 # 模块级序号 _used = set() # 模块级"已用券"缓存 def use_coupon(coupon, cart_total, user, now, db, audit_log): global _last_seq if coupon["id"] in _used: # 关注点1:查状态(读全局缓存) return None if coupon["expire"] < now: # 关注点2:校验有效期 return None if cart_total < coupon["threshold"]: # 关注点3:校验门槛 return None _used.add(coupon["id"]) # 关注点4:改全局状态 _last_seq += 1 discount = cart_total * coupon["rate"] if discount > coupon["cap"]: discount = coupon["cap"] # 关注点5:封顶计算(纯逻辑) db.execute("UPDATE coupons SET used=1 WHERE id=%s", (coupon["id"],)) audit_log.append({"seq": _last_seq, "coupon": coupon["id"]}) return cart_total - discount

这段代码的病灶与第 2.1 节的缉拿清单几乎逐条对应:读全局缓存(输入型副作用)、改全局状态(状态突变)、直接 IO、业务规则(封顶计算)埋在状态操作中间。它有个广为人知的症状:单元测试必须连数据库,且第一个用例永远过、第二个用例随机挂——因为 _used 集合在用例间残留。开始重构。

第一步:提纯——把计算与决策剥出来

操作:什么都不改架构,只把"纯计算"的部分搬进独立函数。判据用第 1 章的替换测试:能不能直接换成返回值。搬运结果:

def coupon_discount(coupon, cart_total): """纯函数:给定券与订单金额,算折扣额。可立即单测。""" discount = cart_total * coupon["rate"] return min(discount, coupon["cap"]) # 封顶逻辑,一行说清 def check_coupon(coupon, cart_total, now): """纯函数:给定券、金额、时刻,返回 None 或拒绝原因。""" if coupon["expire"] < now: return "已过期" if cart_total < coupon["threshold"]: return "未达门槛" return None

验证:这两个函数零依赖(不碰全局、不碰 db),测试就是"构造入参、断言输出",第二个用例随机挂的问题在这两个函数上已经不存在。此刻总行数没有减少,甚至略增——提纯阶段的收益是可测性,不是行数。向团队预告这一点能省掉"重构有什么用"的质疑。

第二步:拆意图——让每个函数只说一件事

操作:审视 use_coupon 剩余部分,发现它同时负责"防重复核销"与"记账"两件事。拆开:

def mark_used(seen, coupon_id): """纯函数:返回"标记之后的集合",不改入参(不可变风格)。""" return seen | {coupon_id} def build_record(seq, coupon, cart_total, discount): """纯函数:构造审计记录,不落库。""" return {"seq": seq, "coupon": coupon["id"], "charged": cart_total - discount}

注意 mark_used 的签名设计:它接收集合并返回新集合,而不是原地 add——第 1 章不可变公理的直接应用。这一步完成后,原函数里已经没有任何"业务规则",剩下的只有状态与 IO 的搬运工。

第三步:组管道——用纯函数拼出业务主流程

操作:把前两步的纯函数串成决策管道。用 Python 的薄封装表达(返回"结果对象"而非 None,为第四步的边界处理留接口):

from dataclasses import dataclass @dataclass(frozen=True) class Redeemed: record: dict charged: float @dataclass(frozen=True) class Refused: reason: str def redeem(coupon, cart_total, seen, seq, now): """纯管道:核销决策全流程。IO 零参与,重复执行结果一致。""" if coupon["id"] in seen: return Refused(reason="已核销") reason = check_coupon(coupon, cart_total, now) if reason: return Refused(reason=reason) discount = coupon_discount(coupon, cart_total) record = build_record(seq, coupon, cart_total, discount) return Redeemed(record=record, charged=cart_total - discount)

redeem 是全新的业务主流程:参数齐全、返回值穷尽两种结局、重复调用结果一致。原来的五个全局依赖只剩 seenseq 两个参数——它们从"藏在世界里的状态"变成"调用方显式携带的状态"。并发正确性论证也随之简化:两个请求同时核销同一张券,谁先提交 mark_used 的结果谁赢,冲突检测点收拢到提交处一处。

第四步:收边界——IO 收进外壳

操作:外壳负责真实世界的部分——取时钟、取序号、查缓存、落库。它薄到没有逻辑,只有搬运:

class CouponShell: def __init__(self, db, audit_log, clock, seq_gen): self.db, self.audit_log = db, audit_log self.clock, self.seq_gen = clock, seq_gen self._seen = set() # 状态住在外壳,生命周期明确 def use_coupon(self, coupon, cart_total, user): result = redeem(coupon, cart_total, self._seen, self.seq_gen.next(), self.clock.now()) match result: case Refused(reason): return None case Redeemed(record, charged): self._seen = mark_used(self._seen, coupon["id"]) self.db.execute("UPDATE coupons SET used=1 WHERE id=%s", (coupon["id"],)) self.audit_log.append(record) return charged

图:重构前后模块结构与副作用分布对比

图:重构前后模块结构与副作用分布对比

结果与收益盘点

功能验证:外壳对外接口不变(use_coupon 签名原样),调用方零改动,灰度两周无回归。测试收益的实测数字:核心逻辑新增测试三十七条,全部纯内存执行,单条耗时从秒级降到毫秒级;此前"连库才能测"的借口消失后,边界用例(恰好等于门槛金额、券面额超过订单额、过期临界时刻)第一次被系统性补齐。可维护性收益:两周后需求变更"券叠加会员日翻倍封顶",改动只发生在 coupon_discount 一个纯函数与一个新积木函数,评审十分钟通过——对照第 3.2 节的判断标准,"加规则=加函数"的成熟度特征出现了。

诚实的成本盘点也要记录:重构总投入约三个工作日(含回归测试),代码总量增加约两成(外壳与结果类型的样板);团队里两位成员在结果对象的模式匹配上需要适应期。这类重构的性价比取决于核心逻辑的变更频率——月月改规则的核销模块回报极高,三年不动一行的工具脚本则不值得。

解读与变式

复盘四步法的适用边界:第一步提纯与第四步收边界几乎总能做(收益独立成立);第二步拆意图与第三步组管道在"状态协作复杂"的模块(第 1.3 节第三梯队)里要收敛——拆到某个函数开始需要传五六个上下文参数时,说明它本质是状态机,硬拆是负资产。变式练习一:给 redeem 增加"券与商品类目互斥"规则,验证是否真的只动一个函数。变式练习二:把外壳换成 Haskell 版本,体验第 4 章的 IO 类型如何把"外壳"变成类型签名里的显式标记——那是下一章的开场。

💡 关键直觉:重构的方向不是"消灭副作用",而是把副作用从逻辑里挤出去,挤到外壳的两个明确点位。判断完成的标准:核心逻辑可以脱离任何环境重复执行一万次,结果恒一致。

本节要点回顾

  • 四步顺序不可颠倒:提纯、拆意图、组管道、收边界;第一步只买可测性,行数收益从第三步才开始。
  • 状态变参数seenseq 从全局变量改为显式入参,是纯化的核心动作,也是并发点收拢的前提。
  • 结果对象替代 None:Refused 与 Redeemed 两种结局穷尽返回,调用方处理完整,类型即文档。
  • 性价比公式:核心逻辑变更频率越高,重构回报越大;静态模块不值得动。
  • 完成判据:核心逻辑脱离环境重复执行结果恒一致,副作用收敛到外壳明确点位。

工具与实战都齐了,下一章上抽象阶梯:当值可能缺失、可能出错、可能带着副作用时,map 与折叠如何以函子与 Monad 的形态回归——那是函数式类型系统的正厅。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U