2.1 副作用缉拿录:识别、标注与隔离


2.1 副作用缉拿录:识别、标注与隔离

本节摘要:副作用是函数式编程里的"管制对象"——不禁止,但要登记在册、圈定活动范围。本节给出六类副作用的完整识别清单,讲清函数式语言的两套管制手段(运行时约定与类型系统标注),并以参数注入为例,演示如何把散落各处的 IO 收拢成一层可测试、可替换的薄外壳。

先把"缉拿对象"的画像立起来

在命令式代码里,副作用像空气一样无处不在,以至于没人把它当回事。要做管制,第一步是画出通缉画像。副作用的操作性定义:一个函数执行时,若它的影响超出了"接收参数、返回值"这条窄巷,就带有副作用。影响分两个方向:向外施加影响(改状态、发 IO),向内依赖外部状态(读全局、读时钟——它让输出不再由输入唯一决定)。两个方向都算作案。

容易漏判的是第二个方向。很多工程师能记得"写文件是副作用",却意识不到"读一下配置"同样是——它让函数从纯函数变成了"对环境有隐式依赖的函数",测试与复放的难度因此完全不同。管制的目标不是消灭这两类行为(做不到也没必要),而是让它们显形、可查、可替换

一、六类副作用清单

把工程中最常见的副作用归成六类,逐一给出识别特征与典型嫌疑代码:

类别 识别特征 典型嫌疑 ──────────────────────────────────────────────────────────── 状态突变 修改全局/模块级/入参 self.items.sort()、counter += 1 输入型副作用 读时钟/随机源/环境变量/全局配置 time.now()、os.environ.get(...) IO 输出 写文件/数据库/网络/终端 print、db.execute、http.post 异常逃逸 抛出未被类型签名声明的异常 raise RuntimeError(...) 隐式路径 并发干扰 操作共享内存、竞争锁 直接读写共享 dict/数组 隐式时序 依赖调用顺序才正确 第一次调用初始化、后续调用依赖之

六类的危险度并不相同。状态突变与隐式时序是最危险的两类:它们不声不响地改变程序语义,且跨函数传播——一个函数改了状态,一百个函数的行跟着变。IO 输出反而最"诚实":它明晃晃地站在那里,谁都看得见,真正的麻烦只是它污染了纯度,让逻辑没法单独测试。输入型副作用居中:单独出现时只是破坏确定性,与缓存组合后(第一次读、后续用缓存)就升级成隐式时序。

用一段真实风格的业务代码做一次缉拿示范:

class OrderService: _seq = 0 # 嫌疑1:模块级可变状态 def create_order(self, cart, user): OrderService._seq += 1 # 作案:状态突变 now = datetime.now() # 作案:输入型副作用(读时钟) if cart.total > user.credit: raise ValueError("信用不足") # 作案:异常逃逸(未声明) order = {"id": OrderService._seq, "at": now, "total": cart.total} self.db.insert(order) # 作案:IO 输出 return order

短短七行,四类副作用全部到齐。这个函数无法单测(要 mock 时钟、mock 数据库、重置序号)、无法并发(序号会撞)、无法离线复放(时间戳不受控)。下面的治理方案会把它拆开。

二、管制手段一:约定与架构(所有语言可用)

第一套手段不依赖任何语言特性,纯靠架构纪律,任何语言明天就能用:把副作用从逻辑中剥离,收拢到系统边缘的一层。业界常称"函数式核心,命令式外壳"(functional core, imperative shell)。决策逻辑全部做成纯函数——给定购物车和用户,返回"订单或拒绝原因";IO 全部住在外壳——外壳负责取时间、生成序号、写库,然后把干净的输入递给核心,拿到输出后落盘。

把上面的类重构一遍:

from dataclasses import dataclass from typing import Union @dataclass(frozen=True) class Rejection: reason: str def decide_order(cart, user, seq: int, at) -> Union[dict, Rejection]: """纯函数核心:所有决策逻辑,零副作用。时间与序号都是入参。""" if cart.total > user.credit: return Rejection(reason="信用不足") return {"id": seq, "at": at, "total": cart.total} class OrderShell: """命令式外壳:只做三件事——取环境、调核心、落结果。""" def __init__(self, db, clock, seq_gen): self.db, self.clock, self.seq_gen = db, clock, seq_gen def create_order(self, cart, user): result = decide_order(cart, user, self.seq_gen.next(), self.clock.now()) if isinstance(result, Rejection): raise ValueError(result.reason) # 异常在外壳显式抛出 self.db.insert(result) # IO 在外壳集中发生 return result

注意三个收益。其一,decide_order 的测试退化成"构造输入、断言输出",不需要任何 mock 框架——时钟和序号都是普通参数。其二,业务规则(信用判断)与基础设施(存储、时钟)从此独立演化,改规则不碰 IO,换存储不动逻辑。其三,并发问题被压缩到外壳的 seq_gen 一个点上,管制范围从"整个类"缩小到"一个对象"。这套架构就是本章标题里"治理"二字的实体。

图:函数式核心与命令式外壳的分层管制

图:函数式核心与命令式外壳的分层管制

三、管制手段二:类型系统标注(Haskell 方案)

第二套手段更彻底:把副作用写进类型签名,让编译器当管制局。Haskell 的方案是 IO 类型:一个返回 IO String 的函数,意思是"这函数要执行外部操作,产出字符串"。纯函数与 IO 函数在签名上一眼可辨,而且类型系统强制隔离——IO 里的操作无法混进纯函数,除非通过第 4 章讲的受控通道:

-- 纯函数:签名干干净净,只有箭头 totalAfterDiscount :: Double -> Double -> Double totalAfterDiscount price rate = price * (1 - rate) -- IO 函数:签名里的 IO 就是"副作用许可证" loadRate :: IO Double loadRate = readFile "rate.conf" >>= return . read -- 读文件,类型如实申报 -- 编译器强制你的程序分层:main 是唯一被允许的 IO 大门 main :: IO () main = do rate <- loadRate -- 外圈:取环境 let total = totalAfterDiscount 199.0 rate -- 内圈:纯计算,let 绑定 print total -- 外圈:落结果

这个设计最妙的地方在于管制的强制性:约定与架构靠团队自觉,review 疏漏一天就全线失守;类型标注靠编译器执法,想越界直接编译失败。代价是表达成本上升——简单脚本也要在 do 块里绕。Python、TypeScript 等语言没有这套机制,但第 5 章会展示用类型别名与约定模拟"效果标注"的折中方案。

四、工程实践:一次真实的迁移

给出一个可以把本节内容直接落地的顺序。某支付团队的迁移分三步走了六周:第一周,只做"识别"——用六类清单扫一遍订单模块,把每处副作用登记成表格,不做任何修改,团队第一次看清了全貌(两百多处散落的直接 IO);第二、三周,做"剥离"——按"先测试覆盖厚的模块先拆"的顺序,把纯逻辑抽成无状态函数,外壳保留;第四周起,做"收拢"——时钟与序号生成器改为注入,数据库访问统一到仓储层。迁移完成后,核心逻辑的单元测试从"启动 Spring 上下文加内存库"的四秒一条,变成"纯内存调用"的毫秒百条,测试数量翻了三倍——因为测纯函数太便宜了,以前不敢写的边界用例全补上了。

⚠️ 常见坑:剥离时最容易犯"顺带重构"的贪心——本来只做副作用搬迁,忍不住改了业务逻辑,出 bug 后没法区分是搬迁引入还是重构引入。搬迁与重构必须分成两次提交,这是纪律。

本节要点回顾

  • 副作用的两个方向:向外施加影响、向内依赖外部状态,后者更容易漏判。
  • 六类清单:状态突变、输入型副作用、IO 输出、异常逃逸、并发干扰、隐式时序,危险度不同。
  • 第一套管制靠架构:函数式核心加命令式外壳,副作用被物理隔离到边缘薄层。
  • 第二套管制靠类型:Haskell 的 IO 类型把副作用写进签名,编译器强制分层。
  • 先识别后治理:全量登记副作用是迁移的第一步,勿在识别完成前动手重构。

副作用圈好了,本章下一节转向时间维度:表达式究竟什么时候化简,严格与惰性两套节拍器如何让同一段代码呈现两种性格。


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