<audio id="audio" title="加餐4 | 一篇文章，带你快速理解函数式编程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/de/28/dedb0fcdceff5526512e9996fb510228.mp3"></audio>

你好，我是月影。今天，我们来讨论函数式编程。

我看到很多同学留言说，课程中给出的代码例子有的地方看不明白。我把同学们看不懂的地方汇总了一下，发现大部分都是我使用函数式编程来写的。比如，我在第7讲说过的 parametric 高阶函数，第12讲说过的 traverse 的设计，还有第15讲中使用的 toPolar/fromPolar 和改进版的 parametric 设计，以及数据篇中的数据处理和D3.js的使用。

如果你还不习惯函数式编程思想，并且也觉得这些代码不容易理解，想知道为什么一定要这么设计，那这节课，你一定要好好听，我会和你系统地说说过程抽象和函数式编程这个话题。

## 两种编程范式：命令式与声明式

首先，我先来说说什么是编程范式。编程范式有两种，分别是命令式（Imperative）和声明式（Declarative），命令式强调做的步骤也就是怎么做，而声明式强调做什么本身，以及做的结果。因此，编程语言也可以分成命令式和声明式两种类型，如果再细分的话，命令式又可以分成过程式和面向对象，而声明式则可以分成逻辑式和函数式。下面这张图列出了编程语言的分类和每个类型下经典的编程语言。

<img src="https://static001.geekbang.org/resource/image/37/ae/3797e89ec55b48662c0e2ca58aa792ae.jpeg" alt="">

你注意看，这张图里并没有JavaScript。实际上像JavaScript这样的现代脚本语言，通常具有混合范式，也就是说JavaScript同时拥有命令式和声明式的特征。因此开发者可以同时用JavaScript写出命令式与声明式风格的代码。举个例子，我们要遍历一个数组，将每一个元素的数值翻倍，我们可以分别用命令式和声明式来实现。

首先，是命令式的实现代码：

```
let list = [1, 2, 3, 4];

let map1 = [];
for(let i = 0; i &lt; list.length; i++){
  map1.push(list[i] * 2);
}

```

然后是声明式的实现代码：

```
let list = [1, 2, 3, 4];
const double = x =&gt; x * 2;
list.map(double);

```

从上面的代码我们可以看到，虽然两段代码的目的相同，但是具体的实现手段差别很大。其中命令式强调怎么做，使用的是for循环来遍历，而声明式强调做什么，用到了double算子。

## 函数式与纯函数

既然编程风格有命令式和声明式，为什么我们在一些设计中更多会选择声明式风格的函数式编程，它究竟有什么好处呢？通过和前面的代码对比，我们看到似乎声明式（函数式）代码写起来更加简洁。是的，大部分情况下，函数式编程的代码更加简洁。但除了能减少代码量之外，函数式还有什么具体的好处呢？这个就要从纯函数说起了。

我们知道，函数是对过程的封装，但函数的实现本身可能依赖外部环境，或者有副作用（Side-effect)。所谓函数的副作用，是指函数执行本身对外部环境的改变。我们把不依赖外部环境和没有副作用的函数叫做纯函数，依赖外部环境或有副作用的函数叫做非纯函数。

这里，我们先来看一组例子：

```
function add(x, y) {
  return x + y;
}

function getEl(id) {
  return document.getElementById(id);
}

funciton join(arr1, arr2) {
  arr1.push(...arr2);
  return arr1;
}

```

在上面的代码中，add是一个纯函数，它的返回结果只依赖于输入的参数，与调用的次数、次序、时机等等均无关。而getEl是一个非纯函数，它的返回值除了依赖于参数id，还和外部环境（文档的DOM结构）有关。另外，join也是一个非纯函数，它的副作用是会改变输入参数对象本身的内容，所以它的调用次数、次序和时机不同，我们得到的结果也不同。

## 纯函数的优点

现在我们知道了纯函数与非纯函数的区别，但我们又为什么要人为地把函数划分为纯函数和非纯函数呢？这是因为纯函数与非纯函数相比，有三个非常大的优点，分别是易于测试（上下文无关）、可并行计算（时序无关）、有良好的Bug自限性。下面，我一一来解释一下。

首先纯函数易于测试，在用单元测试框架的时候，因为纯函数不需要依赖外部环境，所以我们直接写一个简单的测试case就可以了。

```
//test with pure functions
test(t =&gt; {
  dosth...
  
  done!
});

```

而非纯函数因为比较依赖外部环境，在测试的时候我们还需要构建外部环境。

```
//test with impure functions

//always need hooks
test.before(t =&gt; {
  //setup environments
});

test.after('cleanup', t =&gt; {
  //clean
});

test(t =&gt; {
  dosth...
  
  done!
});

```

其次，纯函数可以并行计算。在浏览器中，我们可以利用Worker来并行执行多个纯函数，在Node.js中，我们也可以用Cluster来实现同样的并行执行，而使用WebGL的时候，纯函数有时候还可以转换为Shader代码，利用GPU的特性来进行计算。

最后，纯函数有良好的Bug自限性。这是什么意思呢？因为纯函数不会依赖和改变外部环境，所以它产生的Bug不会扩散到系统的其他部分。而非纯函数，尤其是有副作用的非纯函数，在产生Bug后，因为Bug可能意外改变了外部环境，所以问题会扩散到系统其他部分。这样在调试的时候，就算发现了Bug，你可能也找不到真正导致Bug的原因，这就给系统的维护和Bug追踪带来困难。

总而言之，我们设计系统的时候，要尽可能多设计纯函数，少设计非纯函数，这样能够有效提升系统的可测试性、性能优化空间以及系统的可维护性。

## 函数式编程范式与纯函数

那么问题来了，我们该如何让系统的纯函数尽可能多，非纯函数尽可能少呢？答案是用函数式编程范式。我们还是通过一个例子来理解。

我们要实现一个模块，用它来操作DOM中列表元素，改变元素的文字颜色，具体的实现代码如下：

```
function setColor(el, color){
  el.style.color = color;
}

function setColors(els, color){
  els.forEach(el =&gt; setColor(el, color));
}

```

这个模块中有两个方法，其中setColor是操作一个DOM元素，改变它的文字颜色，而setColors则是批量操作若干个DOM元素，改变所有元素的颜色。

尽管这两个方法都非常简单，但它们都改变了外部环境（DOM）所以它们是两个非纯函数。因此，我们在做系统测试的时候，两个方法都需要构建外部环境来实现测试。

如果想让系统测试更简单，我们是不是可以采用函数式编程思想，把非纯函数的个数减少一个呢？当然可以，我们可以实现一个batch函数来优化。batch函数接受的参数是一个函数f，就会返回一个新的函数。在这个过程中，我们要遵循的调用规则是，如果这个参数有length属性，我们就以数组来遍历这个参数，用每一个元素迭代f，否则直接用当前调用参数来调用f就可以了。

具体的实现代码如下：

```
function batch(fn){
  return function(target, ...args){
    if(target.length &gt;= 0){
      return Array.from(target).map(item =&gt; fn.apply(this, [item, ...args]));
    }else{
      return fn.apply(this, [target, ...args]);
    }
  }
}

```

因为batch函数的参数和返回值都是函数，所以它有一个专属的名字，**高阶函数(High Order Function)**。高阶函数虽然看上去复杂，但它实际上就是一个纯函数。它的执行结果只依赖于参数（传入的函数），与外部环境无关。

我们可以测试一下这个batch 函数的正确性，方法十分简单只要用下面这个Case就行了。

```
test(t =&gt; {
  let add = (x, y) =&gt; x + y;
  let listAdd = batch(add);
  
  t.deepEqual(listAdd([1,2,3], 1), [2,3,4]);
});

```

有了batch函数之后，我们的模块就可以减少为一个非纯函数。

```
function setColor(el, color){
  el.style.color = color;
}

let setColors = batch(setColor);

```

这里我们用 batch 来实现 setColors，只要 batch 实现正确，setColors 的行为就可以保证是正确的。

## 高阶函数与函数装饰器

刚才我说，batch是一个高阶函数。所谓高阶函数，是指输入参数是函数，或者返回值是函数的函数。

<img src="https://static001.geekbang.org/resource/image/63/57/63d5941b67608e33cb71ebca2352c557.jpeg" alt="">

如果输入参数和返回值都是函数，这样的高阶函数又叫做**函数装饰器（Function Decorators）**。当一个高阶函数是用来修饰函数本身的，它就是函数装饰器。也就是说，它是在原始函数上增加了某些带有辅助功能的函数。

这么说你可能不太理解，我们再来看一个例子。

假设，我们的代码库要进行大版本升级，在未来最新的版本中我们想要废弃掉某些API，由于很多业务中使用了老版本的库，不可能一次升级完，因此我们需要做一个平缓过渡。具体来说就是在当前这个版本中，先不取消这些旧的API，而是给它们增加一个提示信息，告诉调用它们的用户，这些API将会在下一次升级中被废弃。

如果我们手工修改要废弃的API代码，这会是一件非常繁琐的事情。而且，我们很容易遗漏或者弄错些什么，从而产生不可预料的Bug。

所以，一个比较聪明的办法是，我们实现一个通用的函数装饰器。

```
function deprecate(fn, oldApi, newApi) {
  const message = `The ${oldApi} is deprecated.
Please use the ${newApi} instead.`;

  return function(...args) {
    console.warn(message);
    return fn.apply(this, args);
  }
}

```

然后，在模块导出API的时候，对需要废弃的方法统一应用这个装饰器。

```
// deprecation.js
// 引入要废弃的 API
import {foo, bar} from './foo';
...
// 用高阶函数修饰
const _foo = deprecate(foo, 'foo', 'newFoo');
const _bar = deprecate(bar, 'bar', 'newBar');


// 重新导出修饰过的API
export {
  foo: _foo,
  bar: _bar,
  ...
}

```

这样，我们就利用函数装饰器，无侵入地修改了模块的API，将要废弃的模块用deprecate包装之后再输出，就实现了我们想要的效果。这里，我们实现的deprecate就是一个纯函数，它的维护和使用都非常简单。

## 过程抽象

理解了前面的例子之后，咱们再回过头来，说说课程中的函数式编程。我们直接来看第7节课里parametric函数的实现。

```
function parametric(xFunc, yFunc) {
  return function (start, end, seg = 100, ...args) {
    const points = [];
    for(let i = 0; i &lt;= seg; i++) {
      const p = i / seg;
      const t = start * (1 - p) + end * p;
      const x = xFunc(t, ...args); // 计算参数方程组的x
      const y = yFunc(t, ...args);  // 计算参数方程组的y
      points.push([x, y]);
    }
    return {
      draw: draw.bind(null, points),
      points,
    };
  };


```

如上面代码所示，parametric是一个高阶函数，它比上面的函数装饰器更加复杂一点的是，它的输入是两个函数xFunc和yFunc，输出也是一个函数，返回的这个函数实际上是一个**过程**，这个过程是对x、y的参数方程根据变量t的值进行采样。

所以，实际上parametric函数封装的是一个过程，这种封装过程的思路，叫做**过程抽象。<strong>前面的函数装饰器，还有batch方法，实际上也是过程抽象。对应的一般程序设计中我们不是封装过程，而是封装数据，所以叫做**数据抽象</strong>。

过程抽象是函数式编程的基础，函数式编程对待函数就像对待数据一样，都会进行封装和抽象，这样能够设计出非常通用的功能模块。

## 要点总结

函数式编程的内容非常多，这一节课，我只是借助了这些基础的概念和代码，把你带进了函数式编程的大门。

首先，我们了解了两种不同的编程范式，分别是命令式和声明式。其中，函数式属于声明式，而过程式和面向对象则属于命令式。JavaScript语言是同时具有命令式和声明式特征的编程语言。

然后，我们知道函数式有一个非常大的优点，就是能够减少非纯函数的数量，这也是我们设计系统时要遵循的原则。因为相比于非纯函数，纯函数具有更好的可测试性、执行效率和可维护性。

最后，我们还学会了使用高阶函数和函数装饰器来设计纯函数，实现通用的功能。这种思路是对过程封装，所以叫做过程抽象，它是函数式编程的基础。

## 小试牛刀

1. 如果你了解react，你会发现react-hooks其实上就是纯函数设计。你可以思考一下，如果引入了它，能给你的系统带来什么好处？
1. 我们在前端业务中，也会用到一些常用的函数装饰器，比如，节流throttle和防抖debounce，你能说说它们的使用场景吗？如果让你实现这两个函数装饰器，你又会怎么做呢？

函数式编程的思想你都理解了吗？那不妨也把这节课分享给你的朋友吧。今天的内容就到这里了，我们下节课见！
