本节摘要:函数式编程的全部纪律可以压缩成三条公理——纯函数、不可变数据、引用透明。本节把这三条从口号还原为可操作的判别标准:每条公理给出一个"看起来合规实则违规"的反例,说明它封堵的具体 bug 类型,并把它兑换成测试、并发、缓存三类工程红利。读完本节,你应当能用这把尺子量出任意一段代码的"纯度成色"。
先看一个几乎人人都写过的函数,它自认为很纯:
discount = 0.9 # 模块级配置,运营同学随时会改 def final_price(price): return round(price * discount, 2) # 看似只依赖入参 # 上午调用:final_price(100) -> 90.0 # 下午运营改了配置后调用:final_price(100) -> 85.0
final_price 没有改任何东西、没有打印、没有请求网络,但它的返回值取决于一个它看不见的外部变量。同样的输入,不同的时刻,不同的答案——测试没法写死断言,日志没法离线复放,出问题时你连"当时折扣是多少"都查不到。本节的三条公理,就是把这个隐形的坑一类一类填掉。
操作化定义:一个函数是纯的,当且仅当满足两条——同样的输入永远得到同样的输出(确定性);执行过程中不产生任何可观察的外部影响(无副作用)。可观察的外部影响包括:修改全局变量或入参、写文件、发网络请求、读系统时钟、抛出未被捕获的异常路径、打印日志。
判别时最常出错的是隐式依赖。下面三个"伪装者"都不纯,坑各不相同:
import time # 伪装者一:依赖系统时钟,确定性破产 def session_id(user): return f"{user}-{int(time.time())}" # 同一输入,不同输出 # 伪装者二:修改了入参列表,调用方毫不知情 def normalize(items): items.sort() # 原地排序,副作用! return items # 伪装者三:首次调用与后续调用行为不同(缓存了状态) _cache = {} def lookup(key, loader): if key not in _cache: _cache[key] = loader(key) # 改了模块级状态 return _cache[key]
纯函数的判别技巧是问一个问题:"能不能把函数调用直接替换成它昨天的返回值,而不让程序出任何差别?"能,就是纯的;不能,就藏着你还没识别的依赖。这条直觉的学名就是第三条公理。
不可变性说的是数据形态:一个值一旦构造完成,任何代码都无权修改它。"变化"在函数式世界里被重新定义——不是涂改旧对象,而是基于旧对象构造新对象。持久化数据结构(persistent data structure)让这件事在工程上可行:新旧版本共享未变动的部分,只有分叉的节点被复制,于是"每次都全量拷贝"的性能恐惧并不成立。
用 Python 的元组与命名元组演示"替换式更新",并对比修改式写法的风险:
from collections import namedtuple Account = namedtuple("Account", ["owner", "balance"]) def deposit(account, amount): # 不改 account,而是返回一个新值 return account._replace(balance=account.balance + amount) a1 = Account("alice", 100) a2 = deposit(a1, 50) print(a1) # Account(owner='alice', balance=100) 旧值完好 print(a2) # Account(owner='alice', balance=150) 新值独立 # 反例:用 dict 就会踩到共享可变状态 b1 = {"owner": "bob", "balance": 100} b2 = b1 b2["balance"] = 0 print(b1["balance"]) # 0 —— b1 被 b2 的修改连坐了
b1 无辜躺枪的原因是别名(aliasing):两个名字指向同一块内存。不可变数据从语言层面消灭了这类事故——值不会变,别名就无害。这一点在第 6 章会成为并发的立身之本:数据不可能被修改,锁的理论必要性就坍塌了一半。
JavaScript 里同理,Object.freeze 是浅层的,嵌套对象仍可变;工程上普遍借助不可变数据结构库或语言内建记录类型。下面的对比值得亲手跑一遍:
const cfg1 = Object.freeze({ retry: 3, meta: { depth: 1 } }); // cfg1.retry = 5; // 静默失败(非严格模式)或报错(严格模式) // cfg1.meta.depth = 9; // 却成功了——freeze 只冻第一层 const cfg2 = Object.freeze({ ...cfg1, meta: { ...cfg1.meta, depth: 2 } }); console.log(cfg1.meta.depth); // 1,原值完好 console.log(cfg2.meta.depth); // 2,新值独立
⚠️ 常见坑:浅冻结与浅拷贝在嵌套结构上都会留后门。判断标准只有一条——递归检查每一层是否都是不可变类型,缺一层就等于不设防。
引用透明是三条公理中最抽象、也最有杠杆的一条:一个表达式在任何上下文中,都可以被替换为它的求值结果,而不改变程序行为。纯函数与不可变数据是引用透明的因,引用透明是它们的果,也是编译器一切优化的许可证。
它为什么值钱?看一个编译器视角的例子:
-- 纯函数:平方 square :: Int -> Int square x = x * x -- 调用方写了一串重复计算 area = square (width + 1) + square (width + 1)
因为 square (width + 1) 引用透明,编译器可以放心地把它只求值一次、复用结果(公共子表达式消除),甚至重排求值顺序、跨线程并行求值——都不需要征得任何人同意,因为行为保证不变。反过来,如果 square 内部会写日志、读时钟,这些优化全部违法。纯度不是道德姿态,是把优化权和安全并行权交给机器的合同条款。

公理要变成习惯,最好物化成评审清单。给出我们团队在用的六问:一问函数是否读取了模块级变量、环境变量或时钟;二问是否修改了入参(集合原地操作是最常见漏网鱼);三问返回的可变对象是否可能被调用方改坏内部状态;四问同一输入在不同时刻调用,日志轨迹是否一致;五问缓存有没有引入"第一次与后续不一样"的行为;六问随机数来源是否受控(带种子的可复现随机是纯的,读系统熵池的不是)。
💡 关键直觉:三条公理不是三个独立开关,而是一条因果链——不可变数据让"同样的输入"成为可能,纯函数让"同样的输出"成为必然,引用透明让这一切对编译器和并行运行时可见。评审时抓最上游的不可变性,后两条往往自动成立。
三条公理叠加后有一个不那么显眼的红利:生产问题可以离线复现。纯函数的行为完全由输入决定,那么只要把生产流量的输入快照存下来,任何 bug 都能在开发机上精确重放。命令式系统要做到这一点,得录制数据库状态、时钟、随机源、线程交错顺序——成本高到通常放弃。第 7 章的排错法会把这套"时间旅行调试"展开成具体操作。
公理立完了,下一节回答一个更现实的问题:哪些场景该把这套纪律全量引入,哪些场景它是负资产——边界不清的范式推广,是团队内耗的第一来源。