企业🤖AI Agent构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
## 五、高阶函数 > 原文:[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 ![](https://img.kancloud.cn/af/55/af55614cbfd2273c0fe4ec0279992c6f_420x247.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 种书写系统来编写文本,其中许多我甚至不认识。 例如,以下是泰米尔语手写体的示例。 ![](https://img.kancloud.cn/93/7f/937f10fa13dac2510b4ce75b55ceab0d_559x105.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 ```