## 五、高阶函数

> 原文：[Higher-Order Functions](http://eloquentjavascript.net/05_higher_order.html)
> 
> 译者：[飞龙](https://github.com/wizardforcel)
> 
> 协议：[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/)
> 
> 自豪地采用[谷歌翻译](https://translate.google.cn/)
> 
> 部分参考了[《JavaScript 编程精解（第 2 版）》](https://book.douban.com/subject/26707144/)

> Tzu-li and Tzu-ssu were boasting about the size of their latest programs. ‘Two-hundred thousand lines,’ said Tzu-li, ‘not counting comments!’ Tzu-ssu responded, ‘Pssh, mine is almost a million lines already.’ Master Yuan-Ma said, ‘My best program has five hundred lines.’ Hearing this, Tzu-li and Tzu-ssu were enlightened.
> 
> Master Yuan-Ma，《The Book of Programming》
> 
> There are two ways of constructing a software design: One way is to make it so simple that there are obviously no deficiencies, and the other way is to make it so complicated that there are no obvious deficiencies.
> 
> C.A.R. Hoare，1980 ACM Turing Award Lecture

![](img/5-0.jpg)

开发大型程序通常需要耗费大量财力和物力，这绝不仅仅是因为构建程序所花费时间的问题。大型程序的复杂程度总是很高，而这些复杂性也会给开发人员带来不少困扰，而程序错误或 bug 往往就是这些时候引入的。大型程序为这些 bug 提供了良好的藏身之所，因此我们更加难以在大型程序中找到它们。

让我们简单回顾一下前言当中的两个示例。其中第一个程序包含了 6 行代码并可以直接运行。

```js
let total = 0, count = 1;
while (count <= 10) {
  total += count;
  count += 1;
}
console.log(total);
```

第二个程序则依赖于外部函数才能执行，且只有一行代码。

```js
console.log(sum(range(1, 10)));
```

哪一个程序更有可能含有 bug 呢？

如果算上`sum`和`range`两个函数的代码量，显然第二个程序的代码量更大。不过，我仍然觉得第二个程序包含 bug 的可能性比第一个程序低。

之所以这么说的原因是，第二个程序编写的代码很好地表达了我们期望解决的问题。对于计算一组数字之和这个操作来说，我们关注的是计算范围和求和运算，而不是循环和计数。

`sum`和`range`这两个函数定义的操作当然会包含循环、计数和其他一些操作。但相比于将这些代码直接写到一起，这种表述方式更为简单，同时也易于避免错误。

## 抽象

在程序设计中，我们把这种编写代码的方式称为抽象。抽象可以隐藏底层的实现细节，从更高（或更加抽象）的层次看待我们要解决的问题。

举个例子，比较一下这两份豌豆汤的食谱：

按照每人一杯的量将脱水豌豆放入容器中。倒水直至浸没豌豆，然后至少将豌豆浸泡 12 个小时。将豌豆从水中取出沥干，倒入煮锅中，按照每人四杯水的量倒入水。将食材盖满整个锅底，并慢煮 2 个小时。按照每人半个的量加入洋葱，用刀切片，然后放入豌豆中。按照每人一根的量加入芹菜，用刀切片，然后放入豌豆当中。按照每人一根的量放入胡萝卜，用刀切片，然后放入豌豆中。最后一起煮 10 分钟以上即可。

第二份食谱：

一个人的量：一杯脱水豌豆、半个切好的洋葱、一根芹菜和一根胡萝卜。

将豌豆浸泡 12 个小时。按照每人四杯水的量倒入水，然后用文火煨 2 个小时。加入切片的蔬菜，煮 10 分钟以上即可。

相比第一份食谱，第二份食谱更简短且更易于理解。但你需要了解一些有关烹调的术语：浸泡、煨、切片，还有蔬菜。

在编程的时候，我们不能期望所有功能都是现成的。因此，你可能就会像第一份食谱那样编写你的程序，逐个编写计算机需要执行的代码和步骤，而忽略了这些步骤之上的抽象概念。

在编程时，注意你的抽象级别什么时候过低，是一项非常有用的技能。

## 重复的抽象

我们已经了解的普通函数就是一种很好的构建抽象的工具。但有些时候，光有函数也不一定能够解决我们的问题。

程序以给定次数执行某些操作很常见。 你可以为此写一个`for`循环，就像这样：

```js
for (let i = 0; i < 10; i++) {
  console.log(i);
}
```

我们是否能够将“做某件事`N`次”抽象为函数？ 编写一个调用`console.log` `N`次的函数是很容易的。

```js
function repeatLog(n) {
  for (let i = 0; i < n; i++) {
    console.log(i);
  }
}
```

但如果我们想执行打印数字以外的操作该怎么办呢？我们可以使用函数来定义我们想做的事，而函数也是值，因此我们可以将期望执行的操作封装成函数，然后传递进来。

```js
function repeat(n, action) {
  for (let i = 0; i < n; i++) {
    action(i);
  }
}

repeat(3, console.log);
// → 0
// → 1
// → 2
```

你不必将预定义的函数传递给`repeat`。 通常情况下，你希望原地创建一个函数值。

```js
let labels = [];
repeat(5, i => {
  labels.push(`Unit ${i + 1}`);
});
console.log(labels);
// → ["Unit 1", "Unit 2", "Unit 3", "Unit 4", "Unit 5"]
```

这个结构有点像`for`循环 - 它首先描述了这种循环，然后提供了一个主体。 但是，主体现在写为一个函数值，它被包裹在`repeat`调用的括号中。 这就是它必须用右小括号和右大括号闭合的原因。 在这个例子中，主体是单个小表达式，你也可以省略大括号并将循环写成单行。

## 高阶函数

如果一个函数操作其他函数，即将其他函数作为参数或将函数作为返回值，那么我们可以将其称为高阶函数。因为我们已经看到函数就是一个普通的值，那么高阶函数也就不是什么稀奇的概念了。高阶这个术语来源于数学，在数学当中，函数和值的概念有着严格的区分。

我们可以使用高阶函数对一系列操作和值进行抽象。高阶函数有多种表现形式。比如你可以使用高阶函数来新建另一些函数。

```js
function greaterThan(n) {
  return m => m > n;
}
let greaterThan10 = greaterThan(10);
console.log(greaterThan10(11));
// → true
```

你也可以使用高阶函数来修改其他的函数。

```js
function noisy(f) {
  return (...args) => {
    console.log("calling with", args);
    let result = f(...args);
    console.log("called with", args, ", returned", result);
    return result;
  };
}
noisy(Math.min)(3, 2, 1);
// → calling with [3, 2, 1]
// → called with [3, 2, 1] , returned 1
```

你甚至可以使用高阶函数来实现新的控制流。

```js
function unless(test, then) {
  if (!test) then();
}
repeat(3, n => {
  unless(n % 2 == 1, () => {
    console.log(n, "is even");
  });
});
// → 0 is even
// → 2 is even
```

有一个内置的数组方法，`forEach`，它提供了类似`for/of`循环的东西，作为一个高阶函数。

```js
["A", "B"].forEach(l => console.log(l));
// → A
// → B
```

## 脚本数据集

数据处理是高阶函数表现突出的一个领域。 为了处理数据，我们需要一些真实数据。 本章将使用脚本书写系统的数据集，例如拉丁文，西里尔文或阿拉伯文。

请记住第 1 章中的 Unicode，该系统为书面语言中的每个字符分配一个数字。 大多数这些字符都与特定的脚本相关联。 该标准包含 140 个不同的脚本 - 81 个今天仍在使用，59 个是历史性的。

虽然我只能流利地阅读拉丁字符，但我很欣赏这样一个事实，即人们使用其他至少 80 种书写系统来编写文本，其中许多我甚至不认识。 例如，以下是泰米尔语手写体的示例。

![](img/5-1.png)

示例数据集包含 Unicode 中定义的 140 个脚本的一些信息。 本章的[编码沙箱](https://eloquentjavascript.net/code#5)中提供了`SCRIPTS`绑定。 该绑定包含一组对象，其中每个对象都描述了一个脚本。

```json
{
  name: "Coptic",
  ranges: [[994, 1008], [11392, 11508], [11513, 11520]],
  direction: "ltr",
  year: -200,
  living: false,
  link: "https://en.wikipedia.org/wiki/Coptic_alphabet"
}
```

这样的对象会告诉你脚本的名称，分配给它的 Unicode 范围，书写方向，（近似）起始时间，是否仍在使用以及更多信息的链接。 方向可以是从左到右的`"ltr"`，从右到左的`"rtl"`（阿拉伯语和希伯来语文字的写法），或者从上到下的`"ttb"`（蒙古文的写法）。

`ranges`属性包含 Unicode 字符范围数组，每个数组都有两元素，包含下限和上限。 这些范围内的任何字符码都会分配给脚本。 下限是包括的（代码 994 是一个科普特字符），并且上限排除在外（代码 1008 不是）。

## 数组过滤

为了找到数据集中仍在使用的脚本，以下函数可能会有所帮助。 它过滤掉数组中未通过测试的元素：

```js
function filter(array, test) {
  let passed = [];
  for (let element of array) {
    if (test(element)) {
      passed.push(element);
    }
  }
  return passed;
}

console.log(filter(SCRIPTS, script => script.living));
// → [{name: "Adlam", …}, …]
```

该函数使用名为`test`的参数（一个函数值）填充计算中的“间隙” - 决定要收集哪些元素的过程。

需要注意的是，`filter`函数并没有从当前数组中删除元素，而是新建了一个数组，并将满足条件的元素存入新建的数组中。这个函数是一个“纯函数”，因为该函数并未修改给定的数组。

与`forEach`一样，`filter`函数也是标准的数组方法。本例中定义的函数只是用于展示内部实现原理。今后我们会使用以下方法来过滤数据：

```js
console.log(SCRIPTS.filter(s => s.direction == "ttb"));
// → [{name: "Mongolian", …}, …]
```

## 使用`map`函数转换数组

假设我们已经通过某种方式过滤了`SCRIPTS`数组，生成一个用于表示脚本的信息数组。但我们想创建一个包含名称的数组，因为这样更加易于检查。

`map`方法对数组中的每个元素调用函数，然后利用返回值来构建一个新的数组，实现转换数组的操作。新建数组的长度与输入的数组一致，但其中的内容却通过对每个元素调用的函数“映射”成新的形式。

```js
function map(array, transform) {
  let mapped = [];
  for (let element of array) {
    mapped.push(transform(element));
  }
  return mapped;
}

let rtlScripts = SCRIPTS.filter(s => s.direction == "rtl");
console.log(map(rtlScripts, s => s.name));
// → ["Adlam", "Arabic", "Imperial Aramaic", …]
```

与`forEach`和`filter`一样，`map`也是标准的数组方法。

## 使用`reduce`汇总数据

与数组有关的另一个常见事情是从它们中计算单个值。 我们的递归示例，汇总了一系列数字，就是这样一个例子。 另一个例子是找到字符最多的脚本。

表示这种模式的高阶操作称为归约（reduce）（有时也称为折叠（fold））。 它通过反复从数组中获取单个元素，并将其与当前值合并来构建一个值。 在对数字进行求和时，首先从数字零开始，对于每个元素，将其与总和相加。

`reduce`函数包含三个参数：数组、执行合并操作的函数和初始值。该函数没有`filter`和`map`那样直观，所以仔细看看：

```js
function reduce(array, combine, start) {
  let current = start;
  for (let element of array) {
    current = combine(current, element);
  }
  return current;
}

console.log(reduce([1, 2, 3, 4], (a, b) => a + b, 0));
// → 10
```

数组中有一个标准的`reduce`方法，当然和我们上面看到的那个函数一致，可以简化合并操作。如果你的数组中包含多个元素，在调用`reduce`方法的时候忽略了`start`参数，那么该方法将会使用数组中的第一个元素作为初始值，并从第二个元素开始执行合并操作。

```js
console.log([1, 2, 3, 4].reduce((a, b) => a + b));
// → 10
```

为了使用`reduce`（两次）来查找字符最多的脚本，我们可以这样写：

```js
function characterCount(script) {
  return script.ranges.reduce((count, [from, to]) => {
    return count + (to - from);
  }, 0);
}

console.log(SCRIPTS.reduce((a, b) => {
  return characterCount(a) < characterCount(b) ? b : a;
}));
// → {name: "Han", …}
```

`characterCount`函数通过累加范围的大小，来减少分配给脚本的范围。 请注意归约器函数的参数列表中使用的解构。 `reduce'的第二次调用通过重复比较两个脚本并返回更大的脚本，使用它来查找最大的脚本。

Unicode 标准分配了超过 89,000 个字符给汉字脚本，它成为数据集中迄今为止最大的书写系统。 汉字是一种（有时）用于中文，日文和韩文的文字。 这些语言共享很多字符，尽管他们倾向于以不同的方式写它们。 （基于美国的）Unicode 联盟决定将它们看做一个单独的书写系统来保存字符码。 这被称为中日韩越统一表意文字（Han unification），并且仍然使一些人非常生气。

## 可组合性

考虑一下，我们怎样才可以在不使用高阶函数的情况下，编写以上示例（找到最大的脚本）？代码没有那么糟糕。

```js
let biggest = null;
for (let script of SCRIPTS) {
  if (biggest == null ||
      characterCount(biggest) < characterCount(script)) {
    biggest = script;
  }
}
console.log(biggest);
// → {name: "Han", …}
```

这段代码中多了一些绑定，虽然多了两行代码，但代码逻辑还是很容易让人理解的。

当你需要组合操作时，高阶函数的价值就突显出来了。举个例子，我们编写一段代码，找出数据集中男人和女人的平均年龄。

```js
function average(array) {
  return array.reduce((a, b) => a + b) / array.length;
}

console.log(Math.round(average(
  SCRIPTS.filter(s => s.living).map(s => s.year))));
// → 1185
console.log(Math.round(average(
  SCRIPTS.filter(s => !s.living).map(s => s.year))));
// → 209
```

因此，Unicode 中的死亡脚本，平均比活动脚本更老。 这不是一个非常有意义或令人惊讶的统计数据。 但是我希望你会同意，用于计算它的代码不难阅读。 你可以把它看作是一个流水线：我们从所有脚本开始，过滤出活动的（或死亡的）脚本，从这些脚本中抽出时间，对它们进行平均，然后对结果进行四舍五入。

你当然也可以把这个计算写成一个大循环。

```js
let total = 0, count = 0;
for (let script of SCRIPTS) {
  if (script.living) {
    total += script.year;
    count += 1;
  }
}
console.log(Math.round(total / count));
// → 1185
```

但很难看到正在计算什么以及如何计算。 而且由于中间结果并不表示为一致的值，因此将“平均值”之类的东西提取到单独的函数中，需要更多的工作。

就计算机实际在做什么而言，这两种方法也是完全不同的。 第一个在运行`filter`和`map`的时候会建立新的数组，而第二个只会计算一些数字，从而减少工作量。 你通常可以采用可读的方法，但是如果你正在处理巨大的数组，并且多次执行这些操作，那么抽象风格的加速就是值得的。

## 字符串和字符码

这个数据集的一种用途是确定一段文本所使用的脚本。 我们来看看执行它的程序。

请记住，每个脚本都有一组与其相关的字符码范围。 所以给定一个字符码，我们可以使用这样的函数来找到相应的脚本（如果有的话）：

```js
function characterScript(code) {
  for (let script of SCRIPTS) {
    if (script.ranges.some(([from, to]) => {
      return code >= from && code < to;
    })) {
      return script;
    }
  }
  return null;
}

console.log(characterScript(121));
// → {name: "Latin", …}
```

`some`方法是另一个高阶函数。 它需要一个测试函数，并告诉你该函数是否对数组中的任何元素返回`true`。

但是，我们如何获得字符串中的字符码？

在第一章中，我提到 JavaScript 字符串被编码为一个 16 位数字的序列。 这些被称为代码单元。 一个 Unicode 字符代码最初应该能放进这样一个单元（它给你超 65,000 个字符）。 后来人们发现它不够用了，很多人避开了为每个字符使用更多内存的需求。 为了解决这些问题，人们发明了 UTF-16，JavaScript 字符串使用的格式 。它使用单个 16 位代码单元描述了大多数常见字符，但是为其他字符使用一对两个这样的单元。

今天 UTF-16 通常被认为是一个糟糕的主意。 它似乎总是故意设计来引起错误。 很容易编写程序，假装代码单元和字符是一个东西。 如果你的语言不使用两个单位的字符，显然能正常工作。 但只要有人试图用一些不太常见的中文字符来使用这样的程序，就会中断。 幸运的是，随着 emoji 符号的出现，每个人都开始使用两个单元的字符，处理这些问题的负担更加分散。

```js
// Two emoji characters, horse and shoe
let horseShoe = "\ud83d\udc34\ud83d\udc5f";
console.log(horseShoe.length);
// → 4
console.log(horseShoe[0]);
// → (Invalid half-character)
console.log(horseShoe.charCodeAt(0));
// → 55357 (Code of the half-character)
console.log(horseShoe.codePointAt(0));
// → 128052 (Actual code for horse emoji)
```

JavaScript的`charCodeAt`方法为你提供了一个代码单元，而不是一个完整的字符代码。 稍后添加的`codePointAt`方法确实提供了完整的 Unicode 字符。 所以我们可以使用它从字符串中获取字符。 但传递给`codePointAt`的参数仍然是代码单元序列的索引。 因此，要运行字符串中的所有字符，我们仍然需要处理一个字符占用一个还是两个代码单元的问题。

在上一章中，我提到`for/of`循环也可以用在字符串上。 像`codePointAt`一样，这种类型的循环，是在人们敏锐地意识到 UTF-16 的问题的时候引入的。 当你用它来遍历一个字符串时，它会给你真正的字符，而不是代码单元。

```js
let roseDragon = "\ud83c\udf45\ud83d\udc09";
for (let char of roseDragon) {
  console.log(char);
// → (emoji rose)
// → (emoji dragon)
```

如果你有一个字符（它是一个或两个代码单元的字符串），你可以使用`codePointAt(0)`来获得它的代码。

## 识别文本

我们有了`characterScript`函数和一种正确遍历字符的方法。 下一步将是计算属于每个脚本的字符。 下面的计数抽象会很实用：

```js
function countBy(items, groupName) {
  let counts = [];
  for (let item of items) {
    let name = groupName(item);
    let known = counts.findIndex(c => c.name == name);
    if (known == -1) {
      counts.push({name, count: 1});
    } else {
      counts[known].count++;
    }
  }
  return counts;
}  

console.log(countBy([1, 2, 3, 4, 5], n => n > 2));
// → [{name: false, count: 2}, {name: true, count: 3}]
```

`countBy`函数需要一个集合（我们可以用`for/of`来遍历的任何东西）以及一个函数，它计算给定元素的组名。 它返回一个对象数组，每个对象命名一个组，并告诉你该组中找到的元素数量。

它使用另一个数组方法`findIndex`。 这个方法有点像`indexOf`，但它不是查找特定的值，而是查找给定函数返回`true`的第一个值。 像`indexOf`一样，当没有找到这样的元素时，它返回 -1。

使用`countBy`，我们可以编写一个函数，告诉我们在一段文本中使用了哪些脚本。

```js
function textScripts(text) {
  let scripts = countBy(text, char => {
    let script = characterScript(char.codePointAt(0));
    return script ? script.name : "none";
  }).filter(({name}) => name != "none");

  let total = scripts.reduce((n, {count}) => n + count, 0);
  if (total == 0) return "No scripts found";

  return scripts.map(({name, count}) => {
    return `${Math.round(count * 100 / total)}% ${name}`;
  }).join(", ");
}

console.log(textScripts('英国的狗说"woof", 俄罗斯的狗说"тяв"'));
// → 61% Han, 22% Latin, 17% Cyrillic
```

该函数首先按名称对字符进行计数，使用`characterScript`为它们分配一个名称，并且对于不属于任何脚本的字符，回退到字符串`"none"`。 `filter`调用从结果数组中删除`"none"`的条目，因为我们对这些字符不感兴趣。

为了能够计算百分比，我们首先需要属于脚本的字符总数，我们可以用`reduce`来计算。 如果没有找到这样的字符，该函数将返回一个特定的字符串。 否则，它使用`map`将计数条目转换为可读的字符串，然后使用`join`合并它们。

## 本章小结

能够将函数值传递给其他函数，是 JavaScript 的一个非常有用的方面。 它允许我们编写函数，用它们中的“间隙”对计算建模。 调用这些函数的代码，可以通过提供函数值来填补间隙。

数组提供了许多有用的高阶方法。 你可以使用`forEach`来遍历数组中的元素。 `filter`方法返回一个新数组，只包含通过谓词函数的元素。 通过将函数应用于每个元素的数组转换，使用`map`来完成。 你可以使用`reduce`将数组中的所有元素合并为一个值。 `some`方法测试任何元素是否匹配给定的谓词函数。 `findIndex`找到匹配谓词的第一个元素的位置。

## 习题

### 展开

联合使用`reduce`方法和`concat`方法，将一个数组的数组“展开”成一个单个数组，包含原始数组的所有元素。

```js
let arrays = [[1, 2, 3], [4, 5], [6]];
// Your code here.
// → [1, 2, 3, 4, 5, 6]
```

### 你自己的循环

编写一个高阶函数`loop`，提供类似`for`循环语句的东西。 它接受一个值，一个测试函数，一个更新函数和一个主体函数。 每次迭代中，它首先在当前循环值上运行测试函数，并在返回`false`时停止。 然后它调用主体函数，向其提供当前值。 最后，它调用`update`函数来创建一个新的值，并从头开始。

定义函数时，可以使用常规循环来执行实际循环。

```js
// Your code here.

loop(3, n => n > 0, n => n - 1, console.log);
// → 3
// → 2
// → 1
```

### `every`

类似于`some`方法，数组也有`every`方法。 当给定函数对数组中的每个元素返回`true`时，此函数返回`true`。 在某种程度上，`some`是作用于数组的`||`运算符的一个版本，`every`就像`&&`运算符。

将`every`实现为一个函数，接受一个数组和一个谓词函数作为参数。编写两个版本，一个使用循环，另一个使用`some`方法。

```js
function every(array, test) {
  // Your code here.
}

console.log(every([1, 3, 5], n => n < 10));
// → true
console.log(every([2, 4, 16], n => n < 10));
// → false
console.log(every([], n => n < 10));
// → true
```
