函数组合
函数组合(Function Composition)是函数式编程的核心技术之一。它提供了一种优雅的方式,将多个简单函数组合成复杂的功能,是构建可复用、可维护代码的关键工具。
什么是函数组合?
函数组合的数学定义是:如果有两个函数 f 和 g,它们的组合 f ∘ g(读作"f after g")定义为:
这意味着先将 g 应用于 x,然后将 f 应用于结果。在编程中,函数组合就是将一个函数的输出作为另一个函数的输入,形成一条处理链。
为什么函数组合重要?
在面向对象编程中,我们通过对象和继承来组织代码。而在函数式编程中,函数组合是组织代码的主要方式。它的优势在于:
- 可组合性:小函数可以像积木一样自由组合
- 可读性:数据流向清晰,从左到右或从右到左
- 可测试性:每个小函数都可以独立测试
- 可复用性:组合产生的新函数可以再次被组合
基础组合
compose 函数
最基础的组合函数是 compose,它从右到左执行函数:
// 基本的 compose 实现
const compose = (f, g) => x => f(g(x));
// 使用示例
const addOne = x => x + 1;
const double = x => x * 2;
// compose(double, addOne)(5) 等同于 double(addOne(5))
const addOneThenDouble = compose(double, addOne);
console.log(addOneThenDouble(5)); // 12
// 执行过程:5 → addOne → 6 → double → 12
pipe 函数
pipe 与 compose 类似,但从左到右执行,更符合直觉:
// 基本的 pipe 实现
const pipe = (f, g) => x => g(f(x));
const addOneThenDouble = pipe(addOne, double);
console.log(addOneThenDouble(5)); // 12
// 执行过程:5 → addOne → 6 → double → 12
多函数组合
实际应用中,我们需要组合任意数量的函数:
// compose 的通用实现
const compose = (...fns) => x =>
fns.reduceRight((acc, fn) => fn(acc), x);
// pipe 的通用实现
const pipe = (...fns) => x =>
fns.reduce((acc, fn) => fn(acc), x);
// 使用示例
const addOne = x => x + 1;
const double = x => x * 2;
const subtractThree = x => x - 3;
const process = pipe(addOne, double, subtractThree);
console.log(process(5)); // 9
// 执行过程:5 → 6 → 12 → 9
组合的本质
理解函数组合需要理解几个关键点:
1. 类型契约
组合的函数之间必须满足类型契约:前一个函数的输出类型必须是后一个函数能够接受的输入类型。
// 类型不匹配的例子
const getLength = str => str.length; // String → Number
const toUpperCase = str => str.toUpperCase(); // String → String
// 错误:getLength 返回 Number,toUpperCase 需要 String
const broken = pipe(getLength, toUpperCase); // 运行时错误
// 正确的组合
const processString = pipe(toUpperCase, getLength);
console.log(processString('hello')); // 5
2. 单一输入
标准的函数组合要求每个函数只有一个参数。对于多参数函数,需要使用柯里化:
// 多参数函数需要先柯里化
const add = a => b => a + b;
const multiply = a => b => a * b;
const addFiveThenDouble = pipe(
add(5), // 部分应用:创建 x => x + 5
multiply(2) // 部分应用:创建 x => x * 2
);
console.log(addFiveThenDouble(10)); // 30
3. 纯函数
函数组合最适合纯函数。不纯的函数(有副作用)组合在一起可能导致难以追踪的问题:
// 有副作用的函数不适合组合
let state = 0;
const add = x => {
state += x; // 副作用
return state;
};
// 这样的组合会产生意外的结果
const process = pipe(add, add);
console.log(process(1)); // 2(不是预期的,因为 state 被修改了)
console.log(process(1)); // 4(每次调用结果不同)
实际应用示例
数据处理管道
函数组合最常见的应用是数据处理:
const users = [
{ name: '张三', age: 25, active: true, score: 85 },
{ name: '李四', age: 30, active: false, score: 92 },
{ name: '王五', age: 35, active: true, score: 78 },
{ name: '赵六', age: 28, active: true, score: 95 }
];
// 定义处理函数
const getActiveUsers = users => users.filter(u => u.active);
const sortByScore = users => [...users].sort((a, b) => b.score - a.score);
const getTopUsers = n => users => users.slice(0, n);
const getNames = users => users.map(u => u.name);
// 组合成完整的处理流程
const getTopActiveUserNames = pipe(
getActiveUsers,
sortByScore,
getTopUsers(2),
getNames
);
console.log(getTopActiveUserNames(users));
// ['赵六', '张三']
字符串处理
const trim = str => str.trim();
const toLowerCase = str => str.toLowerCase();
const split = separator => str => str.split(separator);
const join = separator => arr => arr.join(separator);
const map = fn => arr => arr.map(fn);
// 创建 URL 友好的 slug
const slugify = pipe(
trim,
toLowerCase,
split(' '),
map(word => word.replace(/[^a-z0-9]/g, '')),
join('-')
);
console.log(slugify('Hello World! This is a Test.'));
// 'hello-world-this-is-a-test'
// 清理和格式化用户输入
const cleanInput = pipe(
trim,
str => str.replace(/\s+/g, ' '),
toLowerCase
);
console.log(cleanInput(' HELLO WORLD '));
// 'hello world'
数据转换
// 处理 API 响应
const extractData = response => response.data;
const filterValid = items => items.filter(item => item.id && item.name);
const normalizeItems = items => items.map(item => ({
id: Number(item.id),
name: item.name.trim(),
active: Boolean(item.active)
}));
const sortBy = key => items => [...items].sort((a, b) =>
a[key] > b[key] ? 1 : -1
);
const processApiResponse = pipe(
extractData,
filterValid,
normalizeItems,
sortBy('name')
);
const response = {
data: [
{ id: '1', name: '张三 ', active: 'true' },
{ id: '2', name: '李四', active: 'false' },
{ id: null, name: '无效数据', active: 'true' }
]
};
console.log(processApiResponse(response));
// [
// { id: 1, name: '张三', active: true },
// { id: 2, name: '李四', active: false }
// ]
表单验证
// 创建验证器
const createValidator = (rule, message) => value =>
rule(value) ? { valid: true } : { valid: false, error: message };
const required = createValidator(
val => val !== null && val !== undefined && val !== '',
'此字段为必填项'
);
const minLength = min => createValidator(
val => val.length >= min,
`长度至少需要 ${min} 个字符`
);
const maxLength = max => createValidator(
val => val.length <= max,
`长度不能超过 ${max} 个字符`
);
const isEmail = createValidator(
val => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),
'请输入有效的邮箱地址'
);
// 组合多个验证器
const combineValidators = (...validators) => value => {
for (const validate of validators) {
const result = validate(value);
if (!result.valid) return result;
}
return { valid: true };
};
// 使用
const validateEmail = combineValidators(
required,
minLength(5),
maxLength(100),
isEmail
);
console.log(validateEmail('')); // { valid: false, error: '此字段为必填项' }
console.log(validateEmail('ab')); // { valid: false, error: '长度至少需要 5 个字符' }
console.log(validateEmail('invalid')); // { valid: false, error: '请输入有效的邮箱地址' }
console.log(validateEmail('[email protected]')); // { valid: true }
组合定律
函数组合遵循数学上的结合律,这意味着我们可以自由地分组组合:
// 结合律:(f ∘ g) ∘ h = f ∘ (g ∘ h)
const f = x => x + 1;
const g = x => x * 2;
const h = x => x - 3;
// 两种分组方式结果相同
const way1 = pipe(pipe(f, g), h);
const way2 = pipe(f, pipe(g, h));
console.log(way1(5)); // 9
console.log(way2(5)); // 9
这个特性非常重要,因为它允许我们:
- 提取子组合:将常用的组合提取为独立函数
- 逐步构建:可以先组合一部分,稍后再添加更多函数
// 提取子组合
const basicFormat = pipe(trim, toLowerCase);
const advancedFormat = pipe(basicFormat, slugify);
// 逐步构建
const pipeline = pipe(
basicFormat,
addPrefix('user-')
);
使用 Ramda 进行组合
Ramda 是一个专门为函数式编程设计的库,它提供了更强大的组合功能:
const R = require('ramda');
// R.pipe 和 R.compose
const process = R.pipe(
R.filter(R.propEq('active', true)),
R.sortBy(R.prop('score')),
R.take(3),
R.pluck('name')
);
// R.compose(从右到左)
const processCompose = R.compose(
R.pluck('name'),
R.take(3),
R.sortBy(R.prop('score')),
R.filter(R.propEq('active', true))
);
// R.pipeK(支持 Promise 的组合)
const fetchUser = id => fetch(`/api/users/${id}`).then(r => r.json());
const getPosts = user => fetch(`/api/posts?userId=${user.id}`).then(r => r.json());
const formatPosts = posts => posts.map(p => p.title);
const getUserPosts = R.pipeWith(R.then, [
fetchUser,
getPosts,
formatPosts
]);
组合与调试
函数组合的一个挑战是调试。当组合链很长时,很难定位问题:
使用 tap 调试
// tap 函数:检查中间值但不改变它
const tap = label => value => {
console.log(`${label}:`, value);
return value;
};
const process = pipe(
tap('输入'),
getActiveUsers,
tap('活跃用户'),
sortByScore,
tap('排序后'),
getNames
);
使用 trace 函数
const trace = label => value => {
console.log(`[${label}]: ${JSON.stringify(value)}`);
return value;
};
const processWithTrace = pipe(
trace('开始处理'),
str => str.toUpperCase(),
trace('转大写后'),
str => str.split(''),
trace('分割后'),
arr => arr.reverse(),
trace('反转后'),
arr => arr.join('')
);
console.log(processWithTrace('hello'));
// [开始处理]: "hello"
// [转大写后]: "HELLO"
// [分割后]: ["H","E","L","L","O"]
// [反转后]: ["O","L","L","E","H"]
// "OLLEH"
组合的最佳实践
1. 保持函数简单
每个函数应该只做一件事,并且做好:
// 不好:一个函数做多件事
const processUser = user => {
const normalized = {
...user,
name: user.name.trim().toLowerCase()
};
if (!normalized.email) {
normalized.email = `${normalized.name}@example.com`;
}
return normalized;
};
// 好:拆分为小函数
const normalizeName = user => ({
...user,
name: user.name.trim().toLowerCase()
});
const ensureEmail = user => user.email
? user
: { ...user, email: `${user.name}@example.com` };
const processUser = pipe(normalizeName, ensureEmail);
2. 命名有意义的组合
对于常用的组合,给予有意义的名字:
// 不好的命名
const process = pipe(filter, sort, map);
// 好的命名
const getActiveUserNames = pipe(
filterActiveUsers,
sortByName,
extractNames
);
3. 注意组合的长度
过长的组合链会降低可读性:
// 过长的组合
const result = pipe(
fn1, fn2, fn3, fn4, fn5, fn6, fn7, fn8, fn9, fn10
);
// 更好的做法:拆分为逻辑段落
const step1 = pipe(fn1, fn2, fn3);
const step2 = pipe(fn4, fn5, fn6);
const step3 = pipe(fn7, fn8, fn9);
const result = pipe(step1, step2, step3, fn10);
4. 类型签名帮助理解
使用 TypeScript 或 JSDoc 添加类型签名:
// TypeScript
type User = { name: string; age: number; active: boolean };
type UserName = string;
const getActiveUsers = (users: User[]): User[] =>
users.filter(u => u.active);
const getNames = (users: User[]): UserName[] =>
users.map(u => u.name);
// TypeScript 会自动推断类型
const getActiveUserNames = pipe(getActiveUsers, getNames);
// (users: User[]) => UserName[]
Hindley-Milner 类型签名
在函数式编程社区,Hindley-Milner(简称 HM)类型签名是一种广泛使用的类型表示法。它简洁、表达力强,能够在一行代码中清晰地描述函数的类型信息。Ramda、Haskell 等函数式编程库和语言的文档中都使用这种签名。
基本语法
HM 类型签名的基本形式是 输入类型 -> 输出类型:
// capitalize :: String -> String
const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
// strLength :: String -> Number
const strLength = s => s.length;
// add :: Number -> Number -> Number
const add = a => b => a + b;
capitalize 的签名读作:"接受一个 String,返回一个 String"。箭头 -> 表示函数映射,左边是输入类型,右边是输出类型。
多参数函数
在 HM 类型签名中,多参数函数不是写成 (a, b) -> c,而是写成 a -> b -> c。这反映了柯里化的本质:一个接受两个参数的函数实际上是接受一个参数后返回另一个函数。
// 多参数函数的类型签名
// add :: Number -> Number -> Number
const add = a => b => a + b;
// 可以这样理解:
// add :: Number -> (Number -> Number)
// add 接受一个 Number,返回一个"接受 Number 返回 Number"的函数
// 实际使用
add(1); // 返回一个函数: x => 1 + x
add(1)(2); // 3
// 更多例子
// join :: String -> [String] -> String
const join = separator => arr => arr.join(separator);
// match :: RegExp -> String -> [String]
const match = regex => str => str.match(regex) || [];
// replace :: RegExp -> String -> String -> String
const replace = regex => replacement => str => str.replace(regex, replacement);
理解这个签名的关键:最后一个类型是返回值类型,前面的都是参数类型。
类型变量
HM 类型签名支持类型变量(也叫多态类型),用小写字母表示。常见的类型变量有 a、b、c 等。类型变量可以代表任何类型,但如果同一个变量名出现多次,它们必须是同一类型。
// id :: a -> a
// 接受任何类型,返回相同类型
const id = x => x;
// head :: [a] -> a
// 接受一个元素类型为 a 的数组,返回一个 a
const head = arr => arr[0];
// map :: (a -> b) -> [a] -> [b]
// 接受一个转换函数(a 到 b)和一个 a 数组,返回 b 数组
const map = fn => arr => arr.map(fn);
// filter :: (a -> Boolean) -> [a] -> [a]
// 接受一个谓词函数和一个数组,返回同类型数组
const filter = predicate => arr => arr.filter(predicate);
// reduce :: (b -> a -> b) -> b -> [a] -> b
const reduce = fn => initial => arr => arr.reduce(fn, initial);
类型变量的约束:
a -> a表示输入和输出必须是同一类型a -> b表示输入和输出可以是不同类型[a] -> a表示数组元素的类型和返回值类型相同
类型签名的阅读技巧
让我们通过几个例子来练习阅读 HM 类型签名:
// 例子 1: compose
// compose :: (b -> c) -> (a -> b) -> a -> c
const compose = f => g => x => f(g(x));
// 阅读:
// 接受一个函数 b -> c
// 返回一个函数,接受 a -> b
// 返回一个函数,接受 a
// 返回 c
// 这就是从右到左的组合!
// 例子 2: pipe
// pipe :: (a -> b) -> (b -> c) -> a -> c
const pipe = f => g => x => g(f(x));
// 与 compose 相同的原理,只是函数顺序相反
// 例子 3: flip
// flip :: (a -> b -> c) -> b -> a -> c
const flip = fn => b => a => fn(a)(b);
// flip 接受一个二元函数,返回参数顺序互换的函数
// 例子 4: curry
// curry :: ((a, b, ...) -> c) -> a -> b -> ... -> c
// curry 将普通函数转换为柯里化函数
从类型签名推导行为
HM 类型签名的一个强大特性是:仅从类型签名就能推导出函数的行为。这被称为"参数性"(Parametricity)。
// 考虑这个签名:head :: [a] -> a
// 函数接受一个 a 数组,返回一个 a
// 由于 a 是类型变量,函数无法对 a 做任何特定操作
// 它能做的只有:取第一个元素、取最后一个元素、或取随机元素
// 从函数名 head 可以推断是取第一个元素
// 再看这个:reverse :: [a] -> [a]
// 接受 a 数组,返回 a 数组
// 由于不能操作 a 本身,只能重新排列元素
// 从名字推断是反转数组
// 这就是类型的威力:签名本身就揭示了函数的功能
类型约束
有时我们需要约束类型变量必须满足某些条件。在 HM 类型签名中,这通过类型类约束来表示:
// 在 Haskell 中,约束写在签名前面
// sort :: Ord a => [a] -> [a]
// 这里的 Ord a 表示 a 必须是可比较的类型
// 在 JavaScript 中,我们用注释说明约束
// sortBy :: Ord b => (a -> b) -> [a] -> [a]
// 约束:b 必须是可以比较大小的类型
const sortBy = fn => arr =>
[...arr].sort((a, b) => {
const va = fn(a), vb = fn(b);
return va < vb ? -1 : va > vb ? 1 : 0;
});
在文档中使用类型签名
很多函数式编程库的文档都使用 HM 类型签名:
// Ramda 文档示例
// map :: Functor f => (a -> b) -> f a -> f b
R.map(x => x * 2, [1, 2, 3]); // [2, 4, 6]
R.map(x => x * 2, Just(5)); // Just(10)
// 这告诉我们 map 不仅适用于数组,还适用于任何 Functor
// prop :: k -> {k: v} -> v
R.prop('name', { name: '张三' }); // '张三'
// 这个签名说明 prop 接受一个键和一个对象,返回对应的值
// lens :: (s -> a) -> ((a, s) -> s) -> Lens s a
// 这是 Ramda 中创建 Lens 的函数签名
// 虽然看起来复杂,但它准确描述了 getter 和 setter 的类型
实际应用
在开发中,我们可以在函数上方添加类型签名注释:
// 用户数据处理管道
// getActiveUsers :: [User] -> [User]
const getActiveUsers = users => users.filter(u => u.active);
// sortByAge :: [User] -> [User]
const sortByAge = users => [...users].sort((a, b) => a.age - b.age);
// getNames :: [User] -> [String]
const getNames = users => users.map(u => u.name);
// processUsers :: [User] -> [String]
const processUsers = pipe(
getActiveUsers,
sortByAge,
getNames
);
// 清晰的类型签名让代码自文档化
// 其他开发者可以快速理解每个函数的类型信息
类型签名的价值
HM 类型签名为函数式编程带来几个关键价值:
- 文档化:签名本身就是最好的文档,一目了然
- 类型推导:可以从签名推导函数行为
- 类型安全:帮助发现类型不匹配的错误
- 通用语言:跨语言通用,函数式编程社区的标准
学习阅读和书写 HM 类型签名是深入函数式编程的重要一步。当你能熟练地从类型签名理解函数行为时,你会发现函数式编程文档变得更加清晰易懂。
点无风格(Point-Free Style)
点无风格是指在函数定义中不显式提到参数。这是函数组合的高级用法:
// 有点(显式参数)
const getActiveUserNames = users =>
users
.filter(u => u.active)
.map(u => u.name);
// 点无风格
const getActiveUserNames = pipe(
filter(u => u.active),
map(u => u.name)
);
// 使用 Ramda 完全点无
const getActiveUserNames = pipe(
R.filter(R.prop('active')),
R.map(R.prop('name'))
);
点无风格的优点是更简洁、更声明式。但过度使用可能降低可读性,需要平衡。
小结
函数组合是函数式编程的核心技术:
- 基本概念:将一个函数的输出作为另一个函数的输入
- compose vs pipe:compose 从右到左,pipe 从左到右
- 类型契约:前后函数的输入输出类型必须匹配
- 最佳实践:保持函数简单、合理命名、控制组合长度
函数组合的价值在于:
- 将复杂问题分解为简单的小问题
- 通过组合小函数构建复杂功能
- 代码更易测试、更易复用
- 数据流向清晰,便于理解
掌握函数组合是成为函数式编程高手的关键一步。它不仅是代码组织的方式,更是一种思维方式——将程序视为数据流的转换过程。