Skip to content

函数柯里化:参数的分步处理艺术

想象你在一家定制咖啡店点咖啡。普通的点单方式是一次性说完所有要求:"我要一杯大杯的拿铁,加双份浓缩,要燕麦奶,不加糖。"但有些咖啡师会分步骤确认:"先选择饮品类型?""拿铁。""杯型?""大杯。""浓缩份数?""双份。"这种分步骤处理的方式就类似于函数柯里化——不是一次接收所有参数,而是每次接收一部分参数,然后返回一个新函数来处理剩余的参数。

什么是函数柯里化

函数柯里化(Currying)是一种函数转换技术,它将一个接受多个参数的函数转换为一系列接受单个参数的函数。柯里化的名字来源于数学家 Haskell Curry,但这个概念实际上由 Moses Schönfinkel 首先提出。

让我们通过一个简单的例子来理解柯里化:

javascript
// 普通函数:一次接收所有参数
function add(a, b, c) {
  return a + b + c;
}

console.log(add(1, 2, 3)); // 6

// 柯里化函数:每次接收一个参数
function addCurried(a) {
  return function (b) {
    return function (c) {
      return a + b + c;
    };
  };
}

console.log(addCurried(1)(2)(3)); // 6

// 使用箭头函数的简洁写法
const addCurriedArrow = (a) => (b) => (c) => a + b + c;

console.log(addCurriedArrow(1)(2)(3)); // 6

普通的 add 函数一次性接收三个参数 abc,然后返回它们的和。而柯里化版本 addCurried 接收第一个参数 a,返回一个新函数;这个新函数接收第二个参数 b,又返回一个新函数;最后这个函数接收第三个参数 c,并计算最终结果。

调用方式也从 add(1, 2, 3) 变成了 addCurried(1)(2)(3)。每次调用都返回一个新函数,直到接收完所有参数。

柯里化的核心原理

柯里化的核心在于闭包。每个返回的函数都记住了之前传入的参数,这些参数被保存在闭包中,直到收集完所有参数后才执行最终的计算。

让我们详细看一个例子:

javascript
function multiply(a) {
  console.log(`Received first argument: ${a}`);

  return function (b) {
    console.log(`Received second argument: ${b}`);

    return function (c) {
      console.log(`Received third argument: ${c}`);
      console.log(`Computing: ${a} * ${b} * ${c}`);
      return a * b * c;
    };
  };
}

// 逐步调用
const step1 = multiply(2);
console.log("After first call, returned a function");

const step2 = step1(3);
console.log("After second call, returned another function");

const result = step2(4);
console.log(`Final result: ${result}`);

// 输出:
// Received first argument: 2
// After first call, returned a function
// Received second argument: 3
// After second call, returned another function
// Received third argument: 4
// Computing: 2 * 3 * 4
// Final result: 24

每个函数都保持着对之前参数的引用。当我们调用 multiply(2) 时,参数 a = 2 被保存在闭包中。返回的函数可以访问这个 a。当我们调用 step1(3) 时,参数 b = 3 被保存,同时仍然可以访问 a。最后调用 step2(4) 时,所有三个参数都可用,执行最终计算。

实现通用的柯里化函数

手动柯里化每个函数很繁琐。我们可以创建一个通用的柯里化函数,自动将任何函数转换为柯里化版本。

基础柯里化实现

javascript
function curry(fn) {
  return function curried(...args) {
    // 如果传入的参数数量足够,直接执行原函数
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }

    // 否则返回一个新函数,等待更多参数
    return function (...nextArgs) {
      return curried.apply(this, args.concat(nextArgs));
    };
  };
}

// 示例:普通函数
function sum(a, b, c) {
  return a + b + c;
}

// 转换为柯里化函数
const curriedSum = curry(sum);

// 多种调用方式都可以
console.log(curriedSum(1)(2)(3)); // 6
console.log(curriedSum(1, 2)(3)); // 6
console.log(curriedSum(1)(2, 3)); // 6
console.log(curriedSum(1, 2, 3)); // 6

这个 curry 函数检查当前收到的参数数量。如果参数数量已经达到原函数所需的数量(通过 fn.length 获取),就执行原函数。否则,返回一个新函数,这个新函数会将新参数与之前的参数合并,然后递归调用 curried

支持占位符的高级柯里化

有时我们想跳过某些参数,稍后再填充。我们可以使用占位符来实现:

javascript
function advancedCurry(fn, placeholder = Symbol("placeholder")) {
  return function curried(...args) {
    // 检查是否有足够的实际参数(排除占位符)
    const hasPlaceholder = args.some((arg) => arg === placeholder);
    const validArgs = args.filter((arg) => arg !== placeholder);

    if (!hasPlaceholder && args.length >= fn.length) {
      return fn.apply(this, args);
    }

    return function (...nextArgs) {
      // 合并参数,用新参数替换占位符
      let mergedArgs = args.map((arg) =>
        arg === placeholder && nextArgs.length > 0 ? nextArgs.shift() : arg
      );

      // 添加剩余的新参数
      mergedArgs = mergedArgs.concat(nextArgs);

      return curried.apply(this, mergedArgs);
    };
  };
}

// 使用占位符
const _ = Symbol("placeholder");
const curriedSubtract = advancedCurry((a, b, c) => a - b - c, _);

// 可以跳过中间参数
const subtractFrom10 = curriedSubtract(10);
const subtract5From10 = subtractFrom10(5);
console.log(subtract5From10(2)); // 3 - (10 - 5 - 2)

// 使用占位符跳过第一个参数
const subtractFromPlaceholder = curriedSubtract(_, 5, 2);
console.log(subtractFromPlaceholder(10)); // 3 - (10 - 5 - 2)

柯里化的实际应用

1. 参数复用

柯里化的一个主要用途是创建特定配置的函数变体,实现参数复用。

javascript
// 格式化日志的函数
function log(level, timestamp, message) {
  console.log(`[${level}] ${timestamp}: ${message}`);
}

// 柯里化
const curriedLog = curry(log);

// 创建特定级别的日志函数
const logError = curriedLog("ERROR");
const logWarning = curriedLog("WARNING");
const logInfo = curriedLog("INFO");

// 创建带时间戳的日志函数
const now = new Date().toISOString();
const logErrorNow = logError(now);
const logWarningNow = logWarning(now);

// 使用
logErrorNow("Database connection failed");
// [ERROR] 2025-12-04T14:51:20.000Z: Database connection failed

logWarningNow("API response time exceeds threshold");
// [WARNING] 2025-12-04T14:51:20.000Z: API response time exceeds threshold

logInfo(now)("Application started successfully");
// [INFO] 2025-12-04T14:51:20.000Z: Application started successfully

通过柯里化,我们创建了预配置的日志函数。logError 已经固定了日志级别为 "ERROR",logErrorNow 进一步固定了时间戳。这避免了每次都重复传入相同的参数。

2. 延迟执行

柯里化允许你逐步收集参数,在合适的时机执行函数。

javascript
// 计算折扣价格
function calculatePrice(basePrice, taxRate, discount) {
  const priceWithTax = basePrice * (1 + taxRate);
  const finalPrice = priceWithTax * (1 - discount);
  return finalPrice.toFixed(2);
}

const curriedPrice = curry(calculatePrice);

// 设置基础配置(税率在不同地区可能不同)
const priceInNY = curriedPrice(_, 0.08); // 纽约的税率
const priceInCA = curriedPrice(_, 0.0725); // 加州的税率

// 设置折扣(不同会员等级)
const premiumUserInNY = priceInNY(_, 0.2); // 高级会员8折
const regularUserInNY = priceInNY(_, 0.1); // 普通会员9折

// 计算具体商品价格
console.log(premiumUserInNY(100)); // $86.40
console.log(regularUserInNY(100)); // $97.20
console.log(priceInCA(100, 0.15)); // $91.16

3. 事件处理

在事件处理中,柯里化可以优雅地传递额外参数。

javascript
// 通用的事件处理器
function handleEvent(eventType, handler, element) {
  element.addEventListener(eventType, handler);
  return () => element.removeEventListener(eventType, handler);
}

const curriedHandle = curry(handleEvent);

// 创建特定事件类型的处理器
const handleClick = curriedHandle("click");
const handleHover = curriedHandle("mouseover");

// 创建特定处理逻辑的处理器
const handleClickWithLog = handleClick((e) => {
  console.log("Element clicked:", e.target);
});

// 应用到具体元素(假设在浏览器环境中)
/*
const button1 = document.getElementById("submit-btn");
const button2 = document.getElementById("cancel-btn");

const removeListener1 = handleClickWithLog(button1);
const removeListener2 = handleClickWithLog(button2);

// 稍后可以移除监听器
removeListener1();
removeListener2();
*/

4. 函数组合的准备

柯里化后的函数更容易组合,因为每个函数都返回单一参数的函数。

javascript
// 数据处理函数
const multiply = curry((factor, value) => value * factor);
const add = curry((addition, value) => value + addition);
const divide = curry((divisor, value) => value / divisor);

// 创建具体的转换函数
const double = multiply(2);
const addTen = add(10);
const halve = divide(2);

// 组合函数
function compose(...fns) {
  return function (value) {
    return fns.reduceRight((acc, fn) => fn(acc), value);
  };
}

const transform = compose(addTen, double, halve);

console.log(transform(20)); // 30
// 计算过程:
// halve(20) = 10
// double(10) = 20
// addTen(20) = 30

5. 表单验证

柯里化在构建可配置的验证函数时非常有用。

javascript
// 通用验证函数
const validate = curry((validator, errorMessage, value) => {
  return validator(value)
    ? { valid: true }
    : { valid: false, error: errorMessage };
});

// 具体验证器
const minLength = (min) => (str) => str.length >= min;
const maxLength = (max) => (str) => str.length <= max;
const hasPattern = (pattern) => (str) => pattern.test(str);

// 创建特定的验证函数
const validateMinLength = validate(
  minLength(8),
  "Must be at least 8 characters"
);
const validateMaxLength = validate(
  maxLength(20),
  "Must be at most 20 characters"
);
const validateHasUpperCase = validate(
  hasPattern(/[A-Z]/),
  "Must contain at least one uppercase letter"
);
const validateHasNumber = validate(
  hasPattern(/[0-9]/),
  "Must contain at least one number"
);

// 组合验证
function validatePassword(password) {
  const validators = [
    validateMinLength,
    validateMaxLength,
    validateHasUpperCase,
    validateHasNumber,
  ];

  for (let validator of validators) {
    const result = validator(password);
    if (!result.valid) {
      return result;
    }
  }

  return { valid: true };
}

console.log(validatePassword("weak"));
// { valid: false, error: "Must be at least 8 characters" }

console.log(validatePassword("WeakPassword"));
// { valid: false, error: "Must contain at least one number" }

console.log(validatePassword("StrongPass123"));
// { valid: true }

6. API 请求构建

柯里化可以帮助构建灵活的 API 请求函数。

javascript
// 通用的 API 请求函数
const apiRequest = curry((baseURL, method, endpoint, data) => {
  const url = `${baseURL}${endpoint}`;
  const options = {
    method: method,
    headers: {
      "Content-Type": "application/json",
    },
  };

  if (data) {
    options.body = JSON.stringify(data);
  }

  return fetch(url, options).then((res) => res.json());
});

// 配置基础 URL
const api = apiRequest("https://api.example.com");

// 配置不同的请求方法
const get = api("GET");
const post = api("POST");
const put = api("PUT");
const del = api("DELETE");

// 创建特定资源的请求函数
const getUsers = get("/users");
const createUser = post("/users");
const updateUser = put("/users");

// 使用
// getUsers(); // GET https://api.example.com/users
// createUser({ name: "John", email: "[email protected]" });
// updateUser({ id: 1, name: "John Updated" });

// 更具体的函数
const getUserById = (id) => get(`/users/${id}`)();
// getUserById(123); // GET https://api.example.com/users/123

柯里化 vs 偏函数应用

柯里化经常与偏函数应用(Partial Application)混淆。它们相似但有区别:

柯里化:将 n 元函数转换为 n 个一元函数的嵌套。每次只接收一个参数。

偏函数应用:固定函数的部分参数,返回一个接收剩余参数的新函数。可以一次固定多个参数。

javascript
// 原始函数
function greet(greeting, title, firstName, lastName) {
  return `${greeting}, ${title} ${firstName} ${lastName}!`;
}

// 柯里化:每次一个参数
const curriedGreet = curry(greet);
const hello = curriedGreet("Hello");
const helloMr = hello("Mr.");
const helloMrJohn = helloMr("John");
console.log(helloMrJohn("Doe")); // Hello, Mr. John Doe!

// 偏函数应用:可以一次固定多个参数
function partial(fn, ...fixedArgs) {
  return function (...remainingArgs) {
    return fn(...fixedArgs, ...remainingArgs);
  };
}

const greetMr = partial(greet, "Hello", "Mr.");
console.log(greetMr("John", "Doe")); // Hello, Mr. John Doe!
console.log(greetMr("Jane", "Smith")); // Hello, Mr. Jane Smith!

// 偏函数可以从中间固定参数(使用占位符)
const greetDoe = partial(greet, "Hi", _, _, "Doe");
// 但这需要更复杂的实现

柯里化总是返回一元函数的嵌套,调用语法是 f(a)(b)(c)。偏函数应用可以灵活地固定任意数量的参数,调用语法是 f(a, b, c)f(a)(b, c) 等。

在实践中,很多库(如 Lodash 的 _.curry)实现的其实是一个混合版本,既支持柯里化的单参数调用,也支持多参数的偏应用。

性能考虑

柯里化虽然强大,但也有性能成本。每次柯里化调用都会创建新的闭包和函数,这会消耗内存和执行时间。

javascript
// 性能测试示例
function normalAdd(a, b, c) {
  return a + b + c;
}

const curriedAdd = (a) => (b) => (c) => a + b + c;

// 测试普通函数
console.time("Normal");
for (let i = 0; i < 1000000; i++) {
  normalAdd(1, 2, 3);
}
console.timeEnd("Normal"); // Normal: ~5ms

// 测试柯里化函数
console.time("Curried");
for (let i = 0; i < 1000000; i++) {
  curriedAdd(1)(2)(3);
}
console.timeEnd("Curried"); // Curried: ~15ms

柯里化函数通常比普通函数慢 2-3 倍。在性能关键的代码路径中(如每帧执行的动画循环),应该避免过度使用柯里化。

优化建议:

  1. 仅在需要时使用:不要为了柯里化而柯里化,只在真正受益的场景使用
  2. 缓存柯里化结果:如果某个部分应用的函数会被重复使用,将其缓存起来
  3. 在开发中使用,在生产中优化:柯里化提高了开发体验,但在性能瓶颈处可以考虑去柯里化
javascript
// 缓存柯里化结果以提高性能
const logger = curry(log);
const logError = logger("ERROR"); // 缓存这个部分应用的函数

// 在循环中重复使用
for (let i = 0; i < 1000; i++) {
  logError(new Date().toISOString())(`Processing item ${i}`);
}

常见陷阱

1. 参数顺序很重要

柯里化函数的参数顺序应该从"最不可能改变"到"最可能改变"排列。

javascript
// ❌ 不好的参数顺序
const badFormat = curry((value, format) => {
  return format.replace("{value}", value);
});

// 每次都要先传value,不灵活
const formatNumber = badFormat(123);
console.log(formatNumber("Value: {value}")); // Value: 123
console.log(formatNumber("Number: {value}")); // Number: 123

// ✅ 好的参数顺序
const goodFormat = curry((format, value) => {
  return format.replace("{value}", value);
});

// 可以复用format
const priceFormat = goodFormat("Price: ${value}");
console.log(priceFormat(99.99)); // Price: $99.99
console.log(priceFormat(149.99)); // Price: $149.99

通用规则:将配置参数放在前面,数据参数放在后面。

2. 过度柯里化

不是所有函数都需要柯里化。过度使用会让代码难以理解。

javascript
// ❌ 过度柯里化
const calculate = (a) => (b) => (c) => (d) => (e) => (f) => {
  return ((a + b) * c - d) / e + f;
};

console.log(calculate(1)(2)(3)(4)(5)(6)); // 难以阅读

// ✅ 合理分组
const calculate2 = curry((a, b, c, d, e, f) => {
  return ((a + b) * c - d) / e + f;
});

console.log(calculate2(1, 2)(3)(4, 5, 6)); // 更清晰

3. 调试困难

柯里化的嵌套结构可能让调试变得困难。

javascript
// 难以调试
const complexOperation = curry((a, b, c, d) => {
  // 如果这里出错,堆栈跟踪会很深
  return a + b + c + d;
});

// 添加中间日志有助于调试
const debugCurry = curry((a, b, c, d) => {
  console.log("Arguments:", { a, b, c, d });
  const result = a + b + c + d;
  console.log("Result:", result);
  return result;
});

总结

函数柯里化是函数式编程中的一个强大工具,它通过将多参数函数转换为单参数函数的嵌套来提供更大的灵活性。

关键要点:

  • 柯里化将 f(a, b, c) 转换为 f(a)(b)(c)
  • 核心机制是闭包,每个函数记住之前的参数
  • 主要优势:参数复用、延迟执行、函数组合
  • 与偏函数应用相似但不同:柯里化总是单参数,偏应用可以多参数
  • 参数顺序很重要:从不变到多变
  • 有性能成本,在性能关键代码中谨慎使用
  • 不要过度使用,只在真正受益的场景使用

柯里化让代码更加模块化和可复用。配合纯函数使用,可以构建出清晰、可测试的函数式代码。在下一章中,我们将学习函数组合,它与柯里化结合可以创建强大的数据处理管道。