摘要:通過對一系列任務(wù)建模來理解一些非常重要的函數(shù)式編程在列表操作中的價值一些些看起來不像列表的語句作為列表操作,而不是多帶帶執(zhí)行。映射我們將采用最基礎(chǔ)和最簡單的操作來開啟函數(shù)式編程列表操作的探索。函子是采用運(yùn)算函數(shù)有效用操作的值。
原文地址:Functional-Light-JS
原文作者:Kyle Simpson-《You-Dont-Know-JS》作者
第 8 章:列表操作關(guān)于譯者:這是一個流淌著滬江血液的純粹工程:認(rèn)真,是 HTML 最堅(jiān)實(shí)的梁柱;分享,是 CSS 里最閃耀的一瞥;總結(jié),是 JavaScript 中最嚴(yán)謹(jǐn)?shù)倪壿嫛=?jīng)過捶打磨練,成就了本書的中文版。本書包含了函數(shù)式編程之精髓,希望可以幫助大家在學(xué)習(xí)函數(shù)式編程的道路上走的更順暢。比心。
譯者團(tuán)隊(duì)(排名不分先后):阿希、blueken、brucecham、cfanlife、dail、kyoko-df、l3ve、lilins、LittlePineapple、MatildaJin、冬青、pobusama、Cherry、蘿卜、vavd317、vivaxy、萌萌、zhouyao
你是否還沉迷于上一節(jié)介紹的閉包/對象之中?歡迎回來!
如果你能做一些令人驚嘆的事情,請持續(xù)保持下去。
本文之前已經(jīng)簡要的提及了一些實(shí)用函數(shù):map(..)、filter(..) 和 reduce(..),現(xiàn)在深入了解一下它們。在 Javascript 中,這些實(shí)用函數(shù)通常被用于 Array(即 “l(fā)ist” )的原型上。因此可以很自然的將這些實(shí)用函數(shù)和數(shù)組或列表操作聯(lián)系起來。
在討論具體的數(shù)組方法之前,我們應(yīng)該很清楚這些操作的作用。在這章中,弄明白為何有這些列表操作和這些操作如何工作同等重要。請保持頭腦清晰,跟上節(jié)奏。
在本章內(nèi)外,有大量常見且通俗易懂的列表操作的例子,它們描述一些細(xì)小的操作去處理一系列的值(如數(shù)組中的每一個值加倍)。這樣通俗易懂。
但是不要停留在這些簡單示例的表面,而錯過了更深層次的點(diǎn)。通過對一系列任務(wù)建模來理解一些非常重要的函數(shù)式編程在列表操作中的價值 —— 一些些看起來不像列表的語句 —— 作為列表操作,而不是多帶帶執(zhí)行。
這不僅僅是編寫許多簡練代碼的技巧。我們所要做的是,從命令式轉(zhuǎn)變?yōu)槁暶魇斤L(fēng)格,使代碼模式更容易辨認(rèn),從而可讀性更好。
但這里有一些更需要掌握的東西。在命令式代碼中,一組計(jì)算的中間結(jié)果都是通過賦值來存儲。代碼中依賴的命令模式越多,越難驗(yàn)證它們不是錯誤。比如,在邏輯上,值的意外改變,或隱藏的潛在原因/影響。
通過與/或鏈接組合列表操作,中間結(jié)果被隱式地跟蹤,并在很大程度上避免了這些風(fēng)險(xiǎn)。
注意: 相比前面幾章,為了代碼片段更加簡練,我們將采用 ES6 的箭頭函數(shù)。盡管第 2 章中對于箭頭函數(shù)的建議依舊普遍適用于編碼中。
非函數(shù)式編程列表處理作為本章討論的快速預(yù)覽,我想調(diào)用一些操作,這些操作看上去可以將 Javascript 數(shù)組和函數(shù)式編程列表操作相關(guān)聯(lián),但事實(shí)上并沒有。我們不會在這里討論這些,因?yàn)樗鼈兣c一般的函數(shù)式編程最佳實(shí)踐不一致:
forEach(..)
some(..)
every(..)
forEach(..) 是遍歷輔助函數(shù),但是它被設(shè)計(jì)為帶有副作用的函數(shù)來處理每次遍歷;你或許已經(jīng)猜測到了它為什么不是我們正在討論的函數(shù)式編程列表操作!
some(..) 和?every(..) 鼓勵使用純函數(shù)(具體來說,就像 filter(..) 這樣的謂詞函數(shù)),但是它們不可避免地將列表化簡為 true 或?false 的值,本質(zhì)上就像搜索和匹配。這兩個實(shí)用函數(shù)和我們期望采用函數(shù)式編程來組織代碼相匹配,因此,這里我們將跳過它們。
映射我們將采用最基礎(chǔ)和最簡單的操作 map(..) 來開啟函數(shù)式編程列表操作的探索。
映射的作用就將一個值轉(zhuǎn)換為另一個值。例如,如果你將 2 乘以 3,你將得到轉(zhuǎn)換的結(jié)果 6 。需要重點(diǎn)注意的是,我們并不是在討論映射轉(zhuǎn)換是暗示就地轉(zhuǎn)換或重新賦值,而是將一個值從一個地方映射到另一個新的地方。
換句話說
var x = 2, y; // 轉(zhuǎn)換/投影 y = x * 3; // 變換/重新賦值 x = x * 3;
如果我們定義了乘 3 這樣的函數(shù),這個函數(shù)充當(dāng)映射(轉(zhuǎn)換)的功能。
var multipleBy3 = v => v * 3; var x = 2, y; // 轉(zhuǎn)換/投影 y = multiplyBy3( x );
我們可以自然的將映射的概念從單個值擴(kuò)展到值的集合。map(..) 操作將列表中所有的值轉(zhuǎn)換為新列表中的列表項(xiàng),如下圖所示:
實(shí)現(xiàn) map(..) 的代碼如下:
function map(mapperFn,arr) { var newList = []; for (let idx = 0; idx < arr.length; idx++) { newList.push( mapperFn( arr[idx], idx, arr ) ); } return newList; }
注意: mapperFn, arr 的參數(shù)順序,乍一看像是在倒退。但是這種方式在函數(shù)式編程類庫中非常常見。因?yàn)檫@樣做,可以讓這些實(shí)用函數(shù)更容易被組合。
mapperFn(..) 自然地將傳入的列表項(xiàng)做映射/轉(zhuǎn)換,并且也傳入了 idx 和?arr。這樣做,可以和內(nèi)置的數(shù)組的 map(..) 保持一致。在某些情況下,這些額外的參數(shù)非常有用。
但是,在一些其他情況中,你只希望傳遞列表項(xiàng)到 mapperFn(..)。因?yàn)轭~外的參數(shù)可能會改變它的行為。在第三章的“共同目的( All for one )”中,我們介紹了 unary(..),它限制函數(shù)僅僅接受一個參數(shù),不論多少個參數(shù)被傳入。
回顧第三章關(guān)于把 parseInt() 的參數(shù)數(shù)量限制為 1,從而使之成為可被安全使用的 mapperFn() 的例子:
map( ["1","2","3"], unary( parseInt ) ); // [1,2,3]
Javascript 提供了內(nèi)置的數(shù)組操作方法 map(..),這個方法使得列表中的鏈?zhǔn)讲僮鞲鼮楸憷?/p>
注意: Javascript 數(shù)組中的原型中定義的操作( map(..)、filter(..) 和 reduce(..) )的最后一個可選參數(shù)可以被用于綁定 “this” 到當(dāng)前函數(shù)。我們在第二章中曾經(jīng)討論過“什么是 this?”,以及在函數(shù)式編程的最佳實(shí)踐中應(yīng)該避免使用 this。基于這個原因,在這章中的示例中,我們不采用 this 綁定功能。
除了明顯的字符和數(shù)字操作外,你可以對列表中的這些值類型進(jìn)行操作。我們可以采用 map(..) 方法來通過函數(shù)列表轉(zhuǎn)換得到這些函數(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(..) 的一些有趣的事情:我們通常假定列表是從左往右執(zhí)行的,但 map(..) 沒有這個概念,它確實(shí)不需要這個次序。每一個轉(zhuǎn)換應(yīng)該獨(dú)立于其他的轉(zhuǎn)換。
映射普遍適用于并行處理的場景中,尤其在處理大列表時可以提升性能。但是在 Javascript 中,我們并沒有看到這樣的場景。因?yàn)檫@里不需要你傳入諸如 mapperFn(..) 這樣的純函數(shù),即便你應(yīng)當(dāng)這樣做。如果傳入了非純函數(shù),JS 在不同的順序中執(zhí)行不同的方法,這將很快產(chǎn)生大問題。
盡管從理論上講,單個映射操作是獨(dú)立的,但 JS 需要假定它們不是。這是令人討厭的。
同步 vs 異步這篇文章中討論的列表操作都是同步地操作一組已經(jīng)存在的值組成的列表,map(..) 在這里被看作是急切的操作。但另外一種思考方式是將映射函數(shù)作為時間處理器,該處理器會在新元素加入到列表中時執(zhí)行。
想象一下這樣的場景:
var newArr = arr.map(); arr.addEventListener( "value", multiplyBy3 );
現(xiàn)在,任何時候,當(dāng)一個值加入到 arr 中的時候,multiplyBy3(..) 事件處理器(映射函數(shù))將加入的值當(dāng)參數(shù)執(zhí)行,將轉(zhuǎn)換后的結(jié)果加入到 newArr。
我們建議,數(shù)組以及在數(shù)組上應(yīng)用的數(shù)組操作都是迫切的同步的,然而,這些相同的操作也可以應(yīng)用在一直接受新值的“惰性列表”(即流)上。我們將在第 10 章中深入討論它。
映射 vs 遍歷有些人提倡在迭代的時候采用 map(..) 替代 forEach(..),它本質(zhì)上不會去觸碰接受到的值,但仍有可能產(chǎn)生副作用:
[1,2,3,4,5] .map( function mapperFn(v){ console.log( v ); // 副作用! return v; } ) ..
這種技術(shù)似乎非常有用的原因是 map(..) 返回?cái)?shù)組,這樣你可以在它之后繼續(xù)鏈?zhǔn)綀?zhí)行更多的操作。而 forEach(..) 返回的的值是 undefined。然而,我認(rèn)為你應(yīng)當(dāng)避免采用這種方式使用 map(..),因?yàn)檫@里明顯的以非函數(shù)式編程的方式使用核心的函數(shù)式編程操作,將引起巨大的困惑。
你應(yīng)該聽過一句老話,用合適的工具做合適的事,對嗎?錘子敲釘子,螺絲刀擰螺絲等等。這里有些細(xì)微的不同:采用恰當(dāng)?shù)姆绞?/strong>使用合適的工具。
錘子是揮動手敲的,如果你嘗試采用嘴去釘釘子,效率會大打折扣。map(..) 是用來映射值的,而不是帶來副作用。
一個詞:函子在這本書中,我們盡可能避免使用人為創(chuàng)造的函數(shù)式編程術(shù)語。我們有時候會使用官方術(shù)語,但在大多數(shù)時候,采用日常用語來描述更加通俗易懂。
這里我將被一個可能會引起恐慌的詞:函子來短暫地打斷這種通俗易懂的模式。這里之所以要討論函子的原因是我們已經(jīng)了解了它是干什么的,并且這個詞在函數(shù)式編程文獻(xiàn)中被大量使用。你不會被這個詞嚇到而帶來副作用。
函子是采用運(yùn)算函數(shù)有效用操作的值。
如果問題中的值是復(fù)合的,意味著它是由單個值組成,就像數(shù)組中的情況一樣。例如,函子在每個多帶帶的值上執(zhí)行操作函數(shù)。函子實(shí)用函數(shù)創(chuàng)建的新值是所有單個操作函數(shù)執(zhí)行的結(jié)果的組合。
這就是用 map(..) 來描述我們所看到東西的一種奇特方式。map(..) 函數(shù)采用關(guān)聯(lián)值(數(shù)組)和映射函數(shù)(操作函數(shù)),并為數(shù)組中的每一個獨(dú)立元素執(zhí)行映射函數(shù)。最后,它返回由所有新映射值組成的新數(shù)組。
另一個例子:字符串函子是一個字符串加上一個實(shí)用函數(shù),這個實(shí)用函數(shù)在字符串的所有字符上執(zhí)行某些函數(shù)操作,返回包含處理過的字符的字符串。參考如下非常刻意的例子:
function uppercaseLetter(c) { var code = c.charCodeAt( 0 ); // 小寫字母? if (code >= 97 && code <= 122) { // 轉(zhuǎn)換為大寫! code = code - 32; } return String.fromCharCode( code ); } function stringMap(mapperFn,str) { return [...str].map( mapperFn ).join( "" ); } stringMap( uppercaseLetter, "Hello World!" ); // 你好,世界!
stringMap(..) 允許字符串作為函子。你可以定義一個映射函數(shù)用于任何數(shù)據(jù)類型。只要實(shí)用函數(shù)滿足這些規(guī)則,該數(shù)據(jù)結(jié)構(gòu)就是一個函子。
過濾器想象一下,我?guī)е栈@子去逛食品雜貨店的水果區(qū)。這里有很多水果(蘋果、橙子和香蕉)。我真的很餓,因此我想要盡可能多的水果,但是我真的更喜歡圓形的水果(蘋果和橙子)。因此我逐一篩選每一個水果,然后帶著裝滿蘋果和橙子的籃子離開。
我們將這個篩選的過程稱為“過濾”。將這次購物描述為從空籃子開始,然后只過濾(挑選,包含)出蘋果和橙子,或者從所有的水果中過濾掉(跳過,不包括)香蕉。你認(rèn)為哪種方式更自然?
如果你在一鍋水里面做意大利面條,然后將這鍋面條倒入濾網(wǎng)(過濾)中,你是過濾了意大利面條,還是過濾掉了水? 如果你將咖啡渣放入過濾器中,然后泡一杯咖啡,你是將咖啡過濾到了杯子里,還是說將咖啡渣過濾掉?
你有沒有發(fā)現(xiàn)過濾的結(jié)果取決于你想要把什么保留在過濾器中,還是說用過濾器將其過濾出去?
那么在航空/酒店網(wǎng)站上如何指定過濾選項(xiàng)呢?你是按照你的標(biāo)準(zhǔn)過濾結(jié)果,還是將不符合標(biāo)準(zhǔn)的過濾掉?仔細(xì)想想,這個例子也許和前面有不相同的語意。
取決于你的想法,過濾是排除的或者保留的,這種概念上的融合,使其難以理解。
我認(rèn)為最通常的理解過濾(在編程之外)是剔除掉不需要的成員。不幸的是,在程序中我們基本上將這個語意倒轉(zhuǎn)為更像是過濾需要的成員。
列表的 filter(..) 操作采用一個函數(shù)確定每一項(xiàng)在新數(shù)組中是保留還是剔除。這個函數(shù)返回 true 將保留這一項(xiàng),返回 false 將剔除這一項(xiàng)。這種返回 true/false 來做決定的函數(shù)有一個特別的稱謂:謂詞函數(shù)。
如果你認(rèn)為 true 是積極的信號,filter(..) 的定義是你是“保留”一個值,而不是“拋棄”一個值。
如果 filter(..) 被用于剔除操作,你需要轉(zhuǎn)動你的腦子,積極的返回 false 發(fā)出排除的信號,并且被動的返回 true 來讓一個值通過過濾器。
這種語意上不匹配的原因是你會將這個函數(shù)命名為 predicateFn(..),這對于代碼的可讀性有意義,我們很快會討論這一點(diǎn)。
下圖很形象的介紹了列表間的 filter(..) 操作:
實(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(..) 不僅僅傳入了值,還傳入了 idx 和 arr。如果有必要,也可以采用 unary(..) 來限制它的形參。
正如 map(..),filter(..) 也是 JS 數(shù)組內(nèi)置支持的實(shí)用函數(shù)。
我們將謂詞函數(shù)定義這樣:
var whatToCallIt = v => v % 2 == 1;
這個函數(shù)采用 v % 2 == 1 來返回 true 或 false。這里的效果是,值為奇數(shù)時返回 true,值為偶數(shù)時返回 false。這樣,我們該如何命名這個函數(shù)?一個很自然的名字可能是:
var isOdd = v => v % 2 == 1;
考慮一下如何在你的代碼中使用 isOdd(..) 來做簡單的值檢查:
var midIdx; if (isOdd( list.length )) { midIdx = (list.length + 1) / 2; } else { midIdx = list.length / 2; }
有感覺了,對吧?讓我們采用內(nèi)置的數(shù)組的 filter(..) 來對一組值做篩選:
[1,2,3,4,5].filter( isOdd ); // [1,3,5]
如果讓你描述 [1,3,5] 這個結(jié)果,你是說“我將偶數(shù)過濾掉了”,還是說“我做了奇數(shù)的篩選” ?我認(rèn)為前者是更自然的描述。但后者的代碼可讀性更好。閱讀代碼幾乎是逐字的,這樣我們“過濾的每一個數(shù)字都是奇數(shù)”。
我個人覺得這語意混亂。對于經(jīng)驗(yàn)豐富的開發(fā)者來說,這里毫無疑問有大量的先例。但是對于一個新手來說,這個邏輯表達(dá)看上去不采用雙重否定不好表達(dá),換句話說,采用雙重否定來表達(dá)比較好。
為了便以理解,我們可以將這個函數(shù)從 isOdd(..) 重命名為 isEven(..):
var isEven = v => v % 2 == 1; [1,2,3,4,5].filter( isEven ); // [1,3,5]
耶,但是這個函數(shù)名變得無意義,下面的示例中,傳入的偶數(shù),確返回了 false
isEven( 2 ); // false
呸!
回顧在第 3 章中的 "No Points",我們定義 not(..) 操作來反轉(zhuǎn)謂詞函數(shù),代碼如下:
var isEven = not( isOdd ); isEven( 2 ); // true
但在前面定義的 filter(..) 方式中,無法使用這個 isEven(..),因?yàn)樗倪壿嬕呀?jīng)反轉(zhuǎn)了。我們將以偶數(shù)結(jié)束,而不是奇數(shù),我們需要這么做:
[1,2,3,4,5].filter( not( isEven ) ); // [1,3,5]
這樣完全違背了我們的初衷,所以我們不要這么做。這樣,我們轉(zhuǎn)一圈又回來了。
過濾掉 & 過濾為了消除這些困惑,我們定義 filterOut(..) 函數(shù)來執(zhí)行過濾掉那些值,而實(shí)際上其內(nèi)部執(zhí)行否定的謂詞檢查。這樣,我們將已經(jīng)定義的 filter(..) 設(shè)置別名為 filterIn(..)。
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]
我認(rèn)為采用 filterIn(..) 和 filterOut(..)(在 Ramda 中稱之為 reject(..) )會讓代碼的可讀性比僅僅采用 filter(..) 更好。
Reducemap(..) 和 filter(..) 都會產(chǎn)生新的數(shù)組,而第三種操作(reduce(..))則是典型地將列表中的值合并(或減少)到單個值(非列表),比如數(shù)字或者字符串。本章后續(xù)會探討如何采用高級的方式使用 reduce(..)。reduce(..) 是函數(shù)式編程中的最重要的實(shí)用函數(shù)之一。就像瑞士軍刀一樣,具有豐富的用途。
組合或縮減被抽象的定義為將兩個值轉(zhuǎn)換成一個值。有些函數(shù)式編程文獻(xiàn)將其稱為“折疊”,就像你將兩個值合并到一個值。我認(rèn)為這對于可視化是很有幫助的。
就像映射和過濾,合并的方式完全取決于你,一般取決于列表中值的類型。例如,數(shù)字通常采用算術(shù)計(jì)算合并,字符串采用拼接的方式合并,函數(shù)采用組合調(diào)用來合并。
有時候,縮減操作會指定一個 initialValue,然后將這個初始值和列表的第一個元素合并。然后逐一和列表中剩余的元素合并。如下圖所示:
你也可以去掉上述的 initialValue,直接將第一個列表元素當(dāng)做 initialValue,然后和列表中的第二個元素合并,如下圖所示:
警告: 在 JavaScript 中,如果在縮減操作的列表中一個值都沒有(在數(shù)組中,或沒有指定 initialValue ),將會拋出異常。一個縮減操作的列表有可能為空的時候,需要小心采用不指定 initialValue 的方式。
傳遞給 reduce(..) 執(zhí)行縮減操作的函數(shù)執(zhí)行一般稱為縮減器。縮減器和之前介紹的映射和謂詞函數(shù)有不同的特征。縮減器主要接受當(dāng)前的縮減結(jié)果和下一個值來做縮減操作。每一步縮減的當(dāng)前結(jié)果通常稱為累加器。
例如,對 5、10、15 采用初始值為 3 執(zhí)行乘的縮減操作:
3 * 5 = 15
15 * 10 = 150
150 * 15 = 2250
在 JavaScript 中采用內(nèi)置的 reduce(..) 方法來表達(dá)列表的縮減操作:
[5,10,15].reduce( (product,v) => product * v, 3 ); // 2250
我們可以采用下面的方式實(shí)現(xiàn) reduce(..):
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 形參,以防縮減操作需要。我不會經(jīng)常用到它們,但我覺得保留它們是明智的。
在第 4 章中,我們討論了 compose(..) 實(shí)用函數(shù),和展示了用 reduce(..) 來實(shí)現(xiàn)的例子:
function compose(...fns) { return function composed(result){ return fns.reverse().reduce( function reducer(result,fn){ return fn( result ); }, result ); }; }
基于不同的組合,為了說明 reduce(..),可以認(rèn)為縮減器將函數(shù)從左到右組合(就像 pipe(..) 做的事情)。在列表中這樣使用:
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)自由的(見第 3 章中的“無形參”),但我們不能僅僅以縮減器本身來傳遞 pipe(..),因?yàn)樗强勺兊模粋鬟f給 reduce(..) 額外的參數(shù)(idx 和 arr)會產(chǎn)生問題。
前面,我們討論采用 unary(..) 來限制 mapperFn(..) 或 predicateFn(..) 僅采用一個參數(shù)。binary(..) 做了類似的事情,但在 reducerFn(..) 中限定兩個參數(shù):
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ù)組的次序沒有要求。reduce(..) 明確要采用從左到右的處理方式。如果你想從右到左縮減,JavaScript 提供了 reduceRight(..) 函數(shù),它和 reduce(..) 的行為出了次序不一樣外,其他都相同。
var hyphenate = (str,char) => str + "-" + char; ["a","b","c"].reduce( hyphenate ); // "a-b-c" ["a","b","c"].reduceRight( hyphenate ); // "c-b-a"
reduce(..) 采用從左到右的方式工作,很自然的聯(lián)想到組合函數(shù)中的 pipe(..)。reduceRight(..) 從右往左的方式能自然的執(zhí)行 compose(..)。因此,我們重新采用 reduceRight(..) 實(shí)現(xiàn) compose(..):
function compose(...fns) { return function composed(result){ return fns.reduceRight( function reducer(result,fn){ return fn( result ); }, result ); }; }
這樣,我們不需要執(zhí)行 fns.reverse();我們只需要從另一個方向執(zhí)行縮減操作!
Map 也是 Reducemap(..) 操作本質(zhì)來說是迭代,因此,它也可以看作是(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]
注意: 我們欺騙了這個縮減器,并允許采用 list.push(..) 去改變傳入的列表所帶來的副作用。一般來說,這并不是一個好主意,但我們清楚創(chuàng)建和傳入 [] 列表,這樣就不那么危險(xiǎn)了。創(chuàng)建一個新的列表,并將 val 合并到這個列表的最后面。這樣更有條理,并且性能開銷較小。我們將在附錄 A 中討論這種欺騙。
通過 reduce(..) 實(shí)現(xiàn) map(..),并不是表面上的明顯的步驟,甚至是一種改善。然而,這種能力對于理解更高級的技術(shù)是至關(guān)重要的,如在附錄 A 中的“轉(zhuǎn)換”。
Filter 也是 Reduce就像通過 reduce(..) 實(shí)現(xiàn) map(..) 一樣,也可以使用它實(shí)現(xiàn) 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]
注意: 這里有更加不純的縮減器欺騙。不采用 list.push(..),我們也可以采用 list.concat(..) 并返回合并后的新列表。我們將在附錄 A 中繼續(xù)介紹這個欺騙。
高級列表操作現(xiàn)在,我們對這些基礎(chǔ)的列表操作 map(..)、filter(..) 和 reduce(..) 感到比較舒服。讓我們看看一些更復(fù)雜的操作,這些操作在某些場合下很有用。這些常用的實(shí)用函數(shù)存在于許多函數(shù)式編程的類庫中。
去重篩選列表中的元素,僅僅保留唯一的值。基于 indexOf(..) 函數(shù)查找(它采用 === 嚴(yán)格等于表達(dá)式):
var unique = arr => arr.filter( (v,idx) => arr.indexOf( v ) == idx );
實(shí)現(xiàn)的原理是,當(dāng)從左往右篩選元素時,列表項(xiàng)的 idx 位置和 indexOf(..) 找到的位置相等時,表明該列表項(xiàng)第一次出現(xiàn),在這種情況下,將列表項(xiàng)加入到新數(shù)組中。
另一種實(shí)現(xiàn) unique(..) 的方式是遍歷 arr,當(dāng)列表項(xiàng)不能在新列表中找到時,將其插入到新的列表中。這樣可以采用 reduce(..) 來實(shí)現(xiàn):
var unique = arr => arr.reduce( (list,v) => list.indexOf( v ) == -1 ? ( list.push( v ), list ) : list , [] );
注意: 這里還有很多其他的方式實(shí)現(xiàn)這個去重算法,比如循環(huán),并且其中不少還更高效,實(shí)現(xiàn)方式更聰明。然而,這兩種方式的優(yōu)點(diǎn)是,它們使用了內(nèi)建的列表操作,它們能更方便的和其他列表操作鏈?zhǔn)剑M合調(diào)用。我們會在本章的后面進(jìn)一步討論這些。
unique(..) 令人滿意地產(chǎn)生去重后的新列表:
unique( [1,4,7,1,3,1,7,9,2,6,4,0,5,3] ); // [1, 4, 7, 3, 9, 2, 6, 0, 5]扁平化
大多數(shù)時候,你看到的數(shù)組的列表項(xiàng)不是扁平的,很多時候,數(shù)組嵌套了數(shù)組,例如:
[ [1, 2, 3], 4, 5, [6, [7, 8]] ]
如果你想將其轉(zhuǎn)化成下面的形式:
[ 1, 2, 3, 4, 5, 6, 7, 8 ]
我們尋找的這個操作通常稱為 flatten(..)。它可以采用如同瑞士軍刀般的 reduce(..) 實(shí)現(xiàn):
var flatten = arr => arr.reduce( (list,v) => list.concat( Array.isArray( v ) ? flatten( v ) : v ) , [] );
注意: 這種處理嵌套列表的實(shí)現(xiàn)方式依賴于遞歸,我們將在后面的章節(jié)中進(jìn)一步討論。
在嵌套數(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]
也許你會限制遞歸的層次到指定的層次。我們可以通過增加額外的 depth 形參來實(shí)現(xiàn):
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]映射,然后扁平化
flatten(..) 的常用用法之一是當(dāng)你映射一組元素列表,并且將每一項(xiàng)值從原來的值轉(zhuǎn)換為數(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ù)組,我們可以對這個結(jié)果執(zhí)行 flatten(..):
flatten( firstNames .map( entry => [entry.name].concat( entry.variations ) ) ); // ["Jonathan","John","Jon","Jonny","Stephanie","Steph","Stephy","Frederick", // "Fred","Freddy"]
除了稍顯啰嗦之外,將 map(..) 和 flatten(..) 采用獨(dú)立的步驟的最主要的缺陷是關(guān)于性能方面。它會處理列表兩次。
函數(shù)式編程的類庫中,通常會定義一個 flatMap(..)(通常命名為 chain(..))函數(shù)。這個函數(shù)將映射和之后的扁平化的操作組合起來。為了連貫性和組合(通過閉包)的簡易性,flatMap(..) / chain(..) 實(shí)用函數(shù)的形參 mapperFn, arr 順序通常和我們之前看到的獨(dú)立的 map(..)、filter(..)和 reduce(..) 一致。
flatMap( entry => [entry.name].concat( entry.variations ), firstNames ); // ["Jonathan","John","Jon","Jonny","Stephanie","Steph","Stephy","Frederick", // "Fred","Freddy"]
幼稚的采用獨(dú)立的兩步來實(shí)現(xiàn) flatMap(..):
var flatMap = (mapperFn,arr) => flatten( arr.map( mapperFn ), 1 );
注意: 我們將扁平化的層級指定為 1,因?yàn)橥ǔ?flatMap(..) 的定義是扁平化第一級。
盡管這種實(shí)現(xiàn)方式依舊會處理列表兩次,帶來了不好的性能。但我們可以將這些操作采用 reduce(..) 手動合并:
var flatMap = (mapperFn,arr) => arr.reduce( (list,v) => list.concat( mapperFn( v ) ) , [] );
現(xiàn)在 flatMap(..) 方法帶來了便利性和性能。有時你可能需要其他操作,比如和 filter(..) 混合使用。這樣的話,將 map(..) 和 flatten(..) 獨(dú)立開來始終更加合適。
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(..) 被定義為將兩個列表中的值挑選出來。如果兩個列表的的元素的個數(shù)不一致,這個選擇會持續(xù)到較短的數(shù)組末尾時結(jié)束,另一個數(shù)組中多余的元素會被忽略。
一種 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; }
采用 arr1.slice() 和 arr2.slice() 可以確保 zip(..) 是純的,不會因?yàn)榻邮艿降綌?shù)組引用造成副作用。
注意: 這個實(shí)現(xiàn)明顯存在一些非函數(shù)式編程的思想。這里有一個命令式的 while 循環(huán)并且采用 shift() 和 push(..) 改變列表。在本書前面,我認(rèn)為在純函數(shù)中使用非純的行為(通常是為了性能)是有道理的,只要其產(chǎn)生的副作用完全包含在這個函數(shù)內(nèi)部。這種實(shí)現(xiàn)是安全純凈的。
合并采用插入每個列表中的值的方式合并兩個列表,如下所示:
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(..),他選擇較短列表的最后一個值,忽視掉剩余的值; 而合并兩個數(shù)組會很自然地保留這些額外的列表值。并且 flatten(..) 采用遞歸處理嵌套列表,但你可能只期望較淺地合并列表,保留嵌套的子列表。
這樣,讓我們定義一個更符合我們期望的 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; }
注意: 許多函數(shù)式編程類庫并不會定義 mergeLists(..),反而會定義 merge(..) 方法來合并兩個對象的屬性。這種 merge(..) 返回的結(jié)果和我們的 mergeLists(..) 不同。
另外,這里有一些選擇采用縮減器實(shí)現(xiàn)合并列表的方法:
// 來自 @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 獨(dú)立對于函數(shù)式編程者來說,普遍感到失望的原因是 Javascript 采用統(tǒng)一的策略處理實(shí)用函數(shù),但其中的一些也被作為獨(dú)立函數(shù)提供了出來。想想在前面的章節(jié)中的介紹的大量的函數(shù)式編程實(shí)用程序,以及另一些實(shí)用函數(shù)是數(shù)組的原型方法,就像在這章中看到的那些。
當(dāng)你想合并多個操作的時候,這個問題的痛苦程度更加明顯:
[1,2,3,4,5] .filter( isOdd ) .map( double ) .reduce( sum, 0 ); // 18 // 采用獨(dú)立的方法. reduce( map( filter( [1,2,3,4,5], isOdd ), double ), sum, 0 ); // 18
兩種方式的 API 實(shí)現(xiàn)了同樣的功能。但它們的風(fēng)格完全不同。很多函數(shù)式編程者更傾向采用后面的方式,但是前者在 Javascript 中毫無疑問的更常見。后者特別地讓人不待見之處是采用嵌套調(diào)用。人們更偏愛鏈?zhǔn)秸{(diào)用 —— 通常稱為流暢的API風(fēng)格,這種風(fēng)格被 jQuery 和一些工具采用 —— 這種風(fēng)格緊湊/簡潔,并且可以采用聲明式的自上而下的順序閱讀。
這種獨(dú)立風(fēng)格的手動合并的視覺順序既不是嚴(yán)格的從左到右(自上而下),也不是嚴(yán)格的從右到左,而是從里往外。
從右往左(自下而上)這兩種風(fēng)格自動組成規(guī)范的閱讀順序。因此為了探索這些風(fēng)格隱藏的差異,讓我們特別的檢查組合。他看上去應(yīng)當(dāng)簡潔,但這兩種情況都有點(diǎn)尷尬。
鏈?zhǔn)浇M合方法這些數(shù)組方法接收絕對的 this 形參,因此盡管從外表上看,它們不能被當(dāng)作一元運(yùn)算看待,這會使組合更加尷尬。為了應(yīng)對這些,我首先需要一個 partial(..) 版本的 this:
var partialThis = (fn,...presetArgs) => // 故意采用 function 來為了 this 綁定 function partiallyApplied(...laterArgs){ return fn.apply( this, [...presetArgs, ...laterArgs] ); };
我們也需要一個特殊的 compose(..),它在上下文鏈中調(diào)用每一個部分應(yīng)用的方法。它的輸入值(即絕對的 this)由前一步傳入:
var composeChainedMethods = (...fns) => result => fns.reduceRight( (result,fn) => fn.call( result ) , result );
一起使用這兩個 this 實(shí)用函數(shù):
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 采用了內(nèi)置的 Array.prototype.* 方法,這樣我們可以在數(shù)組中重復(fù)使用它們。
獨(dú)立組合實(shí)用函數(shù)獨(dú)立的 compose(..),組合這些功能函數(shù)的風(fēng)格不需要所有的這些廣泛令人喜歡的 this 參數(shù)。例如,我們可以獨(dú)立的定義成這樣:
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ù)組上下文是第一個形參,而不是最后一個。因此我們需要采用右偏應(yīng)用(right-partial application)來組合它們。
compose( partialRight( reduce, sum, 0 ), partialRight( map, double ), partialRight( filter, isOdd ) ) ( [1,2,3,4,5] ); // 18
這就是為何函數(shù)式編程類庫通常定義 filter(..)、map(..) 和 reduce(..) 交替采用最后一個形參接收數(shù)組,而不是第一個。它們通常自動地柯理化實(shí)用函數(shù):
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 );
采用這種方式定義實(shí)用函數(shù),組合流程會顯得更加友好:
compose( reduce( sum )( 0 ), map( double ), filter( isOdd ) ) ( [1,2,3,4,5] ); // 18
這種很整潔的實(shí)現(xiàn)方式,就是函數(shù)式編程者喜歡獨(dú)立的實(shí)用程序風(fēng)格,而不是實(shí)例方法的原因。但這種情況因人而異。
方法適配獨(dú)立在前面的 filter(..) / map(..) / reduce(..) 的定義中,你可能發(fā)現(xiàn)了這三個方法的共同點(diǎn):它們都派發(fā)到相對應(yīng)的原生數(shù)組方法。因此,我們能采用實(shí)用函數(shù)生成這些獨(dú)立適配函數(shù)嗎?當(dāng)然可以,讓我們定義 unboundMethod(..) 來做這些:
var unboundMethod = (methodName,argCount = 2) => curry( (...args) => { var obj = args.pop(); return obj[methodName]( ...args ); }, argCount );
使用這個實(shí)用函數(shù):
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)格),你有兩個選擇:
采用額外的方法擴(kuò)展內(nèi)建的 Array.prototype
把獨(dú)立實(shí)用函數(shù)適配成一個縮減函數(shù),并且將其傳遞給 reduce(..) 實(shí)例方法。
不要采用第一種 擴(kuò)展諸如 Array.prototype 的原生方法從來不是一個好主意,除非定義一個 Array 的子類。但是這超出了這里的討論范圍。為了不鼓勵這種不好的習(xí)慣,我們不會進(jìn)一步去探討這種方式。
讓我們關(guān)注第二種。為了說明這點(diǎn),我們將前面定義的遞歸實(shí)現(xiàn)的 flatten(..) 轉(zhuǎn)換為獨(dú)立實(shí)用函數(shù):
var flatten = arr => arr.reduce( (list,v) => list.concat( Array.isArray( v ) ? flatten( v ) : v ) , [] );
讓我們將里面的 reducer(..) 函數(shù)抽取成獨(dú)立的實(shí)用函數(shù)(并且調(diào)整它,讓其獨(dú)立于外部的 flatten(..) 運(yùn)行):
// 刻意使用具名函數(shù)用于遞歸中的調(diào)用 function flattenReducer(list,v) { return list.concat( Array.isArray( v ) ? v.reduce( flattenReducer, [] ) : v ); }
現(xiàn)在,我們可以在數(shù)組方法鏈中通過 reduce(..) 調(diào)用這個實(shí)用函數(shù):
[ [1, 2, 3], 4, 5, [6, [7, 8]] ] .reduce( flattenReducer, [] ) // ..查尋列表
到此為止,大部分示例有點(diǎn)無聊,它們基于一列數(shù)字或者字符串,讓我們討論一些有亮點(diǎn)的列表操作:聲明式地建模一些命令式語句。
看看這個基本例子:
var getSessionId = partial( prop, "sessId" ); var getUserId = partial( prop, "uId" ); var session, sessionId, user, userId, orders; session = getCurrentSession(); if (session != null) sessionId = getSessionId( session ); if (sessionId != null) user = lookupUser( sessionId ); if (user != null) userId = getUserId( user ); if (userId != null) orders = lookupOrders( userId ); if (orders != null) processOrders( orders );
首先,我們可以注意到聲明和運(yùn)行前的一系列 If 語句確保了由 getCurrentSession()、getSessionId(..)、lookupUser(..)、getUserId(..)、lookupOrders(..) 和 processOrders(..) 這六個函數(shù)組合調(diào)用時的有效。理想地,我們期望擺脫這些變量定義和命令式的條件。
不幸的是,在第 4 章中討論的 compose(..)/pipe(..) 實(shí)用函數(shù)并沒有提供給一個便捷的方式來表達(dá)在這個組合中的 != null 條件。讓我們定義一個實(shí)用函數(shù)來解決這個問題:
var guard = fn => arg => arg != null ? fn( arg ) : arg;
這個 guard(..) 實(shí)用函數(shù)讓我們映射這五個條件確保函數(shù):
[ getSessionId, lookupUser, getUserId, lookupOrders, processOrders ] .map( guard )
這個映射的結(jié)果是組合的函數(shù)數(shù)組(事實(shí)上,這是個有列表順序的管道)。我們可以展開這個數(shù)組到 pipe(..),但由于我們已經(jīng)做列表操作,讓我們采用 reduce(..) 來處理。采用 getCurrentSession() 返回的會話值作為初始值:
.reduce( (result,nextFn) => nextFn( result ) , getCurrentSession() )
接下來,我們觀察到 getSessionId(..) 和 getUserId(..) 可以看成對應(yīng)的 "sessId" 和 "uId" 的映射:
[ "sessId", "uId" ].map( propName => partial( prop, propName ) )
但是為了使用這些,我們需要將另外三個函數(shù)(lookupUser(..)、lookupOrders(..) 和 processOrders(..))插入進(jìn)來,用來獲取上面討論的那五個守護(hù)/組合函數(shù)。
為了實(shí)現(xiàn)插入,我們采用列表合并來模擬這些。回顧本章前面介紹的 mergeReducer(..):
var mergeReducer = (merged,v,idx) => (merged.splice( idx * 2, 0, v ), merged);
我們可以采用 reduce(..)(我們的瑞士軍刀,還記得嗎?)在生成的 getSessionId(..) 和 getUserId(..) 函數(shù)之間的數(shù)組中“插入” lookupUser(..),通過合并這兩個列表:
.reduce( mergeReducer, [ lookupUser ] )
然后我們將 lookupOrders(..) 和 processOrders(..) 加入到正在執(zhí)行的函數(shù)數(shù)組末尾:
.concat( lookupOrders, processOrders )
總結(jié)下,生成的五個函數(shù)組成的列表表達(dá)為:
[ "sessId", "uId" ].map( propName => partial( prop, propName ) ) .reduce( mergeReducer, [ lookupUser ] ) .concat( lookupOrders, processOrders )
最后,將所有函數(shù)合并到一起,將這些函數(shù)數(shù)組添加到之前的守護(hù)和組合上:
[ "sessId", "uId" ].map( propName => partial( prop, propName ) ) .reduce( mergeReducer, [ lookupUser ] ) .concat( lookupOrders, processOrders ) .map( guard ) .reduce( (result,nextFn) => nextFn( result ) , getCurrentSession() );
所有必要的變量聲明和條件一去不復(fù)返了,取而代之的是采用整潔和聲明式的列表操作鏈接在一起。
如果你覺得現(xiàn)在的這個版本比之前要難,不要擔(dān)心。毫無疑問的,前面的命令式的形式,你可能更加熟悉。進(jìn)化為函數(shù)式編程者的一步就是開發(fā)一些具有函數(shù)式編程風(fēng)格的代碼,比如這些列表操作。隨著時間推移,我們跳出這些代碼,當(dāng)你切換到聲明式風(fēng)格時更容易感受到代碼的可讀性。
在離開這個話題之前,讓我們做一個真實(shí)的檢查:這里的示例過于造作。不是所有的代碼片段被簡單的采用列表操作模擬。務(wù)實(shí)的獲取方式是本能的尋找這些機(jī)會,而不是過于追求代碼的技巧;一些改進(jìn)比沒有強(qiáng)。經(jīng)常退一步,并且問自己,是提升了還是損害了代碼的可讀性。
融合當(dāng)你更多的考慮在代碼中使用函數(shù)式列表操作,你可能會很快地開始看到鏈?zhǔn)浇M合行為,如:
.. .filter(..) .map(..) .reduce(..);
往往,你可能會把多個相鄰的操作用鏈?zhǔn)絹碚{(diào)用,比如:
someList .filter(..) .filter(..) .map(..) .map(..) .map(..) .reduce(..);
好消息是,鏈?zhǔn)斤L(fēng)格是聲明式的,并且很容易看出詳盡的執(zhí)行步驟和順序。它的不足之處在于每一個列表操作都需要循環(huán)整個列表,意味著不必要的性能損失,特別是在列表非常長的時候。
采用交替獨(dú)立的風(fēng)格,你可能看到的代碼如下:
map( fn3, map( fn2, map( fn1, someList ) ) );
采用這種風(fēng)格,這些操作自下而上列出,這依然會循環(huán)數(shù)組三遍。
融合處理了合并相鄰的操作,這樣可以減少列表的迭代次數(shù)。這里我們關(guān)注于合并相鄰的 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"]
注意在這個轉(zhuǎn)換流程中的每一個值。在 words 列表中的第一個值,開始為 "Mr.",變?yōu)?"Mr",然后為 "MR",然后通過 elide(..) 不變。另一個數(shù)據(jù)流為:"responsible" -> "responsible" -> "RESPONSIBLE" -> "RESPONS..."。
換句話說,你可以將這些數(shù)據(jù)轉(zhuǎn)換看成這樣:
elide( upper( removeInvalidChars( "Mr." ) ) ); // "MR" elide( upper( removeInvalidChars( "responsible" ) ) ); // "RESPONS..."
你抓住重點(diǎn)了嗎?我們可以將那三個獨(dú)立的相鄰的 map(..) 調(diào)用步驟看成一個轉(zhuǎn)換組合。因?yàn)樗鼈兌际且辉瘮?shù),并且每一個返回值都是下一個點(diǎn)輸入值。我們可以采用 compose(..) 執(zhí)行映射功能,并將這個組合函數(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ù)呢?通常視為一元函數(shù),它們似乎適合組合。但是有個小問題,每一個函數(shù)返回了不同類型的值(boolean),這些返回值并不是下一個函數(shù)需要的輸入?yún)?shù)。融合相鄰的 reduce(..) 調(diào)用也是可能的,但縮減器并不是一元的,這也會帶來不小的挑戰(zhàn)。我們需要更復(fù)雜的技巧來實(shí)現(xiàn)這些融合。我們將在附錄 A 的“轉(zhuǎn)換”中討論這些高級方法。
列表之外到目前為止,我們討論的操作都是在列表(數(shù)組)數(shù)據(jù)結(jié)構(gòu)中,這是迄今為止你遇到的最常見的場景。但是更普遍的意義是,這些操作可以在任一集合執(zhí)行。
就像我們之前說過,數(shù)組的 map(..) 方法對數(shù)組中的每一個值做單值操作,任何數(shù)據(jù)結(jié)構(gòu)都可以采用 map(..) 操作做類似的事情。同樣的,也可以實(shí)現(xiàn) filter(..),reduce(..) 和其他能工作于這些數(shù)據(jù)結(jié)構(gòu)的值的操作。
函數(shù)式編程精神中重要的部分是這些操作必須依賴值的不變性,意味著它們必須返回一個新的值,而不是改變存在的值。
讓我們描述那個廣為人知的數(shù)據(jù)結(jié)構(gòu):二叉樹。二叉樹指的是一個節(jié)點(diǎn)(只有一個對象!)有兩個字節(jié)點(diǎn)(這些字節(jié)點(diǎn)也是二叉樹),這兩個字節(jié)點(diǎn)通常稱之為左和右子樹。樹中的每個節(jié)點(diǎn)包含總體數(shù)據(jù)結(jié)構(gòu)的值。
在這個插圖中,我們將我們的二叉樹描述為二叉搜索樹(BST)。然而,樹的操作和其他非二叉搜索樹沒有區(qū)別。
注意: 二叉搜索樹是特定的二叉樹,該樹中的節(jié)點(diǎn)值彼此之間存在特定的約束關(guān)系。每個樹中的左子節(jié)點(diǎn)的值小于根節(jié)點(diǎn)的值,跟子節(jié)點(diǎn)的值也小于右子節(jié)點(diǎn)的值。這里“小于”的概念是相對于樹中存儲數(shù)據(jù)的類型。它可以是數(shù)字的數(shù)值,也可以是字符串在詞典中的順序,等等。二叉搜索樹的價值在于在處理在樹中搜索一個值非常高效便捷,采用一個遞歸的二叉搜索算法。
讓我們采用這個工廠函數(shù)創(chuàng)建二叉樹對象:
var BinaryTree = (value,parent,left,right) => ({ value, parent, left, right });
為了方便,我們在每個Node中不僅僅保存了 left 和 right 子樹節(jié)點(diǎn),也保存了其自身的 parent 節(jié)點(diǎn)引用。
現(xiàn)在,我們將一些常見的產(chǎn)品名(水果,蔬菜)定義為二叉搜索樹:
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 );
在這個樹形結(jié)構(gòu)中,banana 是根節(jié)點(diǎn),這棵樹可能采用不同的方式創(chuàng)建節(jié)點(diǎn),但其依舊可以采用二叉搜索樹一樣的方式訪問。
這棵樹如下圖所示:
這里有多種方式來遍歷一顆二叉樹來處理它的值。如果這棵樹是二叉搜索樹,我們還可以有序的遍歷它。通過先訪問左側(cè)子節(jié)點(diǎn),然后自身節(jié)點(diǎn),最后右側(cè)子節(jié)點(diǎn),這樣我們可以得到升序排列的值。
現(xiàn)在,你不能僅僅通過像在數(shù)組中用 console.log(..) 打印出二叉樹。我們先定義一個便利的方法,主要用來打印。定義的 forEach(..) 方法能像和數(shù)組一樣的方式來訪問二叉樹:
// 順序遍歷 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(..) 實(shí)用函數(shù)采用遞歸調(diào)用自身來處理左右字節(jié)點(diǎn)。我們將在后續(xù)的章節(jié)章深入討論遞歸。
回顧在本章開頭描述的 forEach(..),它存在有用的副作用,通常函數(shù)式編程期望有這個副作用。在這種情況下,我們僅僅在 I/O 的副作用下使用 forEach(..),因此它是完美的理想的輔助函數(shù)。
采用 forEach(..) 打印那個二叉樹中的值:
BinaryTree.forEach( node => console.log( node.value ), banana ); // apple apricot avocado banana cantelope cherry cucumber grape // 僅訪問根節(jié)點(diǎn)為 `cherry` 的子樹 BinaryTree.forEach( node => console.log( node.value ), cherry ); // cantelope cherry cucumber grape
為了采用函數(shù)式編程的方式操作我們定義的那個二叉樹,我們定義一個 map(..) 函數(shù):
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; } };
你可能會認(rèn)為采用 map(..) 僅僅處理節(jié)點(diǎn)的 value 屬性,但通常情況下,我們可能需要映射樹的節(jié)點(diǎn)本身。因此,mapperFn(..) 傳入整個訪問的節(jié)點(diǎn),在應(yīng)用了轉(zhuǎn)換之后,它期待返回一個全新的 BinaryTree(..) 節(jié)點(diǎn)回來。如果你返回了同樣的節(jié)點(diǎn),這個操作會改變你的樹,并且很可能會引起意想不到的結(jié)果!
讓我們映射我們的那個樹,得到一列大寫產(chǎn)品名:
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é)點(diǎn)都不同),就像在列表中執(zhí)行 map(..) 返回一個新的數(shù)組。就像其他對象/數(shù)組的數(shù)組,如果 node.value 本身是某個對象/數(shù)組的引用,如果你想做深層次的轉(zhuǎn)換,那么你就需要在映射函數(shù)中手動的對它做深拷貝。
如何處理 reduce(..)?相同的基本處理過程:有序遍歷樹的節(jié)點(diǎn)的方式。一種可能的用法是 reduce(..) 我們的樹得到它的值的數(shù)組。這對將來適配其他典型的列表操作很有幫助。或者,我們可以 reduce(..) 我們的樹,得到一個合并了它所有產(chǎn)品名的字符串。
我們模仿數(shù)組中 reduce(..) 的行為,它接受那個可選的 initialValue 參數(shù)。該算法有一點(diǎn)難度,但依舊可控:
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(..) 產(chǎn)生一個購物單(一個數(shù)組):
BinaryTree.reduce( (result,node) => result.concat( node.value ), [], banana ); // ["apple","apricot","avocado","banana","cantelope" // "cherry","cucumber","grape"]
最后,讓我們考慮在樹中用 filter(..)。這個算法迄今為止最棘手,因?yàn)樗行В▽?shí)際上沒有)影響從樹上刪除節(jié)點(diǎn),這需要處理幾個問題。不要被這種實(shí)現(xiàn)嚇到。如果你喜歡,現(xiàn)在跳過它,關(guān)注我們?nèi)绾问褂盟皇菍?shí)現(xiàn)。
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)存在重復(fù)的樹形結(jié)構(gòu)中的節(jié)點(diǎn)被“刪除”(過濾掉)的時候,移動節(jié)點(diǎn)的父/子引用。
作為一個描述使用 filter(..) 的例子,讓我們產(chǎn)生僅僅包含蔬菜的樹:
var vegetables = [ "asparagus", "avocado", "brocolli", "carrot", "celery", "corn", "cucumber", "lettuce", "potato", "squash", "zucchini" ]; var whatToBuy = BinaryTree.filter( // 將蔬菜從農(nóng)產(chǎn)品清單中過濾出來 node => vegetables.indexOf( node.value ) != -1, banana ); // 購物清單 BinaryTree.reduce( (result,node) => result.concat( node.value ), [], whatToBuy ); // ["avocado","cucumber"]
你會在簡單列表中使用本章大多數(shù)的列表操作。但現(xiàn)在你發(fā)現(xiàn)這個概念適用于你可能需要的任何數(shù)據(jù)結(jié)構(gòu)和操作。函數(shù)式編程可以廣泛應(yīng)用在許多不同的場景,這是非常強(qiáng)大的!
總結(jié)三個強(qiáng)大通用的列表操作:
map(..): 轉(zhuǎn)換列表項(xiàng)的值到新列表。
filter(..): 選擇或過濾掉列表項(xiàng)的值到新數(shù)組。
reduce(..): 合并列表中的值,并且產(chǎn)生一個其他的值(經(jīng)常但不總是非列表的值)。
其他一些非常有用的處理列表的高級操作:unique(..)、flatten(..) 和 merge(..)。
融合采用函數(shù)組合技術(shù)來合并多個相鄰的 map(..)調(diào)用。這是常見的性能優(yōu)化方式,并且它也使得列表操作更加自然。
列表通常以數(shù)組展現(xiàn),但它也可以作為任何數(shù)據(jù)結(jié)構(gòu)表達(dá)/產(chǎn)生一個有序的值集合。因此,所有這些“列表操作”都是“數(shù)據(jù)結(jié)構(gòu)操作”。
【上一章】翻譯連載 | JavaScript輕量級函數(shù)式編程-第7章: 閉包vs對象 |《你不知道的JS》姊妹篇
iKcamp原創(chuàng)新書《移動Web前端高效開發(fā)實(shí)戰(zhàn)》已在亞
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://specialneedsforspecialkids.com/yun/91830.html
摘要:我稱之為輕量級函數(shù)式編程。序眾所周知,我是一個函數(shù)式編程迷。函數(shù)式編程有很多種定義。本書是你開啟函數(shù)式編程旅途的絕佳起點(diǎn)。事實(shí)上,已經(jīng)有很多從頭到尾正確的方式介紹函數(shù)式編程的書了。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson - 《You-Dont-Know-JS》作者 譯者團(tuán)隊(duì)(排名不分先后):阿希、blueken、brucecham、...
摘要:一旦我們滿足了基本條件值為,我們將不再調(diào)用遞歸函數(shù),只是有效地執(zhí)行了。遞歸深諳函數(shù)式編程之精髓,最被廣泛引證的原因是,在調(diào)用棧中,遞歸把大部分顯式狀態(tài)跟蹤換為了隱式狀態(tài)。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson-《You-Dont-Know-JS》作者 關(guān)于譯者:這是一個流淌著滬江血液的純粹工程:認(rèn)真,是 HTML 最堅(jiān)實(shí)的梁柱;...
摘要:所以我覺得函數(shù)式編程領(lǐng)域更像學(xué)者的領(lǐng)域。函數(shù)式編程的原則是完善的,經(jīng)過了深入的研究和審查,并且可以被驗(yàn)證。函數(shù)式編程是編寫可讀代碼的最有效工具之一可能還有其他。我知道很多函數(shù)式編程編程者會認(rèn)為形式主義本身有助于學(xué)習(xí)。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson - 《You-Dont-Know-JS》作者 關(guān)于譯者:這是一個流淌著滬江血液...
摘要:為了盡可能提升互通性,已經(jīng)成為函數(shù)式編程庫遵循的實(shí)際標(biāo)準(zhǔn)。與輕量級函數(shù)式編程的概念相反,它以火力全開的姿態(tài)進(jìn)軍的函數(shù)式編程世界。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson-《You-Dont-Know-JS》作者 關(guān)于譯者:這是一個流淌著滬江血液的純粹工程:認(rèn)真,是 HTML 最堅(jiān)實(shí)的梁柱;分享,是 CSS 里最閃耀的一瞥;總結(jié),...
摘要:從某些方面來講,這章回顧的函數(shù)知識并不是針對函數(shù)式編程者,非函數(shù)式編程者同樣需要了解。什么是函數(shù)針對函數(shù)式編程,很自然而然的我會想到從函數(shù)開始。如果你計(jì)劃使用函數(shù)式編程,你應(yīng)該盡可能多地使用函數(shù),而不是程序。指的是一個函數(shù)聲明的形參數(shù)量。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson - 《You-Dont-Know-JS》作者 關(guān)于譯者:...
摘要:把數(shù)據(jù)的流向想象成糖果工廠的一條傳送帶,每一次操作其實(shí)都是冷卻切割包裝糖果中的一步。在該章節(jié)中,我們將會用糖果工廠的類比來解釋什么是組合。糖果工廠靠這套流程運(yùn)營的很成功,但是和所有的商業(yè)公司一樣,管理者們需要不停的尋找增長點(diǎn)。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson-《You-Dont-Know-JS》作者 關(guān)于譯者:這是一個流淌...
閱讀 2628·2021-11-23 09:51
閱讀 2418·2021-09-30 09:48
閱讀 2044·2021-09-22 15:24
閱讀 1009·2021-09-06 15:02
閱讀 3303·2021-08-17 10:14
閱讀 1934·2021-07-30 18:50
閱讀 1980·2019-08-30 15:53
閱讀 3168·2019-08-29 18:43