函数式编程艺术 / 02 纯函数与副作用
02 纯函数与副作用
“纯函数是函数式编程的基石——它让代码变得可预测、可测试、可组合。”
2.1 什么是纯函数
纯函数(Pure Function) 是满足以下两个条件的函数:
- 确定性(Deterministic):相同的输入永远产生相同的输出
- 无副作用(No Side Effects):不修改外部状态,不产生可观察的外部影响
2.1.1 形式化定义
∀ x, y: x = y ⟹ f(x) = f(y) -- 引用透明
f 的求值不改变任何外部状态 -- 无副作用
2.1.2 纯函数 vs 不纯函数
| 特征 | 纯函数 | 不纯函数 |
|---|---|---|
| 相同输入 → 相同输出 | ✅ 是 | ❌ 不一定 |
| 无副作用 | ✅ 是 | ❌ 否 |
| 可缓存 | ✅ 是 | ❌ 不安全 |
| 可并行执行 | ✅ 是 | ⚠️ 需要同步 |
| 可测试 | ✅ 简单 | ⚠️ 需要 mock |
2.2 纯函数示例
Haskell:
-- 纯函数:计算圆的面积
circleArea :: Double -> Double
circleArea r = pi * r * r
-- 纯函数:列表求和
sumList :: [Int] -> Int
sumList = foldl (+) 0
-- 纯函数:字符串首字母大写
capitalize :: String -> String
capitalize [] = []
capitalize (x:xs) = toUpper x : xs
JavaScript:
// 纯函数:计算圆的面积
const circleArea = (r) => Math.PI * r * r;
// 纯函数:列表求和
const sumList = (xs) => xs.reduce((a, b) => a + b, 0);
// 纯函数:字符串首字母大写
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
// 纯函数:价格计算(含折扣和税)
const calculatePrice = (basePrice, discountRate, taxRate) =>
basePrice * (1 - discountRate) * (1 + taxRate);
Python:
import math
# 纯函数:计算圆的面积
def circle_area(r):
return math.pi * r * r
# 纯函数:列表求和
def sum_list(xs):
return sum(xs)
# 纯函数:价格计算
def calculate_price(base_price, discount_rate, tax_rate):
return base_price * (1 - discount_rate) * (1 + tax_rate)
Rust:
// 纯函数:计算圆的面积
fn circle_area(r: f64) -> f64 {
std::f64::consts::PI * r * r
}
// 纯函数:列表求和
fn sum_list(xs: &[i32]) -> i32 {
xs.iter().sum()
}
// 纯函数:价格计算
fn calculate_price(base_price: f64, discount_rate: f64, tax_rate: f64) -> f64 {
base_price * (1.0 - discount_rate) * (1.0 + tax_rate)
}
Clojure:
;; 纯函数:计算圆的面积
(defn circle-area [r]
(* Math/PI r r))
;; 纯函数:列表求和
(defn sum-list [xs]
(reduce + 0 xs))
;; 纯函数:价格计算
(defn calculate-price [base-price discount-rate tax-rate]
(* base-price (- 1 discount-rate) (+ 1 tax-rate)))
2.3 副作用(Side Effect)
副作用是指函数在计算结果之外对外部世界产生的任何可观察影响。
2.3.1 常见副作用类型
| 副作用类型 | 示例 | 严重程度 |
|---|---|---|
| 修改可变状态 | 修改全局变量、修改传入对象 | 高 |
| I/O 操作 | 读写文件、网络请求、打印 | 中 |
| 修改数据库 | 插入、更新、删除记录 | 高 |
| 抛出异常 | throw, panic | 中 |
| 调用不纯函数 | Math.random(), Date.now() | 低-高 |
| 修改 DOM | 网页元素操作 | 中 |
| 日志记录 | console.log, logger.info | 低 |
2.3.2 不纯函数示例
JavaScript:
// 不纯:依赖外部可变变量
let taxRate = 0.1;
const calculateTax = (price) => price * taxRate; // 外部状态依赖
// 不纯:修改输入参数
const addItem = (cart, item) => {
cart.items.push(item); // 修改了 cart
return cart;
};
// 不纯:有 I/O 副作用
const saveUser = async (user) => {
await db.insert('users', user); // 数据库操作
console.log('User saved'); // 日志输出
return user;
};
// 不纯:依赖随机数
const generateId = () => Math.random().toString(36).substr(2, 9);
// 不纯:依赖时间
const getTimestamp = () => Date.now();
2.3.3 将不纯函数转为纯函数
JavaScript:
// 方案 1:通过参数注入依赖
const calculateTax = (price, taxRate) => price * taxRate;
// 方案 2:返回新对象而非修改输入
const addItem = (cart, item) => ({
...cart,
items: [...cart.items, item],
});
// 方案 3:将随机数作为参数传入
const generateId = (seed) => `id-${seed.toString(36)}`;
// 方案 4:将时间作为参数传入
const getTimestamp = (now) => now;
2.4 引用透明性(Referential Transparency)
引用透明性是指一个表达式可以被它的值替换而不改变程序的行为。这是纯函数最重要的性质。
2.4.1 示例
// 引用透明
const double = (x) => x * 2;
double(5) // 可以被替换为 10
// 非引用透明
let count = 0;
const increment = () => ++count;
increment() // 每次调用结果不同,不可替换
2.4.2 引用透明的好处
| 好处 | 说明 |
|---|---|
| 等式推理 | 可以用数学方式推导程序行为 |
| 编译优化 | 编译器可安全内联、消除重复计算 |
| 缓存(Memoization) | 相同输入直接返回缓存结果 |
| 并行安全 | 无共享可变状态,无需同步 |
| 重构安全 | 替换等价表达式不会引入 bug |
2.4.3 Memoization 示例
JavaScript:
const memoize = (fn) => {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
};
// 纯函数可以安全地 memoize
const fibonacci = memoize((n) => {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
});
fibonacci(100) // 快速计算,因为中间结果被缓存
Python:
from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
fibonacci(100) # 快速计算
Rust:
use std::collections::HashMap;
use std::cell::RefCell;
thread_local! {
static CACHE: RefCell<HashMap<u64, u64>> = RefCell::new(HashMap::new());
}
fn fibonacci(n: u64) -> u64 {
CACHE.with(|cache| {
if let Some(&result) = cache.borrow().get(&n) {
return result;
}
let result = if n <= 1 { n } else { fibonacci(n - 1) + fibonacci(n - 2) };
cache.borrow_mut().insert(n, result);
result
})
}
Clojure:
;; Clojure 内置 memoize
(def fibonacci
(memoize
(fn [n]
(if (<= n 1) n
(+ (fibonacci (- n 1)) (fibonacci (- n 2)))))))
(fibonacci 100) ;; 快速计算
2.5 幂等性(Idempotency)
幂等性是指一个操作执行一次和执行多次的结果相同。
2.5.1 幂等 vs 纯函数
| 概念 | 定义 | 举例 |
|---|---|---|
| 纯函数 | f(x) ≡ f(x),无副作用 | abs(-5) 永远返回 5 |
| 幂等函数 | f(f(x)) ≡ f(x) | HTTP GET 请求 |
| 关系 | 纯函数一定是幂等的,幂等函数不一定是纯的 | deleteUser(42) 幂等但有副作用 |
2.5.2 幂等性示例
JavaScript:
// 幂等:设置值
const setValue = (key, value, store) => ({
...store,
[key]: value,
});
// setValue('x', 1, {}) === setValue('x', 1, setValue('x', 1, {}))
// 非幂等:增加值
const increment = (key, store) => ({
...store,
[key]: (store[key] || 0) + 1,
});
// increment('x', {}) !== increment('x', increment('x', {}))
// HTTP 中的幂等性
// GET /users/42 → 幂等
// PUT /users/42 → 幂等(完整替换)
// POST /users → 非幂等(创建新资源)
// DELETE /users/42 → 幂等
2.6 副作用的隔离策略
既然副作用不可避免,关键是如何隔离和管理它们。
2.6.1 洋葱架构(Onion Architecture)
外部世界(I/O、数据库、网络)
↓ 副作用层(Impure Shell)
↓ 核心业务逻辑(Pure Functions)
↑ 副作用层(Impure Shell)
外部世界
2.6.2 策略对比
| 策略 | 描述 | 适用场景 |
|---|---|---|
| 参数注入 | 将副作用结果作为参数传入 | 简单场景 |
| 依赖反转 | 通过接口抽象外部依赖 | 中大型项目 |
| IO Monad | 用类型系统标记副作用 | Haskell |
| Effect System | 专用效果系统管理副作用 | Koka, Eff, Unison |
| 纯函数内核 | 核心逻辑纯函数,外壳处理副作用 | 大型系统 |
2.6.3 纯函数内核示例
JavaScript:
// 纯函数核心 — 业务逻辑
const processOrder = (order, products) => {
const items = order.items.map(item => ({
...item,
product: products.find(p => p.id === item.productId),
subtotal: item.quantity * products.find(p => p.id === item.productId).price,
}));
const total = items.reduce((sum, i) => sum + i.subtotal, 0);
return { ...order, items, total };
};
// 不纯外壳 — I/O 操作
const handleOrderRequest = async (orderId) => {
const order = await db.getOrder(orderId); // 副作用:数据库读
const products = await db.getProducts(); // 副作用:数据库读
const result = processOrder(order, products); // 纯函数调用
await db.saveOrder(result); // 副作用:数据库写
await email.sendConfirmation(result.customerEmail); // 副作用:邮件发送
return result;
};
Haskell(使用 IO Monad):
-- 纯函数核心
processOrder :: Order -> [Product] -> ProcessedOrder
processOrder order products =
let items = map (enrichItem products) (orderItems order)
total = sum $ map subtotal items
in order { processedItems = items, orderTotal = total }
-- IO 外壳
handleOrderRequest :: OrderId -> IO ProcessedOrder
handleOrderRequest orderId = do
order <- getOrderFromDB orderId -- IO Action
products <- getProductsFromDB -- IO Action
let result = processOrder order products -- 纯计算
saveOrderToDB result -- IO Action
sendConfirmationEmail result -- IO Action
return result
2.7 可测试性
纯函数最大的工程优势之一是卓越的可测试性。
2.7.1 测试对比
| 测试需求 | 纯函数 | 不纯函数 |
|---|---|---|
| 输入输出测试 | ✅ 直接断言 | ⚠️ 需要验证副作用 |
| Mock 外部依赖 | ❌ 不需要 | ✅ 必须 mock |
| 测试顺序依赖 | ❌ 无 | ✅ 有 |
| 并行测试 | ✅ 安全 | ⚠️ 可能冲突 |
| 测试速度 | ✅ 快 | ⚠️ 可能慢(I/O) |
2.7.2 测试示例
JavaScript(使用 Jest):
// 纯函数测试 — 非常简单
describe('calculatePrice', () => {
test('applies discount and tax correctly', () => {
expect(calculatePrice(100, 0.1, 0.08)).toBe(97.2);
});
test('zero discount', () => {
expect(calculatePrice(100, 0, 0.08)).toBe(108);
});
test('zero tax', () => {
expect(calculatePrice(100, 0.1, 0)).toBe(90);
});
});
// 可以轻松进行 Property-based Testing
const fc = require('fast-check');
describe('sumList properties', () => {
test('identity: sum of empty list is 0', () => {
expect(sumList([])).toBe(0);
});
test('commutativity: order does not matter', () => {
fc.assert(fc.property(
fc.array(fc.integer()),
(arr) => sumList(arr) === sumList([...arr].reverse())
));
});
});
Python(使用 pytest):
import pytest
def test_calculate_price_basic():
assert calculate_price(100, 0.1, 0.08) == 97.2
def test_calculate_price_no_discount():
assert calculate_price(100, 0, 0.08) == 108.0
def test_calculate_price_no_tax():
assert calculate_price(100, 0.1, 0) == 90.0
# Property-based testing with Hypothesis
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_sum_list_empty(xs):
# sum of reversed list equals sum of original
assert sum_list(xs) == sum_list(list(reversed(xs)))
2.8 业务场景
2.8.1 电商价格计算引擎
// 纯函数:完整的价格计算规则
const applyPromotionRules = (rules, cart) =>
rules.reduce((acc, rule) => rule(acc), cart);
const buyOneGetOneFree = (cart) => ({
...cart,
items: cart.items.map(item =>
item.category === 'books'
? { ...item, price: item.price * Math.ceil(item.quantity / 2) / item.quantity }
: item
),
});
const bulkDiscount = (cart) => ({
...cart,
items: cart.items.map(item =>
item.quantity >= 10
? { ...item, price: item.price * 0.8 }
: item
),
});
// 组合规则
const cart = { items: [{ category: 'books', price: 50, quantity: 3 }] };
const finalCart = applyPromotionRules([buyOneGetOneFree, bulkDiscount], cart);
// 每个规则都是纯函数,可以单独测试
2.8.2 数据验证管道
def validate_email(email):
if '@' not in email:
return Left("Invalid email: missing @")
return Right(email)
def validate_age(age):
if not (0 <= age <= 150):
return Left(f"Invalid age: {age}")
return Right(age)
def validate_name(name):
if len(name.strip()) == 0:
return Left("Name cannot be empty")
return Right(name.strip())
# 纯函数:可独立测试、可组合
def validate_user(user):
return (
validate_name(user['name'])
.flat_map(lambda n: validate_email(user['email'])
.flat_map(lambda e: validate_age(user['age'])
.map(lambda a: {'name': n, 'email': e, 'age': a})))
)
2.9 注意事项
2.9.1 常见陷阱
| 陷阱 | 说明 | 解决方案 |
|---|---|---|
| 隐式依赖 | 函数依赖全局变量 | 通过参数传入所有依赖 |
| 对象变异 | 修改传入的对象 | 返回新对象或深拷贝 |
| 数组变异 | push/splice 修改原数组 | 使用展开运算符或 concat |
| Date 陷阱 | new Date() 产生不同结果 | 将时间作为参数传入 |
| 随机数 | Math.random() 不确定 | 将种子作为参数传入 |
| 异常 | 抛出异常是副作用 | 使用 Result/Either 类型 |
2.9.2 容易被忽略的不纯操作
// 看起来纯,实际不纯的操作
const obj = { a: 1, b: 2 };
// 不纯:Object.keys 的顺序在某些引擎中依赖内部状态
Object.keys(obj); // 可能因引擎实现而异
// 不纯:Array.prototype.sort 修改原数组
const arr = [3, 1, 2];
arr.sort(); // arr 被修改了!
// 纯版本
const sorted = [...arr].sort((a, b) => a - b);
2.10 小结
| 要点 | 说明 |
|---|---|
| 纯函数 | 相同输入 → 相同输出,无副作用 |
| 引用透明 | 表达式可被其值替换,程序行为不变 |
| 副作用 | I/O、可变状态修改、异常等 |
| 隔离策略 | 参数注入、依赖反转、IO Monad、纯函数内核 |
| 幂等性 | 执行多次与执行一次结果相同 |
| 可测试性 | 纯函数无需 mock,可直接测试 |
扩展阅读
- 《Functional-Light JavaScript》 — Kyle Simpson,第 5 章 Pure Functions
- Why Functional Programming Matters — John Hughes
- Purity is Compelling — Matt Parsons
- 《计算机程序的构造和解释》 — Abelson & Sussman,第 1 章
下一章:03 不可变数据 — 理解不可变性如何让代码更安全