柯里化与函数组合

柯里化把参数分步固定,函数组合把单输入函数连接成数据流;重点理解参数数量、this、同步与异步边界。

难度 进阶 时长 标准深度约 12分钟
版本 Node 24
what

柯里化把多参数调用改写成一连串单参数调用;函数组合则把前一函数的返回值交给下一函数。

trap

通用 curry 若依赖 fn.length,会误判默认参数与剩余参数;同步 pipe 也不会自动等待 Promise。

fix

明确参数数量、实参顺序、this 与异步契约,并用有名称的单输入阶段连接数据流。

是什么,为什么存在

柯里化(currying) 把一个接收多个参数的函数转换为一串函数,每个函数接收一个参数。若原调用是 format(symbol, digits, amount),严格柯里化后的形式就是 format(symbol)(digits)(amount)。前几次调用返回函数,最后一次调用才得到结果。

每一层返回的函数都是 高阶函数(higher-order function) 的结果。它通过 闭包(closure) 保留已经传入的参数,所以你可以先固定稳定配置,再把得到的专用函数交给别处使用。例如,先固定币种和小数位,就能得到只接收金额的格式化函数。

偏应用(partial application) 与柯里化有关,但两者不是同一个定义。偏应用预先绑定任意一部分实参,返回接收其余实参的新函数;一次可以绑定多个实参。柯里化改变的是调用形状,严格形式每层只接收一个实参。

JavaScript 社区中的 curry 辅助函数常允许 fn(a)(b, c)fn(a, b)(c)。这种接口同时提供了柯里化和分组偏应用,使用起来方便,却不再是严格的逐个参数形式。阅读库代码时应先查它对分组、占位符、多余实参和完成条件的约定。

函数组合(function composition) 把多个函数连接成一个函数。compose(f, g)(value) 等价于 f(g(value)),因此调用顺序从右向左。常见的 pipe(f, g)(value) 则等价于 g(f(value)),把同一过程写成从左向右的数据流。

柯里化与组合经常一起出现,因为组合链中除入口外的阶段通常只接收一个值。先用柯里化或偏应用固定配置,就能把多参数操作变成合适的单输入阶段。不过,这只是便利的搭配;二者都可以独立使用,JavaScript 标准库也没有内置通用的 currycomposepipe

这组技巧适合配置复用、数组转换、校验步骤和小型数据处理流程。若操作主要依赖可变对象、this、多个返回通道或复杂错误恢复,直接调用和有名称的普通函数往往更清楚。Point-free 风格只是省略形参名称的写法,不应成为设计目标。

工作原理

柯里化函数的每次调用都会把新实参与先前实参合并。实参数量达到约定值时,辅助函数调用原函数;否则,它返回另一个收集实参的函数。闭包保存的是这次部分调用自己的实参数组,所以从同一入口创建的两个分支不会互相追加实参。

严格柯里化无需推断参数数量,因为函数结构本身写明还有几层。通用辅助函数则需要一个完成条件,最常见的默认值是 fn.length。这个属性不是完整签名:它只计算第一个带默认值的形参之前的形参,剩余参数不计入,解构形参只算一个。

下面是几个签名在运行时暴露的 length。这些值符合语言规则,但未必等于业务上要求的实参数量。

函数签名length对通用 curry 的影响
(a, b, c)3可作为默认完成条件
(a, b = 0, c)1收到 a 后可能过早执行
(...values)0无法推断至少需要多少项
({ id }, options)2解构对象整体只算一个形参

因此,可靠的辅助函数应允许调用方显式传入参数数量,或者只服务于签名受控的一小组函数。若目标函数有可选参数,另一种清楚的设计是先定义固定配置对象,再返回只接收业务数据的函数。不要把反射得到的数字当作业务契约。

组合的机械过程更简单。pipe 从初始输入开始,用 reduce() 依次把当前值交给每个阶段;composereduceRight() 反向遍历同一组阶段。每个中间阶段获得的是一个值,而不是自动展开的实参数组。

这意味着相邻阶段必须在运行时协议上兼容。前一阶段返回数组,下一阶段就会收到一个数组;它不会自动收到数组中的多个元素。JavaScript 不检查这种输入输出关系,类型错误通常要到链条执行至对应阶段时才出现。

同步和异步组合也有不同契约。同步 pipe 会把 Promise 当作普通对象立刻传给下一阶段;它不会因为某个函数带有 async 就切换模式。异步版本可以从 Promise.resolve(input) 开始,并让每个阶段通过 .then() 串联,这样同步返回值和 Promise 都会被统一等待。

this 同样不会由柯里化或组合自动解决。把对象方法取出来交给辅助函数会失去原来的调用形式,而不同实现还可能保留第一次调用或最后一次调用的接收者。最稳妥的契约是组合不依赖 this 的函数;确实需要接收者时,先用 bind() 固定它。

示例

下面四个例子依次展示严格柯里化、按参数数量收集实参、同步组合和异步组合。所有输出都由本地 Node 24.14.0 执行对应文件得到。

固定格式化配置

金额格式化函数把稳定配置放在前面,把每次变化的金额放在最后。两次部分调用分别生成美元和日元格式化函数,调用方不必重复传入配置。

format-amount.js
const formatAmount = symbol => decimals => amount =>
  `${symbol}${amount.toFixed(decimals)}`;

const formatUsd = formatAmount('$')(2);
const formatYen = formatAmount('¥')(0);

console.log(formatUsd(19.5));
console.log(formatUsd(0));
console.log(formatYen(2400.4));
$19.50
$0.00
¥2400

formatAmount('$') 返回的函数捕获 symbol,下一层又捕获 decimalsformatUsdformatYen 来自不同调用链,各自拥有自己的配置。这里没有推断参数数量,也没有占位符规则。

参数顺序决定可复用性。把变化最频繁的数据放在最后,才能自然地产生 formatUsd 这样的专用函数。若调用方通常同时拥有三个值,直接写普通三参数函数可能更易读。

收集分组实参

这个小型辅助函数默认读取 fn.length,也允许显式传入 arity。它接受逐个或分组实参,并在收集数量达到目标后调用原函数。

curry-by-arity.js
function curryByArity(fn, arity = fn.length) {
  if (!Number.isInteger(arity) || arity < 1) {
    throw new TypeError('arity must be a positive integer');
  }

  function collect(collected) {
    return function curried(...next) {
      const all = [...collected, ...next];
      return all.length >= arity ? fn(...all) : collect(all);
    };
  }

  return collect([]);
}

const createMessage = (level, service, message) =>
  `[${level}] ${service}: ${message}`;
const message = curryByArity(createMessage);
const warnBilling = message('WARN')('billing');

console.log(warnBilling('payment delayed'));
console.log(message('INFO', 'search')('index ready'));
[WARN] billing: payment delayed
[INFO] search: index ready

warnBilling 固定了日志级别和服务名,只留下消息参数。第二次调用一次提供两个实参,说明这个辅助函数采用灵活分组,而不是严格柯里化。名称 curryByArity 暴露了完成条件,避免把它误认为适用于任意函数。

达到参数数量后,代码把所有已收集实参传给目标函数。普通 JavaScript 函数可能忽略多余实参,也可能通过剩余参数或 arguments 读取它们,所以生产实现必须明确是否允许超额输入。这个示例也特意不保留动态 this

连接订单转换阶段

filtermap 的参数顺序都是配置在前、数据在后。部分调用后,它们产生接收一个数组的阶段,正好可以交给 pipe

order-pipeline.js
const pipe = (...steps) => input =>
  steps.reduce((value, step) => step(value), input);

const compose = (...steps) => input =>
  steps.reduceRight((value, step) => step(value), input);

const filter = predicate => items => items.filter(predicate);
const map = project => items => items.map(project);
const atLeast = minimum => order => order.total >= minimum;
const orderLabel = compose(
  label => label.toUpperCase(),
  order => `${order.id}:${order.total}`,
);

const selectOrderLabels = pipe(
  filter(atLeast(50)),
  map(orderLabel),
  labels => labels.toSorted(),
);

const orders = [
  { id: 'a-101', total: 35 },
  { id: 'a-103', total: 120 },
  { id: 'a-102', total: 75 },
];

console.log(JSON.stringify(selectOrderLabels(orders)));
console.log(orders.map(order => order.id).join(','));
["A-102:75","A-103:120"]
a-101,a-103,a-102

orderLabel 用右向左组合先创建标签,再转成大写。外层管道按书写顺序筛选、映射和排序。每一步的输入输出形状都能单独描述,名称也能直接出现在错误堆栈中。

最后一行证明原订单数组的顺序没有变化。filter()map() 返回新数组,toSorted() 也不修改接收者。若生成代码把最后一步换成 sort(),管道就会悄悄改变上一阶段返回的数组;该数组若被其他代码共享,副作用会越过管道边界。

串联同步与异步阶段

异步管道先把输入规范化为 Promise,再通过 .then() 连接每个阶段。异步查询、同步校验和同步格式化可以使用同一条执行链。

async-pipeline.js
const pipeAsync = (...steps) => input =>
  steps.reduce(
    (pending, step) => pending.then(step),
    Promise.resolve(input),
  );

const findAccount = async id => {
  const accounts = new Map([
    [7, { id: 7, name: 'Ada', planId: 'pro' }],
  ]);
  return accounts.get(id) ?? null;
};

const requireAccount = account => {
  if (account === null) throw new Error('account not found');
  return account;
};

const summarize = account => `${account.name} uses ${account.planId}`;
const describeAccount = pipeAsync(findAccount, requireAccount, summarize);

async function main() {
  console.log(await describeAccount(7));
  try {
    await describeAccount(99);
  } catch (error) {
    console.log(`${error.name}: ${error.message}`);
  }
}

main();
Ada uses pro
Error: account not found

当账户存在时,Promise 的履约值依次通过三个阶段。同步阶段返回的普通值会被下一次 .then() 自动采用。账户不存在时,requireAccount 抛出的异常变成拒绝,后续 summarize 不会执行。

这里的错误策略是整条链快速失败。若业务需要累积多个校验错误或区分可恢复失败,应把结果类型写进阶段契约,而不是让某些阶段抛异常、另一些阶段返回 { error }。混用两套通道会迫使后续每一步猜测输入形状。

陷阱

把灵活分组当成柯里化定义

修复方法: 在文档和测试中写出允许的调用形状。至少覆盖逐个调用、分组调用、多余实参和空调用;若库支持占位符,还要验证占位符的填充顺序。

fn.length 当作完整签名

修复方法: 对默认参数、剩余参数和可选参数显式传入参数数量,或者为目标函数手写一层配置工厂。测试应证明部分调用仍返回函数,最终调用才返回业务值。

丢失或混淆 this

修复方法: 优先让可组合函数通过显式参数接收依赖。必须调用对象方法时,先执行 account.charge.bind(account),再做偏应用或柯里化,并测试提取后的函数调用。

在同步链中放入 Promise

修复方法: 一条链只采用一种等待策略。只要任一阶段可能返回 Promise,就使用明确的 pipeAsync,并测试履约、拒绝以及同步抛错三条路径。

隐藏类型变化和原地修改

修复方法: 为非平凡阶段命名,并写出或标注输入输出形状。对共享输入保留调用前快照并检查对象标识;需要复制型数组操作时使用 toSorted()toReversed() 等明确接口。

深入 柯里化辅助函数的契约

柯里化辅助函数的契约

JavaScript 没有统一的柯里化协议,因此函数名相同不表示行为相同。一个可用的契约至少要回答五件事:完成所需的参数数量、是否允许一次传多个实参、是否接受多余实参、是否支持占位符,以及 this 取自哪里。库升级时,这些行为比辅助函数的短小实现更值得检查。

严格的一元形式最容易推理,因为每层只有一个实参。灵活分组减少括号,却引入空调用和超额调用的边界。例如,curried()()(a) 是否继续收集,以及 curried(a, b, c, d) 是否丢弃 d,都不是语言替你决定的。

占位符让调用方可以固定非前缀位置,却会把合并算法变复杂。实现必须区分真正作为数据传入的符号和占位符,还要决定新实参先补旧空位还是追加到末尾。除非项目确实需要这项语法,具名配置对象或小型包装函数通常更容易审查。

bind() 是语言内置的偏应用工具,但它同时固定 this 和前置实参。绑定函数再次调用 bind() 不能替换已经固定的接收者,构造调用还有自己的规则。因此,不能把 bind() 简化描述为通用 curry,也不应在不了解接收者契约时互换两者。

参数顺序属于 API 设计。稳定配置放前、变化数据放后,便于生成可复用的单输入函数;但若大多数调用总是同时拥有所有参数,这种排序可能只增加间接层。先观察真实调用点,再决定是否提供柯里化入口。

组合规律与运行时边界

对于输入输出兼容的一元函数,compose(f, g, h)(x) 展开为 f(g(h(x)))。组合满足结合律,因为无论先把 fg 分组,还是先把 gh 分组,调用嵌套都不变。纯函数让这种等式可以安全替换;隐藏状态不会改变括号展开,却会让结果依赖链外条件。

恒等函数 value => value 是空组合的自然结果。于是 pipe()compose() 不带阶段时仍能返回可调用函数,而不必设置特殊的 undefined 结果。项目也可以禁止空链,但必须让实现、类型和测试保持一致。

有些实现允许入口阶段接收多个实参:compose(f, g)(a, b) 会先调用 g(a, b),之后仍保持单值传递。本文示例选择更窄的单输入契约,因为它使所有阶段一致。复制别处的 compose 时,不要假设两种入口规则相同。

运行时只传递值,不传递静态类型证明。若一个阶段可能返回 null、抛异常或返回联合形状,下一阶段必须显式处理该分支。把校验、转换与错误通道写进阶段名称和类型,比依赖 point-free 的简短外观更可靠。

副作用会使中间值难以重放和比较。需要日志时,可以使用返回原值的具名 tap 阶段,但日志本身仍可能失败或泄露数据。把它当作真实副作用测试,而不是把 tap 当成纯函数。

异步组合与诊断

Promise.resolve(input) 开始的异步管道会吸收普通值和 Promise。每次 .then(step) 还会把同步抛出的异常转换成拒绝,所以链尾可以用一次 awaittry...catch 观察统一结果。这种统一不等于错误已经分类,业务仍要决定哪些失败可以重试。

并行工作不能仅靠把多个异步函数放进 pipeAsync 实现,因为管道按顺序等待每一阶段。互不依赖的请求应由一个明确阶段通过 Promise.all() 发起,再把聚合结果交给后续步骤。顺序依赖和并行分支需要在代码结构上可见。

调试长链时,先把匿名箭头函数提取为具名阶段。然后在阶段边界断言输入形状,并分别测试每个阶段;集成测试再验证方向和完整顺序。这样堆栈、覆盖率与失败消息都会指向业务名称,而不是第几个匿名回调。

组合器本身的测试应覆盖空链、单阶段、多个阶段和抛错阶段。柯里化器的测试则应覆盖参数边界与分支隔离。不要只用加法测试,因为数字加法会掩盖参数顺序错误,并让多个错误实现得到同一个结果。

函数标识也可能成为外部契约。每次调用柯里化器或组合器都会得到新的函数对象,所以事件监听器解除注册、缓存键和引用相等测试必须保存返回值。用相同参数再次构建一个外观相同的函数,不能替代原对象。

设计可复用阶段

可复用阶段应该有一个清楚的数据参数。配置可以通过前置偏应用固定,也可以放进具名对象中;二者都比从全局变量读取隐式配置更容易测试。若一个阶段需要两个不断变化的业务值,它可能不适合直接放进一元组合链。

适配器应位于边界,而不是散落在每个阶段内部。例如,入口可以把两个原始实参整理成一个记录,后续阶段始终接收这一个记录。这样既保留多字段信息,也不需要组合器猜测何时展开数组或对象。

工厂调用的位置决定配置函数的生命周期。模块加载时创建的部分应用函数会长期保留配置;请求处理中创建的函数只应服务于该请求。若闭包捕获大型客户端或请求上下文,返回函数被缓存后也会延长这些对象的可达时间。

公开 API 可以同时提供普通入口和预配置工厂,但名称必须区分用途。formatAmount(config, amount) 适合偶尔调用,createAmountFormatter(config) 则清楚表示会返回可复用函数。仅把同一函数暴露成多种括号组合,往往让文档和类型更难表达。

选择形式时可以按调用模式判断,而不是按函数式风格标签判断。

调用模式更直接的形式原因
每次都有全部实参普通多参数函数没有需要保留的配置
多次复用相同前置配置柯里化或偏应用可生成专用单输入函数
可选项多且顺序不稳定配置对象工厂字段名称比位置更明确
多个公开操作共享状态对象或类所有权与生命周期更可见
数据依次经过兼容转换pipe执行顺序与阅读顺序一致

单输入不等于只能传递一个简单值。阶段可以接收包含数据、诊断信息和上下文的记录,再返回同一协议的记录。关键是所有阶段都同意该协议,而不是为了保持一元外观不断套匿名对象。

Point-free 写法适合已经有清楚名称和稳定签名的函数。若必须反复回看 propflipuncurry 等适配器才能理解方向,显式写出参数通常更短。可读性取决于读者能否看见数据变化,不取决于源码中是否出现形参。

组合器应放在项目中一个明确位置,并带有测试。多个团队各写一个略有差异的 pipe,很容易在空链、多实参入口和异步行为上产生不兼容约定。复用的价值来自共享契约,不是来自少写两行 reduce()

阶段名称也应描述结果,而不是描述动作的模糊类别。parseInvoicevalidateInvoicetoInvoiceRow 能显示形状变化,processhandletransform 则提供不了边界信息。错误堆栈出现这些名称时,区别尤其明显。

测试参数与阶段边界

柯里化测试首先验证调用形状,而不是只验证最终算术结果。同一个入口至少创建两个部分应用分支,并交错完成它们,才能证明闭包中的实参没有串线。字符串拼接或记录构造比加法更容易暴露实参顺序。

带显式参数数量的辅助函数要测试刚好不足、刚好达到和超过数量三条边界。若空调用合法,还要验证它是否保持当前收集状态。占位符实现则需要重复空位、空位未补满和占位符本身作为数据等契约测试。

组合测试应让每个阶段产生容易识别的变化。例如,先追加字符再包裹括号,可以直接区分从左到右和从右到左。只使用两个可交换的数学操作会让方向写反后仍然通过测试。

错误路径也属于组合契约。测试应确认哪个阶段停止执行、异常或拒绝是否保留原原因,以及清理阶段是否仍会运行。若管道用结果对象表达失败,就要验证后续阶段不会把失败对象误当成功数据。

一组紧凑的边界用例可以覆盖主要风险:

  • 两个部分应用分支按不同顺序完成,结果仍彼此独立。
  • 默认参数函数使用显式参数数量,并在预期调用点执行。
  • 同步管道拒绝 Promise,或异步管道明确等待它。
  • 调用前后的输入对象保持约定的标识和内容。

测试组合器时,还要分别覆盖零个、一个和多个阶段。单阶段用例能发现组合器错误地包裹或展开输入,空阶段用例则固定恒等或拒绝策略。多个阶段才负责验证方向。

测试业务管道时,不必重复证明 reduce() 本身。重点应放在相邻阶段的协议、真实边界值和副作用所有权。这样即使实现从 pipe 改成普通语句,测试仍描述相同业务契约。

审查失败时可以先记录阶段轨迹,而不是立即改写组合器。轨迹包含阶段名、输入类别、输出类别和同步/异步状态即可,生产日志不应直接输出敏感值。拿到第一个协议变化位置后,再检查对应阶段的独立测试。

最终判断仍来自调用点。如果柯里化版本让配置复用清楚、组合版本让阶段协议可见,就保留它们;若调用者必须学习项目独有的占位符和接收者规则才能读懂一行代码,普通函数通常是更小的接口。

延伸阅读

检查点

5个问题 · 2 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?