纯函数:可预测且可靠的代码基石
在数学课上,你可能记得这样的函数:f(x) = 2x + 1。无论何时何地,只要输入是 3,输出永远是 7。这个函数不会因为今天是星期几、你在哪里计算、或者你之前输入过什么而改变结果。这种可预测性正是纯函数的核心特征。在编程世界中,纯函数就像这样的数学函数——给定相同的输入,永远返回相同的输出,并且不会影响外部世界。
什么是纯函数
纯函数(Pure Function)是函数式编程的核心概念,它必须同时满足两个关键条件:
第一,相同的输入总是产生相同的输出。 这意味着函数的返回值只依赖于输入参数,不依赖任何外部状态。无论调用多少次,只要参数相同,结果就相同。
第二,不产生副作用。 函数执行过程中不会修改外部状态,不会改变传入的参数对象,不会进行 I/O 操作,不会修改全局变量。函数就像一个封闭的黑盒,只接收输入,产生输出,但不会影响外部世界。
让我们看一个简单的纯函数示例:
// ✅ 纯函数示例
function add(a, b) {
return a + b;
}
console.log(add(2, 3)); // 5
console.log(add(2, 3)); // 5 - 永远返回相同结果
console.log(add(2, 3)); // 5 - 无论调用多少次这个 add 函数是一个纯函数。它的返回值只依赖于参数 a 和 b,每次用相同的参数调用都会得到相同的结果。它不会修改任何外部变量,不会产生任何副作用。
相比之下,这是一个不纯的函数:
// ❌ 非纯函数:依赖外部变量
let multiplier = 2;
function multiply(number) {
return number * multiplier;
}
console.log(multiply(5)); // 10
multiplier = 3;
console.log(multiply(5)); // 15 - 相同输入,不同输出这个函数不是纯函数,因为它的返回值依赖于外部变量 multiplier。当 multiplier 的值改变时,即使输入相同,输出也会不同。函数的行为变得不可预测。
纯函数的两个核心特性
1. 引用透明性(Referential Transparency)
纯函数具有引用透明性,这意味着你可以用函数的返回值直接替换函数调用,程序的行为不会改变。这个特性让代码更容易理解和重构。
// 纯函数
function square(x) {
return x * x;
}
function sumOfSquares(a, b) {
return square(a) + square(b);
}
console.log(sumOfSquares(3, 4)); // 25
// 由于引用透明性,可以直接替换
console.log(9 + 16); // 25 - 行为完全相同因为 square(3) 总是返回 9,square(4) 总是返回 16,我们可以直接用这些值替换函数调用,程序的行为保持不变。这种特性让编译器和开发者都能更容易地优化和理解代码。
2. 无副作用(No Side Effects)
纯函数不会产生任何可观察的副作用。常见的副作用包括:
- 修改全局变量或外部作用域的变量
- 修改传入的参数对象
- 执行 I/O 操作(读写文件、网络请求、控制台输出)
- 修改 DOM
- 调用不纯的函数
- 生成随机数或获取当前时间
让我们通过对比来理解副作用:
// ❌ 有副作用:修改外部变量
let total = 0;
function addToTotal(value) {
total += value; // 修改了外部变量
return total;
}
console.log(addToTotal(5)); // 5
console.log(addToTotal(5)); // 10 - 相同输入,不同输出
console.log(total); // 10 - 外部状态被改变
// ✅ 无副作用:纯函数实现
function calculateNewTotal(currentTotal, value) {
return currentTotal + value; // 只返回新值,不修改任何东西
}
let myTotal = 0;
myTotal = calculateNewTotal(myTotal, 5);
console.log(myTotal); // 5
myTotal = calculateNewTotal(myTotal, 5);
console.log(myTotal); // 10纯函数版本不会修改任何外部状态。它接收当前总数和要添加的值作为参数,返回新的总数。虽然我们仍然需要管理状态(myTotal),但这个管理是显式的、可控的。
识别纯函数与非纯函数
理解如何识别纯函数对编写高质量代码至关重要。让我们通过更多示例来练习识别:
纯函数示例
// ✅ 纯函数:字符串处理
function getFullName(firstName, lastName) {
return `${firstName} ${lastName}`;
}
console.log(getFullName("John", "Doe")); // John Doe
console.log(getFullName("John", "Doe")); // John Doe - 可预测
// ✅ 纯函数:数组操作(不修改原数组)
function doubleNumbers(numbers) {
return numbers.map((num) => num * 2);
}
let original = [1, 2, 3];
let doubled = doubleNumbers(original);
console.log(doubled); // [2, 4, 6]
console.log(original); // [1, 2, 3] - 原数组未被修改
// ✅ 纯函数:对象操作(不修改原对象)
function updateUserAge(user, newAge) {
return {
...user,
age: newAge,
};
}
let user = { name: "Alice", age: 25 };
let updatedUser = updateUserAge(user, 26);
console.log(updatedUser); // { name: "Alice", age: 26 }
console.log(user); // { name: "Alice", age: 25 } - 原对象未被修改
// ✅ 纯函数:条件逻辑
function getDiscountedPrice(price, isPremium) {
return isPremium ? price * 0.8 : price;
}
console.log(getDiscountedPrice(100, true)); // 80
console.log(getDiscountedPrice(100, false)); // 100这些函数都是纯函数,因为它们:
- 输出只依赖于输入参数
- 不修改任何外部状态
- 不修改传入的参数
- 具有可预测性
非纯函数示例
// ❌ 非纯函数:依赖外部状态
let discount = 0.1;
function applyDiscount(price) {
return price * (1 - discount); // 依赖外部变量
}
// ❌ 非纯函数:修改传入的参数
function addItemToCart(cart, item) {
cart.push(item); // 修改了传入的数组
return cart;
}
// ❌ 非纯函数:使用随机数
function generateRandomId() {
return Math.random().toString(36).substr(2, 9); // 每次返回不同结果
}
// ❌ 非纯函数:依赖当前时间
function isBusinessHours() {
let hour = new Date().getHours(); // 依赖当前时间
return hour >= 9 && hour < 17;
}
// ❌ 非纯函数:执行 I/O 操作
function logAndReturn(value) {
console.log(value); // 控制台输出是副作用
return value;
}
// ❌ 非纯函数:修改 DOM
function updatePageTitle(title) {
document.title = title; // 修改DOM是副作用
return title;
}这些函数都不是纯函数,因为它们要么依赖外部状态,要么产生了副作用,要么两者兼而有之。
将非纯函数转换为纯函数
很多时候,我们可以通过重新设计来将非纯函数转换为纯函数。关键思路是将外部依赖作为参数传入,将副作用移到函数外部。
示例 1:消除外部依赖
// ❌ 非纯:依赖外部配置
let taxRate = 0.08;
function calculateTotal(price) {
return price * (1 + taxRate);
}
// ✅ 纯函数:将外部依赖作为参数
function calculatePureTotal(price, taxRate) {
return price * (1 + taxRate);
}
// 使用时显式传入配置
let total = calculatePureTotal(100, 0.08);
console.log(total); // 108通过将 taxRate 作为参数传入,函数变成了纯函数。现在它的行为完全由输入决定,不依赖任何外部状态。
示例 2:避免修改参数
// ❌ 非纯:修改传入的对象
function incrementAge(person) {
person.age++;
return person;
}
let alice = { name: "Alice", age: 25 };
incrementAge(alice);
console.log(alice.age); // 26 - 原对象被修改
// ✅ 纯函数:返回新对象
function createOlderPerson(person) {
return {
...person,
age: person.age + 1,
};
}
let bob = { name: "Bob", age: 30 };
let olderBob = createOlderPerson(bob);
console.log(bob.age); // 30 - 原对象未被修改
console.log(olderBob.age); // 31 - 新对象包含更新的年龄纯函数版本使用展开运算符创建了一个新对象,而不是修改原对象。这确保了函数没有副作用。
示例 3:处理数组操作
// ❌ 非纯:修改原数组
function addNumber(numbers, newNumber) {
numbers.push(newNumber); // 修改了原数组
return numbers;
}
// ✅ 纯函数:返回新数组
function addNumberPure(numbers, newNumber) {
return [...numbers, newNumber];
}
let originalNumbers = [1, 2, 3];
let newNumbers = addNumberPure(originalNumbers, 4);
console.log(originalNumbers); // [1, 2, 3] - 未被修改
console.log(newNumbers); // [1, 2, 3, 4] - 新数组
// ✅ 纯函数:移除元素
function removeItem(array, index) {
return array.filter((_, i) => i !== index);
}
let fruits = ["apple", "banana", "orange"];
let remaining = removeItem(fruits, 1);
console.log(fruits); // ["apple", "banana", "orange"] - 原数组未变
console.log(remaining); // ["apple", "orange"] - 新数组示例 4:隔离时间依赖
// ❌ 非纯:依赖当前时间
function getGreeting() {
let hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 18) return "Good afternoon";
return "Good evening";
}
// ✅ 纯函数:接收时间作为参数
function getGreetingPure(hour) {
if (hour < 12) return "Good morning";
if (hour < 18) return "Good afternoon";
return "Good evening";
}
// 在调用时传入当前时间(副作用在外部处理)
let currentHour = new Date().getHours();
let greeting = getGreetingPure(currentHour);
console.log(greeting);通过将时间作为参数传入,函数本身变成了纯函数。获取当前时间的副作用被移到了函数外部,使函数更容易测试和理解。
纯函数的优势
1. 易于测试
纯函数是最容易测试的代码类型。因为输出只依赖输入,你不需要设置复杂的测试环境或模拟外部状态。
// 纯函数
function calculateShippingCost(weight, distance) {
let baseCost = 5;
let weightCost = weight * 0.5;
let distanceCost = distance * 0.1;
return baseCost + weightCost + distanceCost;
}
// 测试非常简单
console.log(calculateShippingCost(10, 100) === 20); // true
console.log(calculateShippingCost(5, 50) === 12.5); // true
console.log(calculateShippingCost(0, 0) === 5); // true你可以直接调用函数并验证结果,不需要任何设置或清理工作。测试是确定性的,每次运行都会得到相同的结果。
2. 可预测性
纯函数的行为完全可预测。给定相同的输入,你总是知道会得到什么输出。这让代码更容易理解和调试。
// 可预测的纯函数
function formatPrice(amount, currency) {
let symbols = {
USD: "$",
EUR: "€",
GBP: "£",
};
let symbol = symbols[currency] || currency;
return `${symbol}${amount.toFixed(2)}`;
}
// 行为完全可预测
console.log(formatPrice(99.99, "USD")); // $99.99
console.log(formatPrice(99.99, "EUR")); // €99.99
console.log(formatPrice(99.99, "JPY")); // JPY99.99无论何时何地调用这个函数,只要参数相同,结果就相同。这种可预测性让代码更可靠。
3. 可缓存性(Memoization)
因为纯函数对于相同输入总是返回相同输出,我们可以缓存函数的结果来优化性能。这种技术称为记忆化(memoization)。
// 创建一个记忆化的包装器
function memoize(fn) {
let cache = new Map();
return function (...args) {
let key = JSON.stringify(args);
if (cache.has(key)) {
console.log("Returning cached result");
return cache.get(key);
}
console.log("Computing result");
let result = fn(...args);
cache.set(key, result);
return result;
};
}
// 纯函数:计算斐波那契数(简化版)
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// 使用记忆化优化
let memoizedFib = memoize(fibonacci);
console.log(memoizedFib(5)); // Computing result -> 5
console.log(memoizedFib(5)); // Returning cached result -> 5
console.log(memoizedFib(6)); // Computing result -> 8这种优化只对纯函数有效。如果函数有副作用或依赖外部状态,缓存就会产生错误的结果。
4. 并发安全
纯函数不依赖或修改共享状态,因此它们天生是线程安全的。多个函数可以并发执行而不会互相干扰。
// 纯函数可以安全地并发执行
function processUserData(user) {
return {
id: user.id,
fullName: `${user.firstName} ${user.lastName}`.toUpperCase(),
email: user.email.toLowerCase(),
age: new Date().getFullYear() - user.birthYear,
};
}
// 可以并行处理多个用户,不会有竞态条件
let users = [
{
id: 1,
firstName: "John",
lastName: "Doe",
email: "[email protected]",
birthYear: 1990,
},
{
id: 2,
firstName: "Jane",
lastName: "Smith",
email: "[email protected]",
birthYear: 1985,
},
];
let processedUsers = users.map(processUserData);
console.log(processedUsers);
// [
// { id: 1, fullName: "JOHN DOE", email: "[email protected]", age: 35 },
// { id: 2, fullName: "JANE SMITH", email: "[email protected]", age: 40 }
// ]5. 易于组合
纯函数可以轻松组合成更复杂的函数。因为每个函数都是独立的、可预测的,将它们组合在一起不会产生意外的行为。
// 简单的纯函数
function double(x) {
return x * 2;
}
function addOne(x) {
return x + 1;
}
function square(x) {
return x * x;
}
// 组合函数
function compose(...fns) {
return function (value) {
return fns.reduceRight((acc, fn) => fn(acc), value);
};
}
// 创建组合函数
let doubleThenAddOne = compose(addOne, double);
let squareThenDouble = compose(double, square);
console.log(doubleThenAddOne(5)); // 11 - (5 * 2) + 1
console.log(squareThenDouble(3)); // 18 - (3 * 3) * 2实际应用场景
1. 数据转换管道
纯函数非常适合构建数据转换管道,每个步骤都是独立、可测试的。
// 一系列纯函数处理用户数据
function validateUser(user) {
return {
...user,
isValid: Boolean(user.email && user.name && user.age >= 18),
};
}
function normalizeEmail(user) {
return {
...user,
email: user.email.toLowerCase().trim(),
};
}
function addMembershipLevel(user) {
let level = user.age < 25 ? "junior" : user.age < 60 ? "standard" : "senior";
return { ...user, membershipLevel: level };
}
function addFullName(user) {
return {
...user,
fullName: `${user.firstName} ${user.lastName}`,
};
}
// 组合成处理管道
function processUser(user) {
return addFullName(addMembershipLevel(normalizeEmail(validateUser(user))));
}
let rawUser = {
firstName: "Alice",
lastName: "Johnson",
email: " [email protected] ",
age: 28,
};
let processedUser = processUser(rawUser);
console.log(processedUser);
// {
// firstName: "Alice",
// lastName: "Johnson",
// email: "[email protected]",
// age: 28,
// isValid: true,
// membershipLevel: "standard",
// fullName: "Alice Johnson"
// }2. 状态更新(React/Redux 风格)
在现代前端框架中,纯函数被广泛用于状态更新逻辑。
// Redux风格的纯函数reducer
function cartReducer(state, action) {
switch (action.type) {
case "ADD_ITEM":
return {
...state,
items: [...state.items, action.payload],
total: state.total + action.payload.price,
};
case "REMOVE_ITEM":
let itemToRemove = state.items[action.payload.index];
return {
...state,
items: state.items.filter((_, i) => i !== action.payload.index),
total: state.total - itemToRemove.price,
};
case "CLEAR_CART":
return {
items: [],
total: 0,
};
default:
return state;
}
}
// 使用
let initialState = { items: [], total: 0 };
let state1 = cartReducer(initialState, {
type: "ADD_ITEM",
payload: { name: "Book", price: 20 },
});
let state2 = cartReducer(state1, {
type: "ADD_ITEM",
payload: { name: "Pen", price: 5 },
});
console.log(state2);
// {
// items: [
// { name: "Book", price: 20 },
// { name: "Pen", price: 5 }
// ],
// total: 25
// }
console.log(initialState); // { items: [], total: 0 } - 原始状态未被修改3. 数据验证
纯函数使数据验证逻辑清晰、可测试。
// 纯函数验证器
function isValidEmail(email) {
let emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
function isValidAge(age) {
return typeof age === "number" && age >= 0 && age <= 150;
}
function isStrongPassword(password) {
return (
password.length >= 8 &&
/[A-Z]/.test(password) &&
/[a-z]/.test(password) &&
/[0-9]/.test(password)
);
}
// 组合验证函数
function validateRegistration(data) {
return {
emailValid: isValidEmail(data.email),
ageValid: isValidAge(data.age),
passwordValid: isStrongPassword(data.password),
get isValid() {
return this.emailValid && this.ageValid && this.passwordValid;
},
};
}
let registrationData = {
email: "[email protected]",
age: 25,
password: "Secure123",
};
let validation = validateRegistration(registrationData);
console.log(validation);
// {
// emailValid: true,
// ageValid: true,
// passwordValid: true,
// isValid: true
// }常见陷阱与注意事项
1. 对象和数组的浅拷贝陷阱
使用展开运算符只进行浅拷贝,嵌套对象可能仍然被修改。
// ❌ 浅拷贝的陷阱
function updateUserCity(user, newCity) {
return {
...user, // 浅拷贝
address: {
...user.address,
city: newCity,
},
};
}
let user = {
name: "John",
address: {
city: "New York",
street: "5th Avenue",
},
};
let updatedUser = updateUserCity(user, "Boston");
console.log(user.address.city); // New York - 正确,未被修改
console.log(updatedUser.address.city); // Boston
// ❌ 错误的做法(会修改原对象)
function updateUserCityWrong(user, newCity) {
let newUser = { ...user };
newUser.address.city = newCity; // 修改了共享的嵌套对象!
return newUser;
}
let user2 = {
name: "Jane",
address: {
city: "Paris",
street: "Champs-Élysées",
},
};
let updatedUser2 = updateUserCityWrong(user2, "London");
console.log(user2.address.city); // London - 糟糕!原对象被修改了2. 数组方法的选择
某些数组方法会修改原数组,要小心选择。
// ❌ 会修改原数组的方法
let numbers = [3, 1, 4, 1, 5];
numbers.sort(); // 修改原数组
numbers.push(9); // 修改原数组
numbers.pop(); // 修改原数组
numbers.splice(1, 1); // 修改原数组
// ✅ 不修改原数组的方法(返回新数组)
let original = [3, 1, 4, 1, 5];
let sorted = [...original].sort(); // 先拷贝再排序
let filtered = original.filter((n) => n > 2); // 返回新数组
let mapped = original.map((n) => n * 2); // 返回新数组
let sliced = original.slice(1, 3); // 返回新数组
console.log(original); // [3, 1, 4, 1, 5] - 未被修改3. 性能考量
频繁创建新对象和数组可能影响性能。在性能关键的场景中,需要权衡纯函数的优势和性能成本。
// 对于小数据量,纯函数方式性能良好
function addItem(list, item) {
return [...list, item];
}
// 对于大数组,频繁复制可能影响性能
let largeArray = new Array(10000).fill(0);
// ❌ 性能较差:每次都复制整个数组
for (let i = 0; i < 1000; i++) {
largeArray = addItem(largeArray, i);
}
// ✅ 在某些情况下,可以考虑变异方式(权衡取舍)
// 或者使用专门的不可变数据结构库(如 Immutable.js)4. 并非所有代码都需要是纯函数
虽然纯函数有很多优势,但并非所有代码都应该是纯函数。I/O 操作、DOM 操作、API 调用等本质上就是副作用。
// ✅ 纯函数:处理逻辑
function prepareUserData(formData) {
return {
username: formData.username.trim().toLowerCase(),
email: formData.email.trim().toLowerCase(),
timestamp: Date.now(),
};
}
// ❌ 必须有副作用:保存数据
async function saveUser(userData) {
// API 调用是副作用,但这是必需的
let response = await fetch("/api/users", {
method: "POST",
body: JSON.stringify(userData),
});
return response.json();
}
// 组合使用:纯函数处理数据,非纯函数执行副作用
async function registerUser(formData) {
let userData = prepareUserData(formData); // 纯函数
let result = await saveUser(userData); // 副作用
return result;
}关键是要明确区分纯函数和非纯函数,将副作用隔离到特定的地方,而将大部分逻辑保持为纯函数。
总结
纯函数是编写可靠、可维护代码的强大工具。它们就像数学函数一样可预测、可靠,为复杂的程序提供了坚实的基础。
关键要点:
- 纯函数满足两个条件:相同输入产生相同输出,且无副作用
- 纯函数具有引用透明性,可以用返回值直接替换函数调用
- 纯函数的优势包括:易于测试、可预测、可缓存、并发安全、易于组合
- 避免修改外部变量、传入参数、或执行 I/O 操作
- 使用展开运算符、
map、filter等方法创建新数据而非修改原数据 - 注意嵌套对象的深拷贝问题
- 在性能关键场景权衡纯函数的优势和成本
- 并非所有代码都需要是纯函数,关键是隔离副作用
掌握纯函数的概念将帮助你编写更健壮的代码,也为学习函数式编程的其他概念(如函数柯里化和函数组合)打下基础。在实际开发中,尽可能多地使用纯函数,将副作用隔离到边界,你会发现代码变得更容易理解、测试和维护。