纯函数与副作用:测试友好且可预测的代码

FreeGuideOnline 最新 2026-06-18

什么是纯函数?

纯函数是函数式编程的核心概念。一个函数如果满足以下两个条件,它就是纯的:

  1. 相同的输入永远得到相同的输出。无论调用多少次,无论何时调用,只要参数一样,返回值就一样。
  2. 没有任何可观察的副作用。调用函数不会修改外部状态,也不会依赖外部可变状态,它仅仅通过参数和返回值与外界沟通。

纯函数的三个关键特征

  • 确定性:函数的结果完全由其输入值决定。
    // ✅ 纯函数
    function add(a, b) {
      return a + b;
    }
    add(2, 3); // 总是返回 5
    
  • 引用透明:一个表达式可以被它的计算结果替换,而不改变程序的行为。这意味着我们可以安全地缓存纯函数的结果。
    // 因为 add(2, 3) 永远等于 5,所以在任何地方都可以用 5 替换它
    const total = add(2, 3) === 5; // true
    
  • 无副作用:函数内部不读取或修改外部变量、不操作 DOM、不发送网络请求、不修改入参、不打印日志、不抛出异常(异常也是一种副作用,但可以通过返回值处理)。

纯函数的优点

  • 可预测性:只需看输入输出,就能完全理解函数行为,不需要关注复杂的上下文。
  • 极易测试:不需要模拟任何外部依赖,直接给定输入,断言输出即可。
  • 支持缓存与记忆化:因为输入输出确定,可以安全地缓存结果,提升性能。
  • 天然并发安全:纯函数不共享状态,在多线程或异步环境下没有竞态问题。
  • 可组合性:纯函数是构建更大功能的安全积木,很小的纯函数可以组合成复杂逻辑,同时保持可维护性。

什么是副作用?

副作用是指函数在执行过程中,除了返回一个值之外,还对外部环境产生了影响,或者依赖于外部环境的状态。

常见的副作用类型

  • 修改一个可变的外部变量、对象属性或数据结构。
  • 修改函数的入参(可变引用)。
  • 读写文件、数据库等 I/O 操作。
  • 发送网络请求(HTTP、WebSocket)。
  • DOM 操作、console.logalert
  • 触发一个进程、启动计时器。
  • 抛出异常或修改错误处理流程。
  • 调用其他非纯函数。
let counter = 0;

// ❌ 副作用:修改了外部变量 counter
function increment() {
  counter++;
  return counter;
}

// ❌ 副作用:打印日志
function greet(name) {
  console.log(`Hello, ${name}`);
}

// ❌ 副作用:修改了传入的对象
function updateUser(user) {
  user.lastLogin = new Date(); // 直接变更了外部对象
}

副作用不是恶魔,但需要管理

任何有用的程序都需要副作用:没有副作用就无法向用户显示界面、保存数据或获取网络数据。关键不是消除副作用,而是将其隔离、延迟、集中处理。函数式编程提倡“用纯函数编写核心逻辑,将副作用推到程序的边界”。


纯函数与副作用的对比

角度 纯函数 有副作用的函数
输入输出 相同输入 → 相同输出 相同输入,可能因外部状态而不同输出
外部状态 不访问、不修改 读取或修改外部变量、文件等
可测试性 极易测试,无需 mock 需要模拟外部依赖,测试复杂
可推理 完全可预测 结果依赖于执行顺序和上下文
并发安全 天生安全 极易产生竞态问题
引用透明
组合难度 低,可随意组合 高,需考虑副作用时序

如何编写纯函数并管理副作用

1. 避免直接改变状态,返回新数据

使用不可变数据的方式:不修改原数据,而是返回一个新的副本。

// ❌ 不纯:原地修改
function addItem(list, item) {
  list.push(item);
}

// ✅ 纯函数:返回新数组
function addItem(list, item) {
  return [...list, item];
}

2. 将副作用作为参数传入

让函数依赖抽象而非具体实现,例如将日志函数作为参数传入,而非直接调用 console.log

// ❌ 直接依赖副作用
function calculate(a, b) {
  const result = a + b;
  console.log('结果:', result);
  return result;
}

// ✅ 将日志能力以参数形式提供
function calculate(a, b, logger = () => {}) {
  const result = a + b;
  logger(result);
  return result;
}

// 测试时可传入一个空函数,生产环境传入真正 logger

3. 使用“IO Monad”或类似模式推迟副作用

通过返回一个描述副作用的“指令”对象,而不是立即执行它。这需要框架或工具支持(如 Redux-Saga),但核心思想是:将副作用从核心逻辑中剥离。

// 纯函数:只返回需要做什么的描述
function fetchUserAction(id) {
  return {
    type: 'FETCH_USER',
    payload: { id }
  };
}

// 副作用在专门的运行时层处理,例如中间件

4. 依赖注入

将不纯的依赖通过函数参数或模块参数注入,使核心逻辑保持纯净。

// 一个计算增值税的纯逻辑
function computeVat(price, vatRate) {
  return price * vatRate;
}

// 获取增值税率需要读取数据库(副作用),我们拆开处理
function getVatRate(dbConnection) {
  // 副作用在这里隔离
  return dbConnection.query('SELECT rate FROM config');
}

// 主流程组合纯函数与副作用
async function main(db) {
  const rate = await getVatRate(db);  // 副作用边界
  return computeVat(100, rate);       // 纯计算
}

5. 将纯逻辑集中编写,隔离副作用

设计程序时,有意识地将代码划分为两层:

  • 核心层:由纯函数组成,处理验证、计算、数据转换、业务规则。这一层完全可测试。
  • 外壳层:处理 I/O、数据库、外部服务、UI 渲染。这一层尽可能薄,只负责传递给核心层并执行结果。
// 纯业务逻辑
function isEligibleForDiscount(user, orderTotal) {
  return user.isMember && orderTotal > 100;
}

// 副作用层(路由处理器)
app.post('/checkout', (req, res) => {
  const user = req.user;         // 从请求中获取
  const orderTotal = req.body.total;
  const eligible = isEligibleForDiscount(user, orderTotal);
  if (eligible) {
    // 副作用:应用折扣,更新数据库
  }
  res.send({ eligible });
});

纯函数对测试友好的具体体现

测试一个纯函数就像是做一道数学题,只需要输入与输出。

// 被测试的纯函数
function calculateDiscount(price, percentage) {
  if (percentage < 0 || percentage > 100) {
    throw new Error('Invalid percentage');
  }
  return price * (1 - percentage / 100);
}

// 测试用例
test('计算 100 元打 8 折应为 80', () => {
  expect(calculateDiscount(100, 20)).toBe(80);
});

test('百分比超过 100 会抛出异常', () => {
  expect(() => calculateDiscount(100, 150)).toThrow();
});

没有全局状态需要重置,没有模拟对象需要创建,测试稳定且运行速度极快。


常见误区

  • “纯函数不能有局部变量”:纯函数可以有自己的局部变量,只要它们不影响外部世界。例如循环计数器、中间计算结果等。
  • “所有副作用都必须消除”:不需要。合理的程序必定需要副作用。目标是隔离而非消灭。
  • “使用纯函数就会降低性能”:不可变数据可能会产生更多对象,但现代垃圾回收和结构共享(如 Immutable.js)已经优化了这一点,且带来的可维护性收益远远超过微小的性能成本。

总结

纯函数是编写可预测、可测试、可维护代码的基石。副作用是程序与外部世界互动的必要手段。优秀的开发者不会试图消除所有副作用,而是明确地区分纯逻辑与副作用,让前者占据核心,让后者简洁可控。通过持续的实践,这种风格能显著降低 bug 率,提升开发体验。