Ramda 哪些讓人困惑的函數(shù)簽名規(guī)則
在我們查閱 Ramda 的文檔 時, 常會見到一些"奇怪"的類型簽名和用法,例如:
(Applicative f, Traversable t) => (a → f a) → t (f a) → f (t a)
或者,某一些函數(shù)"奇怪"的用法:
// R.ap can also be used as S combinator // when only two functions are passed
R.ap(R.concat, R.toUpper)('Ramda') //=> 'RamdaRAMDA'
這些"奇怪"的點背后投射著 Ramda "更深"一層的設(shè)計邏輯, 本文將會對此作出講解, 并闡述背后通用的函數(shù)式編程理論知識。
Ramda 為人熟知的一面?
Ramda 經(jīng)常被當(dāng)做 Lodash 的另外一個"更加FP"的替代庫,相對于 Lodash,Ramda 的優(yōu)勢(之一)在于完備的柯里化與 data last 的設(shè)計帶來的便捷的管道式編程(pipe)。
舉一個簡單的代碼對比示例:
- Ramda:
const myFn = R.pipe (
R.fn1,
R.fn2 ('arg1', 'arg2'),
R.fn3 ('arg3'),
R.fn4
)
- Lodash:
const myFn = (x, y) => {
const var1 = _.fn1 (x, y)
const var2 = _.fn2 (var1, 'arg1', 'arg2')
const var3 = _.fn3 (var2, 'arg3')
return _.fn4 (var3)
}
Ramda 類型簽名?
在 Ramda 的 API 文檔中, 類型簽名的語法有些"奇怪":
- add: Number → Number → Number
我們結(jié)合 Ramda 的柯里化規(guī)則, 稍加推測, 可以將這個函數(shù)轉(zhuǎn)換為TypeScript 的定義:
export function add(a: number, b: number): number;
export function add(a: number): (b: number) => number;
OK, 那為什么Ramda 的文檔不直接使用TypeScript 表達(dá)函數(shù)的類型呢? -- 因為更加簡潔!
Ramda 文檔中的類型簽名使用的是Haskell 的語法, Haskell 作為一門純函數(shù)式編程語言, 可以很簡潔地表達(dá)柯里化的語義, 相較之下, TypeScript 的表達(dá)方式就顯得比較臃腫。
當(dāng)然, 使用Haskell 的類型簽名的意義不僅于此, 讓我們再看看其他"奇怪"的函數(shù)類型:
- ap:
[a → b] → [a] → [b]
Apply f => f (a → b) → f a → f b
(r → a → b) → (r → a) → (r → b)
結(jié)合文檔中的demo:
R.ap([R.multiply(2), R.add(3)], [1,2,3]); //=> [2, 4, 6, 4, 5, 6]
R.ap([R.concat('tasty '), R.toUpper], ['pizza', 'salad']); //=> ["tasty pizza", "tasty salad", "PIZZA", "SALAD"]
// R.ap can also be used as S combinator
// when only two functions are passed
R.ap(R.concat, R.toUpper)('Ramda') //=> 'RamdaRAMDA'
[a → b] → [a] → [b]我們好理解, 就是笛卡爾積;
(r → a → b) → (r → a) → (r → b)我們也能理解, 就是兩個函數(shù)的串聯(lián);
Apply f => f (a → b) → f a → f b就有點難理解了, 語法上就有些陌生, 我們先將其翻譯成TypeScript 語法:
:), 好吧, 這段類型沒法簡單地翻譯成TypeScript, 因為: TypeScript 不支持將 「類型構(gòu)造器」 作為類型參數(shù)!舉個例子:
type T<F> = F<number>;
報錯信息如下:
Type 'F' is not generic.
在類型簽名中F?是一個類型構(gòu)造器, 既和Array一樣的 「返回類型的類型」, 然而, TypeScript 里根本無法聲明"一個類型參數(shù)為類型構(gòu)造器"。
正如示例中type T<F> = F<number>;?中, 我們無法告訴TypeScript, 這里的F?是一個類型構(gòu)造器, 所以當(dāng)將number?傳入F的時候, 就報錯了。
OK, 我們假設(shè)TypeScript 支持聲明"一個類型參數(shù)為類型構(gòu)造器", 讓我們再來看看Apply f => f (a → b) → f a → f b該怎么翻譯:
type AP = <F extends Appy, A, B>(f: F<((a: A) => B)>) => (fa: F<A>) => F<B>;
這里的F可以理解為一種 「上下文」, 這段類型簽名可以先簡單地理解為:
將一個包裹在上下文中的「函數(shù)」取出, 再將另一個包裹在上下文中的「值」取出, 調(diào)用函數(shù)后, 將函數(shù)的返回值重新包裹進(jìn)上下文中并返回。
這里的 「上下文」 是一個泛指, 比如我們可以將其特異化(specialize)為 Promise :
type AP = <A, B>(f: Promise<((a: A) => B)>) => (fa: Promise<A>) => Promise<B>;
const ap: AP = (f) => fa => f.then(ff => fa.then(ff));
ap? 或說 Apply 作為函數(shù)式編程中的一種常見抽象, 有非常重要重要的學(xué)習(xí)意義, 但其抽象的解析超出本文范圍, 在這里我們只聚焦于「是什么」, 暫不考慮「為什么」。
那么, (r → a → b) → (r → a) → (r → b)與Apply f => f (a → b) → f a → f b是什么關(guān)系?
他們之間是同父異母的關(guān)系, (r → a → b) → (r → a) → (r → b)?是對Apply f => f (a → b) → f a → f b的特異化, 正如我們對Promise 做的那樣。
函數(shù)也可以是一個 「上下文」?
答案是可以的, 我們可以將一個一元函數(shù)a -> b?理解為"一個包裹在上下文中的b?, 只不過為了獲取這個b?, 需要先傳入一個a。
先看看 Haskell 對ap 的定義:
instance Applicative ((->) r) where
(<*>) f g x = f x (g x)
替換為TypeScript 的實現(xiàn), 我們將上面的Promise 的例子稍微修改下, 得出:
type F<A> = (a: any) => A;
type AP = <A, B>(f: F<((a: A) => B)>) => (fa: F<A>) => F<B>;
const ap: AP = f => fa => {
return (r) => f(r)(fa(r));
}
同樣的, 我們得到Apply 特異化為Array 的實現(xiàn):
type AP = <A, B>(f: Array<((a: A) => B)>) => (fa: Array<A>) => Array<B>;
const ap: AP = f => fa => {
return f.flatMap(ff => fa.map(ff));
};
綜上所述, 我們可以得出結(jié)論:
ap的類型簽名[a → b] → [a] → [b]和(r → a → b) → (r → a) → (r → b)是Apply f => f (a → b) → f a → f b的特異化。