JS 柯里化 (Currying):函数参数的偏应用与函数复用

各位程序猿,大家好!我是你们今天下午的JS柯里化专题讲座讲师,叫我老王就行。今天咱们不搞虚的,直接上干货,聊聊JS里一个听起来高大上,用起来贼好使的技术——柯里化(Currying)。

开场白:柯里化,你别怕,它真不难!

很多人一听到“柯里化”三个字,就感觉像进了什么魔法学院,满眼都是咒语和符文,恨不得直接逃课。淡定!柯里化其实没那么可怕,它只是把一个接受多个参数的函数,变成一系列接受单个参数的函数。

第一部分:什么是柯里化?(What the heck is Currying?)

想象一下,你是个厨师,要做一道“蒜蓉烤生蚝”。你需要蒜蓉、生蚝、烤箱。

  • 传统做法: 你把蒜蓉、生蚝一股脑全扔进烤箱,然后等着出锅。这就像一个普通函数,一次性接收所有参数。

    function 烤生蚝(蒜蓉, 生蚝, 烤箱) {
      console.log(`烤箱温度:${烤箱.温度}度`);
      console.log(`蒜蓉香味扑鼻!`);
      console.log(`美味的蒜蓉烤生蚝出炉!`);
    }
    
    const 我的烤箱 = { 温度: 200 };
    烤生蚝("新鲜蒜蓉", "肥美生蚝", 我的烤箱);
  • 柯里化做法: 你先准备好蒜蓉,然后告诉顾客:“您要啥生蚝?” 顾客选好生蚝后,你再问:“用哪个烤箱?” 最后,你才开始烤。这就像柯里化函数,一步一步接收参数。

    function 柯里化烤生蚝(蒜蓉) {
      return function(生蚝) {
        return function(烤箱) {
          console.log(`烤箱温度:${烤箱.温度}度`);
          console.log(`蒜蓉和${生蚝}完美结合!`);
          console.log(`香喷喷的蒜蓉烤${生蚝}出炉!`);
        };
      };
    }
    
    const 准备好的蒜蓉 = "新鲜蒜蓉";
    const 顾客点的生蚝 = "进口生蚝";
    const 顾客指定的烤箱 = { 温度: 220 };
    
    柯里化烤生蚝(准备好的蒜蓉)(顾客点的生蚝)(顾客指定的烤箱);

是不是有点感觉了?

更学术一点的定义:

柯里化(Currying)是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术。 换句话说,它将一个接受 n 个参数的函数转换成 n 个嵌套的函数,每个函数依次接受一个参数。

第二部分:柯里化的好处(Why should I care?)

你可能会问:“老王,你烤个生蚝都这么麻烦,直接一股脑扔进去不香吗?柯里化有啥用?”

别急,柯里化的好处可多了:

  1. 参数复用(Partial Application):

    • 场景: 假设你经常用同一款烤箱烤东西,每次都得传一遍烤箱参数,烦不烦?柯里化可以让你预先设置好烤箱,以后直接用就行。

      // 原始函数
      function calculateArea(length, width) {
        return length * width;
      }
      
      // 柯里化后的函数
      function curriedCalculateArea(length) {
        return function(width) {
          return length * width;
        };
      }
      
      // 假设我们经常计算长度为5的矩形面积
      const calculateAreaWithLength5 = curriedCalculateArea(5);
      
      // 现在,只需要传入宽度即可
      const area1 = calculateAreaWithLength5(10); // 50
      const area2 = calculateAreaWithLength5(20); // 100

      这里,calculateAreaWithLength5 就像一个“半成品”函数,已经固定了长度,只需要传入宽度就能计算面积。

    • 表格对比:

      特性 普通函数调用 柯里化函数调用
      参数传递 一次性传递所有参数 逐步传递参数
      参数复用 每次调用都需要传递所有参数 可以预先设置部分参数,后续调用只需传递剩余参数
      函数灵活性 相对固定 更加灵活,可以根据需要生成不同的“半成品”函数
      代码可读性 在参数较多时,可能降低可读性 提高代码可读性,特别是当参数的含义比较明确时
  2. 延迟执行(Lazy Evaluation):

    • 场景: 有些计算比较耗时,你可能不想立即执行,而是等到真正需要结果的时候再执行。柯里化可以让你把计算推迟到最后一个参数传入时。

      function logMessage(level) {
        return function(message) {
          return function(timestamp) {
            console.log(`[${timestamp}] [${level}] ${message}`);
          };
        };
      }
      
      // 先创建一个debug级别的logger
      const debugLog = logMessage("DEBUG");
      
      // 稍后,当我们需要记录一条debug信息时
      debugLog("Something happened")(new Date()); // [当前时间] [DEBUG] Something happened

      debugLog 函数并没有立即执行 console.log,而是等到 timestamp 参数传入后才执行。

  3. 代码组合(Function Composition):

    • 场景: 你想把多个功能简单的函数组合成一个功能更强大的函数。柯里化可以让你更容易地实现函数组合。

      function add(x) {
        return function(y) {
          return x + y;
        };
      }
      
      function multiply(x) {
        return function(y) {
          return x * y;
        };
      }
      
      // 组合函数:先加 5,再乘以 2
      const add5AndMultiply2 = function(z) {
        return multiply(2)(add(5)(z));
      };
      
      console.log(add5AndMultiply2(3)); // (3 + 5) * 2 = 16

      虽然这个例子比较简单,但它展示了柯里化如何让函数组合变得更加简洁和灵活。

第三部分:手写柯里化函数(Let’s get our hands dirty!)

说了这么多理论,咱们来点实际的。手写一个柯里化函数,让你彻底理解它的原理。

  • 简单版:

    function curry(fn) {
      return function curried(...args) {
        if (args.length >= fn.length) {
          return fn.apply(this, args);
        } else {
          return function(...args2) {
            return curried.apply(this, args.concat(args2));
          };
        }
      };
    }

    代码解释:

    1. curry(fn):接受一个函数 fn 作为参数,返回一个柯里化后的函数。
    2. curried(...args):柯里化后的函数,使用剩余参数语法 ...args 接收所有传入的参数。
    3. if (args.length >= fn.length):判断传入的参数个数是否大于等于原始函数的参数个数。
      • 如果是,说明所有参数都已传入,直接调用原始函数 fn.apply(this, args),并返回结果。
      • 如果不是,说明还有参数未传入,返回一个新的函数,继续接收参数。
    4. function(...args2):返回的新函数,同样使用剩余参数语法 ...args2 接收新的参数。
    5. return curried.apply(this, args.concat(args2)):将之前传入的参数 args 和新传入的参数 args2 合并,然后递归调用 curried 函数,继续接收参数。
  • 使用示例:

    function add(a, b, c) {
      return 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 _ = {}; // 占位符
    
    function curryWithPlaceholder(fn) {
      return function curried(...args) {
        const allArgs = [];
        let argIndex = 0;
    
        for (let i = 0; i < fn.length; i++) {
          if (args[argIndex] === _) {
            allArgs.push(arguments[i]); // 使用arguments访问真实的占位符位置
          } else {
            allArgs.push(args[argIndex]);
          }
          argIndex++;
        }
    
        if (allArgs.filter(arg => arg === _).length === 0) {
          return fn.apply(this, allArgs);
        } else {
          return function(...args2) {
            return curried.apply(this, allArgs.concat(args2));
          };
        }
      };
    }

    代码解释:

    1. _ = {}:定义一个空对象作为占位符。
    2. curried(...args):柯里化后的函数,使用剩余参数语法 ...args 接收所有传入的参数。
    3. allArgs = []:创建一个数组,用于存储所有参数(包括占位符)。
    4. for (let i = 0; i < fn.length; i++):遍历原始函数的参数列表。
      • if (args[argIndex] === _):如果当前参数是占位符,则将占位符添加到 allArgs 数组中。
      • else:如果当前参数不是占位符,则将实际参数添加到 allArgs 数组中。
      • argIndex++:移动到下一个参数。
    5. if (allArgs.filter(arg => arg === _).length === 0):判断 allArgs 数组中是否还有占位符。
      • 如果没有,说明所有参数都已传入,直接调用原始函数 fn.apply(this, allArgs),并返回结果。
      • 如果有,说明还有参数未传入,返回一个新的函数,继续接收参数。
    6. function(...args2):返回的新函数,同样使用剩余参数语法 ...args2 接收新的参数。
    7. return curried.apply(this, allArgs.concat(args2)):将之前传入的参数 allArgs 和新传入的参数 args2 合并,然后递归调用 curried 函数,继续接收参数。
  • 使用示例:

    function divide(a, b) {
        return a / b;
    }
    
    const curriedDivide = curryWithPlaceholder(divide);
    
    // 先传入除数,稍后传入被除数
    const divideBy = curriedDivide(_, 2);
    
    console.log(divideBy(10)); // 5
    console.log(divideBy(20)); // 10

    在这个例子中,我们先传入了占位符和除数 2,得到了一个“半成品”函数 divideBy。 稍后,我们只需要传入被除数,就能计算结果。

第四部分:柯里化的应用场景(Where can I use this magic?)

柯里化可不是屠龙之技,它在实际开发中有很多用武之地:

  1. 事件处理:

    • 场景: 你想为多个按钮绑定相同的事件处理函数,但每个按钮需要传递不同的参数。

      function handleClick(buttonId, event) {
        console.log(`Button ${buttonId} clicked!`);
        console.log(`Event type: ${event.type}`);
      }
      
      function bindButton(buttonId) {
        return function(event) {
          handleClick(buttonId, event);
        };
      }
      
      const button1 = document.getElementById("button1");
      const button2 = document.getElementById("button2");
      
      button1.addEventListener("click", bindButton(1));
      button2.addEventListener("click", bindButton(2));

      bindButton 函数柯里化了 handleClick 函数,预先设置了 buttonId 参数,使得每个按钮都能传递不同的参数。

  2. 数据验证:

    • 场景: 你需要对用户输入的数据进行验证,例如验证邮箱格式、密码强度等。

      function validate(validator, errorMessage, value) {
        if (!validator(value)) {
          console.log(errorMessage);
          return false;
        }
        return true;
      }
      
      function createValidator(validator, errorMessage) {
        return function(value) {
          return validate(validator, errorMessage, value);
        };
      }
      
      const validateEmail = createValidator(
        (email) => /^[^s@]+@[^s@]+.[^s@]+$/.test(email),
        "Invalid email format"
      );
      
      const validatePassword = createValidator(
        (password) => password.length >= 8,
        "Password must be at least 8 characters long"
      );
      
      validateEmail("[email protected]"); // true
      validateEmail("invalid-email");    // Invalid email format, false
      validatePassword("password123");  // true
      validatePassword("short");        // Password must be at least 8 characters long, false

      createValidator 函数柯里化了 validate 函数,预先设置了验证器和错误信息,使得我们可以方便地创建各种验证函数。

  3. 配置化:

    • 场景: 你想根据不同的配置生成不同的函数。

      function createAPIRequest(baseURL) {
        return function(endpoint, method) {
          return function(data) {
            const url = baseURL + endpoint;
            console.log(`Making ${method} request to ${url} with data:`, data);
            // 实际的API请求逻辑
          };
        };
      }
      
      const myAPI = createAPIRequest("https://api.example.com");
      
      const getUser = myAPI("/users", "GET");
      const createUser = myAPI("/users", "POST");
      
      getUser(); // Making GET request to https://api.example.com/users with data: undefined
      createUser({ name: "John Doe", email: "[email protected]" }); // Making POST request to https://api.example.com/users with data: { name: "John Doe", email: "[email protected]" }

      createAPIRequest 函数柯里化了 API 请求函数,预先设置了 baseURL,使得我们可以方便地创建各种 API 请求函数。

第五部分:总结与建议(The End… or is it?)

今天咱们聊了柯里化的概念、好处、实现和应用场景。 相信你已经对柯里化有了更深入的理解。

总结:

  • 柯里化是一种函数转换技术,将接受多个参数的函数转换成一系列接受单个参数的函数。
  • 柯里化可以实现参数复用、延迟执行和代码组合。
  • 柯里化在事件处理、数据验证和配置化等场景中都有广泛应用。

建议:

  • 不要害怕柯里化,它其实很简单。
  • 多练习,才能真正掌握柯里化。
  • 在合适的场景下使用柯里化,可以提高代码的可读性和可维护性。

最后,留个小作业:

请你用柯里化实现一个函数,用于格式化货币。 例如,可以设置货币符号、小数点位数等。

今天的讲座就到这里,希望对你有所帮助。 记住,编程之路,永无止境,Keep coding! 咱们下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注