强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

02 纯函数与副作用

02 纯函数与副作用

“纯函数是函数式编程的基石——它让代码变得可预测、可测试、可组合。”


2.1 什么是纯函数

纯函数(Pure Function) 是满足以下两个条件的函数:

  1. 确定性(Deterministic):相同的输入永远产生相同的输出
  2. 无副作用(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,可直接测试

扩展阅读

  1. 《Functional-Light JavaScript》 — Kyle Simpson,第 5 章 Pure Functions
  2. Why Functional Programming Matters — John Hughes
  3. Purity is Compelling — Matt Parsons
  4. 《计算机程序的构造和解释》 — Abelson & Sussman,第 1 章

下一章03 不可变数据 — 理解不可变性如何让代码更安全