Applicative Functor
應用 applicative functor
考慮到其函數式的出身,applicative functor 這個名稱堪稱簡單明瞭。函數式程式設計師最為人詬病的一點就是,總喜歡搞一些稀奇古怪的命名,比如 mappend
或者 liftA4
。誠然,此類名稱出現在數學實驗室是再自然不過的,但是放在其他任何語境下,這些概念就都像是扮作達斯維達去汽車餐館搞怪的人。(譯者注:此處需要做些解釋,1. 汽車餐館(drive-thru)指的是那種不需要顧客下車就能提供服務的地方,比如麥當勞、星巴克等就會有這種 drive-thru;2. 達斯維達(Darth Vader)是《星球大戰》系列主要反派角色,在美國大眾文化中的有著廣泛的影響力,其造型是很多人致敬模仿的物件;3. 由於 2 的緣故,美國一些星戰迷會扮作 Darth Vader 去 drive-thru 點單,YouTube 上有不少這種搞怪視訊;4. 作者使用這個“典故”是為了說明函數式裡很多概念的名稱有些“故弄玄虛”,而 applicative functor 是少數比較“正常”的。)
無論如何,applicative 這個名字應該能夠向我們表明一些事實,告訴我們作為一個介面,它能為我們帶來什麼:那就是讓不同 functor 可以相互應用(apply)的能力。
然而,你可能為會問了,為何一個正常的、理性的人,比如你自己,會做這種“讓不同 functor 相互應用”的事?而且,“相互應用”到底是什麼意思?
要回答這些問題,我們可以從下面這個場景講起,可能你已經碰到過這種場景了。假設有兩個同類型的 functor,我們想把這兩者作為一個函式的兩個引數傳遞過去來呼叫這個函式。簡單的例子比如讓兩個 Container
的值相加:
// 這樣是行不通的,因為 2 和 3 都藏在瓶子裡。
add(Container.of(2), Container.of(3));
//NaN
// 使用可靠的 map 函式試試
var container_of_add_2 = map(add, Container.of(2));
// Container(add(2))
這時候我們建立了一個 Container
,它內部的值是一個區域性呼叫的(partially applied)的函式。確切點講就是,我們想讓 Container(add(2))
中的 add(2)
應用到 Container(3)
中的 3
上來完成呼叫。也就是說,我們想把一個 functor 應用到另一個上。
巧的是,完成這種任務的工具已經存在了,即 chain
函式。我們可以先 chain
然後再 map
那個區域性呼叫的 add(2)
,就像這樣:
Container.of(2).chain(function(two) {
return Container.of(3).map(add(two));
});
只不過,這種方式有一個問題,那就是 monad 的順序執行問題:所有的程式碼都只會在前一個 monad 執行完畢之後才執行。想想看,我們的這兩個值足夠強健且相互獨立,如果僅僅為了滿足 monad 的順序要求而延遲 Container(3)
的建立,我覺得是非常沒有必要的。
事實上,當遇到這種問題的時候,要是能夠無需藉助這些不必要的函式和變數,以一種簡明扼要的方式把一個 functor 的值應用到另一個上去就好了。
瓶中之船
ap
就是這樣一種函式,能夠把一個 functor 的函式值應用到另一個 functor 的值上。把這句話快速地說上 5 遍。
Container.of(add(2)).ap(Container.of(3));
// Container(5)
// all together now
Container.of(2).map(add).ap(Container.of(3));
// Container(5)
這樣就大功告成了,而且程式碼乾淨整潔。可以看到,Container(3)
從巢狀的 monad 函式的牢籠中釋放了出來。需要再次強調的是,本例中的 add
是被 map
所區域性呼叫(partially apply)的,所以 add
必須是一個 curry 函式。
可以這樣定義一個 ap
函式:
Container.prototype.ap = function(other_container) {
return other_container.map(this.__value);
}
記住,this.__value
是一個函式,將會接收另一個 functor 作為引數,所以我們只需 map
它。由此我們可以得出 applicative functor 的定義:
applicative functor 是實現了
ap
方法的 pointed functor
注意 pointed
這個前提,這是非常重要的一個前提,下面的例子會說明這一點。
講到這裡,我已經感受到你的疑慮了(也或者是困惑和恐懼);心態開放點嘛,ap
還是很有用的。在深入理解這個概念之前,我們先來探索一個特性。
F.of(x).map(f) == F.of(f).ap(F.of(x))
這行程式碼翻譯成人類語言就是,map 一個 f
等價於 ap
一個值為 f
的 functor。或者更好的譯法是,你既可以把 x
放到容器裡然後呼叫 map(f)
,也可以同時讓 f
和 x
發生 lift(參看第 8 章),然後對他們呼叫 ap
。這讓我們能夠以一種從左到右的方式編寫程式碼:
Maybe.of(add).ap(Maybe.of(2)).ap(Maybe.of(3));
// Maybe(5)
Task.of(add).ap(Task.of(2)).ap(Task.of(3));
// Task(5)
細心的讀者可能發現了,上述程式碼中隱約有普通函式呼叫的影子。沒關係,我們稍後會學習 ap
的 pointfree 版本;暫時先把這當作此類程式碼的推薦寫法。通過使用 of
,每一個值都被輸送到了各個容器裡的奇幻之地,就像是在另一個平行世界裡,每個程式都可以是非同步的或者是 null 或者隨便什麼值,而且不管是什麼,ap
都能在這個平行世界裡針對這些值應用各種各樣的函式。這就像是在一個瓶子中造船。
你注意到沒?上例中我們使用了 Task
,這是 applicative functor 主要的用武之地。現在我們來看一個更深入的例子。
協調與激勵
假設我們要建立一個旅遊網站,既需要獲取遊客目的地的列表,還需要獲取地方事件的列表。這兩個請求就是相互獨立的 api 呼叫。
// Http.get :: String -> Task Error HTML
var renderPage = curry(function(destinations, events) { /* render page */ });
Task.of(renderPage).ap(Http.get('/destinations')).ap(Http.get('/events'))
// Task("<div>some page with dest and events</div>")
兩個請求將會同時立即執行,當兩者的響應都返回之後,renderPage
就會被呼叫。這與 monad 版本的那種必須等待前一個任務完成才能繼續執行後面的操作完全不同。本來我們就無需根據目的地來獲取事件,因此也就不需要依賴順序執行。
再次強調,因為我們是使用區域性呼叫的函式來達成上述結果的,所以必須要保證 renderpage
是 curry 函式,否則它就不會一直等到兩個 Task
都完成。而且如果你碰巧自己做過類似的事,那你一定會感激 applicative functor
這個異常簡潔的介面的。這就是那種能夠讓我們離“奇點”(singularity)更近一步的優美程式碼。
再來看另外一個例子。
// 幫助函式:
// ==============
// $ :: String -> IO DOM
var $ = function(selector) {
return new IO(function(){ return document.querySelector(selector) });
}
// getVal :: String -> IO String
var getVal = compose(map(_.prop('value')), $);
// Example:
// ===============
// signIn :: String -> String -> Bool -> User
var signIn = curry(function(username, password, remember_me){ /* signing in */ })
IO.of(signIn).ap(getVal('#email')).ap(getVal('#password')).ap(IO.of(false));
// IO({id: 3, email: "[email protected]"})
signIn
是一個接收 3 個引數的 curry 函式,因此我們需要呼叫 ap
3 次。在每一次的 ap
呼叫中,signIn
就收到一個引數然後執行,直到所有的引數都傳進來,它也就執行完畢了。我們可以繼續擴充套件這種模式,處理任意多的引數。另外,左邊兩個引數在使用 getVal
呼叫後自然而然地成為了一個 IO
,但是最右邊的那個卻需要手動 lift
,然後變成一個 IO
,這是因為 ap
需要呼叫者及其引數都屬於同一型別。
lift
(譯者注:此處原標題是“Bro, do you even lift?”,是一流行語,發源於健身圈,指質疑別人的健身方式和效果並顯示優越感,後擴散至其他領域。再注:作者書中用了不少此類俚語或俗語,有時並非在使用俚語的本意,就像這句,完全就是為了好玩。另,關於 lift 的概念可參看第 8 章。)
我們來試試以一種 pointfree 的方式呼叫 applicative functor。因為 map
等價於 of/ap
,那麼我們就可以定義無數個能夠 ap
通用函式。
var liftA2 = curry(function(f, functor1, functor2) {
return functor1.map(f).ap(functor2);
});
var liftA3 = curry(function(f, functor1, functor2, functor3) {
return functor1.map(f).ap(functor2).ap(functor3);
});
//liftA4, etc
liftA2
是個奇怪的名字,聽起來像是破敗工廠裡挑剔的貨運電梯,或者偽豪華汽車公司的個性車牌。不過你要是真正理解了,那麼它的含義也就不證自明瞭:讓那些小程式碼塊發生 lift,成為 applicative functor 中的一員。
剛開始我也覺得這種 2-3-4 的寫法沒什麼意義,看起來又醜又沒有必要,畢竟我們可以在 JavaScript 中檢查函式的引數數量然後再動態地構造這樣的函式。不過,區域性呼叫(partially apply)liftA(N)
本身,有時也能發揮它的用處,這樣的話,引數數量就固定了。
來看看實際用例:
// checkEmail :: User -> Either String Email
// checkName :: User -> Either String String
// createUser :: Email -> String -> IO User
var createUser = curry(function(email, name) { /* creating... */ });
Either.of(createUser).ap(checkEmail(user)).ap(checkName(user));
// Left("invalid email")
liftA2(createUser, checkEmail(user), checkName(user));
// Left("invalid email")
createUser
接收兩個引數,因此我們使用的是 liftA2
。上述兩個語句是等價的,但是使用了 liftA2
的版本沒有提到 Either
,這就使得它更加通用靈活,因為不必與特定的資料型別耦合在一起。
我們試試以這種方式重寫前一個例子:
liftA2(add, Maybe.of(2), Maybe.of(3));
// Maybe(5)
liftA2(renderPage, Http.get('/destinations'), Http.get('/events'))
// Task("<div>some page with dest and events</div>")
liftA3(signIn, getVal('#email'), getVal('#password'), IO.of(false));
// IO({id: 3, email: "[email protected]"})
操作符
在 haskell、scala、PureScript 以及 swift 等語言中,開發者可以建立自定義的中綴操作符(infix operators),所以你能看到到這樣的語法:
-- haskell
add <$> Right 2 <*> Right 3
// JavaScript
map(add, Right(2)).ap(Right(3))
<$>
就是 map
(亦即 fmap
),<*>
不過就是 ap
。這樣的語法使得開發者可以以一種更自然的風格來書寫函數式應用,而且也能減少一些括號。
免費開瓶器
我們尚未對衍生函式(derived function)著墨過多。不過看到本書介紹的所有這些介面都互相依賴並遵守一些定律,那麼我們就可以根據一些強介面來定義一些弱介面了。
比如,我們知道一個 applicative 首先是一個 functor,所以如果已經有一個 applicative 例項的話,毫無疑問可以依此定義一個 functor。
這種完美的計算上的大和諧(computational harmony)之所以存在,是因為我們在跟一個數學“框架”打交道。哪怕是莫扎特在小時候就下載了 ableton(譯者注:一款專業的音樂製作軟體),他的鋼琴也不可能彈得更好。
前面提到過,of/ap
等價於 map
,那麼我們就可以利用這點來定義 map
:
// 從 of/ap 衍生出的 map
X.prototype.map = function(f) {
return this.constructor.of(f).ap(this);
}
monad 可以說是處在食物鏈的頂端,因此如果已經有了一個 chain
函式,那麼就可以免費得到 functor 和 applicative:
// 從 chain 衍生出的 map
X.prototype.map = function(f) {
var m = this;
return m.chain(function(a) {
return m.constructor.of(f(a));
});
}
// 從 chain/map 衍生出的 ap
X.prototype.ap = function(other) {
return this.chain(function(f) {
return other.map(f);
});
};
定義一個 monad,就既能得到 applicative 也能得到 functor。這一點非常強大,相當於這些“開瓶器”全都是免費的!我們甚至可以審查一個數據型別,然後自動化這個過程。
應該要指出來的一點是,ap
的魅力有一部分就來自於並行的能力,所以通過 chain
來定義它就失去了這種優化。即便如此,開發者在設計出最佳實現的過程中就能有一個立即可用的介面,也是很好的。
為啥不直接使用 monad?因為最好用合適的力量來解決合適的問題,一分不多,一分不少。這樣就能通過排除可能的功能性來做到最小化認知負荷。因為這個原因,相比 monad,我們更傾向於使用 applicative。
向下的巢狀結構使得 monad 擁有序列計算、變數賦值和暫緩後續執行等獨特的能力。不過見識到 applicative 的實際用例之後,你就不必再考慮上面這些問題了。
下面,來看看理論知識。
定律
就像我們探索過的其他數學結構一樣,我們在日常編碼中也依賴 applicative functor 一些有用的特性。首先,你應該知道 applicative functor 是“組合關閉”(closed under composition)的,意味著 ap
永遠不會改變容器型別(另一個勝過 monad 的原因)。這並不是說我們無法擁有多種不同的作用——我們還是可以把不同的型別壓棧的,只不過我們知道它們將會在整個應用的過程中保持不變。
下面的例子可以說明這一點:
var tOfM = compose(Task.of, Maybe.of);
liftA2(_.concat, tOfM('Rainy Days and Mondays'), tOfM(' always get me down'));
// Task(Maybe(Rainy Days and Mondays always get me down))
你看,不必擔心不同的型別會混合在一起。
該去看看我們最喜歡的範疇學定律了:同一律(identity)。
同一律(identity)
// 同一律
A.of(id).ap(v) == v
是的,對一個 functor 應用 id
函式不會改變 v
裡的值。比如:
var v = Identity.of("Pillow Pets");
Identity.of(id).ap(v) == v
Identity.of(id)
的“無用性”讓我不禁莞爾。這裡有意思的一點是,就像我們之前證明了的,of/ap
等價於 map
,因此這個同一律遵循的是 functor 的同一律:map(id) == id
。
使用這些定律的優美之處在於,就像一個富有激情的幼兒園健身教練讓所有的小朋友都能愉快地一塊玩耍一樣,它們能夠強迫所有的介面都能完美結合。
同態(homomorphism)
// 同態
A.of(f).ap(A.of(x)) == A.of(f(x))
同態就是一個能夠保持結構的對映(structure preserving map)。實際上,functor 就是一個在不同範疇間的同態,因為 functor 在經過對映之後保持了原始範疇的結構。
事實上,我們不過是把普通的函式和值放進了一個容器,然後在裡面進行各種計算。所以,不管是把所有的計算都放在容器裡(等式左邊),還是先在外面進行計算然後再放到容器裡(等式右邊),其結果都是一樣的。
一個簡單例子:
Either.of(_.toUpper).ap(Either.of("oreos")) == Either.of(_.toUpper("oreos"))
互換(interchange)
互換(interchange)表明的是選擇讓函式在 ap
的左邊還是右邊發生 lift 是無關緊要的。
// 互換
v.ap(A.of(x)) == A.of(function(f) { return f(x) }).ap(v)
這裡有個例子:
var v = Task.of(_.reverse);
var x = 'Sparklehorse';
v.ap(Task.of(x)) == Task.of(function(f) { return f(x) }).ap(v)
組合(composition)
最後是組合。組合不過是在檢查標準的函式組合是否適用於容器內部的函式呼叫。
// 組合
A.of(compose).ap(u).ap(v).ap(w) == u.ap(v.ap(w));
var u = IO.of(_.toUpper);
var v = IO.of(_.concat("& beyond"));
var w = IO.of("blood bath ");
IO.of(_.compose).ap(u).ap(v).ap(w) == u.ap(v.ap(w))
總結
處理多個 functor 作為引數的情況,是 applicative functor 一個非常好的應用場景。藉助 applicative functor,我們能夠在 functor 的世界裡呼叫函式。儘管已經可以通過 monad 達到這個目的,但在不需要 monad 的特定功能的時候,我們還是更傾向於使用 applicative functor。
至此我們已經基本介紹完容器的 api 了,我們學會了如何對函式呼叫 map
、chain
和 ap
。下一章,我們將學習如何更好地處理多個 functor,以及如何以一種原則性的方式拆解它們。
Chapter 11: Traversable/Foldable Functors
練習
require('./support');
var Task = require('data.task');
var _ = require('ramda');
// 模擬瀏覽器的 localStorage 物件
var localStorage = {};
// 練習 1
// ==========
// 寫一個函式,使用 Maybe 和 ap() 實現讓兩個可能是 null 的數值相加。
// ex1 :: Number -> Number -> Maybe Number
var ex1 = function(x, y) {
};
// 練習 2
// ==========
// 寫一個函式,接收兩個 Maybe 為引數,讓它們相加。使用 liftA2 代替 ap()。
// ex2 :: Maybe Number -> Maybe Number -> Maybe Number
var ex2 = undefined;
// 練習 3
// ==========
// 執行 getPost(n) 和 getComments(n),兩者都執行完畢後執行渲染頁面的操作。(引數 n 可以是任意值)。
var makeComments = _.reduce(function(acc, c){ return acc+"<li>"+c+"</li>" }, "");
var render = _.curry(function(p, cs) { return "<div>"+p.title+"</div>"+makeComments(cs); });
// ex3 :: Task Error HTML
var ex3 = undefined;
// 練習 4
// ==========
// 寫一個 IO,從快取中讀取 player1 和 player2,然後開始遊戲。
localStorage.player1 = "toby";
localStorage.player2 = "sally";
var getCache = function(x) {
return new IO(function() { return localStorage[x]; });
}
var game = _.curry(function(p1, p2) { return p1 + ' vs ' + p2; });
// ex4 :: IO String
var ex4 = undefined;
// 幫助函式
// =====================
function getPost(i) {
return new Task(function (rej, res) {
setTimeout(function () { res({ id: i, title: 'Love them futures' }); }, 300);
});
}
function getComments(i) {
return new Task(function (rej, res) {
setTimeout(function () {
res(["This book should be illegal", "Monads are like space burritos"]);
}, 300);
});
}