輕量函數(shù)式 JavaScript 第八章:列表操作

感謝社區(qū)中各位的大力支持,譯者再次奉上一點(diǎn)點(diǎn)福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠,并抽取幸運(yùn)大獎(jiǎng):點(diǎn)擊這里領(lǐng)取

你在前一章閉包/對(duì)象的兔子洞中玩兒的開心嗎?歡迎回來!

如果你能做很贊的事情,那就反復(fù)做。

我們?cè)诒緯惹暗牟糠忠呀?jīng)看到了對(duì)一些工具的簡(jiǎn)要引用,現(xiàn)在我們要非常仔細(xì)地看看它們,它們是 map(..)filter(..)、和 reduce(..)。在 JavaScript 中,這些工具經(jīng)常作為數(shù)組(也就是“列表”)原型上的方法使用,所以我們很自然地稱它們?yōu)閿?shù)組或列表操作。

在我們討論具體的數(shù)組方法之前,我們要在概念上檢視一下這些操作是用來做什么的。在這一章中有兩件同等重要的事情:理解列表操作 為什么 重要以及理解列表操作是 如何 工作的。確保你在頭腦中帶著這些細(xì)節(jié)來閱讀這一章。

在這本書之外的世界以及這一章中,展示這些操作的最廣泛、最常見方式是,描述一些在值的列表中實(shí)施的微不足道的任務(wù)(比如將一個(gè)數(shù)組中的每個(gè)數(shù)字翻倍);這是一種講清要點(diǎn)的簡(jiǎn)單廉價(jià)的方式。

但是不要僅僅將這些簡(jiǎn)單的例子一帶而過卻忽略了深層次的意義。理解列表操作對(duì)于 FP 來說最重要的價(jià)值來源于能夠?qū)⒁粋€(gè)任務(wù)的序列 —— 一系列 看起來 不太像一個(gè)列表的語句 —— 模型化為一個(gè)操作的列表,而非分別獨(dú)立地執(zhí)行它們。

這不只是一個(gè)編寫更簡(jiǎn)潔代碼的技巧。我們追求的是從指令式轉(zhuǎn)向聲明式,使代碼模式可以輕而易舉地識(shí)別從而可讀性更高。

但是這里還有 更重要的東西需要把握住。使用指令式代碼,一組計(jì)算中的每一個(gè)中間結(jié)果都通過賦值存儲(chǔ)在變量中。你的代碼越依賴這樣的指令式模式,驗(yàn)證它在邏輯上沒有錯(cuò)誤就越困難 —— 對(duì)值的意外改變,或者埋藏下側(cè)因/副作用。

通過將列表操作鏈接和/或組合在一起,中間結(jié)果會(huì)被隱含地追蹤,并極大程度地防止這些災(zāi)難的發(fā)生。

注意: 與前面的章節(jié)相比,為了盡可能地保持后述代碼段的簡(jiǎn)潔,我們將重度應(yīng)用 ES6 的 => 形式。但是,我在第二章中關(guān)于 => 的建議依然在一般性的編碼中適用。

非 FP 列表處理

作為我們?cè)诒菊轮械挠懻摰男蜓?,我要指出幾個(gè)看起來與 JavaScript 數(shù)組和 FP 列表操作相關(guān),但其實(shí)無關(guān)的操作。這些操作將不會(huì)在這里講解,因?yàn)樗鼈儾环弦话愕?FP 最佳實(shí)踐:

  • forEach(..)
  • some(..)
  • every(..)

forEach(..) 是一個(gè)迭代幫助函數(shù),但它被設(shè)計(jì)為對(duì)每一個(gè)執(zhí)行的函數(shù)調(diào)用都帶有副作用;你可能猜到了為什么我們的討論不贊同它是一個(gè) FP 列表操作。

some(..)every(..) 確實(shí)鼓勵(lì)使用純函數(shù)(具體地講,是像 filter(..) 這樣的檢測(cè)函數(shù)),但它們實(shí)質(zhì)上像一個(gè)檢索或匹配一樣,不可避免地將一個(gè)列表遞減為一個(gè) true / false 的結(jié)果。這兩個(gè)工具不是很適合作為我們想要對(duì)代碼進(jìn)行 FP 建模的模具,所以這里我們跳過它們不講。

Map

我們將從最基本而且最基礎(chǔ)的 FP 列表操作之一開始我們的探索:map(..)。

一個(gè)映射是從一個(gè)值到另一個(gè)值的變形。例如,如果你有一個(gè)數(shù)字 2 而你將它乘以 3,那么你就將它映射到了 6。值得注意的是,我們所說的映射變形暗示著 原地 修改或者重新賦值;而不是映射變形將一個(gè)新的值從一個(gè)地方投射到另一個(gè)地方。

換句話說:

var x = 2, y;

// 變形/投射
y = x * 3;

// 改變/重新賦值
x = x * 3;

如果我們將這個(gè)乘以 3 定義為一個(gè)函數(shù),這個(gè)函數(shù)作為一個(gè)映射(變形)函數(shù)動(dòng)作的話:

var multipleBy3 = v => v * 3;

var x = 2, y;

// 變形/投射
y = multiplyBy3( x );

我們可以很自然地將映射從一個(gè)單獨(dú)值的變形擴(kuò)展到一個(gè)值的集合。map(..) 操作將列表中的所有值變形并將它們投射到一個(gè)新的列表中:

要實(shí)現(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 庫中這種慣例非常常見,因?yàn)樗惯@些工具更容易(使用柯里化)組合。

mapperFn(..) 自然地將項(xiàng)目列表傳遞給映射/變形,同時(shí)還有 idxarr。我們這樣做是為了與內(nèi)建的 map(..) 保持一致。在某些情況下這些額外的信息可能十分有用。

但是在其他情況下,你可能想使用一個(gè)僅有列表項(xiàng)目應(yīng)當(dāng)被傳入的 mapperFn(..),因?yàn)轭~外的參數(shù)有可能會(huì)改變它的行為。在第三章中的 “皆歸為一” 中介紹了 unary(..),它限制一個(gè)函數(shù)僅接收一個(gè)實(shí)際參數(shù)(不論有多少被傳遞)。

回憶一下第三章中將 parseInt(..) 限制為一個(gè)參數(shù)的例子,它可以安全地用于 mapperFn(..)

map( ["1","2","3"], unary( parseInt ) );
// [1,2,3]

JavaScript 在數(shù)組上提供了內(nèi)建的 map(..) 工具,使得它很容易地用做一個(gè)列表操作鏈條上的一部分。

注意: JavaScript 原型操作(map(..)、filter(..)、和 reduce(..))都接收一個(gè)最后的可選參數(shù),它用于函數(shù)的 this 綁定。在我們第二章的 “This 是什么” 的討論中,從為了符合 FP 的最佳實(shí)踐的角度講,基于 this 的編碼一般來說應(yīng)當(dāng)盡可能避免。因此,我們?cè)谶@一章例子的實(shí)現(xiàn)中不支持這樣的 this 綁定特性。

除了你可以分別對(duì)數(shù)字和字符串列表實(shí)施的各種顯而易見的操作之外,還有一些其他的映射操作的例子。我們可以使用 map(..) 來把一個(gè)函數(shù)的列表變形為一個(gè)它們返回值的列表:

var one = () => 1;
var two = () => 2;
var three = () => 3;

[one,two,three].map( fn => fn() );
// [1,2,3]

或者我們可以首先將列表中的每一個(gè)函數(shù)都與另一個(gè)函數(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(..) 的一件有趣的事情:我們通常會(huì)臆測(cè)列表是從左到右處理的,但是 map(..) 的概念中對(duì)此沒有任何要求。每一個(gè)變形對(duì)于其他的變形來說都應(yīng)該是獨(dú)立的。

從一般的意義上說,在一個(gè)支持并行的環(huán)境中映射甚至可以是并行化的,這對(duì)很大的列表來說可以極大地提升性能。我們沒有看到 JavaScript 實(shí)際上在這樣做,因?yàn)闆]有任何東西要求你傳入一個(gè)像 mapperFn(..) 這樣的純函數(shù),即使你 本應(yīng)這樣做。如果你傳入一個(gè)非純函數(shù)而且 JS 以不同的順序運(yùn)行不同的調(diào)用,那么這很快就會(huì)引起災(zāi)難。

雖然在理論上每一個(gè)映射操作都是獨(dú)立的,但 JS 不得不假定它們不是。這很掃興。

同步 vs 異步

我們?cè)谶@一章中要討論的列表操作都是在一個(gè)所有值都已存在的列表上同步地操作;map(..) 在這里被認(rèn)為是一種迫切的操作。但另一種考慮映射函數(shù)的方法是將它作為一個(gè)事件處理器,為在列表中出現(xiàn)的每一個(gè)新的值而被調(diào)用。

想象一個(gè)如下虛構(gòu)的東西:

var newArr = arr.map();

arr.addEventListener( "value", multiplyBy3 );

現(xiàn)在,無論什么時(shí)候一個(gè)值被添加到 arr 種,事件處理器 multiplyBy3(..) —— 映射函數(shù) —— 都會(huì)用這個(gè)值被調(diào)用,它變形出的值被添加到 newArr。

我們?cè)诎凳镜氖?,?shù)組以及我們?cè)谒鼈冎鲜┘拥臄?shù)組操作,都是迫切的同步版本,而這些相同的操作也可以在一個(gè)經(jīng)過一段時(shí)間后才收到值的 “懶惰列表” (也就是流)上建模。

映射 vs 迭代

一些人鼓吹將 map(..) 作為一個(gè)一般形式的 forEach(..) 迭代使用,讓它收到的值原封不動(dòng)地通過,但之后可能實(shí)施一些副作用:

[1,2,3,4,5]
.map( function mapperFn(v){
    console.log( v );           // 副作用!
    return v;
} )
..

這種技術(shù)看起來好像有用是因?yàn)?map(..) 返回這個(gè)數(shù)組,于是你就可以繼續(xù)在它后面鏈接更多操作;而 forEach(..) 的返回值是 undefined。然而,我認(rèn)為你應(yīng)當(dāng)避免以這種方式使用 map(..),因?yàn)橐砸环N明確的非 FP 方式使用一種 FP 核心操作除了困惑不會(huì)造成其他任何東西。

你聽說過用正確的工具做正確的事這句諺語,對(duì)吧?錘子對(duì)釘子,改錐對(duì)螺絲,等等。這里有點(diǎn)兒不同:它是 用正確的方式 使用正確的工具。

一把錘子本應(yīng)在你的手中揮動(dòng);但如果你用嘴叼住它來砸釘子,那你就不會(huì)非常高效。map(..) 的本意是映射值,不是造成副作用。

一個(gè)詞:函子

在這本書中我們絕大多數(shù)時(shí)候都盡可能地與人工發(fā)明的 FP 術(shù)語保持距離。我們有時(shí)候用一些官方術(shù)語,但這多數(shù)是在我們可以從中衍生出一些對(duì)普通日常對(duì)話有意義的東西的時(shí)候。

我將要非常簡(jiǎn)要地打破這種模式,而使用一個(gè)可能有點(diǎn)兒嚇人的詞:函子。我想談一下函子的原因是因?yàn)槲覀儸F(xiàn)在已經(jīng)知道它們是做什么的了,也因?yàn)檫@個(gè)詞在其他的 FP 文獻(xiàn)中被頻繁使用;至少你熟悉它而不會(huì)被它嚇到是有好處的。

一個(gè)函子是一個(gè)值,它有一個(gè)工具可以使一個(gè)操作函數(shù)作用于這個(gè)值。

如果這個(gè)值是復(fù)合的,也就是它由一些獨(dú)立的值組成 —— 例如像數(shù)組一樣!—— 那么函子會(huì)在每一個(gè)獨(dú)立的值上應(yīng)用操作函數(shù)。另外,函子工具會(huì)創(chuàng)建一個(gè)新的復(fù)合值來持有所有獨(dú)立操作函數(shù)調(diào)用的結(jié)果。

這只是對(duì)我們剛剛看到的 map(..) 的一種炫酷的描述。map(..) 函數(shù)拿著與它關(guān)聯(lián)的值(一個(gè)數(shù)組)和一個(gè)映射函數(shù)(操作函數(shù)),并對(duì)數(shù)組中每一個(gè)獨(dú)立的值執(zhí)行這個(gè)映射函數(shù)。最終,它返回一個(gè)帶有所有被映射好的值的數(shù)組。

另一個(gè)例子:一個(gè)字符串函子將是一個(gè)字符串外加一個(gè)工具,這個(gè)工具對(duì)字符串中的每一個(gè)字符執(zhí)行一些操作函數(shù),返回一個(gè)帶有被處理過的字符的新字符串??紤]這個(gè)高度造作的例子:

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(..) 允許一個(gè)字符串是一個(gè)函子。你可以為任意數(shù)據(jù)結(jié)構(gòu)定義一個(gè)映射函數(shù);只要這個(gè)工具符合這些規(guī)則,那么這種數(shù)據(jù)結(jié)構(gòu)就是一個(gè)函子。

Filter

想象我在百貨商店里拿著一個(gè)空籃子來到了水果賣場(chǎng);這里有一大堆水果貨架(蘋果、橘子、和香蕉)。我真的很餓,所以要盡可能多地拿水果,但我非常喜歡圓形的水果(蘋果和橘子)。于是我一個(gè)一個(gè)地篩選,然后帶著僅裝有蘋果和橘子的籃子離開了。

假定我們稱這種處理為 過濾。你將如何更自然地描述我的購物過程?是以一個(gè)空籃子為起點(diǎn)并僅僅 濾入(選擇,包含)蘋果和橘子,還是以整個(gè)水果貨架為起點(diǎn)并 濾除(跳過,排除)香蕉,直到我的籃子裝滿水果?

如果你在一鍋水中煮意大利面,然后把它倒進(jìn)水槽上的笊籬(也就是過濾器)中,你是在濾入意大利面還是在濾除水?如果你將咖啡粉末放進(jìn)濾紙中泡一杯咖啡,你是在自己的杯子中濾入了咖啡,還是濾除了咖啡粉末?

你對(duì)過濾的視角是不是有賴于你想要的東西是否 “保留” 在過濾器中或者通過了過濾器?

那么當(dāng)你在航空公司/酒店的網(wǎng)站上制定一些選項(xiàng)來 “過濾你的結(jié)果” 呢?你是在濾入符合你標(biāo)準(zhǔn)的結(jié)果,還是在濾除所有不符合標(biāo)準(zhǔn)的東西?仔細(xì)考慮一下:這個(gè)例子與前一個(gè)比起來可能有不同的語義。

根據(jù)你的視角不同,過濾不是排除性的就是包含性的。這種概念上的沖突非常不幸。

我認(rèn)為對(duì)過濾的最常見的解釋 —— 在編程的世界之外 —— 是你在濾除不想要的東西。不幸的是,在編程中,我們實(shí)質(zhì)上更像是將這種語義反轉(zhuǎn)為濾入想要的東西。

filter(..) 列表操作用一個(gè)函數(shù)來決定原來數(shù)組中的每一個(gè)值是否應(yīng)該保留在新的數(shù)組中。如果一個(gè)值應(yīng)當(dāng)被保留,那么這個(gè)函數(shù)需要返回 true,如果它應(yīng)當(dāng)被跳過則返回 false。一個(gè)為做決定而返回 true / false 的函數(shù)有一個(gè)特殊的名字:判定函數(shù)。

如果你將 true 作為一個(gè)正面的信號(hào),那么 filter(..) 的定義就是你想要 “保留”(濾入)一個(gè)值而非 “丟棄”(濾除)一個(gè)值。

為了將 filter(..) 作為一種排除性操作使用,你不得不把思維擰過來,通過返回 false 將正面信號(hào)考慮為一種排除,并通過返回 true 被動(dòng)地讓一個(gè)值通過。

這種語義錯(cuò)位很重要的原因,是因?yàn)樗鼤?huì)影響你如何命名那個(gè)用作 predicateFn(..) 的函數(shù),以及它對(duì)代碼可讀性的意義。我們馬上就會(huì)談到這一點(diǎn)。

這是 filter(..) 操作在一個(gè)值的列表中的可視化表達(dá):

要實(shí)現(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(..)? 不僅被傳入一個(gè)值,而且還被傳入了 idxarr??梢愿鶕?jù)需要使用 unary(..) 來限制它的參數(shù)。

map(..) 一樣,filter(..) 作為一種 JS 數(shù)組上的內(nèi)建工具被提供。

讓我們考慮一個(gè)這樣的判定函數(shù):

var whatToCallIt = v => v % 2 == 1;

這個(gè)函數(shù)使用 v % 2 == 1 來返回 truefalse。這里的效果是一個(gè)奇數(shù)將會(huì)返回 true,而一個(gè)偶數(shù)將會(huì)返回 false。那么,我們應(yīng)該管這個(gè)函數(shù)叫什么?一個(gè)自然的名字可能是:

var isOdd = v => v % 2 == 1;

考慮一下在你代碼中的某處你將如何使用 isOdd(..) 進(jìn)行簡(jiǎn)單的值校驗(yàn):

var midIdx;

if (isOdd( list.length )) {
    midIdx = (list.length + 1) / 2;
}
else {
    midIdx = list.length / 2;
}

有些道理,對(duì)吧?讓我們?cè)倏紤]一下將它與數(shù)組內(nèi)建的 filter(..) 一起使用來過濾一組值:

[1,2,3,4,5].filter( isOdd );
// [1,3,5]

如果要你描述一下 [1,3,5] 這個(gè)結(jié)果,你會(huì)說 “我濾除了偶數(shù)” 還是 “我濾入了奇數(shù)”?我覺得前者是描述它的更自然的方式。但是代碼讀起來卻相反。代碼讀起來幾乎就是,我們 “過濾(入)了每一個(gè)是奇數(shù)的數(shù)字”。

我個(gè)人覺得這種語義令人糊涂。對(duì)于有經(jīng)驗(yàn)的開發(fā)者來說無疑有許多先例可循。但是如果你剛剛起步,這種邏輯表達(dá)式看起來有點(diǎn)兒像不用雙否定就不會(huì)說話 —— 也就是,用雙否定說話。

我們可以通過把函數(shù) isOdd(..) 重命名為 isEven(..) 讓事情容易一些:

var isEven = v => v % 2 == 1;

[1,2,3,4,5].filter( isEven );
// [1,3,5]

呀!但這個(gè)函數(shù)用這個(gè)名字根本講不通,因?yàn)楫?dāng)數(shù)字為偶數(shù)時(shí)它返回 false

isEven( 2 );        // false

討厭。

回憶一下第三章中的 “無點(diǎn)”,我們定義了一個(gè)對(duì)判定函數(shù)取反的 not(..) 操作符??紤]:

var isEven = not( isOdd );

isEven( 2 );        // true

但是我們將 這個(gè) isEven(..) 按照它當(dāng)前定義的方式與 filter(..) 一起使用,因?yàn)檫壿媽⑹窍喾吹?;我們將?huì)得到偶數(shù),不是奇數(shù)。我們需要這樣做:

[1,2,3,4,5].filter( not( isEven ) );
// [1,3,5]

這違背了我們的初衷,所以不要這樣做。我們只是在繞圈子。

濾除與濾入

為了掃清這一切困惑,讓我們定義一個(gè)通過在內(nèi)部對(duì)判定檢查取反來真正 濾除 一些值的 filterOut(..)。同時(shí)我們將 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(..))將會(huì)使你代碼的可讀性好得多。

Reduce

map(..)filter(..) 產(chǎn)生一個(gè)新的列表,但這第三種操作(reduce(..))經(jīng)常將一個(gè)列表的值結(jié)合(也就是“遞減”)為一個(gè)單獨(dú)的有限(非列表)值,比如數(shù)字或字符串。但是在本章稍后,我們將會(huì)看到如何以更高級(jí)的方式使用 reduce(..)。reduce(..) 是最重要的 FP 工具之一;它就像瑞士軍刀一樣多才多藝。

組合/遞減被抽象地定義為將兩個(gè)值變成一個(gè)值。一些 FP 語境中將之稱為 “折疊(folding)”,就好像你把兩個(gè)值折疊成為一個(gè)值一樣。我覺得這種視覺化很有助于理解。

正如映射與過濾那樣,結(jié)合的行為完全由你來決定,而且這一般要看列表中值的種類而定。例如,數(shù)字通常將通過算數(shù)方式來結(jié)合,字符串通過連接,而函數(shù)通過組合。

有時(shí)遞減將會(huì)指定一個(gè) initialValue 并用它與列表中的第一個(gè)值結(jié)合的結(jié)果做為起點(diǎn),穿過列表中剩余的其他每一個(gè)值??雌饋砭拖襁@樣:

另一種方式是你可以忽略 initialValue,這時(shí)列表中的第一個(gè)值將作為 initialValue,而結(jié)合將始于它與列表中的第二個(gè)值,就像這樣:

注意: 在 JavaScript 中,遞減至少需要一個(gè)值(要么在數(shù)組中,要么作為 initialValue 指定),否則就會(huì)拋出一個(gè)錯(cuò)誤。如果用來遞減的列表在某些環(huán)境下可能為空,那么就要小心不要忽略 initialValue。

你傳遞給 reduce(..) 來執(zhí)行遞減的函數(shù)通常稱為一個(gè)遞減函數(shù)(reducer)。遞減函數(shù)的簽名與我們?cè)缦瓤吹降挠成浜团卸ê瘮?shù)不同。遞減函數(shù)主要接收當(dāng)前的遞減結(jié)果以及下一個(gè)用于與之遞減的值。在遞減每一步中的結(jié)果經(jīng)常被稱為聚集器(accumulator)。

例如,考慮將 3 作為 initialValue 對(duì)數(shù)字 510、和 15 進(jìn)行乘法遞減時(shí)的步驟:

  1. 3 * 5 = 15
  2. 15 * 10 = 150
  3. 150 * 15 = 2250

在 JavaScript 中使用內(nèi)建在數(shù)組上的 reduce(..) 來表達(dá)的話:

[5,10,15].reduce( (product,v) => product * v, 3 );
// 2250

一個(gè)獨(dú)立的 reduce(..) 的實(shí)現(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ù)也會(huì)被傳入不太常用的 idxarr 參數(shù)以備遞減中的不時(shí)之需。我不常用這些東西,但我猜它們可以隨時(shí)取用是一件不錯(cuò)的事情。

回憶一下第四章,我們討論了 compose(..) 工具而且展示了一種使用 reduce(..) 的實(shí)現(xiàn):

function compose(...fns) {
    return function composed(result){
        return fns.reverse().reduce( function reducer(result,fn){
            return fn( result );
        }, result );
    };
}

為了以一種不同的方式展示基于 reduce(..) 的組合,考慮一個(gè)從左到右(像 pipe(..) 那樣)組合函數(shù)的遞減函數(shù),將它用于一個(gè)數(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(..) 不是無點(diǎn)的(參見第三章的 “無點(diǎn)”),但我們不能簡(jiǎn)單地將 pipe(..) 作為遞減函數(shù)本身傳遞,因?yàn)樗菂?shù)可變的;reduce(..) 傳遞給遞減函數(shù)的額外參數(shù)(idxarr)可能會(huì)引起問題。

早先我們談到過使用 unary(..) 來將 mapperFn(..)predicateFn(..) 限定為一個(gè)參數(shù)。對(duì)于一個(gè) reducerFn(..) 來說,要是有一個(gè)與之相似但限定為兩個(gè)參數(shù)的 binary(..) 可能會(huì)很方便:

var binary =
    fn =>
        (arg1,arg2) =>
            fn( arg1, arg2 );

使用 binary(..),我們的前一個(gè)例子會(huì)干凈一些:

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)

對(duì) map(..)filter(..) 來說遍歷數(shù)組的順序?qū)嶋H上不重要,與此不同的是,reduce(..) 絕對(duì)是按照從左到右的順序處理。如果你想要從右到左地遞減,JavaScript 提供了一個(gè) 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ù)時(shí)很自然地像 pipe(..) 一樣動(dòng)作,而 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() 了;我們只要從另一個(gè)方向遞減即可!

Map 作為 Reduce

map(..) 操作天生就是迭代性的,所以它也可以表達(dá)為一種遞減(reduce(..))。這其中的技巧是要理解 reduce(..)initialValue 本身可以是一個(gè)(空)數(shù)組,這樣遞減的結(jié)果就可以是另一個(gè)列表!

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]

注意: 我們?cè)谄垓_這個(gè)遞減函數(shù),并通過 list.push(..) 改變被傳入的列表而引入了副作用。一般來說,這不是個(gè)好主意,但是因?yàn)槲覀冎?[] 是被新建并傳入的,所以這沒那么危險(xiǎn)。你可以更正式一些 —— 性能也差一些!—— 將值 concat(..) 在一個(gè)新列表的末尾。我們將在附錄A中再次回到這個(gè)問題上。

使用 reduce(..) 實(shí)現(xiàn) map(..) 在表面上看來不是顯而易見的,它甚至不是一種改進(jìn)。然而,對(duì)于我們將在附錄A中講解的 “轉(zhuǎn)導(dǎo)(Transducing)” 這樣的更高級(jí)的技術(shù)來說,這種能力將是一種識(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(..) 并返回一個(gè)新列表來取代 list.push(..)。我們將在附錄A中回到這個(gè)問題。

高級(jí)列表操作

現(xiàn)在我們對(duì)基礎(chǔ)的列表操作 map(..)、filter(..)、和 reduce(..) 有些熟悉了,讓我們看幾個(gè)你可能會(huì)在各種情景下覺得很有用的更加精巧的操作。它們通常都是你可以在各種 FP 庫中找到的工具。

Unique

基于 indexOf(..) 搜索(它使用 === 嚴(yán)格等價(jià)比較),過濾一個(gè)列表使之僅包含唯一的值:

var unique =
    arr =>
        arr.filter(
            (v,idx) =>
                arr.indexOf( v ) == idx
        );

這種技術(shù)的工作方式是,僅將第一次出現(xiàn)在 arr 中的項(xiàng)目加入到新的列表中;當(dāng)從左到右運(yùn)行時(shí),這僅在項(xiàng)目的 idx 位置與 indexOf(..) 找到的位置相同時(shí)成立。

另一種實(shí)現(xiàn) unique(..) 的方式是遍歷 arr,如果一個(gè)項(xiàng)目沒有在新的列表中(初始為空)找到就將它加入這個(gè)新的列表。我們?yōu)檫@樣的處理使用 reduce(..)

var unique =
    arr =>
        arr.reduce(
            (list,v) =>
                list.indexOf( v ) == -1 ?
                    ( list.push( v ), list ) : list
        , [] );

注意: 使用諸如循環(huán)之類的更具指令式的方式,有許多種其他不同的方法可以實(shí)現(xiàn)這個(gè)算法,而且其中很多在性能方面可能看起來 “更高效”。然而,上面展示的這兩種方式有一個(gè)優(yōu)勢(shì)是它們都使用了既存的列表操作,這使它們與其他列表操作鏈接/組合起來更容易。我們將會(huì)在本章稍后更多地談到這些問題。

unique(..) 可以出色地產(chǎn)生一個(gè)沒有重復(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í)地,你會(huì)得到(或者從一些其他操作中得到)一個(gè)這樣的數(shù)組:它不只是一個(gè)值的扁平的列表,而是一個(gè)嵌套的數(shù)組,例如:

[ [1, 2, 3], 4, 5, [6, [7, 8]] ]

要是你想要將它變形為這樣呢?

[ 1, 2, 3, 4, 5, 6, 7, 8 ]

我們?cè)趯で蟮牟僮魍ǔ7Q為 flatten(..),它可以使用我們的瑞士軍刀 reduce(..) 像這樣實(shí)現(xiàn):

var flatten =
    arr =>
        arr.reduce(
            (list,v) =>
                list.concat( Array.isArray( v ) ? flatten( v ) : v )
        , [] );

注意: 這種實(shí)現(xiàn)依賴于使用遞歸處理嵌套的列表。后面的章節(jié)中有關(guān)于遞歸的更多內(nèi)容。

要對(duì)一個(gè)(嵌套多層的)數(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]

你可能想要將遞歸平整限制在一個(gè)特定的深度上。我們可以通過為這種實(shí)現(xiàn)添加一個(gè)可選的 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(..) 行為的最常見用法之一是在你映射一個(gè)列表時(shí),每一個(gè)從原數(shù)組變形而來的元素本身就是一個(gè)值的列表。例如:

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"] ]

返回值是一個(gè)數(shù)組的數(shù)組,這使用起來可能很尷尬。如果我們要的是一個(gè)所有名字的一維數(shù)組,那么就可以 flatten(..) 這個(gè)結(jié)果:

flatten(
    firstNames
    .map( entry => [entry.name].concat( entry.variations ) )
);
// ["Jonathan","John","Jon","Jonny","Stephanie","Steph","Stephy","Frederick",
//  "Fred","Freddy"]

除了稍稍繁冗之外,將 map(..)flatten(..) 作為兩個(gè)步驟分開做的缺點(diǎn)主要關(guān)乎性能;這個(gè)方式將列表處理了兩次。

FP 庫中經(jīng)常定義一個(gè)“映射之后平整”組合的 flatMap(..)(也常被稱為 chain(..))。為了一致性和易于(通過柯里化)組合,這些 flatMap(..) / chain(..) 工具通常都與我們以前看到的 map(..)、filter(..)、和 reduce(..) 獨(dú)立工具的 mapperFn, arr 參數(shù)順序相吻合。

flatMap( entry => [entry.name].concat( entry.variations ), firstNames );
// ["Jonathan","John","Jon","Jonny","Stephanie","Steph","Stephy","Frederick",
//  "Fred","Freddy"]

將兩個(gè)步驟分開做的 flatMap(..) 的幼稚的實(shí)現(xiàn)是:

var flatMap =
    (mapperFn,arr) =>
        flatten( arr.map( mapperFn ), 1 );

注意: 我們將 1 用于平整深度是因?yàn)?flatMap(..) 的常見定義是僅發(fā)生在第一層的淺平整。

因?yàn)檫@種方式依然將列表處理兩次而導(dǎo)致差勁的性能,所以我們可以使用 reduce(..) 手動(dòng)組合這些操作:

var flatMap =
    (mapperFn,arr) =>
        arr.reduce(
            (list,v) =>
                list.concat( mapperFn( v ) )
        , [] );

雖然 flatMap(..) 工具會(huì)帶來一些方便與性能上的增益,但有時(shí)候你很可能需要混合一些 filter(..) 這樣的操作。如果是這樣,將 map(..)flatten(..) 分開做可能還是更合適的。

Zip

至此,我們檢視過的列表操作都是在一個(gè)列表上進(jìn)行操作的。但是有些情況需要處理多個(gè)列表。一個(gè)廣為人知的操作是從兩個(gè)輸入列表中交替選擇值放入子列表中,稱為 zip(..)

zip( [1,3,5,7,9], [2,4,6,8,10] );
// [ [1,2], [3,4], [5,6], [7,8], [9,10] ]

12 被選入子列表 [1,2],然后 34 被選入 [3,4],等等。zip(..) 的定義要求從兩個(gè)列表中各取一個(gè)值。如果這兩個(gè)列表長(zhǎng)度不同,那么值的選擇將會(huì)進(jìn)行到較短的那個(gè)列表耗盡為止,在另一個(gè)列表中額外的值將會(huì)被忽略。

一種 zip(..) 的實(shí)現(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(..) 是純粹的。

注意: 在這種實(shí)現(xiàn)中毫無疑問地發(fā)生了一些非 FP 的事情。這里有一個(gè)指令式的 while 循環(huán)而且使用 shift()push(..) 改變了列表。在本書早先的部分中,我論證過在純函數(shù)內(nèi)部(通常是為了性能)使用非純粹的行為是合理的,只要其造成的效果是完全自包含的就行。

Merge

通過將兩個(gè)列表中的值穿插來融合它們,看起來就像:

mergeLists( [1,3,5,7,9], [2,4,6,8,10] );
// [1,2,3,4,5,6,7,8,9,10]

可能不太明顯,但是這個(gè)結(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(..) 對(duì)值的選擇僅截止到較短的列表耗盡為止,而且忽略剩下的值;而融合兩個(gè)列表時(shí)保留那些額外的值將是最自然的。另外,flatten(..) 會(huì)在嵌套的列表中遞歸地執(zhí)行,但你可能希望列表融合僅在淺層工作,保留嵌套的列表。

那么,讓我們定義一個(gè)如我們所愿的 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 庫不會(huì)定義 mergeLists(..),取而代之的是定義了融合兩個(gè)對(duì)象的屬性的 merge(..);這樣的 merge(..) 的結(jié)果將與我們的 mergeLists(..) 的結(jié)果不同。

此外,還有許多其他選擇可以將列表的融合實(shí)現(xiàn)為一個(gè)遞減函數(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]

提示: 我們將在本章稍后使用這個(gè) mergeReducer(..) 技巧。

方法 vs. 獨(dú)立函數(shù)

在 JavaScript 中一個(gè)令 FP 程序員們沮喪的根源是,當(dāng)一些工具作為獨(dú)立函數(shù)被提供 —— 考慮一下我們?cè)谇耙徽轮醒苌鰜淼母鞣N工具 —— 而另一些是數(shù)組原型的方法時(shí) —— 就像我們?cè)诒菊轮锌吹降?—— 如何為使用這些工具來統(tǒng)一他們的策略。

當(dāng)你考慮組合多個(gè)操作時(shí),這個(gè)問題使人頭疼的地方就變得更加明顯:

[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ù),但它們?cè)谌梭w工程學(xué)上十分的不同。相較于前者許多 FP 程序員偏好后者,但前者毫無疑問地在 JavaScript 中更常見。后者一個(gè)特別令人厭惡的東西就是調(diào)用的嵌套。方法鏈風(fēng)格招人喜歡的地方 —— 常被稱為流利的 API 風(fēng)格,就像在 jQuery 和其他工具中那樣 —— 在于其緊湊/簡(jiǎn)潔,以及它讀起來是聲明式的從上到下順序。

對(duì)于獨(dú)立函數(shù)風(fēng)格的手動(dòng)組合來說,它的視覺順序既不是從左到右(從上到下)也不是從右到左(從下到上);而是由內(nèi)而外,這損害了可讀性。

自動(dòng)組合將兩種風(fēng)格的閱讀順序規(guī)范化為從右到左(從下到上)。那么為了探索不同風(fēng)格可能造成的影響,讓我們專門講解一下組合;這看起來應(yīng)當(dāng)很直接,但是實(shí)際上在兩種情況下都有些尷尬。

組合方法鏈

數(shù)組方法接收隱含的 this 參數(shù),所以盡管它們沒有出現(xiàn),這些方法也不能被視為一元的;這使得組合更加尷尬。為了應(yīng)付它,首先我們需要一個(gè) this 敏感版本的 partial(..)

var partialThis =
    (fn,...presetArgs) =>
        // 有意地制造一個(gè)允許 `this` 綁定的 `function`
        function partiallyApplied(...laterArgs){
            return fn.apply( this, [...presetArgs, ...laterArgs] );
        };

我們還需要另一個(gè)版本的 compose(..),它在這個(gè)鏈條的上下文環(huán)境中調(diào)用每一個(gè)被部分應(yīng)用的方法 —— 從上一個(gè)步驟中(通過隱含的 this)被 “傳遞” 過來的輸入值:

var composeChainedMethods =
    (...fns) =>
        result =>
            fns.reduceRight(
                (result,fn) =>
                    fn.call( result )
                , result
            );

一起使用這兩個(gè) this 敏感的工具:

composeChainedMethods(
   partialThis( Array.prototype.reduce, sum, 0 ),
   partialThis( Array.prototype.map, double ),
   partialThis( Array.prototype.filter, isOdd )
)
( [1,2,3,4,5] );                    // 18

注意: 三個(gè) Array.prototype.XXX 風(fēng)格的引用抓住了內(nèi)建的 Array.prototype.* 方法的引用,這樣我們就可以對(duì)自己的數(shù)組復(fù)用它們了。

組合獨(dú)立函數(shù)工具

這些工具的獨(dú)立 compose(..) 風(fēng)格組合不需要所有這些扭曲 this 的操作,這是它最為人稱道的地方。例如,我們可以這樣定義獨(dú)立函數(shù):

var filter = (arr,predicateFn) => arr.filter( predicateFn );

var map = (arr,mapperFn) => arr.map( mapperFn );

var reduce = (arr,reducerFn,initialValue) =>
    arr.reduce( reducerFn, initialValue );

但是這種特別的獨(dú)立風(fēng)格有它自己的尷尬之處;層層傳遞的數(shù)組上下文是第一個(gè)參數(shù)而非最后一個(gè),于是我們不得不使用右側(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)常自動(dò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 程序員喜歡獨(dú)立的工具風(fēng)格而不是實(shí)例方法。但你的感覺可能會(huì)不同。

將方法適配為獨(dú)立函數(shù)

在前面 filter(..) / map(..) / reduce(..) 的定義中,你可能發(fā)現(xiàn)了這三者的共同模式:它們都被分發(fā)到相應(yīng)的原生數(shù)組方法上。那么,我們能否使用一個(gè)工具來生成這些獨(dú)立適配函數(shù)呢?是的!讓我們?yōu)榇酥圃煲粋€(gè)稱為 unboundMethod(..) 的工具:

var unboundMethod =
    (methodName,argCount = 2) =>
        curry(
            (...args) => {
                var obj = args.pop();
                return obj[methodName]( ...args );
            },
            argCount
        );

要使用這個(gè)工具:

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(..)。

將獨(dú)立函數(shù)適配為方法

如果你喜歡只使用數(shù)組方法(流利的鏈?zhǔn)斤L(fēng)格),你有兩個(gè)選擇。你可以:

  1. 使用額外的方法擴(kuò)展內(nèi)建的 Array.prototype。
  2. 適配一個(gè)用于遞減函數(shù)的獨(dú)立工具,并將它傳入 reduce(..) 實(shí)例方法。

不要選擇(1)。 擴(kuò)展 Array.prototype 這樣的原生類型從來都不是一個(gè)好主意 —— 除非你定義一個(gè) Array 的子類,但這超出了我們?cè)诖说挠懻摲秶?。為了不鼓?lì)不好的實(shí)踐,我們不會(huì)在這種方式上再前進(jìn)了。

讓我們 集中于(2)。為了展示其中的要點(diǎn),我們將轉(zhuǎn)換之前遞歸的 flatten(..) 獨(dú)立工具:

var flatten =
    arr =>
        arr.reduce(
            (list,v) =>
                list.concat( Array.isArray( v ) ? flatten( v ) : v )
        , [] );

讓我們將內(nèi)部的 reducer(..) 函數(shù)抽離為一個(gè)獨(dú)立工具(并將它適配為可以脫離外部 flatten(..) 工作):

// 有意地定義一個(gè)允許通過名稱進(jìn)行遞歸的函數(shù)
function flattenReducer(list,v) {
    return list.concat(
        Array.isArray( v ) ? v.reduce( flattenReducer, [] ) : v
    );
}

現(xiàn)在,我們可以在一個(gè)數(shù)組方法鏈中通過 reduce(..) 來使用這個(gè)工具了:

[ [1, 2, 3], 4, 5, [6, [7, 8]] ]
.reduce( flattenReducer, [] )
// ..

尋找列表

目前為止,絕大多數(shù)例子都很瑣碎,它們基于簡(jiǎn)單的數(shù)字或字符串列表?,F(xiàn)在讓我們談?wù)劻斜聿僮骺梢蚤W光的地方:對(duì)一系列指令式的語句進(jìn)行聲明式的建模。

考慮這個(gè)基本的例子:

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 );

首先,我們觀察到那五個(gè)變量聲明和守護(hù)著函數(shù)調(diào)用的一系列 if 條件實(shí)質(zhì)上是這六個(gè)調(diào)用的一個(gè)大組合:getCurrentSession()getSessionId(..)、lookupUser(..)、getUserId(..)、lookupOrders(..)、和 processOrders(..)。理想上,我們想要擺脫所有這些變量聲明和指令式條件。

不幸的是,我們?cè)诘谒恼轮刑剿鬟^的 compose(..) / pipe(..) 工具自身不會(huì)在組合中提供一種表達(dá) != null 條件的簡(jiǎn)便方法。讓我們定義一個(gè)工具來提供幫助:

var guard =
    fn =>
        arg =>
            arg != null ? fn( arg ) : arg;

這個(gè) guard(..) 工具讓我們映射出那五個(gè)條件守護(hù)著的函數(shù):

[ getSessionId, lookupUser, getUserId, lookupOrders, processOrders ]
.map( guard )

這個(gè)映射的結(jié)果是準(zhǔn)備好組合的函數(shù)的數(shù)組(實(shí)際上是 pipe,以這個(gè)列表的順序來說)。我們可以將這個(gè)數(shù)組擴(kuò)散到 pipe(..),但因?yàn)槲覀円呀?jīng)在做列表操作了,讓我們使用一個(gè) reduce(..),將 getCurrentSession() 中的 session 值作為初始值:

.reduce(
    (result,nextFn) => nextFn( result )
    , getCurrentSession()
)

接下來,我們觀察到 getSessionId(..)getUserId(..) 可以被分別表達(dá)為值 "sessId""uId" 的映射:

[ "sessId", "uId" ].map( propName => partial( prop, propName ) )

但為了使用它們,我們需要將它們穿插在其他三個(gè)函數(shù)中(lookupUser(..)、lookupOrders(..)、和 processOrders(..)),來讓這五個(gè)函數(shù)的數(shù)組像上面的討論中那樣守護(hù)/組合。

為了進(jìn)行穿插,我們可以將此模型化為列表融合?;叵胍幌卤菊略缦鹊?mergeReducer

var mergeReducer =
    (merged,v,idx) =>
        (merged.splice( idx * 2, 0, v ), merged);

我們可以使用 reduce(..)(記得到我們的瑞士軍刀嗎!?)通過融合兩個(gè)列表,來將 lookupUser(..) “插入” 到數(shù)組的 getSessionId(..)getUserId(..) 函數(shù)之間:

.reduce( mergeReducer, [ lookupUser ] )

然后我們把 lookupOrders(..)processOrders(..) 連接到運(yùn)行中的函數(shù)數(shù)組的末尾:

.concat( lookupOrders, processOrders )

檢查一下,生成的五個(gè)函數(shù)的列表被表達(dá)為:

[ "sessId", "uId" ].map( propName => partial( prop, propName ) )
.reduce( mergeReducer, [ lookupUser ] )
.concat( lookupOrders, processOrders )

最后,把所有東西放在一起,將函數(shù)的列表向前面討論過的那樣添加守護(hù)功能并組合:

[ "sessId", "uId" ].map( propName => partial( prop, propName ) )
.reduce( mergeReducer, [ lookupUser ] )
.concat( lookupOrders, processOrders )
.map( guard )
.reduce(
    (result,nextFn) => nextFn( result )
    , getCurrentSession()
);

所有的指令式變量聲明和條件都不見了,取而代之的是鏈接在一起的干凈且是聲明式的列表操作。

如果對(duì)你來說這個(gè)版本比原來的版本讀起來更困難,不要擔(dān)心。原來的版本無疑是你可能更加熟悉的指令式形式。你向函數(shù)式程序員演變過程的一部分就是發(fā)展出能夠識(shí)別 FP 模式的能力,比如像這樣的列表操作。久而久之,隨著你對(duì)代碼可讀性的感覺轉(zhuǎn)換為聲明式風(fēng)格,這些東西將會(huì)更容易地從代碼中跳出來。

在我們結(jié)束這個(gè)話題之前,讓我們看一下現(xiàn)實(shí):這里的例子都是嚴(yán)重造作的。不是所有的代碼段都可以簡(jiǎn)單地模型化為列表操作。務(wù)實(shí)的要點(diǎn)是,要開發(fā)尋找這些可以進(jìn)行優(yōu)化的機(jī)會(huì),但不要過于沉迷在代碼的雜耍中;有些改進(jìn)是聊勝于無的??偸峭艘徊讲枂柲阕约?,你是 改善了還是損害了 代碼的可讀性。

融合(Fusion)

隨著你將 FP 列表操作更多地帶入到你對(duì)代碼的思考中,你很可能很快就會(huì)看到像這樣組合行為的鏈條:

..
.filter(..)
.map(..)
.reduce(..);

而且你還可能往往會(huì)得到每種操作有多個(gè)相鄰實(shí)例的鏈條,就像:

someList
.filter(..)
.filter(..)
.map(..)
.map(..)
.map(..)
.reduce(..);

好消息是這種鏈?zhǔn)斤L(fēng)格是聲明式的,而且很容易按順序讀懂將要發(fā)生的具體步驟。它的缺點(diǎn)是這些操作的每一個(gè)都循環(huán)遍歷整個(gè)列表,這意味著性能可能會(huì)有不必要的消耗,特別是在列表很長(zhǎng)的時(shí)候。

用另一種獨(dú)立風(fēng)格,你可能會(huì)看到這樣的代碼:

map(
    fn3,
    map(
        fn2,
        map( fn1, someList )
    )
);

這種風(fēng)格中,操作由下至上地羅列,而且我們依然循環(huán)遍歷列表三遍。

融合通過組合相鄰的操作來減少列表被循環(huán)遍歷的次數(shù)。我們?cè)谶@里將集中于將相鄰的 map(..) 壓縮在一起,因?yàn)樗侵v解起來最直接的。

想象這種場(chǎng)景:

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"]

考慮一下通過這個(gè)變形流程的每一個(gè)值。在 words 列表中的第一個(gè)值從 "Mr." 開始,變成 "Mr",然后變成 "MR",然后原封不動(dòng)地通過 elide(..)。另一個(gè)數(shù)據(jù)流是:"responsible" -> "responsible" -> "RESPONSIBLE" -> "RESPONS..."

換言之,你可以這樣考慮這些數(shù)據(jù)變形:

elide( upper( removeInvalidChars( "Mr." ) ) );
// "MR"

elide( upper( removeInvalidChars( "responsible" ) ) );
// "RESPONS..."

你抓住要點(diǎn)了嗎?我們可以將這三個(gè)相鄰的 map(..) 調(diào)用的分離步驟表達(dá)為一個(gè)變形函數(shù)的組合,因?yàn)樗鼈兌际且辉瘮?shù)而且每一個(gè)的返回值都適合作為下一個(gè)的輸入。我們可以使用 compose(..) 將映射函數(shù)融合,然后將組合好的函數(shù)傳遞給一個(gè) map(..) 調(diào)用:

words
.map(
    compose( elide, upper, removeInvalidChars )
);
// ["MR","JONES","ISNT","RESPONS...","FOR","THIS","DISASTER"]

這是另一個(gè) pipe(..) 可以作為一種更方便的組合形式的例子,由于它在順序上的可讀性:

words
.map(
    pipe( removeInvalidChars, upper, elide )
);
// ["MR","JONES","ISNT","RESPONS...","FOR","THIS","DISASTER"]

要是融合兩個(gè)或更多的 filter(..) 判定函數(shù)呢?它們經(jīng)常被視為一元函數(shù),看起來很適于組合。但別扭的地方是它們每一個(gè)都返回 boolean 種類的值,而這與下一個(gè)所期望的輸入值不同。融合相鄰的 reduce(..) 調(diào)用也是可能的,但遞減函數(shù)不是一元的所以更具挑戰(zhàn)性;我們需要更精巧的方法來抽離這種融合。我們會(huì)在附錄A “轉(zhuǎn)導(dǎo)” 中講解這些高級(jí)技術(shù)。

列表以外

目前為止我們一直在列表(數(shù)組)數(shù)據(jù)結(jié)構(gòu)的語境中討論各種操作;這無疑是你遇到它們的最常見的場(chǎng)景。但在更一般的意義上,這些操作可以對(duì)各種值的集合執(zhí)行。

正如我們?cè)缦日f過的,數(shù)組的 map(..) 將一個(gè)單值操作適配為對(duì)它所有值的操作,任何能夠提供 map(..) 的數(shù)據(jù)結(jié)構(gòu)都可以做到相同的事情。類似地,它可以實(shí)現(xiàn) filter(..),reduce(..),或者任何其他對(duì)于使用這種數(shù)據(jù)結(jié)構(gòu)的值來說有意義的操作。

從 FP 的精神上講,需要維護(hù)的最重要的部分是這些操作必須根據(jù)值的不可變性進(jìn)行動(dòng)作,這意味著它們必須返回一個(gè)新的數(shù)據(jù)結(jié)構(gòu)而非改變既存的。

讓我們通過一個(gè)廣為人知的數(shù)據(jù)結(jié)構(gòu) —— 二叉樹 —— 來展示一下。一個(gè)二叉樹是一個(gè)節(jié)點(diǎn)(就是一個(gè)對(duì)象),它擁有指向其他節(jié)點(diǎn)(本身也是二叉樹)的兩個(gè)引用,通常稱為 子樹。樹上的每一個(gè)節(jié)點(diǎn)都持有整個(gè)數(shù)據(jù)結(jié)構(gòu)中的一個(gè)值。

為了便于展示,我們使我們的二叉樹變?yōu)橐粋€(gè)二叉檢索樹(BST)。但是我們將要看到的操作對(duì)任何非 BST 二叉樹來說工作起來都一樣。

注意: 二叉檢索樹是一種一般的二叉樹,它對(duì)樹上每一個(gè)值之間的關(guān)系有一種特殊的限制。在一個(gè)樹左側(cè)的每一個(gè)節(jié)點(diǎn)的值都要小于樹根節(jié)點(diǎn)的值,而樹根節(jié)點(diǎn)的值要小于樹右側(cè)每一個(gè)節(jié)點(diǎn)的值?!靶∮凇?的概念是相對(duì)于被存儲(chǔ)的數(shù)據(jù)的種類的;對(duì)于數(shù)字它可以是數(shù)值上的,對(duì)于字符串可以是字典順序,等等。BST 很有用,因?yàn)槭褂眠f歸的二元檢索算法時(shí),它們使在樹上檢索一個(gè)值變得很直接而且更高效。

為了制造一個(gè)二叉樹節(jié)點(diǎn)對(duì)象,讓我們使用這個(gè)工廠函數(shù):

var BinaryTree =
    (value,parent,left,right) => ({ value, parent, left, right });

為了方便起見,我們使每個(gè)節(jié)點(diǎn)都存儲(chǔ) leftright 子樹以及一個(gè)指向它自己 parent 節(jié)點(diǎn)的引用。

現(xiàn)在讓我們定義一個(gè)常見作物(水果,蔬菜)名稱的 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 );

在這個(gè)特別的二叉樹中,banana 是根節(jié)點(diǎn);這棵樹可以使用在不同位置的節(jié)點(diǎn)建立,但依然是一個(gè)擁有相同遍歷過程的 BST。

我們的樹看起來像這樣:

遍歷一個(gè)二叉樹來處理它的值有多種方法。如果它是一個(gè) BST(我們的就是?。┒椅覀冞M(jìn)行 按順序 的遍歷 —— 總是先訪問左側(cè)子樹,然后是節(jié)點(diǎn)自身,最后是右側(cè)子樹 —— 那么我們將會(huì)按升序(排序過的順序)訪問所有值。

因?yàn)槟悴荒芟駥?duì)一個(gè)數(shù)組那樣簡(jiǎn)單地 console.log(..) 一個(gè)二叉樹,所以我們先來定義一個(gè)主要為了進(jìn)行打印而生的便利方法。forEach(..) 將會(huì)像訪問一個(gè)數(shù)組那樣訪問一個(gè)二叉樹的節(jié)點(diǎn):

// 按順序遍歷
BinaryTree.forEach = function forEach(visitFn,node){
    if (node) {
        if (node.left) {
            forEach( visitFn, node.left );
        }

        visitFn( node );

        if (node.right) {
            forEach( visitFn, node.right );
        }
    }
};

注意: 遞歸處理對(duì)于使用二叉樹來說再自然不過了。我們的 forEach(..) 工具遞歸地調(diào)用它自己來處理左右子樹。我們將在后面的章節(jié)中詳細(xì)講解遞歸,就是我們將在關(guān)于遞歸的那一章中講解遞歸的那一章。

回憶一下本章開頭,forEach(..) 被描述為僅對(duì)副作用有用處,而在 FP 中這通常不理想。在這個(gè)例子中,我們僅將 forEach(..) 用于 I/O 副作用,所以它作為一個(gè)幫助函數(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),讓我們從定義一個(gè) 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;
    }
};

你可能會(huì)猜測(cè)我們將會(huì)僅僅 map(..) 節(jié)點(diǎn)的 value 屬性,但一般來說我們可能實(shí)際上想要映射樹節(jié)點(diǎn)本身。所以,整個(gè)被訪問的節(jié)點(diǎn)被傳入了 mapperFn(..) 函數(shù),而且它期待取回一個(gè)帶有變形后的值的新 BinaryTree(..)。如果你只是返回相同的節(jié)點(diǎn),那么這個(gè)操作將會(huì)改變你的樹而且很可能造成意外的結(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 是一個(gè)與 banana 不同的樹(所有節(jié)點(diǎn)都不同),就像在一個(gè)數(shù)組上調(diào)用 map(..) 會(huì)返回一個(gè)新數(shù)組一樣。正如其他對(duì)象/數(shù)組的數(shù)組一樣,如果 node.value 本身引用了一些對(duì)象/數(shù)組,那么如果你想要更深層的不可變性的話,你還需要在映射函數(shù)中手動(dòng)拷貝它。

那么 reduce(..) 呢?相同的基本處理:對(duì)樹的節(jié)點(diǎn)進(jìn)行按順序的遍歷。一種用法是將我們的樹 reduce(..) 到一個(gè)它的值的數(shù)組中,這對(duì)將來適配其他常用的列表操作會(huì)很有用?;蛘呶覀兛梢詫⑽覀兊臉?reduce(..) 為一個(gè)所有作物名稱的字符串連接。

我們將模仿數(shù)組 reduce(..) 的行為,這使得參數(shù) initialValue 的傳遞是可選的。這個(gè)算法有些復(fù)雜,但依然是可控的:

BinaryTree.reduce = function reduce(reducerFn,initialValue,node){
    if (arguments.length < 3) {
        // 更換參數(shù),因?yàn)?`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(..) 來制造我們的購物單(一個(gè)數(shù)組):

BinaryTree.reduce(
    (result,node) => result.concat( node.value ),
    [],
    banana
);
// ["apple","apricot","avocado","banana","cantelope"
//   "cherry","cucumber","grape"]

最后,讓我們來為我們的樹考慮一下 filter(..)。這是目前為止最復(fù)雜的算法,因?yàn)樗鼘?shí)質(zhì)上(不是實(shí)際上)引入了對(duì)樹上節(jié)點(diǎn)的刪除,這要求處理幾種極端情況。但不要被它的實(shí)現(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;
    }
};

這個(gè)代碼段的絕大部分都用來處理當(dāng)一個(gè)節(jié)點(diǎn)從樹結(jié)構(gòu)的復(fù)本中“被移除”(濾除)時(shí),其父/子引用的移動(dòng)。

為了展示 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"]

你很可能在簡(jiǎn)單的數(shù)組上下文環(huán)境中使用本章中提到的大多數(shù)列表操作。但我們已經(jīng)看到了,這其中的概念可以應(yīng)用于任何你可能需要的數(shù)據(jù)結(jié)構(gòu)和操作中。這是 FP 如何可以廣泛地應(yīng)用于許多不同應(yīng)用程序場(chǎng)景的有力證明!

總結(jié)

三個(gè)常見而且強(qiáng)大的列表操作:

  • map(..):將值投射到新列表中時(shí)將其變形。
  • filter(..):將值投射到新列表中時(shí)選擇或排除它。
  • reduce(..):將一個(gè)列表中的值結(jié)合為另一個(gè)值(通常但不總是數(shù)組)。

其他幾個(gè)可能在列表處理中非常有用的高級(jí)操作:unique(..)、flatten(..)、和 merge(..)。

融合使用函數(shù)組合技術(shù)將多個(gè)相鄰的 map(..) 調(diào)用合并。這很大程度上是一種性能優(yōu)化,但也改善了你的列表操作的聲明式性質(zhì)。

列表通常在視覺上表現(xiàn)為數(shù)組,但也可以被一般化為任何可以表現(xiàn)/產(chǎn)生一個(gè)有序的值的序列的集合數(shù)據(jù)結(jié)構(gòu)。因此,所有這些“列表操作”實(shí)際上是“數(shù)據(jù)結(jié)構(gòu)操作”。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容