第四版现已推出。点击此处阅读

第五章高阶函数

子利和子思正在吹嘘他们最新程序的大小。“二十万行,”子利说,“不包括注释!”子思回应道,“切,我的已经快到一百万行了。”元玛大师说,“我最好的程序只有五百行。”听了这话,子利和子思恍然大悟。

元玛大师,《编程之书》

构建软件设计有两种方法:一种是使其足够简单,以至于显然没有任何缺陷;另一种是使其足够复杂,以至于没有明显的缺陷。

C.A.R. Hoare,《1980 年 ACM 图灵奖演讲》
Letters from different scripts

大型程序是一个昂贵的程序,不仅因为构建它需要的时间。规模几乎总是意味着复杂性,而复杂性会让程序员困惑。而困惑的程序员反过来会在程序中引入错误(bug)。因此,大型程序为这些 bug 提供了许多隐藏的空间,使得它们难以发现。

让我们简要回顾一下引言中的最后两个示例程序。第一个是自包含的,只有六行。

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

第二个依赖于两个外部函数,只有一行。

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

哪个更有可能包含错误?

如果我们计算 sumrange 的定义大小,第二个程序也很大——甚至比第一个更大。但即便如此,我认为它更有可能正确。

它更有可能正确,是因为解决方案是用与要解决的问题相对应的词汇表达的。求一个数字范围的和不是关于循环和计数器。它关于范围和求和。

这个词汇(函数 sumrange 的定义)仍然会涉及循环、计数器和其他偶然的细节。但由于它们表达的概念比整个程序更简单,因此更容易做到正确。

抽象

在编程的语境中,这些类型的词汇通常被称为抽象。抽象隐藏细节,并赋予我们以更高(或更抽象)的级别来讨论问题的能力。

作为类比,比较这两个豌豆汤的食谱。第一个是这样的

将每人 1 杯干豌豆放入容器中。加水,直到豌豆完全浸没。将豌豆浸泡在水中至少 12 小时。将豌豆从水中取出,放入烹饪锅中。每人加 4 杯水。盖上锅盖,将豌豆用小火炖 2 小时。每人取半个洋葱。用刀切成块。加入豌豆中。每人取一根芹菜。用刀切成块。加入豌豆中。每人取一根胡萝卜。切成块。用刀!加入豌豆中。再煮 10 分钟。

这是第二个食谱

每人:1 杯干豌豆、半个切碎的洋葱、一根芹菜和一根胡萝卜。

将豌豆浸泡 12 小时。在 4 杯水中(每人)用小火炖 2 小时。切碎蔬菜并加入。再煮 10 分钟。

第二个更短,更容易理解。但你需要理解一些额外的烹饪相关词汇,比如浸泡用小火炖切碎,还有,我想,蔬菜

在编程时,我们不能指望字典中已经存在我们需要的所有词汇。因此,我们可能会陷入第一个食谱的模式——逐一列出计算机必须执行的精确步骤,而忽略了它们所表达的更高级别的概念。

在编程中,注意到何时在太低的抽象级别上工作是一项有用的技能。

抽象重复

像我们迄今为止所见的那样,普通函数是构建抽象的好方法。但有时它们还不够。

程序通常会执行某个操作一定次数。你可以为此编写一个 for 循环,如下所示

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

我们可以将“执行某个操作N次”抽象为一个函数吗?嗯,编写一个调用 console.log N 次的函数很容易。

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

但如果我们想做的事情不是记录数字呢?由于“执行某个操作”可以用函数表示,而函数只是值,我们可以将我们的操作作为函数值传递。

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

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

我们不必向 repeat 传递预定义的函数。通常,在现场创建一个函数值更容易。

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 调用的括号中。这就是为什么它必须用闭合的大括号闭合的括号来关闭。在像这个例子这样的情况下,其中主体是一个单一的表达式,你也可以省略大括号并在单行上编写循环。

高阶函数

对其他函数进行操作的函数,无论是将它们作为参数,还是通过返回它们,都被称为高阶函数。由于我们已经看到函数是普通值,因此此类函数的存在并没有什么特别之处。这个术语来自数学,在数学中,函数和其他值之间的区别被更认真地对待。

高阶函数使我们能够抽象出动作,而不仅仅是值。它们有多种形式。例如,我们可以有创建新函数的函数。

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

我们也可以有改变其他函数的函数。

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

我们甚至可以编写提供新控制流类型的函数。

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 循环的高阶函数。

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

脚本数据集

高阶函数发挥作用的一个领域是数据处理。要处理数据,我们需要一些实际数据。本章将使用一个关于脚本(如拉丁语、西里尔语或阿拉伯语等书写系统)的数据集。

还记得 第一章 中的 Unicode 吗?这是一个为书面语言中的每个字符分配一个数字的系统。大多数这些字符与特定的脚本相关联。该标准包含 140 种不同的脚本——其中 81 种仍在使用,59 种是历史上的。

虽然我只能流利地阅读拉丁字母,但我感谢人们至少使用 80 种其他书写系统来书写文本,其中很多我甚至不认识。例如,这是一段泰米尔语手写的样本

Tamil handwriting

示例数据集包含关于 Unicode 中定义的 140 种脚本的一些信息。它可以在本章的 编码沙箱 中作为 SCRIPTS 绑定获得。该绑定包含一个对象数组,每个对象都描述一个脚本。

{
  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 不是)。

过滤数组

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

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 是一个标准的数组方法。该示例定义了该函数只是为了展示它的内部工作原理。从现在开始,我们将使用它,而不是像这样

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

使用 map 变换

假设我们有一个表示脚本的对象数组,它是由对SCRIPTS数组进行某种过滤操作产生的。但我们想要一个名称数组,这样更容易检查。

map方法通过将函数应用于数组的所有元素并将返回值构建成一个新数组来转换数组。新数组的长度将与输入数组相同,但其内容将由函数映射成新的形式。

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", …]

forEachfilter一样,map是一个标准的数组方法。

使用reduce进行总结

对数组进行的另一个常见操作是从数组中计算一个单一的值。我们反复出现的例子,对数字集合求和,就是这种情况的一个例子。另一个例子是找到字符最多的脚本。

代表这种模式的高阶运算称为reduce(有时也称为fold)。它通过反复从数组中取一个元素并将其与当前值结合起来来构建一个值。在对数字求和时,你会从数字零开始,然后对每个元素,将其添加到总和中。

除了数组之外,reduce的参数还有组合函数和起始值。这个函数比filtermap稍微复杂一些,所以仔细看一下它

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,当然对应于这个函数,有一个额外的便利之处。如果你的数组至少包含一个元素,你可以省略start参数。该方法将把数组的第一个元素作为其起始值,并从第二个元素开始进行缩减。

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

为了使用reduce(两次)找到字符最多的脚本,我们可以写一些类似的东西

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函数通过对分配给脚本的范围的尺寸求和来缩减这些范围。请注意,在 reducer 函数的参数列表中使用了解构。然后,对reduce的第二次调用使用此方法通过反复比较两个脚本并返回较大的一个来找到最大的脚本。

汉字在 Unicode 标准中分配了超过 89,000 个字符,使其成为数据集中迄今为止最大的书写系统。汉字是一种(有时)用于中文、日文和韩文的脚本。这些语言共享许多字符,尽管它们往往以不同的方式书写。 (位于美国)的 Unicode 联盟决定将它们视为单个书写系统以节省字符代码。这被称为汉字统一,仍然让一些人非常生气。

可组合性

考虑一下如果没有高阶函数我们将如何编写之前的例子(找到最大的脚本)。代码并没有那么糟糕。

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

有几个额外的绑定,程序多了四行。但这仍然非常易读。

当你需要组合操作时,高阶函数开始闪耀。例如,让我们编写一段代码来找到数据集中存活和已消失脚本的平均起源年份。

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))));
// → 1165
console.log(Math.round(average(
  SCRIPTS.filter(s => !s.living).map(s => s.year))));
// → 204

因此,Unicode 中已消失的脚本平均来说比存活的脚本更古老。这并不是一个特别有意义或令人惊讶的统计数据。但我希望你会同意用于计算它的代码并不难读。你可以把它看成一个管道:我们从所有脚本开始,过滤掉存活的(或已消失的)脚本,从这些脚本中取出年份,求平均值,然后对结果进行舍入。

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

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

但这很难看出正在计算什么以及如何计算。并且因为中间结果没有表示为连贯的值,所以将类似average的东西提取到一个单独的函数中会需要更多工作。

从计算机实际执行的操作的角度来看,这两种方法也大不相同。第一个在运行filtermap时会构建新的数组,而第二个只计算一些数字,执行的工作更少。你通常可以负担得起可读的方法,但是如果你正在处理巨大的数组,并且做了很多次,那么不太抽象的风格可能值得额外的速度。

字符串和字符代码

数据集中的一种用途是找出一段文本正在使用什么脚本。让我们来看一个执行此操作的程序。

请记住,每个脚本都与一个字符代码范围数组相关联。因此,给定一个字符代码,我们可以使用类似这样的函数来查找相应的脚本(如果有)。

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。

但是我们如何获得字符串中的字符代码呢?

第 1 章中,我提到过 JavaScript 字符串被编码为一系列 16 位数字。这些被称为代码单元。Unicode 字符代码最初应该适合这样的单元(这给你提供了超过 65,000 个字符)。当人们意识到这将不足够时,许多人反对每个字符使用更多内存的必要性。为了解决这些问题,发明了 UTF-16,它是 JavaScript 字符串使用的格式。它使用单个 16 位代码单元来描述最常见的字符,但对其他字符使用一对两个这样的单元。

今天,UTF-16 通常被认为是一个糟糕的想法。它似乎是故意设计来邀请错误的。编写将代码单元和字符视为相同事物的程序很容易。如果你的语言不使用双单元字符,那么它看起来将正常工作。但是,一旦有人尝试使用这样的程序来处理一些不太常见的汉字,它就会崩溃。幸运的是,随着表情符号的出现,每个人都开始使用双单元字符,处理此类问题的负担也更加公平地分担了。

不幸的是,对 JavaScript 字符串的明显操作,例如使用length属性获取其长度,以及使用方括号访问其内容,只处理代码单元。

// Two emoji characters, horse and shoe
let horseShoe = "🐴👟";
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 问题的时候引入的。当你使用它来遍历一个字符串时,它会给你真正的字符,而不是代码单元。

let roseDragon = "🌹🐉";
for (let char of roseDragon) {
  console.log(char);
}
// → 🌹
// → 🐉

如果你有一个字符(它将是一个包含一个或两个代码单元的字符串),你可以使用codePointAt(0)来获取它的代码。

识别文本

我们有characterScript函数,以及正确遍历字符的方法。下一步是统计属于每个脚本的字符。以下计数抽象在其中将很有用

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,我们可以编写一个函数来告诉我们一段文本中使用了哪些脚本。

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 方法结合使用,可以将数组的数组“扁平化”成一个包含所有原始数组元素的单个数组。

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

你自己的循环

编写一个高阶函数 loop,它提供类似于 for 循环语句的功能。它接受一个值、一个测试函数、一个更新函数和一个主体函数。每次迭代,它首先对当前循环值运行测试函数,如果返回 false 则停止。然后,它调用主体函数,并将当前值传递给它。最后,它调用更新函数来创建新值,并从头开始。

在定义函数时,可以使用普通循环来执行实际的循环。

// Your code here.

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

所有

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

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

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

&& 运算符类似,every 方法可以停止评估进一步的元素,只要它找到了一个不匹配的元素。因此,基于循环的版本可以在遇到谓词函数返回 false 的元素时跳出循环——使用 breakreturn。如果循环运行到结束而没有找到这样的元素,我们知道所有元素都匹配,我们应该返回 true。

为了在 some 的基础上构建 every,我们可以应用德摩根定律,它指出 a && b 等于 !(!a || !b)。这可以推广到数组,其中数组中的所有元素都匹配,如果数组中没有不匹配的元素。

主导书写方向

编写一个函数,计算文本字符串中的主导书写方向。请记住,每个脚本对象都有一个 direction 属性,它可以是 "ltr"(从左到右)、"rtl"(从右到左)或 "ttb"(从上到下)。

主导方向是与脚本相关联的字符中大多数字符的方向。本章前面定义的 characterScriptcountBy 函数可能在这里有用。

function dominantDirection(text) {
  // Your code here.
}

console.log(dominantDirection("Hello!"));
// → ltr
console.log(dominantDirection("Hey, مساء الخير"));
// → rtl

你的解决方案可能看起来很像 textScripts 例子的前半部分。你再次需要根据 characterScript 对字符进行计数,然后过滤掉结果中与不感兴趣的(无脚本)字符相关的部分。

找到具有最高字符计数的方向可以使用 reduce。如果还不清楚如何操作,请参考本章前面的例子,其中 reduce 用于查找具有最多字符的脚本。