来源: ApacheCN『JavaScript 编程精解 中文第三版』翻译项目原文:Higher-Order Functionsjavascript
译者:飞龙html
协议:CC BY-NC-SA 4.0java
自豪地采用谷歌翻译git
部分参考了《JavaScript 编程精解(第 2 版)》github
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.apache
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.json
C.A.R. Hoare,1980 ACM Turing Award Lecture数组
开发大型程序一般须要耗费大量财力和物力,这毫不仅仅是由于构建程序所花费时间的问题。大型程序的复杂程度老是很高,而这些复杂性也会给开发人员带来很多困扰,而程序错误或 bug 每每就是这些时候引入的。大型程序为这些 bug 提供了良好的藏身之所,所以咱们更加难以在大型程序中找到它们。app
让咱们简单回顾一下前言当中的两个示例。其中第一个程序包含了 6 行代码并能够直接运行。
let total = 0, count = 1; while (count <= 10) { total += count; count += 1; } console.log(total);
第二个程序则依赖于外部函数才能执行,且只有一行代码。
console.log(sum(range(1, 10)));
哪个程序更有可能含有 bug 呢?
若是算上sum
和range
两个函数的代码量,显然第二个程序的代码量更大。不过,我仍然以为第二个程序包含 bug 的可能性比第一个程序低。
之因此这么说的缘由是,第二个程序编写的代码很好地表达了咱们指望解决的问题。对于计算一组数字之和这个操做来讲,咱们关注的是计算范围和求和运算,而不是循环和计数。
sum
和range
这两个函数定义的操做固然会包含循环、计数和其余一些操做。但相比于将这些代码直接写到一块儿,这种表述方式更为简单,同时也易于避免错误。
在程序设计中,咱们把这种编写代码的方式称为抽象。抽象能够隐藏底层的实现细节,从更高(或更加抽象)的层次看待咱们要解决的问题。
举个例子,比较一下这两份豌豆汤的食谱:
按照每人一杯的量将脱水豌豆放入容器中。倒水直至浸没豌豆,而后至少将豌豆浸泡 12 个小时。将豌豆从水中取出沥干,倒入煮锅中,按照每人四杯水的量倒入水。将食材盖满整个锅底,并慢煮 2 个小时。按照每人半个的量加入洋葱,用刀切片,而后放入豌豆中。按照每人一根的量加入芹菜,用刀切片,而后放入豌豆当中。按照每人一根的量放入胡萝卜,用刀切片,而后放入豌豆中。最后一块儿煮 10 分钟以上便可。
第二份食谱:
一我的的量:一杯脱水豌豆、半个切好的洋葱、一根芹菜和一根胡萝卜。
将豌豆浸泡 12 个小时。按照每人四杯水的量倒入水,而后用文火煨 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
数据处理是高阶函数表现突出的一个领域。 为了处理数据,咱们须要一些真实数据。 本章将使用脚本书写系统的数据集,例如拉丁文,西里尔文或阿拉伯文。
请记住第 1 章中的 Unicode,该系统为书面语言中的每一个字符分配一个数字。 大多数这些字符都与特定的脚本相关联。 该标准包含 140 个不一样的脚本 - 81 个今天仍在使用,59 个是历史性的。
虽然我只能流利地阅读拉丁字符,但我很欣赏这样一个事实,即人们使用其余至少 80 种书写系统来编写文本,其中许多我甚至不认识。 例如,如下是泰米尔语手写体的示例。
示例数据集包含 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", …]
与forEach
和filter
同样,map
也是标准的数组方法。
reduce
汇总数据与数组有关的另外一个常见事情是从它们中计算单个值。 咱们的递归示例,汇总了一系列数字,就是这样一个例子。 另外一个例子是找到字符最多的脚本。
表示这种模式的高阶操做称为归约(reduce)(有时也称为折叠(fold))。 它经过反复从数组中获取单个元素,并将其与当前值合并来构建一个值。 在对数字进行求和时,首先从数字零开始,对于每一个元素,将其与总和相加。
reduce
函数包含三个参数:数组、执行合并操做的函数和初始值。该函数没有filter
和map
那样直观,因此仔细看看:
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
参数,那么该方法将会使用数组中的第一个元素做为初始值,并从第二个元素开始执行合并操做。
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
函数经过累加范围的大小,来减小分配给脚本的范围。 请注意归约器函数的参数列表中使用的解构。 `reduce'的第二次调用经过重复比较两个脚本并返回更大的脚本,使用它来查找最大的脚本。
Unicode 标准分配了超过 89,000 个字符给汉字脚本,它成为数据集中迄今为止最大的书写系统。 汉字是一种(有时)用于中文,日文和韩文的文字。 这些语言共享不少字符,尽管他们倾向于以不一样的方式写它们。 (基于美国的)Unicode 联盟决定将它们看作一个单独的书写系统来保存字符码。 这被称为中日韩越统一表意文字(Han unification),而且仍然使一些人很是生气。
考虑一下,咱们怎样才能够在不使用高阶函数的状况下,编写以上示例(找到最大的脚本)?代码没有那么糟糕。
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)))); // → 1185 console.log(Math.round(average( SCRIPTS.filter(s => !s.living).map(s => s.year)))); // → 209
所以,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)); // → 1185
但很难看到正在计算什么以及如何计算。 并且因为中间结果并不表示为一致的值,所以将“平均值”之类的东西提取到单独的函数中,须要更多的工做。
就计算机实际在作什么而言,这两种方法也是彻底不一样的。 第一个在运行filter
和map
的时候会创建新的数组,而第二个只会计算一些数字,从而减小工做量。 你一般能够采用可读的方法,可是若是你正在处理巨大的数组,而且屡次执行这些操做,那么抽象风格的加速就是值得的。
这个数据集的一种用途是肯定一段文本所使用的脚本。 咱们来看看执行它的程序。
请记住,每一个脚本都有一组与其相关的字符码范围。 因此给定一个字符码,咱们可使用这样的函数来找到相应的脚本(若是有的话):
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 符号的出现,每一个人都开始使用两个单元的字符,处理这些问题的负担更加分散。
// 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 的问题的时候引入的。 当你用它来遍历一个字符串时,它会给你真正的字符,而不是代码单元。
let roseDragon = "\ud83c\udf45\ud83d\udc09"; for (let char of roseDragon) { console.log(char); // → (emoji rose) // → (emoji dragon)
若是你有一个字符(它是一个或两个代码单元的字符串),你可使用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
时中止。 而后它调用主体函数,向其提供当前值。 最后,它调用update
函数来建立一个新的值,并从头开始。
定义函数时,可使用常规循环来执行实际循环。
// 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
方法。
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