JS 函数式编程进阶:纯函数、柯里化、函数组合与不可变数据

欢迎来到今天的“JS 函数式编程进阶”讲座!我是你们的老朋友,代码界的段子手,今天咱们不搞虚的,直接上干货,聊聊纯函数、柯里化、函数组合和不可变数据这些听起来高大上,其实挺接地气的概念。准备好了吗? Let’s go!

一、纯函数:像处女座一样严谨的函数

啥叫纯函数?简单来说,就是你给它同样的输入,它永远给你同样的输出,而且没有任何副作用。就像一个靠谱的朋友,你问他借钱,他如果能借,每次都借你同样的金额,而且不会顺便跟你推销理财产品,这就是纯函数。

  • 定义:

    1. 对于相同的输入,总是产生相同的输出。
    2. 没有副作用(Side Effects)。不修改外部状态,不操作DOM,不发送网络请求,不改变全局变量等等。
  • 举例:

    // 纯函数
    function add(x, y) {
        return x + y;
    }
    
    // 非纯函数 (修改了外部变量)
    let z = 1;
    function impureAdd(x, y) {
        z = x + y; // 修改了全局变量 z
        return z;
    }
    
    // 非纯函数 (操作了 DOM)
    function setElementText(elementId, text) {
        document.getElementById(elementId).innerText = text; // 副作用:修改了 DOM
    }
  • 好处:

    • 可预测性: 因为输入确定输出,所以更容易测试和调试。
    • 可缓存性(Memoization): 如果一个纯函数的输入参数不变,那么它的结果就可以被缓存起来,下次直接使用缓存结果,提高性能。
    • 易于组合: 纯函数可以像乐高积木一样组合起来,构建更复杂的逻辑。
    • 并行/并发执行: 由于没有副作用,纯函数可以安全地并行执行,充分利用多核 CPU 的优势。
  • 纯函数的缓存(Memoization):

    function memoize(fn) {
      const cache = {};
    
      return function(...args) {
        const key = JSON.stringify(args);
        if (cache[key]) {
          console.log("Fetching from cache...");
          return cache[key];
        } else {
          console.log("Calculating...");
          const result = fn(...args);
          cache[key] = result;
          return result;
        }
      };
    }
    
    function expensiveCalculation(x, y) {
      console.log("Performing expensive calculation...");
      return x * y;
    }
    
    const memoizedCalculation = memoize(expensiveCalculation);
    
    console.log(memoizedCalculation(5, 10)); // 计算
    console.log(memoizedCalculation(5, 10)); // 从缓存获取
    console.log(memoizedCalculation(6, 10)); // 计算

二、柯里化(Currying):把函数变成“连续剧”

柯里化,不是印度菜,而是一种把接受多个参数的函数,变成接受单个参数的函数,并且返回接受余下参数且返回结果的新函数的技术。 就像把一道大菜分解成几个小步骤,一步一步来。

  • 定义: 将一个接受多个参数的函数转化为一系列只接受一个参数的函数的过程。

  • 举例:

    // 原始函数
    function add(x, y, z) {
        return x + y + z;
    }
    
    // 柯里化后的函数
    function curriedAdd(x) {
        return function(y) {
            return function(z) {
                return x + y + z;
            }
        }
    }
    
    console.log(add(1, 2, 3)); // 6
    console.log(curriedAdd(1)(2)(3)); // 6

    更简洁的写法(ES6):

    const curriedAdd = x => y => z => x + y + z;
  • 通用柯里化函数:

    function curry(fn) {
      return function curried(...args) {
        if (args.length >= fn.length) {
          return fn(...args);
        } else {
          return function(...nextArgs) {
            return curried(...args, ...nextArgs);
          };
        }
      };
    }
    
    // 使用 curry
    function multiply(x, y, z) {
      return x * y * z;
    }
    
    const curriedMultiply = curry(multiply);
    
    console.log(curriedMultiply(2)(3)(4)); // 24
    console.log(curriedMultiply(2, 3)(4));   // 24
    console.log(curriedMultiply(2)(3, 4));   // 24
    console.log(curriedMultiply(2, 3, 4));   // 24
  • 好处:

    • 延迟执行: 可以先传入部分参数,稍后再传入剩余参数,实现延迟计算。
    • 参数复用: 固定某些参数,生成新的函数,简化代码。
    • 代码更具可读性: 将复杂函数拆分成多个小函数,更容易理解和维护。
  • 柯里化的应用:

    // 假设我们有一个日志函数
    function log(date, type, message) {
        console.log(`${date.toISOString()} - ${type}: ${message}`);
    }
    
    // 柯里化 log 函数
    const curriedLog = curry(log);
    
    // 创建一个特定类型的日志函数
    const debugLog = curriedLog(new Date())('DEBUG');
    const errorLog = curriedLog(new Date())('ERROR');
    
    // 使用
    debugLog('This is a debug message');
    errorLog('This is an error message');

三、函数组合(Function Composition):像搭积木一样构建程序

函数组合,就是把多个函数像管道一样连接起来,一个函数的输出作为另一个函数的输入,最终得到一个更强大的函数。 就像搭积木,把一个个小模块组合成一个复杂的模型。

  • 定义: 将多个函数组合成一个函数,前一个函数的输出是后一个函数的输入。

  • 举例:

    function toUpperCase(str) {
        return str.toUpperCase();
    }
    
    function addExclamation(str) {
        return str + '!';
    }
    
    // 函数组合
    function compose(f, g) {
        return function(x) {
            return f(g(x));
        }
    }
    
    const excited = compose(addExclamation, toUpperCase);
    
    console.log(excited('hello')); // HELLO!
  • 通用组合函数(从右向左执行):

    function compose(...fns) {
      return function(x) {
        return fns.reduceRight((acc, fn) => fn(acc), x);
      };
    }
    
    // 示例
    function double(x) {
        return x * 2;
    }
    
    function increment(x) {
        return x + 1;
    }
    
    const composedFunction = compose(double, increment);
    
    console.log(composedFunction(5)); // (5 + 1) * 2 = 12
  • 通用组合函数(从左向右执行,也称为 pipe):

    function pipe(...fns) {
      return function(x) {
        return fns.reduce((acc, fn) => fn(acc), x);
      };
    }
    
    // 示例
    const pipedFunction = pipe(increment, double);
    
    console.log(pipedFunction(5)); // (5 + 1) * 2 = 12
  • 好处:

    • 代码简洁: 将多个小函数组合成一个大函数,减少代码重复。
    • 可读性强: 更容易理解函数的执行流程。
    • 可维护性高: 修改某个小函数不会影响其他函数。
  • 函数组合的应用:

    // 假设我们有三个函数
    function trim(str) {
        return str.trim();
    }
    
    function splitByComma(str) {
        return str.split(',');
    }
    
    function mapToNumber(arr) {
        return arr.map(Number);
    }
    
    // 使用函数组合
    const processData = compose(mapToNumber, splitByComma, trim);
    
    const data = ' 1, 2 , 3 ';
    console.log(processData(data)); // [1, 2, 3]

四、不可变数据(Immutable Data):数据界的“钢铁侠”

不可变数据,就是一旦创建就不能被修改的数据。 就像钢铁侠的战甲,一旦定型就不能随便变形。

  • 定义: 创建后不能被修改的数据。 修改不可变数据时,实际上是创建了一个新的数据副本。

  • 举例:

    // JavaScript 中的基本类型是不可变的
    let str = 'hello';
    str.toUpperCase(); // 'HELLO' (不会修改原始字符串 str)
    console.log(str); // 'hello'
    
    // JavaScript 中的对象和数组是可变的
    let obj = { name: 'Alice' };
    obj.name = 'Bob'; // 修改了原始对象 obj
    console.log(obj); // { name: 'Bob' }
    
    let arr = [1, 2, 3];
    arr.push(4); // 修改了原始数组 arr
    console.log(arr); // [1, 2, 3, 4]
  • 如何实现不可变数据:

    1. 使用 Object.freeze() 可以冻结一个对象,使其属性不能被修改。

      const obj = { name: 'Alice' };
      Object.freeze(obj);
      
      obj.name = 'Bob'; // 尝试修改会静默失败 (严格模式下会抛出 TypeError)
      console.log(obj); // { name: 'Alice' }

      注意: Object.freeze() 只能提供浅层不可变性。 如果对象包含嵌套对象,嵌套对象仍然是可变的。

    2. 使用 Immutable.js 库: 提供了各种不可变数据结构,例如 ListMapSet 等。

      const { Map } = require('immutable'); // 或者 import { Map } from 'immutable';
      
      const map1 = Map({ a: 1, b: 2, c: 3 });
      const map2 = map1.set('b', 50);
      
      console.log(map1.get('b')); // 2
      console.log(map2.get('b')); // 50
      console.log(map1 === map2);  // false (map2 是一个新对象)
    3. 使用 Immer.js 库: 使用更方便的方式来操作不可变数据,基于draft。

       import { produce } from "immer"
      
       const baseState = [
           { title: "Learn TypeScript", done: true },
           { title: "Try Immer", done: false }
       ]
      
       const nextState = produce(baseState, draft => {
           draft.push({title: "Tweet about it", done: false})
           draft[1].done = true
       })
      
       console.log(baseState)
       // => [ { title: "Learn TypeScript", done: true }, { title: "Try Immer", done: false } ]
       console.log(nextState)
       // => [ { title: "Learn TypeScript", done: true }, { title: "Try Immer", done: true }, { title: "Tweet about it", done: false } ]
       console.log(baseState === nextState)
       // => false
    4. 手动创建数据副本: 例如使用 slice() 创建数组副本,使用 Object.assign() 或扩展运算符 (...) 创建对象副本。

      const arr = [1, 2, 3];
      const newArr = arr.slice(); // 创建数组副本
      newArr.push(4);
      
      console.log(arr); // [1, 2, 3]
      console.log(newArr); // [1, 2, 3, 4]
      
      const obj = { name: 'Alice' };
      const newObj = Object.assign({}, obj); // 创建对象副本
      newObj.name = 'Bob';
      
      console.log(obj); // { name: 'Alice' }
      console.log(newObj); // { name: 'Bob' }
  • 好处:

    • 避免意外修改: 防止在程序的不同部分意外修改数据,导致难以追踪的 bug。
    • 更容易进行状态管理: 在 React、Redux 等框架中,不可变数据可以简化状态管理,更容易进行状态追踪和回溯。
    • 提高性能: 在某些情况下,不可变数据可以提高性能,例如在 React 中,当数据发生变化时,只有发生变化的组件才需要重新渲染。
  • 表格总结:

特性 纯函数 柯里化 函数组合 不可变数据
定义 相同输入总是产生相同输出,无副作用 将多参数函数转换为一系列单参数函数 将多个函数组合成一个函数,前一个输出是后一个输入 创建后不能被修改的数据
优点 可预测性,可缓存性,易于组合,易于并行执行 延迟执行,参数复用,代码可读性更强 代码简洁,可读性强,可维护性高 避免意外修改,更容易进行状态管理,提高性能
缺点 可能需要更多的代码来避免副作用 可能会增加代码的复杂性 需要仔细设计函数接口 需要额外的库或技术来实现,可能增加内存占用
适用场景 数据转换,计算密集型任务 需要延迟执行或参数复用的场景 需要将多个操作组合成一个流程的场景 需要保证数据一致性和避免副作用的场景
常用库/方法 curry() 函数 (自定义或使用 lodash/Ramda) compose() / pipe() 函数 (自定义或使用 lodash/Ramda) Object.freeze(), Immutable.js, Immer.js

五、总结:

今天我们聊了纯函数、柯里化、函数组合和不可变数据这四个函数式编程的重要概念。 它们就像编程界的四大金刚,掌握了它们,你就能写出更健壮、更易维护、更易测试的代码。

记住,函数式编程不是银弹,不是所有场景都适用。 但是,在合适的场景下,它可以大大提高你的开发效率和代码质量。

希望今天的讲座对你有所帮助! 下课! (敲黑板)

发表回复

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