來源: 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