高阶函数

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

C.A.R. Hoare,1980 年 ACM 图灵奖演讲
Illustration showing letters and hieroglyphs from different scripts—Latin, Greek, Arabic, ancient Egyptian, and others

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

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

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

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

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

哪一个更有可能包含 bug?

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

这是因为解决方案是用一种与所解决问题相对应的词汇表达的。求一个数字范围的和,不是关于循环和计数器。它是关于范围和求和。

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

抽象

在编程的语境中,这些类型的词汇通常被称为抽象。抽象使我们能够在更高的(或更抽象的)层面上谈论问题,而不会被无关紧要的细节分散注意力。

打个比方,比较一下这两种豌豆汤食谱。第一个是这样的

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

第二个食谱是这样的

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

将豌豆浸泡 12 小时。炖煮 2 小时。切碎并加入蔬菜。继续烹饪 10 分钟。

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

在编程中,我们不能指望所有需要的词语都在字典里等着我们。因此,我们可能会陷入第一个食谱的模式——逐一找出计算机必须执行的精确步骤,而忽略它们所表达的高级概念。

在编程中,能够注意到自己是否在过于低级的抽象层面上工作是一项有用的技能。

抽象重复

到目前为止,我们已经看到了普通函数是构建抽象的一种好方法。但有时它们还不够。

程序经常需要执行某项操作 N 次。你可以为此编写一个 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 吗?它是一个系统,为书面语言中的每个字符分配一个数字,来自第 1 章?大多数这些字符都与一个特定的脚本相关联。该标准包含 140 个不同的脚本,其中 81 个现在仍在使用,59 个是历史性的。

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

A line of verse in Tamil handwriting. The characters are relatively simple, and neatly separated, yet completely different from Latin.

示例数据集包含一些关于 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.find(c => c.name == name);
    if (!known) {
      counts.push({name, count: 1});
    } else {
      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 循环遍历的东西)和一个函数,该函数为给定元素计算一个组名。它返回一个对象数组,每个对象都命名一个组,并告诉您在该组中找到的元素数量。

它使用另一个数组方法 find,它遍历数组中的元素,并返回第一个使函数返回 true 的元素。当它没有找到这样的元素时,它会返回 undefined

使用 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 方法测试任何元素是否与给定谓词函数匹配,而 find 查找与谓词匹配的第一个元素。

练习

扁平化

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用于查找具有最多字符的脚本。