副作用的七宗罪
——函数式编程的“驱魔仪式”
“每一个副作用,都是系统中的一根导火索。
你不知道它何时引爆,也不知道它会炸毁哪块代码。”
引言:为什么我们要给副作用“定罪”?
在函数式编程的语境中,“副作用”不是“错误”,而是可维护性的原罪。
它不直接导致程序崩溃,却在暗中腐蚀系统的可测试性、可推理性、可组合性。
我们将这七类常见副作用称为“七宗罪”,因为它们都违背了函数式编程的“第一戒律”:
“函数应仅通过返回值与外界交互。”
一、第一宗罪:修改全局变量(The Sin of Mutation)
js
let currentUser = null;
function login(user) {
currentUser = user; // 玷污全局状态
}为何是罪?
- 隐式依赖:
login的行为依赖外部currentUser - 不可重入:并发调用会导致状态竞争
- 测试噩梦:每次测试必须重置
currentUser
救赎之道:
js
const login = (currentUser, user) => user; // 返回新状态
// 状态由外层管理状态应像河流,函数是渡船——船不改变河,只运送乘客。
二、第二宗罪:DOM 操作(The Sin of Impurity)
js
function renderUser(user) {
document.getElementById('name').textContent = user.name; // 直接操作 DOM
}为何是罪?
- 强耦合 UI:无法在 Node.js 中运行或测试
- 不可缓存:相同输入不能复用结果
- 破坏组合:
map(renderUser)会立即渲染,无法控制时机
救赎之道:
js
const userView = (user) =>
`<div class="user">${user.name}</div>`;
// 渲染推迟到最外层
const mount = (selector, html) => () => {
document.querySelector(selector).innerHTML = html;
};视图应是纯函数的输出,而非副作用的现场。
三、第三宗罪:网络请求(The Sin of Unpredictability)
js
function fetchUser(id) {
return fetch(`/api/users/${id}`).then(res => res.json());
}为何是罪?
- 非纯异步:相同输入可能返回不同结果(网络失败、数据变更)
- 不可缓存:除非显式处理缓存逻辑
- 测试需 mock:必须模拟
fetch才能测试
救赎之道:将请求“数据化”
js
const Http = {
Get: (url) => ({ type: 'HTTP_GET', url })
};
const fetchUser = (id) => Http.Get(`/api/users/${id}`);
// 外层解释器执行
const runHttp = (effect) => {
switch (effect.type) {
case 'HTTP_GET': return fetch(effect.url).then(res => res.json());
}
};不要发出请求,而是描述“要发出什么请求”。
四、第四宗罪:时间依赖(The Sin of Temporal Coupling)
js
function log(message) {
console.log(`${new Date()}: ${message}`); // 依赖当前时间
}为何是罪?
- 引用不透明:
log('hi')无法替换为值 - 不可重放:无法复现过去的行为
- 测试困难:必须 mock
Date
救赎之道:将时间作为输入
js
const logAt = (timestamp, message) =>
`${timestamp}: ${message}`;
// 外层提供时间
const effectfulLog = (msg) =>
logAt(new Date().toISOString(), msg);时间不应是秘密,而应是参数。
五、第五宗罪:随机数(The Sin of Chaos)
js
function randomColor() {
return `#${Math.random().toString(16).slice(-6)}`;
}为何是罪?
- 非确定性:相同调用返回不同值
- 不可测试:无法断言具体输出
- 不可重放:无法复现“那次特别的随机结果”
救赎之道:传递随机源
js
const randomColor = (random) =>
`#${random().toString(16).slice(-6)}`;
// 测试时传入固定序列
const deterministicRandom = () => 0.5;随机性应是可注入的依赖,而非隐藏的魔鬼。
六、第六宗罪:异常抛出(The Sin of Control Hijacking)
js
function divide(a, b) {
if (b === 0) throw new Error('Divide by zero');
return a / b;
}为何是罪?
- 非局部控制流:调用者必须用
try/catch处理,破坏组合 - 类型不安全:调用者不知道何时会抛出
- 中断管道:
pipe(validate, divide, format)可能在中途崩溃
救赎之道:用数据表达失败
js
const Either = {
Left: (err) => ({ _tag: 'Left', err }),
Right: (val) => ({ _tag: 'Right', val }),
of: (x) => Either.Right(x)
};
const divide = (a, b) =>
b === 0 ? Either.Left('Divide by zero') : Either.Right(a / b);
// 可组合的错误处理
pipe(
divide(10, 0),
map(x => x * 2),
fold(
err => `Error: ${err}`,
result => `Success: ${result}`
)
);错误不是异常,而是计算的一种可能结果。
七、第七宗罪:日志打印(The Sin of Observation)
js
function calculate(x, y) {
console.log('Calculating...', x, y); // 日志即副作用
return x + y;
}为何是罪?
- 违反单一职责:函数既计算又打印
- 污染纯逻辑:日志格式变更需修改业务代码
- 测试干扰:日志输出影响测试断言
救赎之道:分离关注点
js
const add = (a, b) => a + b;
const withLog = (label, f) => (...args) => {
console.log(label, args);
const result = f(...args);
console.log(`${label} result:`, result);
return result;
};
// 使用
const loggedAdd = withLog('ADD', add);日志是装饰,不是本质。
七宗罪的本质:破坏“引用透明性”
所有七宗罪的共同点是:
它们让表达式 f(x) 无法被其值替换。
log('hi')不能替换为undefined,因为日志消失了fetch('/api')不能替换为{user: 'Alice'},因为下次可能不同throw Error()不能替换为任何值,因为它中断了执行
引用透明性是可推理性的基石。
一旦失去它,你就必须“运行代码”才能理解它。
救赎之路:将副作用“显式化、数据化、可控化”
| 罪行 | 数据化表示 | 执行时机 |
|---|---|---|
| 全局变量修改 | State<S, A> | evalState |
| DOM 操作 | VNode / HTML | render |
| 网络请求 | HttpRequest | fetch |
| 时间依赖 | Now | getCurrentTime |
| 随机数 | RandomGen<A> | runRandom |
| 异常抛出 | Either<E, A> | fold |
| 日志打印 | Writer<Log, A> | runWriter |
把“做某事”变成“描述某事”,再在安全时机“解释”它。
结语:承认副作用,但不纵容它
函数式编程不是乌托邦。
我们不否认副作用的必要性,但我们坚持:
副作用必须被识别、隔离、命名、控制。
当你把七宗罪一一列出,你就在系统中建立了一道副作用防火墙。
核心逻辑保持纯净,边缘处理现实世界的混沌。
这才是真正的工程智慧:
在理想的可推理性与现实的需求之间,划出一条清晰的界限。