征服 JavaScript 面試:什么是函數組合
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 組件
如下是一個簡單的實現:
const toSlug = input => encodeURIComponent(
input.split(' ')
.map(str => str.toLowerCase())
.join('-')
);
還不賴...但是假如我告訴你可讀性還可以更強一點會怎么樣呢?
假設每個操作都有一個對應的可組合的函數。上述代碼就可以被寫為:
const toSlug = input => encodeURIComponent(
join('-')(
map(toLowerCase)(
split(' ')(
input
)
)
)
);
console.log(toSlug('JS Cheerleader')); // 'js-cheerleader'
這看起來比我們的第一次嘗試更難讀懂,但是先忍一下,我們就要解決。
為了實現上述代碼,我們將組合幾種常用的工具,比如 split() 、 join() 和 map() 。如下是實現:
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 中得到。可以像這樣導入它們:
import { curry, map, join, split } from 'lodash/fp';
也可以像這樣導入:
const curry = require('lodash/fp/curry');
const map = require('lodash/fp/map');
//...
這里我偷了點懶。注意這個 curry 從技術上來說,并不是一個真正的柯里化函數。真正的柯里化函數總會生成一個一元函數。這里的 curry 只是一個偏函數(partial application)。不過,這里只是為了演示用途,我們就把它當作一個真正的柯里化函數好了。
回到我們的 toSlug() 實現,這里有一些東西真的讓我很煩:
const toSlug = input => encodeURIComponent(
join('-')(
map(toLowerCase)(
split(' ')(
input
)
)
)
);
console.log(toSlug('JS Cheerleader')); // 'js-cheerleader'
對我來說,這里的嵌套太多了,讀起來有點讓人摸不著頭腦。我們可以用一個會自動組合這些函數的函數來扁平化嵌套,就是說,這個函數會從一個函數得到輸出,并自動將它傳遞給下一個函數作為輸入,直到得到最終值為止。
細想一下,好像數組中有一個函數可以做差不多的事情。這個函數就是 reduce() ,它用一系列值為參數,對每個值應用一個函數,最后累加成一個結果。值本身也可以函數。但是 reduce() 是從左到右遞減,為了匹配上面的組合行為,我們需要它從右到左縮減。
好事情是剛好數組也有一個 reduceRight() 方法可以干這事:
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);
像 .reduce() 一樣,數組的 .reduceRight() 方法帶有一個 reducer 函數和一個初始值( x )為參數。我們可以用它從右到左迭代數組,將函數依次應用到每個數組元素上,最后得到累加值( v )。
用 compose ,我們就可以不需要嵌套來重寫上面的組合:
const toSlug = compose(
encodeURIComponent,
join('-'),
map(toLowerCase),
split(' ')
);
console.log(toSlug('JS Cheerleader')); // 'js-cheerleader'
當然,lodash/fp 也提供了 compose() :
import { compose } from 'lodash/fp';
或者:
const compose = require('lodash/fp/compose');
當以數學形式的組合從內到外的角度來思考時,compose 是不錯的。不過,如果想以從左到右的順序的角度來思考,又該怎么辦呢?
還有另外一種形式,通常稱為 pipe() 。Lodash 稱之為 flow() :
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() 函數:
const toSlug = pipe(
split(' '),
map(toLowerCase),
join('-'),
encodeURIComponent
);
console.log(toSlug('JS Cheerleader')); // 'js-cheerleader'
對于我來說,這要更容易讀懂一些。
骨灰級的函數式程序員用函數組合定義他們的整個應用程序。而我經常用它來消除臨時變量。仔細看看 pipe() 版本的 toSlug() ,你會發現一些特殊之處。
在命令式編程中,在一些變量上執行轉換時,在轉換的每個步驟中都會找到對變量的引用。而上面的 pipe() 實現是用 無點 的風格寫的,就是說完全找不到它要操作的參數。
我經常將管道(pipe)用在像單元測試和 Redux 狀態 reducer 這類事情上,用來消除中間變量。中間變量的存在只用來保存一個操作到下一個操作之間的臨時值。
這玩意開始聽起來會比較古怪,不過隨著你用它練習,會發現在函數式編程中,你是在和相當抽象、廣義的函數打交道,而在這樣的函數中,事物的名稱沒那么重要。名稱只會礙事。你會開始把變量當作是多余的樣板。
就是說,我認為無點風格可能會被用過頭。它可能會變得太密集,較難理解。但是如果你搞糊涂了,這里有一個小竅門...你可以利用 flow 來跟蹤是怎么回事:
const trace = curry((label, x) => {
console.log(`== ${ label }: ${ x }`);
return x;
});
如下是你用它來跟蹤的方法:
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() :
const tap = curry((fn, x) => {
fn(x);
return x;
});
現在你可以看到為嘛 trace() 只是一個特殊情況下的 tap() 了:
const trace = label => {
return tap(x => console.log(`== ${ label }: ${ x }`));
};
你應該開始對函數式編程是什么樣子,以及 偏函數 和 柯里化 如何與 函數組合 協作,來幫助你編寫可讀性更強的程序有點感覺了。
來自:http://www.zcfy.cc/article/master-the-javascript-interview-what-is-function-composition-2160.html