【JS】函数式编程

250 阅读5分钟

参考

介绍

数学上的函数,是对集合与集合之间映射关系的一种描述,如f(x)=x^2g(x,y)=x+y,通过向函数中输入一个或多个值,能得到一个确定的输出值。

函数式编程便是一种启发自数学函数的编程范式(即典型的编程风格),只不过它的输入和输出,不是数字,而是数据。譬如,经过多次函数映射,将原始数据x转换为目标数据x'y=f(x), z=g(y), x'=h(z)。这样的处理过程类似于工厂中的流水线,原始产品x通过工序fgh被加工成目标产品x'

外在

现有原始数据x,以及工序ABCD。原始数据x依次经过以上工序,能转换成目标数据y。考虑采用不同写法的代码在形式上有什么不同。

若采用命令式的写法:

const fn = (x) => D(C(B(A(x))));
const y = fn(x);

这是最符合直觉的写法,但代码的可读性差。

若采用面向对象的写法:

const y = x.A().B().C().D();

使用链式调用后,代码的可读性增强了。但是原型链上可供链式调用的函数有限,限制了代码的逻辑表现力。

若采用函数式的写法:

// compose:
// 倒序执行函数并传递返回值,
// 详情请见"函数组合"这一节
const fn = compose(D, C, B, A);
const y = fn(x);

多个函数组合成fn供使用,像是一条真正的流水线。

然而,至此只能说明函数式编程在形式上更为直观和简洁,其精髓尚未体现。

内在

实现前提

在 JS 中,函数是一等公民:函数和其他数据类型一样,能赋值给其他变量,或作为参数传入另一个函数。这是函数式编程得以实现的前提。

核心概念

函数式编程的核心概念,亦核心要求为:

  • 无状态:函数的执行不依赖外部状态(如全局变量、外部变量、this指向和 IO 操作等)。所以每次给定函数相同的输入,必然给出相同的输出。
  • 数据不变:函数的执行不影响外部状态(如全局变量和传入参数等)。如果需要修改一个对象,应该创建该对象的副本用于修改。这保证了函数没有**没有副作用**。

当一个函数符合以上 2 点时,即为纯函数。譬如如下代码中的函数:

const user = {
  name: "Tom",
};
const sayHello = (user) => {
  // 未依赖外部状态
  console.log(`${user.name}: Hello!`);
};
const changeName = (user, name) => {
  // 未影响外部状态
  return {
    ...user,
    name,
  };
};
sayHello(user);
const newUser = changeName(user, "Helen");

而如下代码中的函数,则是不纯的:

const user = {
  name: "Tom",
};
const sayHello = () => {
  // 依赖了外部状态
  console.log(`${user.name}: Hello!`);
};
const changeName = (user, name) => {
  // 影响了外部状态
  user.name = name;
};
sayHello();
changeName(user, "Helen");

使用纯函数的意义在于:执行过程中不依赖外部状态,既保证了函数内部的修改不会对函数外部的代码产生影响,也使得函数的执行结果具有可缓存性、可预测性和便于测试。同时,执行过程中不影响外部状态,加强代码的可维护性,避免了很多由源于共享状态的 BUG。

运算形式

柯里化

柯里化指将一个多元函数,转换成一个依次调用的单元函数。如f(x,y) => f(x)(y)

// 柯里化前
const add = (x, y) => {
  return x + y;
};
add(1, 2); // 3

// 柯里化后
const add = (x) => {
  return (y) => {
    return x + y;
  };
};
add(1)(2); // 3

柯里化的通用式和使用方法为:

// 通用式:
// 此处做了优化,支持一次传入多个参数,
// 当参数个数足够时,便返回结果,
// 可以理解为”高级柯里化“
const curry = (fn, curArgs = []) => {
  return (...newArgs) => {
    const args = [...curArgs, ...newArgs];
    if (args.length < fn.length) {
      return curry(fn, args);
    }
    return fn(...args);
  };
};

// 使用方法:
const add = (a, b, c) => a + b + c;
const curryAdd = curry(add);
curryAdd(3, 4, 7); // 14
curryAdd(3, 4)(7); // 14
curryAdd(3)(4, 7); // 14
curryAdd(3)(4)(7); // 14

通过柯里化将函数单值化(仅有一个输出),增强了函数的多样性和适用性:

const replace = curry((a, b, str) => {
  str.replace(a, b);
});
const replaceSpaceWith = replace(/\s*/);
const replaceSpaceWithComma = replaceSpaceWith(",");
const replaceSpaceWithDash = replaceSpaceWith("-");

更重要的是,单值函数是接下来要讲到的函数组合的基础。

函数组合

函数组合指将多个函数组合成一个函数。如:

// compose(x) = f(g(x))
const compose = (f, g) => {
  return (x) => {
    return f(g(x));
  };
};
// f(x) = x + 1
const f = (x) => {
  return x + 1;
};
// g(x) = x^2
const g = (x) => {
  return x * x;
};
// h(x) = x^2 + 1
const h = compose(f, g);
h(3); // 10

函数组合的通用式和使用方法为:

// 通用式:
const compose = (...fns) => {
  return (...args) => {
    return fns.reduceRight((curVal, nextFn) => {
      return nextFn.apply(null, [].concat(curVal));
    }, args);
  };
};

// 使用方法:
const f = (x) => {
  return x + 1;
};
const g = (x, y) => {
  return x * y;
};
const h = compose(f, g);
h(3, 6); // 19

函数组合使得代码的可读性大大增强,而且:

  • 因为用于组合的函数都是纯函数,所以组合出的函数安全且可靠。
  • 因为用于组合的函数都是单值函数,所以返回值能被正确的传递。

组合函数的过程和搭积木别无二致,只不过在编程中,积木块是函数。复杂的逻辑,都可以通过这样一步步的拆分组合实现,而剩下要做的,就是去构造足够多的积木块(函数)。

相关库

Ramda