纯函数与副作用:测试友好且可预测的代码
FreeGuideOnline
最新
2026-06-18
什么是纯函数?
纯函数是函数式编程的核心概念。一个函数如果满足以下两个条件,它就是纯的:
- 相同的输入永远得到相同的输出。无论调用多少次,无论何时调用,只要参数一样,返回值就一样。
- 没有任何可观察的副作用。调用函数不会修改外部状态,也不会依赖外部可变状态,它仅仅通过参数和返回值与外界沟通。
纯函数的三个关键特征
- 确定性:函数的结果完全由其输入值决定。
// ✅ 纯函数 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.log、alert。 - 触发一个进程、启动计时器。
- 抛出异常或修改错误处理流程。
- 调用其他非纯函数。
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 率,提升开发体验。