Published on

深入了解 JavaScript 中的函数式编程

Authors
  • avatar
    Name
    青雲
    Twitter

函数式编程是一个强大且日益流行的编程范式,它将函数作为程序构建的基本单元,并强调使用纯函数和不可变数据来编写代码。在JavaScript中,函数式编程的概念可以帮助开发者编写出更简洁、更可维护的代码。本文将深入探讨函数式编程在JavaScript中的应用,帮助你理解其核心概念,特性,以及如何在日常开发中实践。

函数式编程概念

函数式编程 (Functional Programming) 是一种编程范式,它强调使用函数作为程序的基本构建块来创建软件系统。它不同于命令式编程和面向对象编程,注重函数的使用和数据的不变性。这些特性都是由数学中的λ演算(Lambda Calculus)启发而来,这是一套抽象的计算模型,由阿隆佐·邱奇于20世纪30年代引入,为函数式编程提供了理论基础。关于λ演算的详细介绍,刚兴趣的可以看下这篇文章:https://www.lumin.tech/articles/lambda-calculus/。 函数式编程的特性有纯函数、不可变性、高阶函数、函数组合、柯里化、缓存函数、单子,接下来逐一介绍下这些核心概念。

纯函数

它们是函数式编程的基础。纯函数的输出只依赖于输入的参数,而与任何外部状态无关。因此,相同的输入永远返回相同的输出,不会产生副作用,即不会改变系统的状态。

// 这是一个纯函数
function add(a, b) {
  return a + b;
}

// 这是一个非纯函数,它依赖外部变量并改变它
let counter = 0;
function increment() {
  return counter++;
}

常见陷阱

  1. 副作用:纯函数不应该有观察到的副作用。常见的副作用包括修改全局变量、I/O 操作(如控制台打印、文件读写)或者改变参数的状态。
  2. 外部状态依赖:纯函数应当只依赖于其输入参数,而不应依赖外部状态。
  3. 日期和时间:直接在函数内得到当前日期或时间会使函数变得不纯,因为相同的输入参数在不同时间执行可能会得到不同的结果。
  4. 随机性:使用随机数生成器可以导致相同输入的多次调用返回不同的结果。
  5. 非函数式操作符:如 JavaScript 中的 ++ 或 -- 操作符会修改原始值,这种就地更新(in-place mutation)违反了纯函数的原则。

规避方法

  1. 避免使用全局变量:切勿在函数中直接修改或依赖任何全局变量,所有需要的输入都应该通过参数传入。
  2. 封装副作用:将那些有副作用的代码封装到一起,与其他纯粹的逻辑分开。
  3. 参数化副作用:如果需要依赖当前时间或随机数,应该将这些值作为参数传递给函数。
  4. 使用不可变数据结构:尽量使用不可变数据结构,如在 JavaScript ES6 所提供的 const 关键词,或使用专门的库。
  5. 无副作用的数据更新:使用函数式更新方法,创建新的副本而不是修改原有数据,例如使用 Array 的 map, filter, reduce 等方法。
  6. 避免使用类型变换或断言:不要假设输入总是正确的类型,函数应当处理不正确的类型或是非预期输入并且有恰当的返回值。

不可变性

Immutable 数据的核心思想是防止对象状态被意外或者刻意修改。使用 ES6 的 const 和扩展运算符,我们可以轻松实现不可变数据结构。

const arr = [1, 2, 3];
const newArr = [...arr, 4]; // 不改变原数组,创建了新数组

JS处理不可变性的方法

  1. 使用 const 关键字:使用 const 声明变量可以防止变量被重新指向新的值,但是它不能防止对象或数组内部的值被修改。
  2. 对象浅冻结(Shallow Freeze):使用 Object.freeze() 方法可以冻结一个对象,使其不再能够被修改(但这是浅冻结,对象内部嵌套的对象依然可以被修改)。
  3. 函数式更新方法:使用 .map(), .filter(), .reduce()等数组方法以不可变的方式更新数组。用展开运算符 (...)Object.assign() 来创建对象的副本。
  4. 深拷贝:当需要复制一个对象以确保原始对象不受更改影响时,应使用深拷贝,例如通过 JSON.parse(JSON.stringify(object))(这种方式有局限性,比如无法复制函数、正则等特殊对象)。

第三方库

  • Immutable.js:Facebook 的 Immutable.js 提供了 List, Map, Set 等不可变数据结构。它使用结构共享来节省内存和提高性能。
import { Map } from 'immutable';

let map1 = Map({ a: 1, b: 2, c: 3 });
let map2 = map1.set('b', 50);
  • Immer:Immer 提供了一个能让你在一个临时的草稿状态下工作,完成所有的更新后,再用这些改动生成一个不可变的状态。
import produce from 'immer';

let baseState = [{ title: "Learn Immer", done: false }];

let nextState = produce(baseState, draftState => {
  draftState.push({ title: "Tweet about it", done: false });
  draftState[0].done = true;
});

高阶函数

高阶函数是指一个函数可以接收函数作为参数或返回一个函数作为结果。

// 接收函数作为参数
function map(arr, fn) {
  const result = [];
  for (const value of arr) {
    result.push(fn(value));
  }
  return result;
}
const doubleValues = map([1, 2, 3], (x) => x * 2); // [2, 4, 6]

// 返回一个函数
function add(x) {
  return function(y) {
    return x + y;
  }
}
const addFive = add(5);
console.log(addFive(3)); // 8

函数组合

函数组合是将多个函数结合在一起,以流水线的方式进行计算的技术。

const compose = (f, g) => x => f(g(x));

const toUpperCase = str => str.toUpperCase();
const exclaim = str => `${str}!`;

const shout = compose(exclaim, toUpperCase);
console.log(shout('hello')); // HELLO!

柯里化(Currying)

柯里化(Currying) 是一种将接受多个参数的函数转换为一系列仅接受一个参数的函数的技术。这些一系列的单参数函数在调用时,每次都会捕获一个参数,并返回另一个函数来处理下一个参数,直到最终收集到所有参数并返回结果。 柯里化在函数式编程中非常重要,因为它允许你部分应用函数。部分应用使得你能够将某个函数的一部分参数永远绑定,这使得代码更简洁、可重用性更高。

以下是一个将普通函数转换为柯里化版本的示例:

function add(a, b) {
  return a + b;
}

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

柯里化后的函数:

function curryAdd(a) {
  return function(b) {
    return a + b;
  };
}

const addOne = curryAdd(1);
console.log(addOne(2)); // 3

在柯里化版本中,curryAdd 是一个接受一个参数 a 的函数,并返回另一个函数,这个函数接受另一个参数 b 并计算 a + b 的和。

实现一个柯里化函数

接下来编写一个通用的 curry 函数,它能够将任何多参数函数转换为柯里化函数。

function curry(fn) {
  return function curried(...args) {
    // fn.length 属性返回的是一个函数定义时的形参个数
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      return function(...moreArgs) {
        return curried.apply(this, args.concat(moreArgs));
      };
    }
  };
}

// 使用示例
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

在这个示例中,我们定义了一个通用的 curry 函数,可以将任何函数柯里化。柯里化后的函数能够一次接受所有参数,也可以分多次接受参数。

柯里化的应用

配置函数

柯里化允许你创建配置函数。通过部分应用,你可以创建预配置的函数,而不必重复输入相同的参数。

function multiplier(factor) {
  return function(number) {
    return number * factor;
  };
}

const double = multiplier(2);
const triple = multiplier(3);

console.log(double(5)); // 10
console.log(triple(5)); // 15

增强代码可读性

柯里化可以使代码更简洁,并且通过分步调用参数增强代码的可读性。

function greet(greeting) {
  return function(name) {
    return `${greeting}, ${name}!`;
  };
}

const sayHello = greet('Hello');
const sayHi = greet('Hi');

console.log(sayHello('John')); // Hello, John!
console.log(sayHi('Jane')); // Hi, Jane!

组合函数

在函数式编程中,柯里化与函数组合一起使用,可以实现更灵活的函数组合。

const compose = (f, g) => (x => f(g(x)));

const addOne = x => x + 1;
const multiplyByTwo = x => x * 2;

const addOneThenMultiplyByTwo = compose(multiplyByTwo, addOne);

console.log(addOneThenMultiplyByTwo(2)); // 6
console.log(addOneThenMultiplyByTwo(3)); // 8

通过使用柯里化,构建链式调用和组合变得更加直观,并且维护和理解更为容易。

缓存函数(Memoization)

Memoization是一种优化技术,通过缓存函数调用的结果来提高性能。当相同的输入再次传递给函数时,会直接返回缓存的结果,而不是重新计算。

function memoize(fn) {
  const cache = new Map();
  return function(...args) {
    const key = args.toString();
    if (cache.has(key)) {
      return cache.get(key);
    }
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

function slowAdd(a, b) {
  for (let i = 0; i < 1e6; i++) {} // 模拟慢速计算
  return a + b;
}

const memoizedAdd = memoize(slowAdd);
console.log(memoizedAdd(1, 2)); // 3(第一次调用,计算并缓存结果)
console.log(memoizedAdd(1, 2)); // 3(第二次调用,使用缓存结果)

通过缓存,我们减少了重复计算的次数,从而提升了函数的性能,尤其是在涉及大量相同输入重复计算的场景中。

为什么需要缓存函数

  1. 性能优化:缓存的核心目的是提高性能。如果一个函数进行了昂贵的运算(例如,复杂计算、数据库查询或网络请求),通过缓存其结果可以避免重复执行相同的运算。下一次调用该函数时,可以直接返回之前缓存的结果,从而节约资源和时间。
  2. 减少计算:对于纯函数(给定相同的输入总是返回相同的输出),使用缓存可以在不改变程序行为的情况下,减少不必要的计算,特别是在递归操作或者频繁请求相同资源的场景中。
  3. 缓解网络延迟:在处理与网络请求相关的函数时,缓存可以减轻网络延迟带来的影响,提高用户体验。
  4. 避免重复请求:当函数执行涉及到数据获取,尤其是通过网络进行时,为了避免数据获取的重复请求,缓存能够确保同样的请求在短时间内只发生一次。
  5. 函数依赖优化:当多个组件或函数依赖同一个计算结果时,缓存可以确保只计算一次,所有依赖者都能够共享这个结果。

缓存的常见实现方法

  1. 使用闭包:缓存数据可以存储在闭包中,这个闭包包裹了你的函数,以便函数可以访问并返回这些缓存值。
  2. 使用对象或Map:创建一个对象或Map来存储参数与计算结果的映射,如果遇到相同的参数,则直接返回之前存储的结果。
  3. 使用现成的库:例如使用Lodash的 _.memoize 函数来自动处理缓存逻辑。

单子(Monads)

单子(Monads)是函数式编程中较为高级的概念,它提供了一种将链式操作和副作用封装在独立计算单元中的方式。虽然单子在传统编程中不太常见,但它们在处理复杂的数据流、异步编程和副作用管理方面有很大帮助。

什么是单子?

单子是一个设计模式,是函数式编程中的一个抽象概念,用来处理包含上下文的计算(如异步操作、错误处理等)。单子通过以下三个基本操作来构建复杂的计算流程:

  • 单位操作(Unit):也称为 purereturnof,它用于将一个普通值封装成单子实例,从而在单子的上下文中使用它。
const Unit = value => ({
  map: func => Unit(func(value)),
  flatMap: func => func(value),
  get: () => value
});
  • 绑定操作(Bind):也称为 flatMapchain ,它允许我们对单子中的值应用函数,并返回另一个单子,这可以让我们串联起一系列操作。
Unit(5)
  .map(x => x + 1)     // value = 6
  .map(x => x * 2)     // value = 12
  .flatMap(x => Unit(x - 3))   // value = 9
  .get();              // 9
  • 链式调用:单子的性质使得我们能够通过链式调用,将多个单位操作和绑定操作串联起来,形成更复杂的计算流程。
const add = a => Unit(a + 1);
const multiply = a => Unit(a * 2);

Unit(5)
  .flatMap(add)        // value = 6
  .flatMap(multiply)   // value = 12
  .get();              // 12

在 JavaScript 中,一种常见的单子形式是 Promise。Promise 是一种处理异步操作的单子,提供 then 和 catch 方法将函数链式调用并处理异步操作。

const fetchData = () =>
  new Promise((resolve, reject) => {
    setTimeout(() => resolve('Data fetched'), 1000);
  });

const processData = (data) =>
  new Promise((resolve, reject) => {
    setTimeout(() => resolve(`${data} and processed`), 1000);
  });

fetchData()
  .then((data) => {
    console.log(data); // Data fetched
    return processData(data);
  })
  .then((processedData) => {
    console.log(processedData); // Data fetched and processed
  })
  .catch((error) => {
    console.error(error);
  });

在这个示例中,Promise 作为一种单子,将异步操作封装在一个计算单元中,并通过 then 方法依次执行操作。在遇到异步操作时,Promise 是一种非常有用的模式,因为它能简化代码,处理异步逻辑并改善代码的可读性。

单子的类型

单子有很多种类型,每种类型都适合不同的应用场景。以下是一些常见的单子:

Maybe 单子(或 Option 单子)

Maybe 单子用于处理可能缺失的值,通常有两种状态:Just 表示值存在,和 Nothing 表示值缺失。它对预防空指针异常或未定义错误特别有用。

const Maybe = value => ({
  map: func => value == null ? Maybe(null) : Maybe(func(value)),
  flatMap: func => value == null ? Maybe(null) : func(value),
  get: () => value
});

const safeDivide = (a, b) =>
  b === 0 ? Maybe(null) : Maybe(a / b);

Maybe(5)
  .flatMap(x => safeDivide(x, 0))
  .get(); // null

Either 单子

Either 单子用于出错处理,有两个子类:Left 用于错误或异常值的情况,而 Right 则代表正确的值或成功的操作。

const Left = value => ({
  map: _ => Left(value),
  flatMap: _ => Left(value),
  getOrElse: other => other
});

const Right = value => ({
  map: func => Right(func(value)),
  flatMap: func => func(value),
  getOrElse: _ => value
});

const divide = (a, b) =>
  b === 0 ? Left('Division by Zero Error') : Right(a / b);

Right(5)
  .flatMap(x => divide(x, 0))
  .getOrElse(0); // 0 (error case)

Right(5)
  .flatMap(x => divide(x, 2))
  .getOrElse(0); // 2.5

应用场景

  1. 异步编程:如 Promise 单子,用于处理多个异步操作,并提供链式调用。
  2. 错误处理与深度嵌套代码的简化:如 MaybeEither 单子,用于处理可能出现的错误或空值。
  3. 数据流的串接:如 Array 单子,用于处理数组中的每个元素,并通过链式操作进行批量处理。

实际应用

假设我们有一个在线商店的购物车应用,需要计算购物车中商品的总价格,并且要处理异步获取商品折扣信息的操作。

// 数据
const cart = [
  { id: 1, name: 'Product 1', price: 100, quantity: 2 },
  { id: 2, name: 'Product 2', price: 200, quantity: 1 },
  { id: 3, name: 'Product 3', price: 150, quantity: 3 }
];

// 纯函数:计算单个商品的总价格
const calculateItemTotal = item => item.price * item.quantity;

// 高阶函数:映射数组
const map = fn => arr => arr.map(fn);

// 函数组合:将各步骤串联起来
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);

// 记忆化:缓存已计算结果的函数
const memoize = fn => {
  const cache = {};
  return (...args) => {
    const key = JSON.stringify(args);
    if (cache[key]) {
      return cache[key];
    } else {
      const result = fn(...args);
      cache[key] = result;
      return result;
    }
  };
};
const memoizedCalculateItemTotal = memoize(calculateItemTotal);

// 柯里化的 reduce 函数
const curriedReduce = fn => init => arr => arr.reduce(fn, init);

// 通用的获取总价格函数
// 让调用者指定初始的累加器值
const getTotalPrice = (init, items) => compose(
  curriedReduce((sum, itemTotal) => sum + itemTotal)(init),
  map(memoizedCalculateItemTotal)
)(items);

// 测试
console.log(getTotalPrice(0, cart)); // 850

// 异步操作使用单子 (Promise)
const getDiscount = item => 
  new Promise(resolve => setTimeout(() => resolve(item.price * 0.1), 1000));

const applyDiscount = async item => {
  const discount = await getDiscount(item);
  return { ...item, price: item.price - discount };
};

// 处理异步操作
const processCartWithDiscounts = async cart => {
  const updatedCart = await Promise.all(cart.map(applyDiscount));
  const totalPrice = getTotalPrice(updatedCart);
  return totalPrice;
};

// 测试与解释输出
processCartWithDiscounts(cart).then(totalPrice => console.log(totalPrice));

在这个例子里:

  1. calculateItemTotal 是一个纯函数,它不依赖外部状态并返回确定的计算结果。
  2. 数据结构如 cart 保持不可变性,通过 map 函数返回新的数组。
  3. memoize 函数用于缓存计算结果,避免重复计算 calculateItemTotal 的结果,提升性能。
  4. memoizedCalculateItemTotal 对商品总价进行缓存。
  5. map 是一个高阶函数,用于对数组中的每个元素应用函数。
  6. curriedReduce 是一个柯里化的求和函数,用于将计算结果累加。
  7. compose 将映射操作和累加操作组合成一个新函数 getTotalPrice,用于计算购物车的总价格。
  8. 使用 Promise 处理异步操作,通过 getDiscountapplyDiscount 函数异步获取折扣信息。
  9. processCartWithDiscounts 函数中,使用 Promise.all 并行处理异步操作,将结果合并并返回最终价格。

总结

函数式编程在现代 JavaScript 开发中已成为一种重要的编程范式,它通过推广无副作用的函数、不变性和数据转换来提高代码的可读性、可维护性和测试性。ECMAScript 2017(也称为ES8)和后续版本通过引入如异步函数(async/await)、Object.valuesObject.entriesString paddingpadStartpadEnd)等新特性,进一步加强了 JavaScript 中的函数式编程能力。特别是异步函数简化了异步编程模型,使函数式风格的错误处理和控制流成为可能。

这些增强的特性和新增的能力结合了函数式编程中的理念,如高阶函数、纯函数和不可变数据结构,进一步展示了函数式编程在处理并发、事件驱动的编程模型以及构建大型应用程序当中的优势。通过致力于学习和应用这些概念和技术,JavaScript 开发者可以提高编写出健壮、可靠和可维护代码的能力。此外,这种编程风格鼓励开发者采用声明式方法表示逻辑,而不是命令式步骤,这在创建复杂系统时具有清晰性和简洁性的优势。

理解和掌握函数式编程的原则与技巧对于现代 JavaScript 开发者而言是极为重要的。随着 JavaScript 语言和环境的持续发展,函数式编程将继续作为一个关键的工具,帮助开发者高效地解决日益复杂的前端开发挑战。