跳到主要内容

函数组合

函数组合(Function Composition)是函数式编程的核心技术之一。它提供了一种优雅的方式,将多个简单函数组合成复杂的功能,是构建可复用、可维护代码的关键工具。

什么是函数组合?

函数组合的数学定义是:如果有两个函数 fg,它们的组合 f ∘ g(读作"f after g")定义为:

(fg)(x)=f(g(x))(f \circ g)(x) = f(g(x))

这意味着先将 g 应用于 x,然后将 f 应用于结果。在编程中,函数组合就是将一个函数的输出作为另一个函数的输入,形成一条处理链。

为什么函数组合重要?

在面向对象编程中,我们通过对象和继承来组织代码。而在函数式编程中,函数组合是组织代码的主要方式。它的优势在于:

  1. 可组合性:小函数可以像积木一样自由组合
  2. 可读性:数据流向清晰,从左到右或从右到左
  3. 可测试性:每个小函数都可以独立测试
  4. 可复用性:组合产生的新函数可以再次被组合

基础组合

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 函数

pipecompose 类似,但从左到右执行,更符合直觉:

// 基本的 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

这个特性非常重要,因为它允许我们:

  1. 提取子组合:将常用的组合提取为独立函数
  2. 逐步构建:可以先组合一部分,稍后再添加更多函数
// 提取子组合
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 类型签名支持类型变量(也叫多态类型),用小写字母表示。常见的类型变量有 abc 等。类型变量可以代表任何类型,但如果同一个变量名出现多次,它们必须是同一类型。

// 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 类型签名为函数式编程带来几个关键价值:

  1. 文档化:签名本身就是最好的文档,一目了然
  2. 类型推导:可以从签名推导函数行为
  3. 类型安全:帮助发现类型不匹配的错误
  4. 通用语言:跨语言通用,函数式编程社区的标准

学习阅读和书写 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 从左到右
  • 类型契约:前后函数的输入输出类型必须匹配
  • 最佳实践:保持函数简单、合理命名、控制组合长度

函数组合的价值在于:

  1. 将复杂问题分解为简单的小问题
  2. 通过组合小函数构建复杂功能
  3. 代码更易测试、更易复用
  4. 数据流向清晰,便于理解

掌握函数组合是成为函数式编程高手的关键一步。它不仅是代码组织的方式,更是一种思维方式——将程序视为数据流的转换过程。