Google 資料中心管道 — Jorge Jorquera — (CC-BY-NC-ND-2.0)
“征服 JavaScript 面試”是我寫的一系列文章,來幫助面試者準備他們在面試 JavaScript 中、高階職位中將可能會遇到的一些問題。這些問題我自己在面試中也經常會問。
函數語言程式設計正在接管 JavaScript 世界。就在幾年前,只有少數 JavaScript 程式設計師知道函數語言程式設計是什麼。然而,在過去 3 年內,我所看到的每個大型應用程式程式碼庫都大量用到了函數語言程式設計理念。
函式組合就是組合兩到多個函式來生成一個新函式的過程。將函式組合在一起,就像將一連串管道扣合在一起,讓資料流過一樣。
簡而言之,函式 f
和 g
的組合可以被定義為 f(g(x))
,從內到外(從右到左)求值。也就是說,求值順序是:
x
g
f
下面我們在程式碼中更近距離觀察一下這個概念。假如你想把使用者的全名轉換為 URL Slug,給每個使用者一個個人資訊頁面。為了實現此需求,你需要經歷一連串的步驟:
- 將姓名根據空格分拆(split)到一個陣列中
- 將姓名對映(map)為小寫
- 用破折號連線(join)
- 編碼 URI 元件
如下是一個簡單的實現:
1 2 3 4 5 |
const toSlug = input => encodeURIComponent( input.split(' ') .map(str => str.toLowerCase()) .join('-') ); |
還不賴…但是假如我告訴你可讀性還可以更強一點會怎麼樣呢?
假設每個操作都有一個對應的可組合的函式。上述程式碼就可以被寫為:
1 2 3 4 5 6 7 8 9 10 11 |
const toSlug = input => encodeURIComponent( join('-')( map(toLowerCase)( split(' ')( input ) ) ) ); console.log(toSlug('JS Cheerleader')); // 'js-cheerleader' |
這看起來比我們的第一次嘗試更難讀懂,但是先忍一下,我們就要解決。
為了實現上述程式碼,我們將組合幾種常用的工具,比如 split()
、join()
和 map()
。如下是實現:
1 2 3 4 5 6 7 8 9 |
const curry = fn => (...args) => fn.bind(null, ...args); const map = curry((fn, arr) => arr.map(fn)); const join = curry((str, arr) => arr.join(str)); const toLowerCase = str => str.toLowerCase(); const split = curry((splitOn, str) => str.split(splitOn)); |
除了 toLowerCase()
外,所有這些函式經產品測試的版本都可以從 Lodash/fp 中得到。可以像這樣匯入它們:
1 |
import { curry, map, join, split } from 'lodash/fp'; |
也可以像這樣匯入:
1 2 3 |
const curry = require('lodash/fp/curry'); const map = require('lodash/fp/map'); //... |
這裡我偷了點懶。注意這個 curry 從技術上來說,並不是一個真正的柯里化函式。真正的柯里化函式總會生成一個一元函式。這裡的 curry 只是一個偏函式應用。請參考“柯里化和偏函式應用之間的區別是什麼?”這篇文章。不過,這裡只是為了演示用途,我們就把它當作一個真正的柯里化函式好了。
回到我們的 toSlug()
實現,這裡有一些東西真的讓我很煩:
1 2 3 4 5 6 7 8 9 10 11 |
const toSlug = input => encodeURIComponent( join('-')( map(toLowerCase)( split(' ')( input ) ) ) ); console.log(toSlug('JS Cheerleader')); // 'js-cheerleader' |
對我來說,這裡的巢狀太多了,讀起來有點讓人摸不著頭腦。我們可以用一個會自動組合這些函式的函式來扁平化巢狀,就是說,這個函式會從一個函式得到輸出,並自動將它傳遞給下一個函式作為輸入,直到得到最終值為止。
細想一下,好像陣列中有一個函式可以做差不多的事情。這個函式就是 reduce()
,它用一系列值為引數,對每個值應用一個函式,最後累加成一個結果。值本身也可以函式。但是 reduce()
是從左到右遞減,為了匹配上面的組合行為,我們需要它從右到左縮減。
好事情是剛好陣列也有一個 reduceRight()
方法可以幹這事:
1 |
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x); |
像 .reduce()
一樣,陣列的 .reduceRight()
方法帶有一個 reducer 函式和一個初始值(x
)為引數。我們可以用它從右到左迭代陣列,將函式依次應用到每個陣列元素上,最後得到累加值(v
)。
用 compose
,我們就可以不需要巢狀來重寫上面的組合:
1 2 3 4 5 6 7 8 |
const toSlug = compose( encodeURIComponent, join('-'), map(toLowerCase), split(' ') ); console.log(toSlug('JS Cheerleader')); // 'js-cheerleader' |
當然,lodash/fp 也提供了 compose()
:
1 |
import { compose } from 'lodash/fp'; |
或者:
1 |
const compose = require('lodash/fp/compose'); |
當以數學形式的組合從內到外的角度來思考時,compose 是不錯的。不過,如果想以從左到右的順序的角度來思考,又該怎麼辦呢?
還有另外一種形式,通常稱為 pipe()
。Lodash 稱之為 flow()
:
1 2 3 4 5 6 7 8 |
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x); const fn1 = s => s.toLowerCase(); const fn2 = s => s.split('').reverse().join(''); const fn3 = s => s + '!' const newFunc = pipe(fn1, fn2, fn3); const result = newFunc('Time'); // emit! |
可以看到,這個實現與 compose()
幾乎完全一樣。唯一的不同之處是,這裡是用 .reduce()
,而不是 .reduceRight()
,即是從左到右縮減,而不是從右到左。
下面我們來看看用 pipe()
實現的 toSlug()
函式:
1 2 3 4 5 6 7 8 |
const toSlug = pipe( split(' '), map(toLowerCase), join('-'), encodeURIComponent ); console.log(toSlug('JS Cheerleader')); // 'js-cheerleader' |
對於我來說,這要更容易讀懂一些。
骨灰級的函式式程式設計師用函式組合定義他們的整個應用程式。而我經常用它來消除臨時變數。仔細看看 pipe()
版本的 toSlug()
,你會發現一些特殊之處。
在指令式程式設計中,在一些變數上執行轉換時,在轉換的每個步驟中都會找到對變數的引用。而上面的 pipe()
實現是用無點的風格寫的,就是說完全找不到它要操作的引數。
我經常將管道(pipe)用在像單元測試和 Redux 狀態 reducer 這類事情上,用來消除中間變數。中間變數的存在只用來儲存一個操作到下一個操作之間的臨時值。
這玩意開始聽起來會比較古怪,不過隨著你用它練習,會發現在函數語言程式設計中,你是在和相當抽象、廣義的函式打交道,而在這樣的函式中,事物的名稱沒那麼重要。名稱只會礙事。你會開始把變數當作是多餘的樣板。
就是說,我認為無點風格可能會被用過頭。它可能會變得太密集,較難理解。但是如果你搞糊塗了,這裡有一個小竅門…你可以利用 flow 來跟蹤是怎麼回事:
1 2 3 4 |
const trace = curry((label, x) => { console.log(`== ${ label }: ${ x }`); return x; }); |
如下是你用它來跟蹤的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const toSlug = pipe( trace('input'), split(' '), map(toLowerCase), trace('after map'), join('-'), encodeURIComponent ); console.log(toSlug('JS Cheerleader')); // '== input: JS Cheerleader' // '== after map: js,cheerleader' // 'js-cheerleader' |
trace()
只是更通用的 tap()
的一種特殊形式,它可以讓你對流過管道的每個值執行一些行為。明白了麼?管道(Pipe)?水龍頭(Tap)?可以像下面這樣編寫 tap()
:
1 2 3 4 |
const tap = curry((fn, x) => { fn(x); return x; }); |
現在你可以看到為嘛 trace()
只是一個特殊情況下的 tap()
了:
1 2 3 |
const trace = label => { return tap(x => console.log(`== ${ label }: ${ x }`)); }; |
你應該開始對函數語言程式設計是什麼樣子,以及偏函式應用和柯里化如何與函式組合協作,來幫助你編寫可讀性更強的程式有點感覺了。