Skip to content

副作用的七宗罪

——函数式编程的“驱魔仪式”

“每一个副作用,都是系统中的一根导火索。
你不知道它何时引爆,也不知道它会炸毁哪块代码。”

引言:为什么我们要给副作用“定罪”?

在函数式编程的语境中,“副作用”不是“错误”,而是可维护性的原罪

它不直接导致程序崩溃,却在暗中腐蚀系统的可测试性、可推理性、可组合性

我们将这七类常见副作用称为“七宗罪”,因为它们都违背了函数式编程的“第一戒律”:

“函数应仅通过返回值与外界交互。”

一、第一宗罪:修改全局变量(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 / HTMLrender
网络请求HttpRequestfetch
时间依赖NowgetCurrentTime
随机数RandomGen<A>runRandom
异常抛出Either<E, A>fold
日志打印Writer<Log, A>runWriter

把“做某事”变成“描述某事”,再在安全时机“解释”它。

结语:承认副作用,但不纵容它

函数式编程不是乌托邦。
我们不否认副作用的必要性,但我们坚持:

副作用必须被识别、隔离、命名、控制。

当你把七宗罪一一列出,你就在系统中建立了一道副作用防火墙

核心逻辑保持纯净,边缘处理现实世界的混沌。

这才是真正的工程智慧:

在理想的可推理性与现实的需求之间,划出一条清晰的界限。