柯里化与偏应用
柯里化(Currying)和偏应用(Partial Application)是函数式编程中处理多参数函数的两种重要技术。它们让函数更加灵活,更容易组合和复用。
什么是柯里化?
柯里化是将一个接受多个参数的函数转换为一系列接受单个参数的函数的过程。这个概念由数学家 Haskell Curry 提出,因此得名。
从一个简单例子开始
// 普通的加法函数:接受三个参数
function add(a, b, c) {
return a + b + c;
}
add(1, 2, 3); // 6
// 柯里化后的加法函数:每次只接受一个参数
function curriedAdd(a) {
return function(b) {
return function(c) {
return a + b + c;
};
};
}
curriedAdd(1)(2)(3); // 6
// 可以分步调用
const add1 = curriedAdd(1); // 返回一个函数
const add1And2 = add1(2); // 返回另一个函数
const result = add1And2(3); // 6
柯里化后的函数可以"逐步"接收参数。每次调用返回一个新函数,等待下一个参数,直到所有参数都提供后才计算结果。
柯里化的意义
你可能会问:为什么要这么麻烦?让我们看几个实际场景:
1. 创建可复用的特定函数
// 通用的柯里化函数
const formatName = curry((prefix, firstName, lastName) =>
`${prefix} ${firstName} ${lastName}`
);
// 创建特定用途的函数
const formatChineseName = formatName('尊敬的');
const formatEnglishName = formatName('Mr./Ms.');
console.log(formatChineseName('张', '三')); // 尊敬的 张 三
console.log(formatEnglishName('John', 'Doe')); // Mr./Ms. John Doe
// 还可以进一步特化
const formatChineseCustomer = formatChineseName('客户');
console.log(formatChineseCustomer('李')); // 尊敬的 客户 李
2. 与高阶函数配合
const multiply = curry((a, b) => a * b);
const numbers = [1, 2, 3, 4, 5];
// 柯里化后,可以轻松创建用于 map 的函数
const double = multiply(2);
const triple = multiply(3);
console.log(numbers.map(double)); // [2, 4, 6, 8, 10]
console.log(numbers.map(triple)); // [3, 6, 9, 12, 15]
// 对比:不使用柯里化
console.log(numbers.map(n => n * 2)); // 需要写箭头函数
console.log(numbers.map(n => n * 3));
3. 延迟计算
const fetchWithConfig = curry((baseUrl, endpoint, options) =>
fetch(`${baseUrl}${endpoint}`, options)
);
// 设置基础 URL
const apiFetch = fetchWithConfig('https://api.example.com');
// 设置端点
const fetchUsers = apiFetch('/users');
const fetchPosts = apiFetch('/posts');
// 最终调用
fetchUsers({ method: 'GET' }).then(r => r.json());
fetchPosts({ method: 'GET' }).then(r => r.json());
实现柯里化函数
基础实现
让我们实现一个通用的柯里化函数:
function curry(fn) {
return function curried(...args) {
// 如果参数数量足够,直接调用原函数
if (args.length >= fn.length) {
return fn.apply(this, args);
}
// 否则返回一个新函数,等待更多参数
return function(...moreArgs) {
return curried.apply(this, args.concat(moreArgs));
};
};
}
// 使用
const add = (a, b, c) => a + b + c;
const curriedAdd = curry(add);
console.log(curriedAdd(1, 2, 3)); // 6
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6
支持占位符的实现
有时我们想跳过某些参数,稍后再填充。这需要占位符支持:
const _ = Symbol('placeholder');
function curryWithPlaceholder(fn) {
return function curried(...args) {
// 检查是否有占位符或者参数不足
const hasPlaceholder = args.some(arg => arg === _);
const needsMoreArgs = args.length < fn.length || hasPlaceholder;
if (!needsMoreArgs) {
return fn.apply(this, args);
}
return function(...moreArgs) {
// 合并参数,替换占位符
const combined = args.map(arg =>
arg === _ && moreArgs.length ? moreArgs.shift() : arg
);
return curried.apply(this, [...combined, ...moreArgs]);
};
};
}
// 使用
const greet = (greeting, name, punctuation) =>
`${greeting}, ${name}${punctuation}`;
const curriedGreet = curryWithPlaceholder(greet);
// 使用占位符跳过第二个参数
const sayHelloToSomeone = curriedGreet('Hello', _, '!');
console.log(sayHelloToSomeone('World')); // Hello, World!
console.log(sayHelloToSomeone('Alice')); // Hello, Alice!
什么是偏应用?
偏应用是固定一个函数的部分参数,返回一个接受剩余参数的新函数。与柯里化不同,偏应用不要求每次只传一个参数。
使用 bind 实现偏应用
JavaScript 的 bind 方法可以实现偏应用:
function greet(greeting, name, punctuation) {
return `${greeting}, ${name}${punctuation}`;
}
// 使用 bind 固定第一个参数
const sayHello = greet.bind(null, 'Hello');
console.log(sayHello('World', '!')); // Hello, World!
// 使用 bind 固定多个参数
const sayHelloToWorld = greet.bind(null, 'Hello', 'World');
console.log(sayHelloToWorld('!')); // Hello, World!
自定义 partial 函数
function partial(fn, ...presetArgs) {
return function(...laterArgs) {
return fn.apply(this, [...presetArgs, ...laterArgs]);
};
}
// 使用
const multiply = (a, b, c) => a * b * c;
const multiplyBy2 = partial(multiply, 2);
console.log(multiplyBy2(3, 4)); // 24
const multiply2By3 = partial(multiply, 2, 3);
console.log(multiply2By3(4)); // 24
柯里化 vs 偏应用
两者有关联但有所不同:
| 特性 | 柯里化 | 偏应用 |
|---|---|---|
| 参数数量 | 每次只接受一个参数 | 可以一次固定多个参数 |
| 调用方式 | f(a)(b)(c) | f(a, b)(c) |
| 转换结果 | 嵌套的一元函数 | 固定部分参数的函数 |
| 主要用途 | 函数组合、延迟计算 | 创建特定版本函数 |
// 原始函数
const add = (a, b, c) => a + b + c;
// 柯里化:转换为嵌套的一元函数
const curriedAdd = curry(add);
curriedAdd(1)(2)(3); // 必须一个一个传
// 偏应用:固定部分参数
const add1 = partial(add, 1);
add1(2, 3); // 可以一次传剩余的
实际应用场景
1. 配置预设
// 日志函数的柯里化
const log = curry((level, prefix, message) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${level}] ${prefix}: ${message}`);
});
// 创建不同级别的日志函数
const infoLog = log('INFO');
const errorLog = log('ERROR');
const debugLog = log('DEBUG');
// 创建特定模块的日志函数
const userServiceLog = infoLog('UserService');
const authServiceLog = infoLog('AuthService');
// 使用
userServiceLog('用户登录成功');
// [2024-01-15T10:30:00.000Z] [INFO] UserService: 用户登录成功
authServiceLog('认证失败');
// [2024-01-15T10:30:00.000Z] [INFO] AuthService: 认证失败
errorLog('Database', '连接超时');
// [2024-01-15T10:30:00.000Z] [ERROR] Database: 连接超时
2. 事件处理
// 表单字段变化处理
const handleChange = curry((setFormData, fieldName, event) => {
setFormData(prev => ({
...prev,
[fieldName]: event.target.value
}));
}));
// 在 React 中使用
function UserForm() {
const [formData, setFormData] = useState({ name: '', email: '' });
// 创建特定字段的处理器
const handleNameChange = handleChange(setFormData, 'name');
const handleEmailChange = handleChange(setFormData, 'email');
return (
<form>
<input
value={formData.name}
onChange={handleNameChange}
/>
<input
value={formData.email}
onChange={handleEmailChange}
/>
</form>
);
}
3. 数据过滤和转换
// 通用的过滤函数
const filterBy = curry((key, value, array) =>
array.filter(item => item[key] === value)
);
// 通用的排序函数
const sortBy = curry((key, order, array) =>
[...array].sort((a, b) => {
if (order === 'asc') return a[key] > b[key] ? 1 : -1;
return a[key] < b[key] ? 1 : -1;
})
);
// 通用的提取函数
const pluck = curry((key, array) =>
array.map(item => item[key])
);
// 组合使用
const users = [
{ name: '张三', age: 25, active: true },
{ name: '李四', age: 30, active: false },
{ name: '王五', age: 35, active: true }
];
// 创建特定过滤器
const getActiveUsers = filterBy('active', true);
const sortByAgeDesc = sortBy('age', 'desc');
const getNames = pluck('name');
// 组合成完整流程
const getActiveUserNamesByAge = pipe(
getActiveUsers,
sortByAgeDesc,
getNames
);
console.log(getActiveUserNamesByAge(users));
// ['王五', '张三']
4. API 客户端
// 创建 API 请求函数
const createRequest = curry((method, baseUrl, endpoint, data) =>
fetch(`${baseUrl}${endpoint}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: data ? JSON.stringify(data) : undefined
}).then(r => r.json())
);
// 创建特定方法的请求函数
const createGet = createRequest('GET');
const createPost = createRequest('POST');
// 创建特定 API 的请求函数
const apiGet = createGet('https://api.example.com');
const apiPost = createPost('https://api.example.com');
// 创建特定端点的请求函数
const fetchUsers = apiGet('/users');
const fetchPosts = apiGet('/posts');
const createUser = apiPost('/users');
// 使用
fetchUsers().then(console.log);
createUser({ name: '张三', email: '[email protected]' });
5. 验证链
// 创建验证函数
const validate = curry((rule, message, value) =>
rule(value) ? { valid: true, value } : { valid: false, error: message }
);
// 定义验证规则
const required = validate(
val => val !== null && val !== undefined && val !== '',
'此字段为必填项'
);
const minLength = min => validate(
val => val.length >= min,
`长度至少需要 ${min} 个字符`
);
const maxLength = max => validate(
val => val.length <= max,
`长度不能超过 ${max} 个字符`
);
const isEmail = validate(
val => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),
'请输入有效的邮箱地址'
);
// 组合验证
const validateEmail = value => {
const r1 = required(value);
if (!r1.valid) return r1;
const r2 = minLength(5)(value);
if (!r2.valid) return r2;
const r3 = maxLength(100)(value);
if (!r3.valid) return r3;
return isEmail(value);
};
// 使用
console.log(validateEmail('')); // { valid: false, error: '此字段为必填项' }
console.log(validateEmail('ab')); // { valid: false, error: '长度至少需要 5 个字符' }
console.log(validateEmail('test@example')); // { valid: false, error: '请输入有效的邮箱地址' }
console.log(validateEmail('[email protected]')); // { valid: true, value: '[email protected]' }
参数顺序的重要性
柯里化和偏应用对参数顺序有要求。通常,最可能变化的参数应该放在最后:
// 好的参数顺序:数据在最后
const map = curry((fn, array) => array.map(fn));
const filter = curry((predicate, array) => array.filter(predicate));
const reduce = curry((fn, initial, array) => array.reduce(fn, initial));
// 这样可以轻松组合
const process = pipe(
filter(x => x > 0),
map(x => x * 2),
reduce((a, b) => a + b, 0)
);
// 不好的参数顺序:数据在前
const mapBad = curry((array, fn) => array.map(fn));
// 无法直接与 pipe 组合,因为数据不是最后传入
Ramda 的参数顺序哲学
Ramda 库遵循"数据在最后"的原则,这让函数更容易组合:
const R = require('ramda');
// Ramda 的函数都是数据在最后
const process = R.pipe(
R.filter(R.gt(R.__, 0)), // x > 0
R.map(R.multiply(2)), // x * 2
R.reduce(R.add, 0) // 求和
);
console.log(process([1, -2, 3, -4, 5])); // 18
使用 Ramda 的柯里化
Ramda 的所有函数都自动柯里化,而且遵循"数据在最后"的原则:
const R = require('ramda');
// 基础运算
const add5 = R.add(5);
console.log(add5(3)); // 8
const multiplyBy3 = R.multiply(3);
console.log(multiplyBy3(4)); // 12
// 对象操作
const getName = R.prop('name');
const user = { name: '张三', age: 25 };
console.log(getName(user)); // '张三'
// 数组操作
const getFirstTwo = R.take(2);
console.log(getFirstTwo([1, 2, 3, 4, 5])); // [1, 2]
// 组合使用
const getActiveUserNames = R.pipe(
R.filter(R.propEq('active', true)),
R.map(R.prop('name')),
R.join(', ')
);
最佳实践
1. 合理的参数顺序
将最可能变化的参数(通常是数据)放在最后:
// 好的设计
const filterBy = curry((key, value, data) =>
data.filter(item => item[key] === value)
);
// 使用时可以先设置条件,后传数据
const getActive = filterBy('active', true);
const activeUsers = getActive(users);
2. 适度使用
不是所有函数都需要柯里化。对于简单的一次性使用,普通函数更直观:
// 不需要柯里化
const add = (a, b) => a + b;
console.log(add(1, 2)); // 简单直接
// 需要柯里化:当你要复用或组合时
const curriedAdd = curry((a, b) => a + b);
const addTen = curriedAdd(10);
numbers.map(addTen); // 可复用
3. 与函数组合配合
柯里化的最大价值在于与函数组合配合:
// 柯里化让函数可以直接用于组合
const process = pipe(
filter(x => x > 0),
map(x => x * 2),
reduce((a, b) => a + b, 0)
);
// 如果没有柯里化
const processNonCurried = arr =>
reduce((a, b) => a + b, 0)(map(x => x * 2)(filter(x => x > 0)(arr)));
4. 命名清晰
给柯里化后的函数起有意义的名字:
// 不好的命名
const f1 = multiply(2);
const f2 = add(10);
// 好的命名
const double = multiply(2);
const addTen = add(10);
小结
柯里化和偏应用是函数式编程中处理多参数函数的重要技术:
柯里化:
- 将多参数函数转换为嵌套的单参数函数
- 支持逐步传参和延迟计算
- 便于函数组合和复用
偏应用:
- 固定部分参数,返回接受剩余参数的函数
- 比
bind更灵活,可读性更好 - 适合创建特定版本的函数
关键要点:
- 参数顺序很重要,数据通常放最后
- 适度使用,简单场景不需要过度柯里化
- 与函数组合配合使用效果最佳
- 使用 Ramda 等库可以获得自动柯里化的便利
掌握柯里化能让你的函数更灵活、更易组合,是函数式编程的重要技能。