關於譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的樑柱;分享,是 CSS 裡最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。經過捶打磨練,成就了本書的中文版。本書包含了函數語言程式設計之精髓,希望可以幫助大家在學習函數語言程式設計的道路上走的更順暢。比心。
譯者團隊(排名不分先後):阿希、blueken、brucecham、cfanlife、dail、kyoko-df、l3ve、lilins、LittlePineapple、MatildaJin、冬青、pobusama、Cherry、蘿蔔、vavd317、vivaxy、萌萌、zhouyao
JavaScript輕量級函數語言程式設計
第 4 章:組合函式
到目前為止,我希望你能更輕鬆地理解在函數語言程式設計中使用函式意味著什麼。
一個函數語言程式設計者,會將他們程式中的每一個函式當成一小塊簡單的樂高部件。他們能一眼辨別出藍色的 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|\b/ )
.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 斯科特
// 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前端高效開發實戰》已在亞馬遜、京東、噹噹開售。
2019年,iKcamp原創新書《Koa與Node.js開發實戰》已在京東、天貓、亞馬遜、噹噹開售啦!