## 五、高階函數
> 原文:[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

開發大型程序通常需要耗費大量財力和物力,這絕不僅僅是因為構建程序所花費時間的問題。大型程序的復雜程度總是很高,而這些復雜性也會給開發人員帶來不少困擾,而程序錯誤或 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 種書寫系統來編寫文本,其中許多我甚至不認識。 例如,以下是泰米爾語手寫體的示例。

示例數據集包含 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
```