摘要:把數(shù)據(jù)的流向想象成糖果工廠的一條傳送帶,每一次操作其實都是冷卻切割包裝糖果中的一步。在該章節(jié)中,我們將會用糖果工廠的類比來解釋什么是組合。糖果工廠靠這套流程運營的很成功,但是和所有的商業(yè)公司一樣,管理者們需要不停的尋找增長點。
原文地址:Functional-Light-JS
原文作者:Kyle Simpson-《You-Dont-Know-JS》作者
JavaScript輕量級函數(shù)式編程 第 4 章:組合函數(shù)關(guān)于譯者:這是一個流淌著滬江血液的純粹工程:認(rèn)真,是 HTML 最堅實的梁柱;分享,是 CSS 里最閃耀的一瞥;總結(jié),是 JavaScript 中最嚴(yán)謹(jǐn)?shù)倪壿?。?jīng)過捶打磨練,成就了本書的中文版。本書包含了函數(shù)式編程之精髓,希望可以幫助大家在學(xué)習(xí)函數(shù)式編程的道路上走的更順暢。比心。
譯者團隊(排名不分先后):阿希、blueken、brucecham、cfanlife、dail、kyoko-df、l3ve、lilins、LittlePineapple、MatildaJin、冬青、pobusama、Cherry、蘿卜、vavd317、vivaxy、萌萌、zhouyao
到目前為止,我希望你能更輕松地理解在函數(shù)式編程中使用函數(shù)意味著什么。
一個函數(shù)式編程者,會將他們程序中的每一個函數(shù)當(dāng)成一小塊簡單的樂高部件。他們能一眼辨別出藍(lán)色的 2x2 方塊,并準(zhǔn)確地知道它是如何工作的、能用它做些什么。當(dāng)構(gòu)建一個更大、更復(fù)雜的樂高模型時,當(dāng)每一次需要下一塊部件的時候,他們能夠準(zhǔn)確地從備用部件中找到這些部件并拿過來使用。
但有些時候,你把藍(lán)色 2x2 的方塊和灰色 4x1 的方塊以某種形式組裝到一起,然后意識到:“這是個有用的部件,我可能會常用到它”。
那么你現(xiàn)在想到了一種新的“部件”,它是兩種其他部件的組合,在需要的時候能觸手可及。這時候,將這個藍(lán)黑色 L 形狀的方塊組合體放到需要使用的地方,比每次分開考慮兩種獨立方塊的組合要有效的多。
函數(shù)有多種多樣的形狀和大小。我們能夠定義某種組合方式,來讓它們成為一種新的組合函數(shù),程序中不同的部分都可以使用這個函數(shù)。這種將函數(shù)一起使用的過程叫做組合。
輸出到輸入我們已經(jīng)見過幾種組合的例子。比如,在第 3 章中,我們對 unary(..) 的討論包含了如下表達式:unary(adder(3))。仔細(xì)想想這里發(fā)生了什么。
為了將兩個函數(shù)整合起來,將第一個函數(shù)調(diào)用產(chǎn)生的輸出當(dāng)做第二個函數(shù)調(diào)用的輸入。在 unary(adder(3)) 中,adder(3) 的調(diào)用返回了一個值(值是一個函數(shù));該值被直接作為一個參數(shù)傳入到 unary(..) 中,同樣的,這個調(diào)用返回了一個值(值為另一個函數(shù))。
讓我們回放一下過程并且將數(shù)據(jù)流動的概念視覺化,是這個樣子:
functionValue <-- unary <-- adder <-- 3
3 是 adder(..) 的輸入。而 adder(..) 的輸出是 unary(..) 的輸入。unary(..) 的輸出是 functionValue。 這就是 unary(..) 和 adder(..) 的組合。
把數(shù)據(jù)的流向想象成糖果工廠的一條傳送帶,每一次操作其實都是冷卻、切割、包裝糖果中的一步。在該章節(jié)中,我們將會用糖果工廠的類比來解釋什么是組合。
讓我們一步一步的來了解組合。首先假設(shè)你程序中可能存在這么兩個實用函數(shù)。
function words(str) { return String( str ) .toLowerCase() .split( /s|/ ) .filter( function alpha(v){ return /^[w]+$/.test( v ); } ); } function unique(list) { var uniqList = []; for (let i = 0; i < list.length; i++) { // value not yet in the new list? if (uniqList.indexOf( list[i] ) === -1 ) { uniqList.push( list[i] ); } } return uniqList; }
使用這兩個實用函數(shù)來分析文本字符串:
var text = "To compose two functions together, pass the output of the first function call as the input of the second function call."; var wordsFound = words( text ); var wordsUsed = unique( wordsFound ); wordsUsed; // ["to","compose","two","functions","together","pass", // "the","output","of","first","function","call","as", // "input","second"]
我們把 words(..) 輸出的數(shù)組命名為 wordsFound。unique(..) 的輸入也是一個數(shù)組,因此我們可以將 wordsFound 傳入給它。
讓我們重新回到糖果工廠的流水線:第一臺機器接收的“輸入”是融化的巧克力,它的“輸出”是一堆成型且冷卻的巧克力。流水線上的下一個機器將這堆巧克力作為它的“輸入”,它的“輸出”是一片片切好的巧克力糖果。下一步就是,流水線上的另一臺機器將這些傳送帶上的小片巧克力糖果處理,并輸出成包裝好的糖果,準(zhǔn)備打包和運輸。
糖果工廠靠這套流程運營的很成功,但是和所有的商業(yè)公司一樣,管理者們需要不停的尋找增長點。
為了跟上更多糖果的生產(chǎn)需求,他們決定拿掉傳送帶這么個玩意,直接把三臺機器疊在一起,這樣第一臺的輸出閥就直接和下一臺的輸入閥直接連一起了。這樣第一臺機器和第二臺機器之間,就再也不會有一堆巧克力在傳送帶上慢吞吞的移動了,并且也不會有空間浪費和隆隆的噪音聲了。
這項革新為工廠節(jié)省了很大的空間,所以管理者很高興,他們每天能夠造更多的糖果了!
等價于這種升級后的糖果工廠配置的代碼跳過了中間步驟(上面代碼片段中的 wordsFound 變量),僅僅是將兩個函數(shù)調(diào)用一起使用:
var wordsUsed = unique( words( text ) );
注意: 盡管我們通常以從左往右的方式閱讀函數(shù)調(diào)用 ———— 先 unique(..) 然后 words(..) ———— 這里的操作順序?qū)嶋H上是從右往左的,或者說是自內(nèi)而外。words(..) 將會首先運行,然后才是 unique(..)。晚點我們會討論符合我們自然的、從左往右閱讀執(zhí)行順序的模式,叫做 pipe(..)。
堆在一起的機器工作的還不錯,但有些笨重了,電線掛的到處都是。創(chuàng)造的機器堆越多,工廠車間就會變得越凌亂。而且,裝配和維護這些機器堆太占用時間了。
有一天早上,一個糖果工廠的工程師突然想到了一個好點子。她想,如果她能在外面做一個大盒子把所有的電線都藏起來,效果肯定超級棒;盒子里面,三臺機器相互連接,而盒子外面,一切都變得很整潔、干凈。在這個很贊的機器的頂部,是傾倒融化巧克力的管道,在它的底部,是吐出包裝好的巧克力糖果的管道。
這樣一個單個的組合版機器,變得更易移動和安裝到工廠需要的地方中去了。工廠的車間工人也會變得更高興,因為他們不用再擺弄三臺機子上的那些按鈕和表盤了;他們很快更喜歡使用這個獨立的很贊的機器。
回到代碼上:我們現(xiàn)在了解到 words(..) 和 unique(..) 執(zhí)行的特定順序 -- 思考:組合的樂高 -- 是一種我們在應(yīng)用中其它部分也能夠用到的東西。所以,現(xiàn)在讓我們定義一個組合這些玩意的函數(shù):
function uniqueWords(str) { return unique( words( str ) ); }
uniqueWords(..) 接收一個字符串并返回一個數(shù)組。它是 unique(..) 和 words(..) 的組合,并且滿足我們的數(shù)據(jù)流向要求:
wordsUsed <-- unique <-- words <-- text
你現(xiàn)在應(yīng)該能夠明白了:糖果工廠設(shè)計模式的演變革命就是函數(shù)的組合。
制造機器糖果工廠一切運轉(zhuǎn)良好,多虧了省下的空間,他們現(xiàn)在有足夠多的地方來嘗試制作新的糖果了。鑒于之前的成功,管理者迫切的想要發(fā)明新的棒棒的組合版機器,從而制造越來越多種類的糖果。
但工廠的工程師們跟不上老板的節(jié)奏,因為每次造一臺新的棒棒的組合版機器,他們就要花費很多的時間來造新的外殼,從而適應(yīng)那些獨立的機器。
所以工程師們聯(lián)系了一家工業(yè)機器制供應(yīng)商來幫他們。他們很驚訝的發(fā)現(xiàn)這家供應(yīng)商竟然提供 機器制造 器!聽起來好像不可思議,他們買入了一臺這樣的機器,這臺機器能夠?qū)⒐S中小一點的機器 ———— 比如負(fù)責(zé)巧克力冷卻、切割的機器 ———— 自動連線,甚至在外面還自動包了一個干凈的大盒子。這么牛的機器簡直能把這家糖果工廠送上天了!
回到代碼上,讓我們定義一個實用函數(shù)叫做 compose2(..),它能夠自動創(chuàng)建兩個函數(shù)的組合,這和我們手動做的是一模一樣的。
function compose2(fn2,fn1) { return function composed(origValue){ return fn2( fn1( origValue ) ); }; } // ES6 箭頭函數(shù)形式寫法 var compose2 = (fn2,fn1) => origValue => fn2( fn1( origValue ) );
你是否注意到我們定義參數(shù)的順序是 fn2,fn1,不僅如此,參數(shù)中列出的第二個函數(shù)(也被稱作 fn1)會首先運行,然后才是參數(shù)中的第一個函數(shù)(fn2)?換句話說,這些函數(shù)是以從右往左的順序組合的。
這看起來是種奇怪的實現(xiàn),但這是有原因的。大部分傳統(tǒng)的 FP 庫為了順序而將它們的 compose(..) 定義為從右往左的工作,所以我們沿襲了這種慣例。
但是為什么這么做?我認(rèn)為最簡單的解釋(但不一定符合真實的歷史)就是我們在以手動執(zhí)行的書寫順序來列出它們時,或是與我們從左往右閱讀這個列表時看到它們的順序相符合。
unique(words(str)) 以從左往右的順序列出了 unique, words 函數(shù),所以我們讓 compose2(..) 實用函數(shù)也以這種順序接收它們。現(xiàn)在,更高效的糖果制造機定義如下:
var uniqueWords = compose2( unique, words );組合的變體
看起來貌似 <-- unique <-- words 的組合方式是這兩種函數(shù)能夠被組合起來的唯一順序。但我們實際上能夠以另外的目的創(chuàng)建一個實用函數(shù),將它們以相反的順序組合起來。
var letters = compose2( words, unique ); var chars = letters( "How are you Henry?" ); chars; // ["h","o","w","a","r","e","y","u","n"]
因為 words(..) 實用函數(shù),上面的代碼才能正常工作。為了值類型的安全,首先使用 String(..) 將它的輸入強轉(zhuǎn)為一個字符串。所以 unique(..) 返回的數(shù)組 -- 現(xiàn)在是 words(..) 的輸入 -- 成為了 "H,o,w, ,a,r,e,y,u,n,?" 這樣的字符串。然后 words(..) 中的行為將字符串處理成為 chars 數(shù)組。
不得不承認(rèn),這是個刻意的例子。但重點是,函數(shù)的組合不總是單向的。有時候我們將灰方塊放到藍(lán)方塊上,有時我們又會將藍(lán)方塊放到最上面。
假如糖果工廠嘗試將包裝好的糖果放入攪拌和冷卻巧克力的機器,那他們最好要小心點了。
通用組合如果我們能夠定義兩個函數(shù)的組合,我們也同樣能夠支持組合任意數(shù)量的函數(shù)。任意數(shù)目函數(shù)的組合的通用可視化數(shù)據(jù)流如下:
finalValue <-- func1 <-- func2 <-- ... <-- funcN <-- origValue
現(xiàn)在糖果工廠擁有了最好的制造機:它能夠接收任意數(shù)量獨立的小機器,并吐出一個大只的、超贊的機器,能把每一步都按照順序做好。這個糖果制作流程簡直棒呆了!簡直是威利·旺卡(譯者注:《查理和巧克力工廠》中的人物,他擁有一座巧克力工廠)的夢想!
我們能夠像這樣實現(xiàn)一個通用 compose(..) 實用函數(shù):
function compose(...fns) { return function composed(result){ // 拷貝一份保存函數(shù)的數(shù)組 var list = fns.slice(); while (list.length > 0) { // 將最后一個函數(shù)從列表尾部拿出 // 并執(zhí)行它 result = list.pop()( result ); } return result; }; } // ES6 箭頭函數(shù)形式寫法 var compose = (...fns) => result => { var list = fns.slice(); while (list.length > 0) { // 將最后一個函數(shù)從列表尾部拿出 // 并執(zhí)行它 result = list.pop()( result ); } return result; };
現(xiàn)在看一下組合超過兩個函數(shù)的例子?;叵胂挛覀兊?uniqueWords(..) 組合例子,讓我們增加一個 skipShortWords(..)。
function skipShortWords(list) { var filteredList = []; for (let i = 0; i < list.length; i++) { if (list[i].length > 4) { filteredList.push( list[i] ); } } return filteredList; }
讓我們再定義一個 biggerWords(..) 來包含 skipShortWords(..)。我們期望等價的手工組合方式是 skipShortWords(unique(words(text))),所以讓我們采用 compose(..) 來實現(xiàn)它:
var text = "To compose two functions together, pass the output of the first function call as the input of the second function call."; var biggerWords = compose( skipShortWords, unique, words ); var wordsUsed = biggerWords( text ); wordsUsed; // ["compose","functions","together","output","first", // "function","input","second"]
現(xiàn)在,讓我們回憶一下第 3 章中出現(xiàn)的 partialRight(..) 來讓組合變的更有趣。我們能夠構(gòu)造一個由 compose(..) 自身組成的右偏函數(shù)應(yīng)用,通過提前定義好第二和第三參數(shù)(unique(..) 和 words(..));我們把它稱作 filterWords(..)(如下)。
然后,我們能夠通過多次調(diào)用 filterWords(..) 來完成組合,但是每次的第一參數(shù)卻各不相同。
// 注意: 使用 a <= 4 來檢查,而不是 skipShortWords(..) 中用到的 > 4 function skipLongWords(list) { /* .. */ } var filterWords = partialRight( compose, unique, words ); var biggerWords = filterWords( skipShortWords ); var shorterWords = filterWords( skipLongWords ); biggerWords( text ); // ["compose","functions","together","output","first", // "function","input","second"] shorterWords( text ); // ["to","two","pass","the","of","call","as"]
花些時間考慮一下基于 compose(..) 的右偏函數(shù)應(yīng)用給了我們什么。它允許我們在組合的第一步之前做指定,然后以不同后期步驟 (biggerWords(..) and shorterWords(..)) 的組合來創(chuàng)建特定的變體。這是函數(shù)式編程中最強大的手段之一。
你也能通過 curry(..) 創(chuàng)建的組合來替代偏函數(shù)應(yīng)用,但因為從右往左的順序,比起只使用 curry( compose, ..),你可能更想使用 curry( reverseArgs(compose), ..)。
注意: 因為 curry(..)(至少我們在第 3 章中實現(xiàn)的是這樣)依賴于探測參數(shù)數(shù)目(length)或手動指定其數(shù)目,而 compose(..) 是一個可變的函數(shù),所以你需要手動指定數(shù)目,就像這樣 curry(.. , 3)。
不同的實現(xiàn)當(dāng)然,你可能永遠(yuǎn)不會在生產(chǎn)中使用自己寫的 compose(..),而更傾向于使用某個庫所提供的方案。但我發(fā)現(xiàn)了解底層工作的原理實際上對強化理解函數(shù)式編程中通用概念非常有用。
所以讓我們看看對于 compose(..) 的不同實現(xiàn)方案。我們能看到每一種實現(xiàn)的優(yōu)缺點,特別是性能方面。
我們將稍后在文中查看 reduce(..) 實用函數(shù)的細(xì)節(jié),但現(xiàn)在,只需了解它將一個列表(數(shù)組)簡化為一個單一的有限值??雌饋硐袷且粋€很棒的循環(huán)體。
舉個例子,如果在數(shù)字列表 [1,2,3,4,5,6] 上做加法約減,你將要循環(huán)它們,并且隨著循環(huán)將它們加在一起。這一過程將首先將 1 加 2,然后將結(jié)果加 3,然后加 4,等等。最后得到總和:21。
原始版本的 compose(..) 使用一個循環(huán)并且饑渴的(也就是,立刻)執(zhí)行計算,將一個調(diào)用的結(jié)果傳遞到下一個調(diào)用。我們可以通過 reduce(..) (代替循環(huán))做到同樣的事。
function compose(...fns) { return function composed(result){ return fns.reverse().reduce( function reducer(result,fn){ return fn( result ); }, result ); }; } // ES6 箭頭函數(shù)形式寫法 var compose = (...fns) => result => fns.reverse().reduce( (result,fn) => fn( result ) , result );
注意到 reduce(..) 循環(huán)發(fā)生在最后的 composed(..) 運行時,并且每一個中間的 result(..) 將會在下一次調(diào)用時作為輸入值傳遞給下一個迭代。
這種實現(xiàn)的優(yōu)點就是代碼更簡練,并且使用了常見的函數(shù)式編程結(jié)構(gòu):reduce(..)。這種實現(xiàn)方式的性能和原始的 for 循環(huán)版本很相近。
但是,這種實現(xiàn)局限處在于外層的組合函數(shù)(也就是,組合中的第一個函數(shù))只能接收一個參數(shù)。其他大多數(shù)實現(xiàn)在首次調(diào)用的時候就把所有參數(shù)傳進去了。如果組合中的每一個函數(shù)都是一元的,這個方案沒啥大問題。但如果你需要給第一個調(diào)用傳遞多參數(shù),那么你可能需要不同的實現(xiàn)方案。
為了修正第一次調(diào)用的單參數(shù)限制,我們可以仍使用 reduce(..) ,但加一個懶執(zhí)行函數(shù)包裹器:
function compose(...fns) { return fns.reverse().reduce( function reducer(fn1,fn2){ return function composed(...args){ return fn2( fn1( ...args ) ); }; } ); } // ES6 箭頭函數(shù)形式寫法 var compose = (...fns) => fns.reverse().reduce( (fn1,fn2) => (...args) => fn2( fn1( ...args ) ) );
注意到我們直接返回了 reduce(..) 調(diào)用的結(jié)果,該結(jié)果自身就是個函數(shù),不是一個計算過的值。該函數(shù)讓我們能夠傳入任意數(shù)目的參數(shù),在整個組合過程中,將這些參數(shù)傳入到第一個函數(shù)調(diào)用中,然后依次產(chǎn)出結(jié)果給到后面的調(diào)用。
相較于直接計算結(jié)果并把它傳入到 reduce(..) 循環(huán)中進行處理,這種實現(xiàn)通過在組合之前只運行 一次 reduce(..) 循環(huán),然后將所有的函數(shù)調(diào)用運算全部延遲了 ———— 稱為惰性運算。每一個簡化后的局部結(jié)果都是一個包裹層級更多的函數(shù)。
當(dāng)你調(diào)用最終組合函數(shù)并且提供一個或多個參數(shù)的時候,這個層層嵌套的大函數(shù)內(nèi)部的所有層級,由內(nèi)而外調(diào)用,以相反的方式連續(xù)執(zhí)行(不是通過循環(huán))。
這個版本的性能特征和之前 reduce(..) 基礎(chǔ)實現(xiàn)版有潛在的差異。在這兒,reduce(..) 只在生成大個的組合函數(shù)時運行過一次,然后這個組合函數(shù)只是簡單的一層層執(zhí)行它內(nèi)部所嵌套的函數(shù)。在前一版本中,reduce(..) 將在每一次調(diào)用中運行。
在考慮哪一種實現(xiàn)更好時,你的情況可能會不一樣,但是要記得后面的實現(xiàn)方式并沒有像前一種限制只能傳一個參數(shù)。
我們也能夠使用遞歸來定義 compose(..)。遞歸式定義的 compose(fn1,fn2, .. fnN) 看起來會是這樣:
compose( compose(fn1,fn2, .. fnN-1), fnN );
注意: 我們將在第 9 章揭示更多的細(xì)節(jié),所以如果這塊看起來讓你疑惑,那么暫時跳過該部分是沒問題的,你可以在閱讀完第 9 章后再來看。
這里是我們用遞歸實現(xiàn) compose(..) 的代碼:
function compose(...fns) { // 拿出最后兩個參數(shù) var [ fn1, fn2, ...rest ] = fns.reverse(); var composedFn = function composed(...args){ return fn2( fn1( ...args ) ); }; if (rest.length == 0) return composedFn; return compose( ...rest.reverse(), composedFn ); } // ES6 箭頭函數(shù)形式寫法 var compose = (...fns) => { // 拿出最后兩個參數(shù) var [ fn1, fn2, ...rest ] = fns.reverse(); var composedFn = (...args) => fn2( fn1( ...args ) ); if (rest.length == 0) return composedFn; return compose( ...rest.reverse(), composedFn ); };
我認(rèn)為遞歸實現(xiàn)的好處是更加概念化。我個人覺得相較于不得不在循環(huán)里跟蹤運行結(jié)果,通過遞歸的方式進行重復(fù)的動作反而更易懂。所以我更喜歡以這種方式的代碼來表達。
其他人可能會覺得遞歸的方法在智力上造成的困擾更讓人有些畏懼。我建議你作出自己的評估。
重排序組合我們早期談及的是從右往左順序的標(biāo)準(zhǔn) compose(..) 實現(xiàn)。這么做的好處是能夠和手工組合列出參數(shù)(函數(shù))的順序保持一致。
不足之處就是它們排列的順序和它們執(zhí)行的順序是相反的,這將會造成困擾。同時,不得不使用 partialRight(compose, ..) 提早定義要在組合過程中 第一個 執(zhí)行的函數(shù)。
相反的順序,從右往左的組合,有個常見的名字:pipe(..)。這個名字據(jù)說來自 Unix/Linux 界,那里大量的程序通過“管道傳輸”(| 運算符)第一個的輸出到第二個的輸入,等等(即,ls -la | grep "foo" | less)。
pipe(..) 與 compose(..) 一模一樣,除了它將列表中的函數(shù)從左往右處理。
function pipe(...fns) { return function piped(result){ var list = fns.slice(); while (list.length > 0) { // 從列表中取第一個函數(shù)并執(zhí)行 result = list.shift()( result ); } return result; }; }
實際上,我們只需將 compose(..) 的參數(shù)反轉(zhuǎn)就能定義出來一個 pipe(..)。
var pipe = reverseArgs( compose );
非常簡單!
回憶下之前的通用組合的例子:
var biggerWords = compose( skipShortWords, unique, words );
以 pipe(..) 的方式來實現(xiàn),我們只需要反轉(zhuǎn)參數(shù)的順序:
var biggerWords = pipe( words, unique, skipShortWords );
pipe(..) 的優(yōu)勢在于它以函數(shù)執(zhí)行的順序排列參數(shù),某些情況下能夠減輕閱讀者的疑惑。pipe(words,unique,skipShortWords) 看起來和讀起來會更簡單,能知道我們首先執(zhí)行 words(..),然后 unique(..),最后是 skipShortWords(..)。
假如你想要部分的應(yīng)用第一個函數(shù)(們)來負(fù)責(zé)執(zhí)行,pipe(..) 同樣也很方便。就像我們之前使用 compose(..) 構(gòu)建的右偏函數(shù)應(yīng)用一樣。
對比:
var filterWords = partialRight( compose, unique, words ); // vs var filterWords = partial( pipe, words, unique );
你可能會回想起第 3 章 partialRight(..) 中的定義,它實際使用了 reverseArgs(..),就像我們的 pipe(..) 現(xiàn)在所做的。所以,不管怎樣,我們得到了同樣的結(jié)果。
在這一特定場景下使用 pipe(..) 的輕微性能優(yōu)勢在于我們不必再通過右偏函數(shù)應(yīng)用的方式來使用 compose(..) 保存從右往左的參數(shù)順序,使用
pipe(..) 我們不必再跟 partialRight(..) 一樣需要將參數(shù)順序反轉(zhuǎn)回去。所以在這里 partial(pipe, ..) 比 partialRight(compose, ..) 要好一點。
一般來說,在使用一個完善的函數(shù)式編程庫時,pipe(..) 和 compose(..) 沒有明顯的性能區(qū)別。
抽象抽象經(jīng)常被定義為對兩個或多個任務(wù)公共部分的剝離。通用部分只定義一次,從而避免重復(fù)。為了展現(xiàn)每個任務(wù)的特殊部分,通用部分需要被參數(shù)化。
舉個例子,思考如下(明顯刻意生成的)代碼:
function saveComment(txt) { if (txt != "") { comments[comments.length] = txt; } } function trackEvent(evt) { if (evt.name !== undefined) { events[evt.name] = evt; } }
這兩個實用函數(shù)都是將一個值存入一個數(shù)據(jù)源,這是通用的部分。不同的是一個是將值放置到數(shù)組的末尾,另一個是將值放置到對象的某個屬性上。
讓我們抽象一下:
function storeData(store,location,value) { store[location] = value; } function saveComment(txt) { if (txt != "") { storeData( comments, comments.length, txt ); } } function trackEvent(evt) { if (evt.name !== undefined) { storeData( events, evt.name, evt ); } }
引用一個對象(或數(shù)組,多虧了 JS 中方便的 [] 符號)屬性和將值設(shè)入的通用任務(wù)被抽象到獨立的 storeData(..) 函數(shù)。這個函數(shù)當(dāng)前只有一行代碼,該函數(shù)能提出其它多任務(wù)中通用的行為,比如生成唯一的數(shù)字 ID 或?qū)r間戳存入。
如果我們在多處重復(fù)通用的行為,我們將會面臨改了幾處但忘了改別處的維護風(fēng)險。在做這類抽象時,有一個原則是,通常被稱作 DRY(don"t repeat yourself)。
DRY 力求能在程序的任何任務(wù)中有唯一的定義。代碼不夠 DRY 的另一個托辭就是程序員們太懶,不想做非必要的工作。
抽象能夠走得更遠(yuǎn)。思考:
function conditionallyStoreData(store,location,value,checkFn) { if (checkFn( value, store, location )) { store[location] = value; } } function notEmpty(val) { return val != ""; } function isUndefined(val) { return val === undefined; } function isPropUndefined(val,obj,prop) { return isUndefined( obj[prop] ); } function saveComment(txt) { conditionallyStoreData( comments, comments.length, txt, notEmpty ); } function trackEvent(evt) { conditionallyStoreData( events, evt.name, evt, isPropUndefined ); }
為了實現(xiàn) DRY 和避免重復(fù)的 if 語句,我們將條件判斷移動到了通用抽象中。我們同樣假設(shè)在程序中其它地方可能會檢查非空字符串或非 undefined 的值,所以我們也能將這些東西 DRY 出來。
這些代碼現(xiàn)在變得更 DRY 了,但有些抽象過度了。開發(fā)者需要對他們程序中每個部分使用恰當(dāng)?shù)某橄蠹墑e保持謹(jǐn)慎,不能太過,也不能不夠。
關(guān)于我們在本章中對函數(shù)的組合進行的大量討論,看起來它的好處是實現(xiàn)這種 DRY 抽象。但讓我們別急著下結(jié)論,因為我認(rèn)為組合實際上在我們的代碼中發(fā)揮著更重要的作用。
而且,即使某些東西只出現(xiàn)了一次,組合仍然十分有用 (沒有重復(fù)的東西可以被抽出來)。
除了通用化和特殊化的對比,我認(rèn)為抽象有更多有用的定義,正如下面這段引用所說:
... 抽象是一個過程,程序員將一個名字與潛在的復(fù)雜程序片段關(guān)聯(lián)起來,這樣該名字就能夠被認(rèn)為代表函數(shù)的目的,而不是代表函數(shù)如何實現(xiàn)的。通過隱藏?zé)o關(guān)的細(xì)節(jié),抽象降低了概念復(fù)雜度,讓程序員在任意時間都可以集中注意力在程序內(nèi)容中的可維護子集上。
《程序設(shè)計語言》, 邁克爾 L 斯科特
https://books.google.com/book...
// TODO: 給這本書或引用弄一個更好的參照,至少找到一個更好的在線鏈接
這段引用表述的觀點是抽象 ———— 通常來說,是指把一些代碼片段放到自己的函數(shù)中 ———— 是圍繞著能將兩部分功能分離,從而達到可以專注于某一獨立的部分為主要目的來服務(wù)的。
需要注意的是,這種場景下的抽象并不是為了隱藏細(xì)節(jié),比如把一些東西當(dāng)作黑盒來對待。這一觀念其實更貼近于編程中的封裝性原則。我們不是為了隱藏細(xì)節(jié)而抽象,而是為了通過分離來突出關(guān)注點。
還記得這段文章的開頭,我說函數(shù)式編程的目的是為了創(chuàng)造更可讀、更易理解的代碼。一個有效的方法是將交織纏繞的 ———— 緊緊編織在一起,像一股繩子 ———— 代碼解綁為分離的、更簡單的 ———— 松散綁定的 ———— 代碼片段。以這種方式來做的話,代碼的閱讀者將不會在尋找其它部分細(xì)節(jié)的時候被其中某塊的細(xì)節(jié)所分心。
我們更高的目標(biāo)是不只對某些東西實現(xiàn)一次,這是 DRY 的觀念。實際上,有些時候我們確實在代碼中不斷重復(fù)。于是,我們尋求更分離的實現(xiàn)方式。我們嘗試突出關(guān)注點,因為這能提高可讀性。
另一種描述這個目標(biāo)的方式就是 ———— 通過命令式 vs 聲明式的編程風(fēng)格。命令式代碼主要關(guān)心的是描述怎么做來準(zhǔn)確完成一項任務(wù)。聲明式代碼則是描述輸出應(yīng)該是什么,并將具體實現(xiàn)交給其它部分。
換句話說,聲明式代碼從怎么做中抽象出了是什么。盡管普通的聲明式代碼在可讀性上強于命令式,但沒有程序(除了機器碼 1 和 0)是完全的聲明式或者命令式代碼。編程者必須在它們之間尋找平衡。
ES6 增加了很多語法功能,能將老的命令式操作轉(zhuǎn)換為新的聲明式形式??赡茏钋逦漠?dāng)屬解構(gòu)了。解構(gòu)是一種賦值模式,它描述了如何將組合值(對象、數(shù)組)內(nèi)的構(gòu)成值分解出來的方法。
這里是一個數(shù)組解構(gòu)的例子:
function getData() { return [1,2,3,4,5]; } // 命令式 var tmp = getData(); var a = tmp[0]; var b = tmp[3]; // 聲明式 var [ a ,,, b ] = getData();
是什么就是將數(shù)組中的第一個值賦給 a,然后第四個值賦給 b。怎么做就是得到一個數(shù)組的引用(tmp)然后手動的通過數(shù)組索引 0 和 3,分別賦值給 a 和 b。
數(shù)組的解構(gòu)是否隱藏了賦值細(xì)節(jié)?這要看你看待的角度了。我認(rèn)為它知識簡單的將是什么從怎么做中分離出來。JS 引擎仍然做了賦值的工作,但它阻止了你自己去抽象怎么做的過程。
相反的是,你閱讀 [ a ,,, b ] = .. 的時候,便能看到該賦值模式只不過是告訴你將要發(fā)生的是什么。數(shù)組的解構(gòu)是聲明式抽象的一個例子。
將組合當(dāng)作抽象函數(shù)組合到底做了什么?函數(shù)組合同樣也是一種聲明式抽象。
回想下之前的 shorterWords(..) 例子。讓我們對比下命令式和聲明式的定義。
// 命令式 function shorterWords(text) { return skipLongWords( unique( words( text ) ) ); } // 聲明式 var shorterWords = compose( skipLongWords, unique, words );
聲明式關(guān)注點在是什么上 -- 這 3 個函數(shù)傳遞的數(shù)據(jù)從一個字符串到一系列更短的單詞 -- 并且將怎么做留在了 compose(..)的內(nèi)部。
在一個更大的層面上看,shorterWords = compose(..) 行解釋了怎么做來定義一個 shorterWords(..) 實用函數(shù),這樣在代碼的別處使用時,只需關(guān)注下面這行聲明式的代碼輸出是什么。
shorterWords( text );
組合將一步步得到一系列更短的單詞的過程抽象了出來。
相反的看,如果我們不使用組合抽象呢?
var wordsFound = words( text ); var uniqueWordsFound = unique( wordsFound ); skipLongWords( uniqueWordsFound );
或者這種:
skipLongWords( unique( words( text ) ) );
這兩個版本展示的都是一種更加命令式的風(fēng)格,違背了聲明式風(fēng)格優(yōu)先原則。閱讀者關(guān)注這兩個代碼片段時,會被更多的要求了解怎么做而不是是什么。
函數(shù)組合并不是通過 DRY 的原則來節(jié)省代碼量。即使 shorterWords(..) 的使用只出現(xiàn)了一次 -- 所以并沒有重復(fù)問題需要避免!-- 從怎么做中分離出是什么仍能幫助我們提升代碼。
組合是一個抽象的強力工具,它能夠?qū)⒚钍酱a抽象為更可讀的聲明式代碼。
回顧形參既然我們已經(jīng)把組合都了解了一遍 -- 那么是時候拋出函數(shù)式編程中很多地方都有用的小技巧了 -- 讓我們通過在某個場景下回顧第 3 章的“無形參”(譯者注:“無形參”指的是移除對函數(shù)形參的引用)段落中的 point-free 代碼,并把它重構(gòu)的稍微復(fù)雜點來觀察這種小技巧。
// 提供該API:ajax( url, data, cb ) var getPerson = partial( ajax, "http://some.api/person" ); var getLastOrder = partial( ajax, "http://some.api/order", { id: -1 } ); getLastOrder( function orderFound(order){ getPerson( { id: order.personId }, function personFound(person){ output( person.name ); } ); } );
我們想要移除的“點”是對 order 和 person 參數(shù)的引用。
讓我們嘗試將 person 形參移出 personFound(..) 函數(shù)。要達到目的,我們需要首先定義:
function extractName(person) { return person.name; }
但據(jù)我們觀察這段操作能夠表達的更通用些:將任意對象的任意屬性通過屬性名提取出來。讓我們把這個實用函數(shù)稱為 prop(..):
function prop(name,obj) { return obj[name]; } // ES6 箭頭函數(shù)形式 var prop = (name,obj) => obj[name];
我們處理對象屬性的時候,也需要定義下反操作的工具函數(shù):setProp(..),為了將屬性值設(shè)到某個對象上。
但是,我們想小心一些,不改動現(xiàn)存的對象,而是創(chuàng)建一個攜帶變化的復(fù)制對象,并將它返回出去。這樣處理的原因?qū)⒃诘?5 章中討論更多細(xì)節(jié)。
function setProp(name,obj,val) { var o = Object.assign( {}, obj ); o[name] = val; return o; }
現(xiàn)在,定義一個 extractName(..) ,它能將對象中的 "name" 屬性拿出來,我們將部分應(yīng)用 prop(..):
var extractName = partial( prop, "name" );
注意: 不要誤解這里的 extractName(..),它其實什么都還沒有做。我們只是部分應(yīng)用 prop(..) 來創(chuàng)建了一個等待接收包含 "name"屬性的對象的函數(shù)。我們也能通過curry(prop)("name")做到一樣的事。
下一步,讓我們縮小關(guān)注點,看下例子中嵌套的這塊查找操作的調(diào)用:
getLastOrder( function orderFound(order){ getPerson( { id: order.personId }, outputPersonName ); } );
我們該如何定義 outputPersonName(..)?為了方便形象化我們所需要的東西,想一下我們需要的數(shù)據(jù)流是什么樣:
output <-- extractName <-- person
outputPersonName(..) 需要是一個接收(對象)值的函數(shù),并將它傳遞給 extractName(..),然后將處理后的值傳給 output(..)。
希望你能看出這里需要 compose(..) 操作。所以我們能夠?qū)?outputPersonName(..) 定義為:
var outputPersonName = compose( output, extractName );
我們剛剛創(chuàng)建的 outputPersonName(..) 函數(shù)是提供給 getPerson(..) 的回調(diào)。所以我們還能定義一個函數(shù)叫做 processPerson(..) 來處理回調(diào)參數(shù),使用 partialRight(..):
var processPerson = partialRight( getPerson, outputPersonName );
讓我們用新函數(shù)來重構(gòu)下之前的代碼:
getLastOrder( function orderFound(order){ processPerson( { id: order.personId } ); } );
唔,進展還不錯!
但我們需要繼續(xù)移除掉 order 這個“形參”。下一步是觀察 personId 能夠被 prop(..) 從一個對象(比如 order)中提取出來,就像我們在 person 對象中提取 name 一樣。
var extractPersonId = partial( prop, "personId" );
為了創(chuàng)建傳遞給 processPerson(..) 的對象( { id: .. } 的形式),讓我們創(chuàng)建一個實用函數(shù) makeObjProp(..),用來以特定的屬性名將值包裝為一個對象。
function makeObjProp(name,value) { return setProp( name, {}, value ); } // ES6 箭頭函數(shù)形式 var makeObjProp = (name,value) => setProp( name, {}, value );
提示: 這個實用函數(shù)在 Ramda 庫中被稱為 objOf(..)。
就像我們之前使用 prop(..) 來創(chuàng)建 extractName(..),我們將部分應(yīng)用 makeObjProp(..) 來創(chuàng)建 personData(..) 函數(shù)用來制作我們的數(shù)據(jù)對象。
var personData = partial( makeObjProp, "id" );
為了使用 processPerson(..) 來完成通過 order 值查找一個人的功能,我們需要的數(shù)據(jù)流如下:
processPerson <-- personData <-- extractPersonId <-- order
所以我們只需要再使用一次 compose(..) 來定義一個 lookupPerson(..) :
var lookupPerson = compose( processPerson, personData, extractPersonId );
然后,就是這樣了!把這整個例子重新組合起來,不帶任何的“形參”:
var getPerson = partial( ajax, "http://some.api/person" ); var getLastOrder = partial( ajax, "http://some.api/order", { id: -1 } ); var extractName = partial( prop, "name" ); var outputPersonName = compose( output, extractName ); var processPerson = partialRight( getPerson, outputPersonName ); var personData = partial( makeObjProp, "id" ); var extractPersonId = partial( prop, "personId" ); var lookupPerson = compose( processPerson, personData, extractPersonId ); getLastOrder( lookupPerson );
哇哦。沒有形參。并且 compose(..) 在兩處地方看起來相當(dāng)有用!
我認(rèn)為在這樣的場景下,即使推導(dǎo)出我們最終答案的步驟有些多,但最終的代碼卻變得更加可讀,因為我們不用再去詳細(xì)的調(diào)用每一步了。
即使你不想看到或命名這么多中間步驟,你依然可以通過不使用獨立變量而是將表達式串起來來來保留無點特性。
partial( ajax, "http://some.api/order", { id: -1 } ) ( compose( partialRight( partial( ajax, "http://some.api/person" ), compose( output, partial( prop, "name" ) ) ), partial( makeObjProp, "id" ), partial( prop, "personId" ) ) );
這段代碼肯定沒那么羅嗦了,但我認(rèn)為比之前的每個操作都有其對應(yīng)的變量相比,可讀性略有降低。但是不管怎樣,組合幫助我們實現(xiàn)了無點的風(fēng)格。
總結(jié)函數(shù)組合是一種定義函數(shù)的模式,它能將一個函數(shù)調(diào)用的輸出路由到另一個函數(shù)的調(diào)用上,然后一直進行下去。
因為 JS 函數(shù)只能返回單個值,這個模式本質(zhì)上要求所有組合中的函數(shù)(可能第一個調(diào)用的函數(shù)除外)是一元的,當(dāng)前函數(shù)從上一個函數(shù)輸出中只接收一個輸入。
相較于在我們的代碼里詳細(xì)列出每個調(diào)用,函數(shù)組合使用 compose(..) 實用函數(shù)來提取出實現(xiàn)細(xì)節(jié),讓代碼變得更可讀,讓我們更關(guān)注組合完成的是什么,而不是它具體做什么。
組合 ———— 聲明式數(shù)據(jù)流 ———— 是支撐函數(shù)式編程其他特性的最重要的工具之一。
【上一章】翻譯連載 | JavaScript 輕量級函數(shù)式編程-第3章:管理函數(shù)的輸入 |《你不知道的JS》姊妹篇
【下一章】翻譯連載 | JavaScript輕量級函數(shù)式編程-第5章:減少副作用 |《你不知道的JS》姊妹篇
iKcamp原創(chuàng)新書《移動Web前端高效開發(fā)實戰(zhàn)》已在亞馬遜、京東、當(dāng)當(dāng)開售。
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://systransis.cn/yun/85185.html
摘要:我稱之為輕量級函數(shù)式編程。序眾所周知,我是一個函數(shù)式編程迷。函數(shù)式編程有很多種定義。本書是你開啟函數(shù)式編程旅途的絕佳起點。事實上,已經(jīng)有很多從頭到尾正確的方式介紹函數(shù)式編程的書了。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson?。 禮ou-Dont-Know-JS》作者 譯者團隊(排名不分先后):阿希、blueken、brucecham、...
摘要:所以我覺得函數(shù)式編程領(lǐng)域更像學(xué)者的領(lǐng)域。函數(shù)式編程的原則是完善的,經(jīng)過了深入的研究和審查,并且可以被驗證。函數(shù)式編程是編寫可讀代碼的最有效工具之一可能還有其他。我知道很多函數(shù)式編程編程者會認(rèn)為形式主義本身有助于學(xué)習(xí)。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson?。 禮ou-Dont-Know-JS》作者 關(guān)于譯者:這是一個流淌著滬江血液...
摘要:一旦我們滿足了基本條件值為,我們將不再調(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 最堅實的梁柱;...
摘要:從某些方面來講,這章回顧的函數(shù)知識并不是針對函數(shù)式編程者,非函數(shù)式編程者同樣需要了解。什么是函數(shù)針對函數(shù)式編程,很自然而然的我會想到從函數(shù)開始。如果你計劃使用函數(shù)式編程,你應(yīng)該盡可能多地使用函數(shù),而不是程序。指的是一個函數(shù)聲明的形參數(shù)量。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson?。 禮ou-Dont-Know-JS》作者 關(guān)于譯者:...
摘要:本書主要探索函數(shù)式編程的核心思想。我們在中應(yīng)用的僅僅是一套基本的函數(shù)式編程概念的子集。我稱之為輕量級函數(shù)式編程。通常來說,關(guān)于函數(shù)式編程的書籍都熱衷于拓展閱讀者的知識面,并企圖覆蓋更多的知識點。,本書統(tǒng)稱為函數(shù)式編程者。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson?。 禮ou-Dont-Know-JS》作者 譯者團隊(排名不分先后)...
閱讀 2457·2021-10-09 09:59
閱讀 2228·2021-09-23 11:30
閱讀 2621·2019-08-30 15:56
閱讀 1173·2019-08-30 14:00
閱讀 2970·2019-08-29 12:37
閱讀 1293·2019-08-28 18:16
閱讀 1683·2019-08-27 10:56
閱讀 1050·2019-08-26 17:23