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