感謝社區(qū)中各位的大力支持,譯者再次奉上一點點福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠,并抽取幸運大獎:點擊這里領(lǐng)取
你在前一章閉包/對象的兔子洞中玩兒的開心嗎?歡迎回來!
如果你能做很贊的事情,那就反復(fù)做。
我們在本書先前的部分已經(jīng)看到了對一些工具的簡要引用,現(xiàn)在我們要非常仔細地看看它們,它們是 map(..)
、filter(..)
、和 reduce(..)
。在 JavaScript 中,這些工具經(jīng)常作為數(shù)組(也就是“列表”)原型上的方法使用,所以我們很自然地稱它們?yōu)閿?shù)組或列表操作。
在我們討論具體的數(shù)組方法之前,我們要在概念上檢視一下這些操作是用來做什么的。在這一章中有兩件同等重要的事情:理解列表操作 為什么 重要以及理解列表操作是 如何 工作的。確保你在頭腦中帶著這些細節(jié)來閱讀這一章。
在這本書之外的世界以及這一章中,展示這些操作的最廣泛、最常見方式是,描述一些在值的列表中實施的微不足道的任務(wù)(比如將一個數(shù)組中的每個數(shù)字翻倍);這是一種講清要點的簡單廉價的方式。
但是不要僅僅將這些簡單的例子一帶而過卻忽略了深層次的意義。理解列表操作對于 FP 來說最重要的價值來源于能夠?qū)⒁粋€任務(wù)的序列 —— 一系列 看起來 不太像一個列表的語句 —— 模型化為一個操作的列表,而非分別獨立地執(zhí)行它們。
這不只是一個編寫更簡潔代碼的技巧。我們追求的是從指令式轉(zhuǎn)向聲明式,使代碼模式可以輕而易舉地識別從而可讀性更高。
但是這里還有 更重要的東西需要把握住。使用指令式代碼,一組計算中的每一個中間結(jié)果都通過賦值存儲在變量中。你的代碼越依賴這樣的指令式模式,驗證它在邏輯上沒有錯誤就越困難 —— 對值的意外改變,或者埋藏下側(cè)因/副作用。
通過將列表操作鏈接和/或組合在一起,中間結(jié)果會被隱含地追蹤,并極大程度地防止這些災(zāi)難的發(fā)生。
注意: 與前面的章節(jié)相比,為了盡可能地保持后述代碼段的簡潔,我們將重度應(yīng)用 ES6 的 =>
形式。但是,我在第二章中關(guān)于 =>
的建議依然在一般性的編碼中適用。
非 FP 列表處理
作為我們在本章中的討論的序言,我要指出幾個看起來與 JavaScript 數(shù)組和 FP 列表操作相關(guān),但其實無關(guān)的操作。這些操作將不會在這里講解,因為它們不符合一般的 FP 最佳實踐:
forEach(..)
some(..)
every(..)
forEach(..)
是一個迭代幫助函數(shù),但它被設(shè)計為對每一個執(zhí)行的函數(shù)調(diào)用都帶有副作用;你可能猜到了為什么我們的討論不贊同它是一個 FP 列表操作。
some(..)
和 every(..)
確實鼓勵使用純函數(shù)(具體地講,是像 filter(..)
這樣的檢測函數(shù)),但它們實質(zhì)上像一個檢索或匹配一樣,不可避免地將一個列表遞減為一個 true
/ false
的結(jié)果。這兩個工具不是很適合作為我們想要對代碼進行 FP 建模的模具,所以這里我們跳過它們不講。
Map
我們將從最基本而且最基礎(chǔ)的 FP 列表操作之一開始我們的探索:map(..)
。
一個映射是從一個值到另一個值的變形。例如,如果你有一個數(shù)字 2
而你將它乘以 3
,那么你就將它映射到了 6
。值得注意的是,我們所說的映射變形暗示著 原地 修改或者重新賦值;而不是映射變形將一個新的值從一個地方投射到另一個地方。
換句話說:
var x = 2, y;
// 變形/投射
y = x * 3;
// 改變/重新賦值
x = x * 3;
如果我們將這個乘以 3
定義為一個函數(shù),這個函數(shù)作為一個映射(變形)函數(shù)動作的話:
var multipleBy3 = v => v * 3;
var x = 2, y;
// 變形/投射
y = multiplyBy3( x );
我們可以很自然地將映射從一個單獨值的變形擴展到一個值的集合。map(..)
操作將列表中的所有值變形并將它們投射到一個新的列表中:
要實現(xiàn) map(..)
的話:
function map(mapperFn,arr) {
var newList = [];
for (let idx = 0; i < arr.length; i++) {
newList.push(
mapperFn( arr[i], idx, arr )
);
}
return newList;
}
注意: 形式參數(shù)的順序 mapperFn, arr
一開始可能感覺是弄反了,但是在 FP 庫中這種慣例非常常見,因為它使這些工具更容易(使用柯里化)組合。
mapperFn(..)
自然地將項目列表傳遞給映射/變形,同時還有 idx
和 arr
。我們這樣做是為了與內(nèi)建的 map(..)
保持一致。在某些情況下這些額外的信息可能十分有用。
但是在其他情況下,你可能想使用一個僅有列表項目應(yīng)當(dāng)被傳入的 mapperFn(..)
,因為額外的參數(shù)有可能會改變它的行為。在第三章中的 “皆歸為一” 中介紹了 unary(..)
,它限制一個函數(shù)僅接收一個實際參數(shù)(不論有多少被傳遞)。
回憶一下第三章中將 parseInt(..)
限制為一個參數(shù)的例子,它可以安全地用于 mapperFn(..)
:
map( ["1","2","3"], unary( parseInt ) );
// [1,2,3]
JavaScript 在數(shù)組上提供了內(nèi)建的 map(..)
工具,使得它很容易地用做一個列表操作鏈條上的一部分。
注意: JavaScript 原型操作(map(..)
、filter(..)
、和 reduce(..)
)都接收一個最后的可選參數(shù),它用于函數(shù)的 this
綁定。在我們第二章的 “This 是什么” 的討論中,從為了符合 FP 的最佳實踐的角度講,基于 this
的編碼一般來說應(yīng)當(dāng)盡可能避免。因此,我們在這一章例子的實現(xiàn)中不支持這樣的 this
綁定特性。
除了你可以分別對數(shù)字和字符串列表實施的各種顯而易見的操作之外,還有一些其他的映射操作的例子。我們可以使用 map(..)
來把一個函數(shù)的列表變形為一個它們返回值的列表:
var one = () => 1;
var two = () => 2;
var three = () => 3;
[one,two,three].map( fn => fn() );
// [1,2,3]
或者我們可以首先將列表中的每一個函數(shù)都與另一個函數(shù)組合,然后再執(zhí)行它們:
var increment = v => ++v;
var decrement = v => --v;
var square = v => v * v;
var double = v => v * 2;
[increment,decrement,square]
.map( fn => compose( fn, double ) )
.map( fn => fn( 3 ) );
// [7,5,36]
關(guān)于 map(..)
的一件有趣的事情:我們通常會臆測列表是從左到右處理的,但是 map(..)
的概念中對此沒有任何要求。每一個變形對于其他的變形來說都應(yīng)該是獨立的。
從一般的意義上說,在一個支持并行的環(huán)境中映射甚至可以是并行化的,這對很大的列表來說可以極大地提升性能。我們沒有看到 JavaScript 實際上在這樣做,因為沒有任何東西要求你傳入一個像 mapperFn(..)
這樣的純函數(shù),即使你 本應(yīng)這樣做。如果你傳入一個非純函數(shù)而且 JS 以不同的順序運行不同的調(diào)用,那么這很快就會引起災(zāi)難。
雖然在理論上每一個映射操作都是獨立的,但 JS 不得不假定它們不是。這很掃興。
同步 vs 異步
我們在這一章中要討論的列表操作都是在一個所有值都已存在的列表上同步地操作;map(..)
在這里被認為是一種迫切的操作。但另一種考慮映射函數(shù)的方法是將它作為一個事件處理器,為在列表中出現(xiàn)的每一個新的值而被調(diào)用。
想象一個如下虛構(gòu)的東西:
var newArr = arr.map();
arr.addEventListener( "value", multiplyBy3 );
現(xiàn)在,無論什么時候一個值被添加到 arr
種,事件處理器 multiplyBy3(..)
—— 映射函數(shù) —— 都會用這個值被調(diào)用,它變形出的值被添加到 newArr
。
我們在暗示的是,數(shù)組以及我們在它們之上施加的數(shù)組操作,都是迫切的同步版本,而這些相同的操作也可以在一個經(jīng)過一段時間后才收到值的 “懶惰列表” (也就是流)上建模。
映射 vs 迭代
一些人鼓吹將 map(..)
作為一個一般形式的 forEach(..)
迭代使用,讓它收到的值原封不動地通過,但之后可能實施一些副作用:
[1,2,3,4,5]
.map( function mapperFn(v){
console.log( v ); // 副作用!
return v;
} )
..
這種技術(shù)看起來好像有用是因為 map(..)
返回這個數(shù)組,于是你就可以繼續(xù)在它后面鏈接更多操作;而 forEach(..)
的返回值是 undefined
。然而,我認為你應(yīng)當(dāng)避免以這種方式使用 map(..)
,因為以一種明確的非 FP 方式使用一種 FP 核心操作除了困惑不會造成其他任何東西。
你聽說過用正確的工具做正確的事這句諺語,對吧?錘子對釘子,改錐對螺絲,等等。這里有點兒不同:它是 用正確的方式 使用正確的工具。
一把錘子本應(yīng)在你的手中揮動;但如果你用嘴叼住它來砸釘子,那你就不會非常高效。map(..)
的本意是映射值,不是造成副作用。
一個詞:函子
在這本書中我們絕大多數(shù)時候都盡可能地與人工發(fā)明的 FP 術(shù)語保持距離。我們有時候用一些官方術(shù)語,但這多數(shù)是在我們可以從中衍生出一些對普通日常對話有意義的東西的時候。
我將要非常簡要地打破這種模式,而使用一個可能有點兒嚇人的詞:函子。我想談一下函子的原因是因為我們現(xiàn)在已經(jīng)知道它們是做什么的了,也因為這個詞在其他的 FP 文獻中被頻繁使用;至少你熟悉它而不會被它嚇到是有好處的。
一個函子是一個值,它有一個工具可以使一個操作函數(shù)作用于這個值。
如果這個值是復(fù)合的,也就是它由一些獨立的值組成 —— 例如像數(shù)組一樣!—— 那么函子會在每一個獨立的值上應(yīng)用操作函數(shù)。另外,函子工具會創(chuàng)建一個新的復(fù)合值來持有所有獨立操作函數(shù)調(diào)用的結(jié)果。
這只是對我們剛剛看到的 map(..)
的一種炫酷的描述。map(..)
函數(shù)拿著與它關(guān)聯(lián)的值(一個數(shù)組)和一個映射函數(shù)(操作函數(shù)),并對數(shù)組中每一個獨立的值執(zhí)行這個映射函數(shù)。最終,它返回一個帶有所有被映射好的值的數(shù)組。
另一個例子:一個字符串函子將是一個字符串外加一個工具,這個工具對字符串中的每一個字符執(zhí)行一些操作函數(shù),返回一個帶有被處理過的字符的新字符串。考慮這個高度造作的例子:
function uppercaseLetter(c) {
var code = c.charCodeAt( 0 );
// 小寫字符?
if (code >= 97 && code <= 122) {
// 將它大寫!
code = code - 32;
}
return String.fromCharCode( code );
}
function stringMap(mapperFn,str) {
return [...str].map( mapperFn ).join( "" );
}
stringMap( uppercaseLetter, "Hello World!" );
// HELLO WORLD!
stringMap(..)
允許一個字符串是一個函子。你可以為任意數(shù)據(jù)結(jié)構(gòu)定義一個映射函數(shù);只要這個工具符合這些規(guī)則,那么這種數(shù)據(jù)結(jié)構(gòu)就是一個函子。
Filter
想象我在百貨商店里拿著一個空籃子來到了水果賣場;這里有一大堆水果貨架(蘋果、橘子、和香蕉)。我真的很餓,所以要盡可能多地拿水果,但我非常喜歡圓形的水果(蘋果和橘子)。于是我一個一個地篩選,然后帶著僅裝有蘋果和橘子的籃子離開了。
假定我們稱這種處理為 過濾。你將如何更自然地描述我的購物過程?是以一個空籃子為起點并僅僅 濾入(選擇,包含)蘋果和橘子,還是以整個水果貨架為起點并 濾除(跳過,排除)香蕉,直到我的籃子裝滿水果?
如果你在一鍋水中煮意大利面,然后把它倒進水槽上的笊籬(也就是過濾器)中,你是在濾入意大利面還是在濾除水?如果你將咖啡粉末放進濾紙中泡一杯咖啡,你是在自己的杯子中濾入了咖啡,還是濾除了咖啡粉末?
你對過濾的視角是不是有賴于你想要的東西是否 “保留” 在過濾器中或者通過了過濾器?
那么當(dāng)你在航空公司/酒店的網(wǎng)站上制定一些選項來 “過濾你的結(jié)果” 呢?你是在濾入符合你標(biāo)準(zhǔn)的結(jié)果,還是在濾除所有不符合標(biāo)準(zhǔn)的東西?仔細考慮一下:這個例子與前一個比起來可能有不同的語義。
根據(jù)你的視角不同,過濾不是排除性的就是包含性的。這種概念上的沖突非常不幸。
我認為對過濾的最常見的解釋 —— 在編程的世界之外 —— 是你在濾除不想要的東西。不幸的是,在編程中,我們實質(zhì)上更像是將這種語義反轉(zhuǎn)為濾入想要的東西。
filter(..)
列表操作用一個函數(shù)來決定原來數(shù)組中的每一個值是否應(yīng)該保留在新的數(shù)組中。如果一個值應(yīng)當(dāng)被保留,那么這個函數(shù)需要返回 true
,如果它應(yīng)當(dāng)被跳過則返回 false
。一個為做決定而返回 true
/ false
的函數(shù)有一個特殊的名字:判定函數(shù)。
如果你將 true
作為一個正面的信號,那么 filter(..)
的定義就是你想要 “保留”(濾入)一個值而非 “丟棄”(濾除)一個值。
為了將 filter(..)
作為一種排除性操作使用,你不得不把思維擰過來,通過返回 false
將正面信號考慮為一種排除,并通過返回 true
被動地讓一個值通過。
這種語義錯位很重要的原因,是因為它會影響你如何命名那個用作 predicateFn(..)
的函數(shù),以及它對代碼可讀性的意義。我們馬上就會談到這一點。
這是 filter(..)
操作在一個值的列表中的可視化表達:
要實現(xiàn) filter(..)
:
function filter(predicateFn,arr) {
var newList = [];
for (let idx = 0; idx < arr.length; idx++) {
if (predicateFn( arr[idx], idx, arr )) {
newList.push( arr[idx] );
}
}
return newList;
}
注意,正如之前的 mapperFn(..)
一樣,predicateFn(..)
? 不僅被傳入一個值,而且還被傳入了 idx
和 arr
。可以根據(jù)需要使用 unary(..)
來限制它的參數(shù)。
和 map(..)
一樣,filter(..)
作為一種 JS 數(shù)組上的內(nèi)建工具被提供。
讓我們考慮一個這樣的判定函數(shù):
var whatToCallIt = v => v % 2 == 1;
這個函數(shù)使用 v % 2 == 1
來返回 true
或 false
。這里的效果是一個奇數(shù)將會返回 true
,而一個偶數(shù)將會返回 false
。那么,我們應(yīng)該管這個函數(shù)叫什么?一個自然的名字可能是:
var isOdd = v => v % 2 == 1;
考慮一下在你代碼中的某處你將如何使用 isOdd(..)
進行簡單的值校驗:
var midIdx;
if (isOdd( list.length )) {
midIdx = (list.length + 1) / 2;
}
else {
midIdx = list.length / 2;
}
有些道理,對吧?讓我們再考慮一下將它與數(shù)組內(nèi)建的 filter(..)
一起使用來過濾一組值:
[1,2,3,4,5].filter( isOdd );
// [1,3,5]
如果要你描述一下 [1,3,5]
這個結(jié)果,你會說 “我濾除了偶數(shù)” 還是 “我濾入了奇數(shù)”?我覺得前者是描述它的更自然的方式。但是代碼讀起來卻相反。代碼讀起來幾乎就是,我們 “過濾(入)了每一個是奇數(shù)的數(shù)字”。
我個人覺得這種語義令人糊涂。對于有經(jīng)驗的開發(fā)者來說無疑有許多先例可循。但是如果你剛剛起步,這種邏輯表達式看起來有點兒像不用雙否定就不會說話 —— 也就是,用雙否定說話。
我們可以通過把函數(shù) isOdd(..)
重命名為 isEven(..)
讓事情容易一些:
var isEven = v => v % 2 == 1;
[1,2,3,4,5].filter( isEven );
// [1,3,5]
呀!但這個函數(shù)用這個名字根本講不通,因為當(dāng)數(shù)字為偶數(shù)時它返回 false
:
isEven( 2 ); // false
討厭。
回憶一下第三章中的 “無點”,我們定義了一個對判定函數(shù)取反的 not(..)
操作符。考慮:
var isEven = not( isOdd );
isEven( 2 ); // true
但是我們將 這個 isEven(..)
按照它當(dāng)前定義的方式與 filter(..)
一起使用,因為邏輯將是相反的;我們將會得到偶數(shù),不是奇數(shù)。我們需要這樣做:
[1,2,3,4,5].filter( not( isEven ) );
// [1,3,5]
這違背了我們的初衷,所以不要這樣做。我們只是在繞圈子。
濾除與濾入
為了掃清這一切困惑,讓我們定義一個通過在內(nèi)部對判定檢查取反來真正 濾除 一些值的 filterOut(..)
。同時我們將 filterIn(..)
作為既存的 filter(..)
的別名:
var filterIn = filter;
function filterOut(predicateFn,arr) {
return filterIn( not( predicateFn ), arr );
}
現(xiàn)在我們可以在代碼的任何地方使用最合理的那一種過濾了:
isOdd( 3 ); // true
isEven( 2 ); // true
filterIn( isOdd, [1,2,3,4,5] ); // [1,3,5]
filterOut( isEven, [1,2,3,4,5] ); // [1,3,5]
與僅使用 filter(..)
并將語義上的混淆和困惑留給讀者相比,我覺得使用 filterIn(..)
和 filterOut(..)
(在 Ramda 中稱為 reject(..)
)將會使你代碼的可讀性好得多。
Reduce
map(..)
和 filter(..)
產(chǎn)生一個新的列表,但這第三種操作(reduce(..)
)經(jīng)常將一個列表的值結(jié)合(也就是“遞減”)為一個單獨的有限(非列表)值,比如數(shù)字或字符串。但是在本章稍后,我們將會看到如何以更高級的方式使用 reduce(..)
。reduce(..)
是最重要的 FP 工具之一;它就像瑞士軍刀一樣多才多藝。
組合/遞減被抽象地定義為將兩個值變成一個值。一些 FP 語境中將之稱為 “折疊(folding)”,就好像你把兩個值折疊成為一個值一樣。我覺得這種視覺化很有助于理解。
正如映射與過濾那樣,結(jié)合的行為完全由你來決定,而且這一般要看列表中值的種類而定。例如,數(shù)字通常將通過算數(shù)方式來結(jié)合,字符串通過連接,而函數(shù)通過組合。
有時遞減將會指定一個 initialValue
并用它與列表中的第一個值結(jié)合的結(jié)果做為起點,穿過列表中剩余的其他每一個值。看起來就像這樣:
另一種方式是你可以忽略 initialValue
,這時列表中的第一個值將作為 initialValue
,而結(jié)合將始于它與列表中的第二個值,就像這樣:
注意: 在 JavaScript 中,遞減至少需要一個值(要么在數(shù)組中,要么作為 initialValue
指定),否則就會拋出一個錯誤。如果用來遞減的列表在某些環(huán)境下可能為空,那么就要小心不要忽略 initialValue
。
你傳遞給 reduce(..)
來執(zhí)行遞減的函數(shù)通常稱為一個遞減函數(shù)(reducer)。遞減函數(shù)的簽名與我們早先看到的映射和判定函數(shù)不同。遞減函數(shù)主要接收當(dāng)前的遞減結(jié)果以及下一個用于與之遞減的值。在遞減每一步中的結(jié)果經(jīng)常被稱為聚集器(accumulator)。
例如,考慮將 3
作為 initialValue
對數(shù)字 5
、10
、和 15
進行乘法遞減時的步驟:
-
3
*5
=15
-
15
*10
=150
-
150
*15
=2250
在 JavaScript 中使用內(nèi)建在數(shù)組上的 reduce(..)
來表達的話:
[5,10,15].reduce( (product,v) => product * v, 3 );
// 2250
一個獨立的 reduce(..)
的實現(xiàn)可能像是這樣:
function reduce(reducerFn,initialValue,arr) {
var acc, startIdx;
if (arguments.length == 3) {
acc = initialValue;
startIdx = 0;
}
else if (arr.length > 0) {
acc = arr[0];
startIdx = 1;
}
else {
throw new Error( "Must provide at least one value." );
}
for (let idx = startIdx; idx < arr.length; idx++) {
acc = reducerFn( acc, arr[idx], idx, arr );
}
return acc;
}
和 map(..)
與 filter(..)
一樣,遞減函數(shù)也會被傳入不太常用的 idx
和 arr
參數(shù)以備遞減中的不時之需。我不常用這些東西,但我猜它們可以隨時取用是一件不錯的事情。
回憶一下第四章,我們討論了 compose(..)
工具而且展示了一種使用 reduce(..)
的實現(xiàn):
function compose(...fns) {
return function composed(result){
return fns.reverse().reduce( function reducer(result,fn){
return fn( result );
}, result );
};
}
為了以一種不同的方式展示基于 reduce(..)
的組合,考慮一個從左到右(像 pipe(..)
那樣)組合函數(shù)的遞減函數(shù),將它用于一個數(shù)組中:
var pipeReducer = (composedFn,fn) => pipe( composedFn, fn );
var fn =
[3,17,6,4]
.map( v => n => v * n )
.reduce( pipeReducer );
fn( 9 ); // 11016 (9 * 3 * 17 * 6 * 4)
fn( 10 ); // 12240 (10 * 3 * 17 * 6 * 4)
不幸的是 pipeReducer(..)
不是無點的(參見第三章的 “無點”),但我們不能簡單地將 pipe(..)
作為遞減函數(shù)本身傳遞,因為它是參數(shù)可變的;reduce(..)
傳遞給遞減函數(shù)的額外參數(shù)(idx
和 arr
)可能會引起問題。
早先我們談到過使用 unary(..)
來將 mapperFn(..)
和 predicateFn(..)
限定為一個參數(shù)。對于一個 reducerFn(..)
來說,要是有一個與之相似但限定為兩個參數(shù)的 binary(..)
可能會很方便:
var binary =
fn =>
(arg1,arg2) =>
fn( arg1, arg2 );
使用 binary(..)
,我們的前一個例子會干凈一些:
var pipeReducer = binary( pipe );
var fn =
[3,17,6,4]
.map( v => n => v * n )
.reduce( pipeReducer );
fn( 9 ); // 11016 (9 * 3 * 17 * 6 * 4)
fn( 10 ); // 12240 (10 * 3 * 17 * 6 * 4)
對 map(..)
和 filter(..)
來說遍歷數(shù)組的順序?qū)嶋H上不重要,與此不同的是,reduce(..)
絕對是按照從左到右的順序處理。如果你想要從右到左地遞減,JavaScript 提供了一個 reduceRight(..)
,它除了順序以外其他一切行為都與 reduce(..)
相同:
var hyphenate = (str,char) => str + "-" + char;
["a","b","c"].reduce( hyphenate );
// "a-b-c"
["a","b","c"].reduceRight( hyphenate );
// "c-b-a"
reduce(..)
從左到右地工作,因此在組合函數(shù)時很自然地像 pipe(..)
一樣動作,而 reduceRight(..)
從右到左的順序?qū)τ?compose(..)
一樣的操作來說更自然。那么,讓我們使用 reduceRight(..)
來重溫 compose(..)
:
function compose(...fns) {
return function composed(result){
return fns.reduceRight( function reducer(result,fn){
return fn( result );
}, result );
};
}
現(xiàn)在我們不需要 fns.reverse()
了;我們只要從另一個方向遞減即可!
Map 作為 Reduce
map(..)
操作天生就是迭代性的,所以它也可以表達為一種遞減(reduce(..)
)。這其中的技巧是要理解 reduce(..)
的 initialValue
本身可以是一個(空)數(shù)組,這樣遞減的結(jié)果就可以是另一個列表!
var double = v => v * 2;
[1,2,3,4,5].map( double );
// [2,4,6,8,10]
[1,2,3,4,5].reduce(
(list,v) => (
list.push( double( v ) ),
list
), []
);
// [2,4,6,8,10]
注意: 我們在欺騙這個遞減函數(shù),并通過 list.push(..)
改變被傳入的列表而引入了副作用。一般來說,這不是個好主意,但是因為我們知道 []
是被新建并傳入的,所以這沒那么危險。你可以更正式一些 —— 性能也差一些!—— 將值 concat(..)
在一個新列表的末尾。我們將在附錄A中再次回到這個問題上。
使用 reduce(..)
實現(xiàn) map(..)
在表面上看來不是顯而易見的,它甚至不是一種改進。然而,對于我們將在附錄A中講解的 “轉(zhuǎn)導(dǎo)(Transducing)” 這樣的更高級的技術(shù)來說,這種能力將是一種識別它的關(guān)鍵方法。
Filter 作為 Reduce
就像 map(..)
可以用 reduce(..)
完成一樣,filter(..)
也能:
var isOdd = v => v % 2 == 1;
[1,2,3,4,5].filter( isOdd );
// [1,3,5]
[1,2,3,4,5].reduce(
(list,v) => (
isOdd( v ) ? list.push( v ) : undefined,
list
), []
);
// [1,3,5]
注意: 這里使用了更不純粹的遞減函數(shù)。我們本可以用 list.concat(..)
并返回一個新列表來取代 list.push(..)
。我們將在附錄A中回到這個問題。
高級列表操作
現(xiàn)在我們對基礎(chǔ)的列表操作 map(..)
、filter(..)
、和 reduce(..)
有些熟悉了,讓我們看幾個你可能會在各種情景下覺得很有用的更加精巧的操作。它們通常都是你可以在各種 FP 庫中找到的工具。
Unique
基于 indexOf(..)
搜索(它使用 ===
嚴(yán)格等價比較),過濾一個列表使之僅包含唯一的值:
var unique =
arr =>
arr.filter(
(v,idx) =>
arr.indexOf( v ) == idx
);
這種技術(shù)的工作方式是,僅將第一次出現(xiàn)在 arr
中的項目加入到新的列表中;當(dāng)從左到右運行時,這僅在項目的 idx
位置與 indexOf(..)
找到的位置相同時成立。
另一種實現(xiàn) unique(..)
的方式是遍歷 arr
,如果一個項目沒有在新的列表中(初始為空)找到就將它加入這個新的列表。我們?yōu)檫@樣的處理使用 reduce(..)
:
var unique =
arr =>
arr.reduce(
(list,v) =>
list.indexOf( v ) == -1 ?
( list.push( v ), list ) : list
, [] );
注意: 使用諸如循環(huán)之類的更具指令式的方式,有許多種其他不同的方法可以實現(xiàn)這個算法,而且其中很多在性能方面可能看起來 “更高效”。然而,上面展示的這兩種方式有一個優(yōu)勢是它們都使用了既存的列表操作,這使它們與其他列表操作鏈接/組合起來更容易。我們將會在本章稍后更多地談到這些問題。
unique(..)
可以出色地產(chǎn)生一個沒有重復(fù)的新列表:
unique( [1,4,7,1,3,1,7,9,2,6,4,0,5,3] );
// [1, 4, 7, 3, 9, 2, 6, 0, 5]
Flatten
時不時地,你會得到(或者從一些其他操作中得到)一個這樣的數(shù)組:它不只是一個值的扁平的列表,而是一個嵌套的數(shù)組,例如:
[ [1, 2, 3], 4, 5, [6, [7, 8]] ]
要是你想要將它變形為這樣呢?
[ 1, 2, 3, 4, 5, 6, 7, 8 ]
我們在尋求的操作通常稱為 flatten(..)
,它可以使用我們的瑞士軍刀 reduce(..)
像這樣實現(xiàn):
var flatten =
arr =>
arr.reduce(
(list,v) =>
list.concat( Array.isArray( v ) ? flatten( v ) : v )
, [] );
注意: 這種實現(xiàn)依賴于使用遞歸處理嵌套的列表。后面的章節(jié)中有關(guān)于遞歸的更多內(nèi)容。
要對一個(嵌套多層的)數(shù)組的數(shù)組使用 flatten(..)
:
flatten( [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]] );
// [0,1,2,3,4,5,6,7,8,9,10,11,12,13]
你可能想要將遞歸平整限制在一個特定的深度上。我們可以通過為這種實現(xiàn)添加一個可選的 depth
限制參數(shù)來處理:
var flatten =
(arr,depth = Infinity) =>
arr.reduce(
(list,v) =>
list.concat(
depth > 0 ?
(depth > 1 && Array.isArray( v ) ?
flatten( v, depth - 1 ) :
v
) :
[v]
)
, [] );
這是不同平整深度的結(jié)果:
flatten( [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]], 0 );
// [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]]
flatten( [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]], 1 );
// [0,1,2,3,4,[5,6,7],[8,[9,[10,[11,12],13]]]]
flatten( [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]], 2 );
// [0,1,2,3,4,5,6,7,8,[9,[10,[11,12],13]]]
flatten( [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]], 3 );
// [0,1,2,3,4,5,6,7,8,9,[10,[11,12],13]]
flatten( [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]], 4 );
// [0,1,2,3,4,5,6,7,8,9,10,[11,12],13]
flatten( [[0,1],2,3,[4,[5,6,7],[8,[9,[10,[11,12],13]]]]], 5 );
// [0,1,2,3,4,5,6,7,8,9,10,11,12,13]
Map,之后 Flatten
flatten(..)
行為的最常見用法之一是在你映射一個列表時,每一個從原數(shù)組變形而來的元素本身就是一個值的列表。例如:
var firstNames = [
{ name: "Jonathan", variations: [ "John", "Jon", "Jonny" ] },
{ name: "Stephanie", variations: [ "Steph", "Stephy" ] },
{ name: "Frederick", variations: [ "Fred", "Freddy" ] }
];
firstNames
.map( entry => [entry.name].concat( entry.variations ) );
// [ ["Jonathan","John","Jon","Jonny"], ["Stephanie","Steph","Stephy"],
// ["Frederick","Fred","Freddy"] ]
返回值是一個數(shù)組的數(shù)組,這使用起來可能很尷尬。如果我們要的是一個所有名字的一維數(shù)組,那么就可以 flatten(..)
這個結(jié)果:
flatten(
firstNames
.map( entry => [entry.name].concat( entry.variations ) )
);
// ["Jonathan","John","Jon","Jonny","Stephanie","Steph","Stephy","Frederick",
// "Fred","Freddy"]
除了稍稍繁冗之外,將 map(..)
和 flatten(..)
作為兩個步驟分開做的缺點主要關(guān)乎性能;這個方式將列表處理了兩次。
FP 庫中經(jīng)常定義一個“映射之后平整”組合的 flatMap(..)
(也常被稱為 chain(..)
)。為了一致性和易于(通過柯里化)組合,這些 flatMap(..)
/ chain(..)
工具通常都與我們以前看到的 map(..)
、filter(..)
、和 reduce(..)
獨立工具的 mapperFn, arr
參數(shù)順序相吻合。
flatMap( entry => [entry.name].concat( entry.variations ), firstNames );
// ["Jonathan","John","Jon","Jonny","Stephanie","Steph","Stephy","Frederick",
// "Fred","Freddy"]
將兩個步驟分開做的 flatMap(..)
的幼稚的實現(xiàn)是:
var flatMap =
(mapperFn,arr) =>
flatten( arr.map( mapperFn ), 1 );
注意: 我們將 1
用于平整深度是因為 flatMap(..)
的常見定義是僅發(fā)生在第一層的淺平整。
因為這種方式依然將列表處理兩次而導(dǎo)致差勁的性能,所以我們可以使用 reduce(..)
手動組合這些操作:
var flatMap =
(mapperFn,arr) =>
arr.reduce(
(list,v) =>
list.concat( mapperFn( v ) )
, [] );
雖然 flatMap(..)
工具會帶來一些方便與性能上的增益,但有時候你很可能需要混合一些 filter(..)
這樣的操作。如果是這樣,將 map(..)
與 flatten(..)
分開做可能還是更合適的。
Zip
至此,我們檢視過的列表操作都是在一個列表上進行操作的。但是有些情況需要處理多個列表。一個廣為人知的操作是從兩個輸入列表中交替選擇值放入子列表中,稱為 zip(..)
:
zip( [1,3,5,7,9], [2,4,6,8,10] );
// [ [1,2], [3,4], [5,6], [7,8], [9,10] ]
值 1
和 2
被選入子列表 [1,2]
,然后 3
和 4
被選入 [3,4]
,等等。zip(..)
的定義要求從兩個列表中各取一個值。如果這兩個列表長度不同,那么值的選擇將會進行到較短的那個列表耗盡為止,在另一個列表中額外的值將會被忽略。
一種 zip(..)
的實現(xiàn):
function zip(arr1,arr2) {
var zipped = [];
arr1 = arr1.slice();
arr2 = arr2.slice();
while (arr1.length > 0 && arr2.length > 0) {
zipped.push( [ arr1.shift(), arr2.shift() ] );
}
return zipped;
}
調(diào)用 arr1.slice()
和 arr2.slice()
通過不在收到的數(shù)組引用上造成副作用來保證 zip(..)
是純粹的。
注意: 在這種實現(xiàn)中毫無疑問地發(fā)生了一些非 FP 的事情。這里有一個指令式的 while
循環(huán)而且使用 shift()
和 push(..)
改變了列表。在本書早先的部分中,我論證過在純函數(shù)內(nèi)部(通常是為了性能)使用非純粹的行為是合理的,只要其造成的效果是完全自包含的就行。
Merge
通過將兩個列表中的值穿插來融合它們,看起來就像:
mergeLists( [1,3,5,7,9], [2,4,6,8,10] );
// [1,2,3,4,5,6,7,8,9,10]
可能不太明顯,但是這個結(jié)果看起來很類似于我們將 flatten(..)
和 zip(..)
組合得到的東西:
zip( [1,3,5,7,9], [2,4,6,8,10] );
// [ [1,2], [3,4], [5,6], [7,8], [9,10] ]
flatten( [ [1,2], [3,4], [5,6], [7,8], [9,10] ] );
// [1,2,3,4,5,6,7,8,9,10]
// 組合后:
flatten( zip( [1,3,5,7,9], [2,4,6,8,10] ) );
// [1,2,3,4,5,6,7,8,9,10]
然而,回憶一下, zip(..)
對值的選擇僅截止到較短的列表耗盡為止,而且忽略剩下的值;而融合兩個列表時保留那些額外的值將是最自然的。另外,flatten(..)
會在嵌套的列表中遞歸地執(zhí)行,但你可能希望列表融合僅在淺層工作,保留嵌套的列表。
那么,讓我們定義一個如我們所愿的 mergeLists(..)
:
function mergeLists(arr1,arr2) {
var merged = [];
arr1 = arr1.slice();
arr2 = arr2.slice();
while (arr1.length > 0 || arr2.length > 0) {
if (arr1.length > 0) {
merged.push( arr1.shift() );
}
if (arr2.length > 0) {
merged.push( arr2.shift() );
}
}
return merged;
}
注意: 各種 FP 庫不會定義 mergeLists(..)
,取而代之的是定義了融合兩個對象的屬性的 merge(..)
;這樣的 merge(..)
的結(jié)果將與我們的 mergeLists(..)
的結(jié)果不同。
此外,還有許多其他選擇可以將列表的融合實現(xiàn)為一個遞減函數(shù):
// 由 @rwaldron 編寫
var mergeReducer =
(merged,v,idx) =>
(merged.splice( idx * 2, 0, v ), merged);
// 由 @WebReflection 編寫
var mergeReducer =
(merged,v,idx) =>
merged
.slice( 0, idx * 2 )
.concat( v, merged.slice( idx * 2 ) );
使用 mergeReducer(..)
:
[1,3,5,7,9]
.reduce( mergeReducer, [2,4,6,8,10] );
// [1,2,3,4,5,6,7,8,9,10]
提示: 我們將在本章稍后使用這個 mergeReducer(..)
技巧。
方法 vs. 獨立函數(shù)
在 JavaScript 中一個令 FP 程序員們沮喪的根源是,當(dāng)一些工具作為獨立函數(shù)被提供 —— 考慮一下我們在前一章中衍生出來的各種工具 —— 而另一些是數(shù)組原型的方法時 —— 就像我們在本章中看到的 —— 如何為使用這些工具來統(tǒng)一他們的策略。
當(dāng)你考慮組合多個操作時,這個問題使人頭疼的地方就變得更加明顯:
[1,2,3,4,5]
.filter( isOdd )
.map( double )
.reduce( sum, 0 ); // 18
// vs.
reduce(
map(
filter( [1,2,3,4,5], isOdd ),
double
),
sum,
0
); // 18
這兩種 API 風(fēng)格都完成相同的任務(wù),但它們在人體工程學(xué)上十分的不同。相較于前者許多 FP 程序員偏好后者,但前者毫無疑問地在 JavaScript 中更常見。后者一個特別令人厭惡的東西就是調(diào)用的嵌套。方法鏈風(fēng)格招人喜歡的地方 —— 常被稱為流利的 API 風(fēng)格,就像在 jQuery 和其他工具中那樣 —— 在于其緊湊/簡潔,以及它讀起來是聲明式的從上到下順序。
對于獨立函數(shù)風(fēng)格的手動組合來說,它的視覺順序既不是從左到右(從上到下)也不是從右到左(從下到上);而是由內(nèi)而外,這損害了可讀性。
自動組合將兩種風(fēng)格的閱讀順序規(guī)范化為從右到左(從下到上)。那么為了探索不同風(fēng)格可能造成的影響,讓我們專門講解一下組合;這看起來應(yīng)當(dāng)很直接,但是實際上在兩種情況下都有些尷尬。
組合方法鏈
數(shù)組方法接收隱含的 this
參數(shù),所以盡管它們沒有出現(xiàn),這些方法也不能被視為一元的;這使得組合更加尷尬。為了應(yīng)付它,首先我們需要一個 this
敏感版本的 partial(..)
:
var partialThis =
(fn,...presetArgs) =>
// 有意地制造一個允許 `this` 綁定的 `function`
function partiallyApplied(...laterArgs){
return fn.apply( this, [...presetArgs, ...laterArgs] );
};
我們還需要另一個版本的 compose(..)
,它在這個鏈條的上下文環(huán)境中調(diào)用每一個被部分應(yīng)用的方法 —— 從上一個步驟中(通過隱含的 this
)被 “傳遞” 過來的輸入值:
var composeChainedMethods =
(...fns) =>
result =>
fns.reduceRight(
(result,fn) =>
fn.call( result )
, result
);
一起使用這兩個 this
敏感的工具:
composeChainedMethods(
partialThis( Array.prototype.reduce, sum, 0 ),
partialThis( Array.prototype.map, double ),
partialThis( Array.prototype.filter, isOdd )
)
( [1,2,3,4,5] ); // 18
注意: 三個 Array.prototype.XXX
風(fēng)格的引用抓住了內(nèi)建的 Array.prototype.*
方法的引用,這樣我們就可以對自己的數(shù)組復(fù)用它們了。
組合獨立函數(shù)工具
這些工具的獨立 compose(..)
風(fēng)格組合不需要所有這些扭曲 this
的操作,這是它最為人稱道的地方。例如,我們可以這樣定義獨立函數(shù):
var filter = (arr,predicateFn) => arr.filter( predicateFn );
var map = (arr,mapperFn) => arr.map( mapperFn );
var reduce = (arr,reducerFn,initialValue) =>
arr.reduce( reducerFn, initialValue );
但是這種特別的獨立風(fēng)格有它自己的尷尬之處;層層傳遞的數(shù)組上下文是第一個參數(shù)而非最后一個,于是我們不得不使用右側(cè)局部應(yīng)用來組合它們:
compose(
partialRight( reduce, sum, 0 )
partialRight( map, double )
partialRight( filter, isOdd )
)
( [1,2,3,4,5] ); // 18
這就是為什么 FP 庫通常將 filter(..)
、map(..)
、和 reduce(..)
定義為最后接收數(shù)組而不是最先接收。它們還經(jīng)常自動地柯里化這些工具:
var filter = curry(
(predicateFn,arr) =>
arr.filter( predicateFn )
);
var map = curry(
(mapperFn,arr) =>
arr.map( mapperFn )
);
var reduce = curry(
(reducerFn,initialValue,arr) =>
arr.reduce( reducerFn, initialValue );
使用這樣定義的工具,組合的流程變得好了一些:
compose(
reduce( sum )( 0 ),
map( double ),
filter( isOdd )
)
( [1,2,3,4,5] ); // 18
這種方式的清晰性在某種程度上就是為什么 FP 程序員喜歡獨立的工具風(fēng)格而不是實例方法。但你的感覺可能會不同。
將方法適配為獨立函數(shù)
在前面 filter(..)
/ map(..)
/ reduce(..)
的定義中,你可能發(fā)現(xiàn)了這三者的共同模式:它們都被分發(fā)到相應(yīng)的原生數(shù)組方法上。那么,我們能否使用一個工具來生成這些獨立適配函數(shù)呢?是的!讓我們?yōu)榇酥圃煲粋€稱為 unboundMethod(..)
的工具:
var unboundMethod =
(methodName,argCount = 2) =>
curry(
(...args) => {
var obj = args.pop();
return obj[methodName]( ...args );
},
argCount
);
要使用這個工具:
var filter = unboundMethod( "filter", 2 );
var map = unboundMethod( "map", 2 );
var reduce = unboundMethod( "reduce", 3 );
compose(
reduce( sum )( 0 ),
map( double ),
filter( isOdd )
)
( [1,2,3,4,5] ); // 18
注意: unboundMethod(..)
在 Ramda 中稱為 invoker(..)
。
將獨立函數(shù)適配為方法
如果你喜歡只使用數(shù)組方法(流利的鏈?zhǔn)斤L(fēng)格),你有兩個選擇。你可以:
- 使用額外的方法擴展內(nèi)建的
Array.prototype
。 - 適配一個用于遞減函數(shù)的獨立工具,并將它傳入
reduce(..)
實例方法。
不要選擇(1)。 擴展 Array.prototype
這樣的原生類型從來都不是一個好主意 —— 除非你定義一個 Array
的子類,但這超出了我們在此的討論范圍。為了不鼓勵不好的實踐,我們不會在這種方式上再前進了。
讓我們 集中于(2)。為了展示其中的要點,我們將轉(zhuǎn)換之前遞歸的 flatten(..)
獨立工具:
var flatten =
arr =>
arr.reduce(
(list,v) =>
list.concat( Array.isArray( v ) ? flatten( v ) : v )
, [] );
讓我們將內(nèi)部的 reducer(..)
函數(shù)抽離為一個獨立工具(并將它適配為可以脫離外部 flatten(..)
工作):
// 有意地定義一個允許通過名稱進行遞歸的函數(shù)
function flattenReducer(list,v) {
return list.concat(
Array.isArray( v ) ? v.reduce( flattenReducer, [] ) : v
);
}
現(xiàn)在,我們可以在一個數(shù)組方法鏈中通過 reduce(..)
來使用這個工具了:
[ [1, 2, 3], 4, 5, [6, [7, 8]] ]
.reduce( flattenReducer, [] )
// ..
尋找列表
目前為止,絕大多數(shù)例子都很瑣碎,它們基于簡單的數(shù)字或字符串列表。現(xiàn)在讓我們談?wù)劻斜聿僮骺梢蚤W光的地方:對一系列指令式的語句進行聲明式的建模。
考慮這個基本的例子:
var getSessionId = partial( prop, "sessId" );
var getUserId = partial( prop, "uId" );
var session, sessionId, user, userId, orders;
session = getCurrentSession();
if (session != null) sessionId = getSessionId( sessionId );
if (sessionId != null) user = lookupUser( sessionId );
if (user != null) userId = getUserId( user );
if (userId != null) orders = lookupOrders( userId );
if (orders != null) processOrders( orders );
首先,我們觀察到那五個變量聲明和守護著函數(shù)調(diào)用的一系列 if
條件實質(zhì)上是這六個調(diào)用的一個大組合:getCurrentSession()
、getSessionId(..)
、lookupUser(..)
、getUserId(..)
、lookupOrders(..)
、和 processOrders(..)
。理想上,我們想要擺脫所有這些變量聲明和指令式條件。
不幸的是,我們在第四章中探索過的 compose(..)
/ pipe(..)
工具自身不會在組合中提供一種表達 != null
條件的簡便方法。讓我們定義一個工具來提供幫助:
var guard =
fn =>
arg =>
arg != null ? fn( arg ) : arg;
這個 guard(..)
工具讓我們映射出那五個條件守護著的函數(shù):
[ getSessionId, lookupUser, getUserId, lookupOrders, processOrders ]
.map( guard )
這個映射的結(jié)果是準(zhǔn)備好組合的函數(shù)的數(shù)組(實際上是 pipe,以這個列表的順序來說)。我們可以將這個數(shù)組擴散到 pipe(..)
,但因為我們已經(jīng)在做列表操作了,讓我們使用一個 reduce(..)
,將 getCurrentSession()
中的 session 值作為初始值:
.reduce(
(result,nextFn) => nextFn( result )
, getCurrentSession()
)
接下來,我們觀察到 getSessionId(..)
和 getUserId(..)
可以被分別表達為值 "sessId"
和 "uId"
的映射:
[ "sessId", "uId" ].map( propName => partial( prop, propName ) )
但為了使用它們,我們需要將它們穿插在其他三個函數(shù)中(lookupUser(..)
、lookupOrders(..)
、和 processOrders(..)
),來讓這五個函數(shù)的數(shù)組像上面的討論中那樣守護/組合。
為了進行穿插,我們可以將此模型化為列表融合。回想一下本章早先的 mergeReducer
:
var mergeReducer =
(merged,v,idx) =>
(merged.splice( idx * 2, 0, v ), merged);
我們可以使用 reduce(..)
(記得到我們的瑞士軍刀嗎!?)通過融合兩個列表,來將 lookupUser(..)
“插入” 到數(shù)組的 getSessionId(..)
和 getUserId(..)
函數(shù)之間:
.reduce( mergeReducer, [ lookupUser ] )
然后我們把 lookupOrders(..)
和 processOrders(..)
連接到運行中的函數(shù)數(shù)組的末尾:
.concat( lookupOrders, processOrders )
檢查一下,生成的五個函數(shù)的列表被表達為:
[ "sessId", "uId" ].map( propName => partial( prop, propName ) )
.reduce( mergeReducer, [ lookupUser ] )
.concat( lookupOrders, processOrders )
最后,把所有東西放在一起,將函數(shù)的列表向前面討論過的那樣添加守護功能并組合:
[ "sessId", "uId" ].map( propName => partial( prop, propName ) )
.reduce( mergeReducer, [ lookupUser ] )
.concat( lookupOrders, processOrders )
.map( guard )
.reduce(
(result,nextFn) => nextFn( result )
, getCurrentSession()
);
所有的指令式變量聲明和條件都不見了,取而代之的是鏈接在一起的干凈且是聲明式的列表操作。
如果對你來說這個版本比原來的版本讀起來更困難,不要擔(dān)心。原來的版本無疑是你可能更加熟悉的指令式形式。你向函數(shù)式程序員演變過程的一部分就是發(fā)展出能夠識別 FP 模式的能力,比如像這樣的列表操作。久而久之,隨著你對代碼可讀性的感覺轉(zhuǎn)換為聲明式風(fēng)格,這些東西將會更容易地從代碼中跳出來。
在我們結(jié)束這個話題之前,讓我們看一下現(xiàn)實:這里的例子都是嚴(yán)重造作的。不是所有的代碼段都可以簡單地模型化為列表操作。務(wù)實的要點是,要開發(fā)尋找這些可以進行優(yōu)化的機會,但不要過于沉迷在代碼的雜耍中;有些改進是聊勝于無的。總是退一步并問問你自己,你是 改善了還是損害了 代碼的可讀性。
融合(Fusion)
隨著你將 FP 列表操作更多地帶入到你對代碼的思考中,你很可能很快就會看到像這樣組合行為的鏈條:
..
.filter(..)
.map(..)
.reduce(..);
而且你還可能往往會得到每種操作有多個相鄰實例的鏈條,就像:
someList
.filter(..)
.filter(..)
.map(..)
.map(..)
.map(..)
.reduce(..);
好消息是這種鏈?zhǔn)斤L(fēng)格是聲明式的,而且很容易按順序讀懂將要發(fā)生的具體步驟。它的缺點是這些操作的每一個都循環(huán)遍歷整個列表,這意味著性能可能會有不必要的消耗,特別是在列表很長的時候。
用另一種獨立風(fēng)格,你可能會看到這樣的代碼:
map(
fn3,
map(
fn2,
map( fn1, someList )
)
);
這種風(fēng)格中,操作由下至上地羅列,而且我們依然循環(huán)遍歷列表三遍。
融合通過組合相鄰的操作來減少列表被循環(huán)遍歷的次數(shù)。我們在這里將集中于將相鄰的 map(..)
壓縮在一起,因為它是講解起來最直接的。
想象這種場景:
var removeInvalidChars = str => str.replace( /[^\w]*/g, "" );
var upper = str => str.toUpperCase();
var elide = str =>
str.length > 10 ?
str.substr( 0, 7 ) + "..." :
str;
var words = "Mr. Jones isn't responsible for this disaster!"
.split( /\s/ );
words;
// ["Mr.","Jones","isn't","responsible","for","this","disaster!"]
words
.map( removeInvalidChars )
.map( upper )
.map( elide );
// ["MR","JONES","ISNT","RESPONS...","FOR","THIS","DISASTER"]
考慮一下通過這個變形流程的每一個值。在 words
列表中的第一個值從 "Mr."
開始,變成 "Mr"
,然后變成 "MR"
,然后原封不動地通過 elide(..)
。另一個數(shù)據(jù)流是:"responsible"
-> "responsible"
-> "RESPONSIBLE"
-> "RESPONS..."
。
換言之,你可以這樣考慮這些數(shù)據(jù)變形:
elide( upper( removeInvalidChars( "Mr." ) ) );
// "MR"
elide( upper( removeInvalidChars( "responsible" ) ) );
// "RESPONS..."
你抓住要點了嗎?我們可以將這三個相鄰的 map(..)
調(diào)用的分離步驟表達為一個變形函數(shù)的組合,因為它們都是一元函數(shù)而且每一個的返回值都適合作為下一個的輸入。我們可以使用 compose(..)
將映射函數(shù)融合,然后將組合好的函數(shù)傳遞給一個 map(..)
調(diào)用:
words
.map(
compose( elide, upper, removeInvalidChars )
);
// ["MR","JONES","ISNT","RESPONS...","FOR","THIS","DISASTER"]
這是另一個 pipe(..)
可以作為一種更方便的組合形式的例子,由于它在順序上的可讀性:
words
.map(
pipe( removeInvalidChars, upper, elide )
);
// ["MR","JONES","ISNT","RESPONS...","FOR","THIS","DISASTER"]
要是融合兩個或更多的 filter(..)
判定函數(shù)呢?它們經(jīng)常被視為一元函數(shù),看起來很適于組合。但別扭的地方是它們每一個都返回 boolean
種類的值,而這與下一個所期望的輸入值不同。融合相鄰的 reduce(..)
調(diào)用也是可能的,但遞減函數(shù)不是一元的所以更具挑戰(zhàn)性;我們需要更精巧的方法來抽離這種融合。我們會在附錄A “轉(zhuǎn)導(dǎo)” 中講解這些高級技術(shù)。
列表以外
目前為止我們一直在列表(數(shù)組)數(shù)據(jù)結(jié)構(gòu)的語境中討論各種操作;這無疑是你遇到它們的最常見的場景。但在更一般的意義上,這些操作可以對各種值的集合執(zhí)行。
正如我們早先說過的,數(shù)組的 map(..)
將一個單值操作適配為對它所有值的操作,任何能夠提供 map(..)
的數(shù)據(jù)結(jié)構(gòu)都可以做到相同的事情。類似地,它可以實現(xiàn) filter(..)
,reduce(..)
,或者任何其他對于使用這種數(shù)據(jù)結(jié)構(gòu)的值來說有意義的操作。
從 FP 的精神上講,需要維護的最重要的部分是這些操作必須根據(jù)值的不可變性進行動作,這意味著它們必須返回一個新的數(shù)據(jù)結(jié)構(gòu)而非改變既存的。
讓我們通過一個廣為人知的數(shù)據(jù)結(jié)構(gòu) —— 二叉樹 —— 來展示一下。一個二叉樹是一個節(jié)點(就是一個對象),它擁有指向其他節(jié)點(本身也是二叉樹)的兩個引用,通常稱為 左 和 右 子樹。樹上的每一個節(jié)點都持有整個數(shù)據(jù)結(jié)構(gòu)中的一個值。
為了便于展示,我們使我們的二叉樹變?yōu)橐粋€二叉檢索樹(BST)。但是我們將要看到的操作對任何非 BST 二叉樹來說工作起來都一樣。
注意: 二叉檢索樹是一種一般的二叉樹,它對樹上每一個值之間的關(guān)系有一種特殊的限制。在一個樹左側(cè)的每一個節(jié)點的值都要小于樹根節(jié)點的值,而樹根節(jié)點的值要小于樹右側(cè)每一個節(jié)點的值。“小于” 的概念是相對于被存儲的數(shù)據(jù)的種類的;對于數(shù)字它可以是數(shù)值上的,對于字符串可以是字典順序,等等。BST 很有用,因為使用遞歸的二元檢索算法時,它們使在樹上檢索一個值變得很直接而且更高效。
為了制造一個二叉樹節(jié)點對象,讓我們使用這個工廠函數(shù):
var BinaryTree =
(value,parent,left,right) => ({ value, parent, left, right });
為了方便起見,我們使每個節(jié)點都存儲 left
和 right
子樹以及一個指向它自己 parent
節(jié)點的引用。
現(xiàn)在讓我們定義一個常見作物(水果,蔬菜)名稱的 BST:
var banana = BinaryTree( "banana" );
var apple = banana.left = BinaryTree( "apple", banana );
var cherry = banana.right = BinaryTree( "cherry", banana );
var apricot = apple.right = BinaryTree( "apricot", apple );
var avocado = apricot.right = BinaryTree( "avocado", apricot );
var cantelope = cherry.left = BinaryTree( "cantelope", cherry );
var cucumber = cherry.right = BinaryTree( "cucumber", cherry );
var grape = cucumber.right = BinaryTree( "grape", cucumber );
在這個特別的二叉樹中,banana
是根節(jié)點;這棵樹可以使用在不同位置的節(jié)點建立,但依然是一個擁有相同遍歷過程的 BST。
我們的樹看起來像這樣:
遍歷一個二叉樹來處理它的值有多種方法。如果它是一個 BST(我們的就是!)而且我們進行 按順序 的遍歷 —— 總是先訪問左側(cè)子樹,然后是節(jié)點自身,最后是右側(cè)子樹 —— 那么我們將會按升序(排序過的順序)訪問所有值。
因為你不能像對一個數(shù)組那樣簡單地 console.log(..)
一個二叉樹,所以我們先來定義一個主要為了進行打印而生的便利方法。forEach(..)
將會像訪問一個數(shù)組那樣訪問一個二叉樹的節(jié)點:
// 按順序遍歷
BinaryTree.forEach = function forEach(visitFn,node){
if (node) {
if (node.left) {
forEach( visitFn, node.left );
}
visitFn( node );
if (node.right) {
forEach( visitFn, node.right );
}
}
};
注意: 遞歸處理對于使用二叉樹來說再自然不過了。我們的 forEach(..)
工具遞歸地調(diào)用它自己來處理左右子樹。我們將在后面的章節(jié)中詳細講解遞歸,就是我們將在關(guān)于遞歸的那一章中講解遞歸的那一章。
回憶一下本章開頭,forEach(..)
被描述為僅對副作用有用處,而在 FP 中這通常不理想。在這個例子中,我們僅將 forEach(..)
用于 I/O 副作用,所以它作為一個幫助函數(shù)還是很合理的。
使用 forEach(..)
來打印樹的值:
BinaryTree.forEach( node => console.log( node.value ), banana );
// apple apricot avocado banana cantelope cherry cucumber grape
// 訪問 `cherry` 作為根的子樹
BinaryTree.forEach( node => console.log( node.value ), cherry );
// cantelope cherry cucumber grape
為了使用 FP 的模式來操作我們的二叉樹結(jié)構(gòu),讓我們從定義一個 map(..)
開始:
BinaryTree.map = function map(mapperFn,node){
if (node) {
let newNode = mapperFn( node );
newNode.parent = node.parent;
newNode.left = node.left ?
map( mapperFn, node.left ) : undefined;
newNode.right = node.right ?
map( mapperFn, node.right ): undefined;
if (newNode.left) {
newNode.left.parent = newNode;
}
if (newNode.right) {
newNode.right.parent = newNode;
}
return newNode;
}
};
你可能會猜測我們將會僅僅 map(..)
節(jié)點的 value
屬性,但一般來說我們可能實際上想要映射樹節(jié)點本身。所以,整個被訪問的節(jié)點被傳入了 mapperFn(..)
函數(shù),而且它期待取回一個帶有變形后的值的新 BinaryTree(..)
。如果你只是返回相同的節(jié)點,那么這個操作將會改變你的樹而且很可能造成意外的結(jié)果!
讓我們將作物的樹映射為所有名稱大寫的列表:
var BANANA = BinaryTree.map(
node => BinaryTree( node.value.toUpperCase() ),
banana
);
BinaryTree.forEach( node => console.log( node.value ), BANANA );
// APPLE APRICOT AVOCADO BANANA CANTELOPE CHERRY CUCUMBER GRAPE
BANANA
是一個與 banana
不同的樹(所有節(jié)點都不同),就像在一個數(shù)組上調(diào)用 map(..)
會返回一個新數(shù)組一樣。正如其他對象/數(shù)組的數(shù)組一樣,如果 node.value
本身引用了一些對象/數(shù)組,那么如果你想要更深層的不可變性的話,你還需要在映射函數(shù)中手動拷貝它。
那么 reduce(..)
呢?相同的基本處理:對樹的節(jié)點進行按順序的遍歷。一種用法是將我們的樹 reduce(..)
到一個它的值的數(shù)組中,這對將來適配其他常用的列表操作會很有用。或者我們可以將我們的樹 reduce(..)
為一個所有作物名稱的字符串連接。
我們將模仿數(shù)組 reduce(..)
的行為,這使得參數(shù) initialValue
的傳遞是可選的。這個算法有些復(fù)雜,但依然是可控的:
BinaryTree.reduce = function reduce(reducerFn,initialValue,node){
if (arguments.length < 3) {
// 更換參數(shù),因為 `initialValue` 被省略了
node = initialValue;
}
if (node) {
let result;
if (arguments.length < 3) {
if (node.left) {
result = reduce( reducerFn, node.left );
}
else {
return node.right ?
reduce( reducerFn, node, node.right ) :
node;
}
}
else {
result = node.left ?
reduce( reducerFn, initialValue, node.left ) :
initialValue;
}
result = reducerFn( result, node );
result = node.right ?
reduce( reducerFn, result, node.right ) : result;
return result;
}
return initialValue;
};
讓我們使用 reduce(..)
來制造我們的購物單(一個數(shù)組):
BinaryTree.reduce(
(result,node) => result.concat( node.value ),
[],
banana
);
// ["apple","apricot","avocado","banana","cantelope"
// "cherry","cucumber","grape"]
最后,讓我們來為我們的樹考慮一下 filter(..)
。這是目前為止最復(fù)雜的算法,因為它實質(zhì)上(不是實際上)引入了對樹上節(jié)點的刪除,這要求處理幾種極端情況。但不要被它的實現(xiàn)嚇到。如果你樂意的話可以先跳過它,而關(guān)注與我們?nèi)绾问褂盟?/p>
BinaryTree.filter = function filter(predicateFn,node){
if (node) {
let newNode;
let newLeft = node.left ?
filter( predicateFn, node.left ) : undefined;
let newRight = node.right ?
filter( predicateFn, node.right ) : undefined;
if (predicateFn( node )) {
newNode = BinaryTree(
node.value,
node.parent,
newLeft,
newRight
);
if (newLeft) {
newLeft.parent = newNode;
}
if (newRight) {
newRight.parent = newNode;
}
}
else {
if (newLeft) {
if (newRight) {
newNode = BinaryTree(
undefined,
node.parent,
newLeft,
newRight
);
newLeft.parent = newRight.parent = newNode;
if (newRight.left) {
let minRightNode = newRight;
while (minRightNode.left) {
minRightNode = minRightNode.left;
}
newNode.value = minRightNode.value;
if (minRightNode.right) {
minRightNode.parent.left =
minRightNode.right;
minRightNode.right.parent =
minRightNode.parent;
}
else {
minRightNode.parent.left = undefined;
}
minRightNode.right =
minRightNode.parent = undefined;
}
else {
newNode.value = newRight.value;
newNode.right = newRight.right;
if (newRight.right) {
newRight.right.parent = newNode;
}
}
}
else {
return newLeft;
}
}
else {
return newRight;
}
}
return newNode;
}
};
這個代碼段的絕大部分都用來處理當(dāng)一個節(jié)點從樹結(jié)構(gòu)的復(fù)本中“被移除”(濾除)時,其父/子引用的移動。
為了展示 filter(..)
使用的例子,讓我們將作物樹收窄為僅含蔬菜:
var vegetables = [ "asparagus", "avocado", "brocolli", "carrot",
"celery", "corn", "cucumber", "lettuce", "potato", "squash",
"zucchini" ];
var whatToBuy = BinaryTree.filter(
// 過濾作物的列表,使之僅含蔬菜
node => vegetables.indexOf( node.value ) != -1,
banana
);
// 購物單
BinaryTree.reduce(
(result,node) => result.concat( node.value ),
[],
whatToBuy
);
// ["avocado","cucumber"]
你很可能在簡單的數(shù)組上下文環(huán)境中使用本章中提到的大多數(shù)列表操作。但我們已經(jīng)看到了,這其中的概念可以應(yīng)用于任何你可能需要的數(shù)據(jù)結(jié)構(gòu)和操作中。這是 FP 如何可以廣泛地應(yīng)用于許多不同應(yīng)用程序場景的有力證明!
總結(jié)
三個常見而且強大的列表操作:
-
map(..)
:將值投射到新列表中時將其變形。 -
filter(..)
:將值投射到新列表中時選擇或排除它。 -
reduce(..)
:將一個列表中的值結(jié)合為另一個值(通常但不總是數(shù)組)。
其他幾個可能在列表處理中非常有用的高級操作:unique(..)
、flatten(..)
、和 merge(..)
。
融合使用函數(shù)組合技術(shù)將多個相鄰的 map(..)
調(diào)用合并。這很大程度上是一種性能優(yōu)化,但也改善了你的列表操作的聲明式性質(zhì)。
列表通常在視覺上表現(xiàn)為數(shù)組,但也可以被一般化為任何可以表現(xiàn)/產(chǎn)生一個有序的值的序列的集合數(shù)據(jù)結(jié)構(gòu)。因此,所有這些“列表操作”實際上是“數(shù)據(jù)結(jié)構(gòu)操作”。