前一篇文章討論了 Functor 作為容器的意義,它提供了操作內容物的方法,並保持容器的結構不變。然而並不是只有容器才能是 Functor,例如(純)函式本身也是一種 Functor。你可以把函式看作是「帶有洞的值」,例如 (\a -> 1 + a * 2) 是一個接受整數並回傳整數的函式,你可以把他寫成帶有佔位符的表達式 (1 + _ * 2),而對它做 map 就只是把這個帶有佔位符的表達式帶入應用的函式,這會形成新的帶有佔位符的表達式:
instance functorFn :: Function a where
map func expr_with_hole = (func $ expr_with_hole _)
他並不是容器內實際的值,而是「帶有洞」的值,而 map 就是對這個抽象的值做操作。類似的,Promise 是代表「未來」的值,對他做 map 時會等到正確時機才應用函式;Lazy 代表直到需要時才會建構出來的值,對他做 map 其實就是增加一個建構步驟;IO 代表與外部世界互動後得到的值,對它做 map 就只是對結果做後處理。他們同時也是 Monad,這部分留到以後再說。
這些例子顯示「容器」並不是對 Functor 合適的描述,Functor 裡的「值」不一定是實際存在的值,而可以是只在「某種情況下」存在的值,我們把它稱為情境(context)。例如 Function a 是「依賴於 a」的情境;Promise 是「未來」的情境;IO 是「與外界互動」的情境。容器也是一種情境,例如 Maybe 是「有值或無值」兩種狀態的情境;Pair 是「第一個和第二個」的情境;List 是「N 個中第 i 個」的情境;Tree 是「這棵樹的某個節點」的情境。我們可以應用任何(純)函式做 map,甚至把外面的值帶入情境,這代表可以對這個情境下的值做「任何」操作,因此它可以看成是實際的值。它不只能做「任何」操作,有些情境還可以賦予其他能力,例如 IO 允許我們在這個情境下與外部世界互動。你可以想像它在內部自成一個程式的世界,這個世界至少擁有所有純函式的能力。
雖然能夠任意處理情境下的值,但是不能從情境中把值取出來,或者說沒有統一的方法。例如 List 需要透過 index 取值;Maybe 不一定有值,因此不一定取得出來;IO 雖然有值但無法取出,如果可以直接取出,我們就能夠複製 IO 本身(也就是外部世界),並對它做不同的 IO 操作(產生平行宇宙),然後取得各個操作後的結果。反過來說,即使現在也能夠複製 IO 本身,但是如果取不出來也沒辦法用。這跟線性特性有關,未來有機會再說。Functor 本來描述的就不是關於取出這件事,如果你真的想要這樣的能力,Comonad 有類似的關聯函式。除了不能從情境中取值,我們也不能根據情境決定如何處理值,例如對 List 做 map 就必須一視同仁地對所有元素處理,若真要根據位置處理可以使用 mapWithIndex。因此使用 map 時必須專注於值而非情境,就像使用 multicursor 或是使用 macro 編輯程式一樣,你必須要從特定的狀況、上下文抽離出來(例如第幾行、在哪個變數底下、哪個括號內等),執行一些與上下文無關的操作,否則可能會把程式碼改壞。
總結目前對於 Functor 的描述:如果 f 是 Functor,f a 就是代表在具有類型 f 的「情境」下具有類型 a 的「值」,而情境與值之間沒有依賴關係。這裡的 a 可以是任意的類型,這代表情境與值之間本來就不可能有依賴關係。所以這代表所有 f :: Type -> Type 都是 Functor?舉一個反例:newtype Predicate a = Predicate (a -> Boolean) 並不是 Functor,我們不可能給定 func :: a -> b 構造出 Predicate b(也就是 b -> Boolean)。Predicate a 其實是 Function Boolean a 的相反(把箭頭反過來),只是這裡的 a 代表的並不是「值」而是「洞」,因此沒辦法對它應用函式。但如果給定 func :: b -> a 倒是能構造出 Predicate b,其實就是把這個函式接在前面而已。我們可以用類似的規則定義相應的 typeclass:
class Contravariant f where
cmap :: (b -> a) -> (f a -> f b)
其中 cmap 必須遵守規則:
cmap f >>> cmap g = cmap (g >>> f)
這個 typeclass 描述的是:如果 f 是 Contravariant,f a 就是代表在具有類型 f 的「情境」下具有類型 a 的「洞」,而情境與洞之間沒有依賴關係。「洞」代表的是我們必須提供數值給他,而不是它會給我們數值。對這個洞做 cmap 可以看作是對「洞」應用函式。舉幾個是 Contravariant 的例子:Predicate 描述「做判斷」情境下的洞,你需要提供一個值給他,他最後會告訴你是與否;newtype Comparison a = Comparison (a -> a -> Ordering) 描述「比較」情境下的洞,你需要提供兩個值給他(在左手邊和右手邊這兩個情境下),他最後會告訴你誰大誰小;Const Unit 理所當然也是 Contravariant,它什麼都沒做。Contravariant 基本上都會跟函式的參數有關,因此很難找到其他不同又實用的範例,但如果允許函式具有副作用,可以從其他語言找到一些例子:rust 的 drop 或是 c++ 的 delete 很像 Contravariant,它會吃掉數值本身;golang 的 send-only channel 也很像 Contravariant,他會傳送提供給他的數值到另一端的 channel。
Contravariant 被稱作逆變,它是相對於 Functor 的另一個名字 Covariant 協變的概念。這兩個概念和 Java 等物件導向語言裡類型約束的協變和逆變基本上是同樣的概念,只是這裡描述的不是子類關係。並不是所有的 f :: Type -> Type 都是 Functor 或是 Contravariant,例如 newtype Func a = Func (a -> a),我們沒辦法給定 func :: a -> b 或 func :: b -> a 就建構出 Func b。事實上如果不考慮類型代表的語意,只要知道實際結構,很容易就能看出它是 Functor 還是 Contravariant 或都不是。規則很簡單:協變為正逆變為負,兩者的組合則為符號相乘。例如 type Ex1 a = Tuple (Either a Int) String,因為 Tuple 和 Either 的兩個類型參數都是協變的,正正得正,因此類型參數 a 是協變的;type Ex2 a = Tuple a Int -> Int,箭頭左邊是逆變的,Tuple 的左邊是協變的,負正得負,因此 a 是逆變的;type Ex3 a = Tuple Int a -> a,這裡的 a 同時出現在協變和逆變的位置,因此它兩者都不是;這個例子很有趣 type Ex4 a = (a -> Int) -> Number,a 位於兩層函式中逆變的位置,負負得正,因此他是協變的。為了證明它的確是 Functor,讓我們試著實作:
instance functorEx4 :: Functor Ex4 where
map :: forall a b. (a -> b) -> (Ex4 a -> Ex4 b)
map func ex = \b_int -> ex (func >>> b_int)
給定 func :: a -> b 和 ex :: (a -> Int) -> Number,為了建構結果 (b -> Int) -> Number,必須把它拆開來看,先把結果寫成 \b_int -> _,我們要設法補上 _ :: Number,但這時我們已經知道了 b_int :: b -> Int,再配合前面的給定參數就能建構出結果。這裡的 a 看似是洞,但事實上要建構出這個結構,勢必要先算出數值才能丟給傳入的函式,例如
average :: List a -> Ex4 a
average list = \score ->
let
count = length list
scores = map score list
total_score = toNumber (sum scores)
in total_score / count
其中必須先有 list :: List a 才能使用 score :: a -> Int,因此它的確是在某種情境下的「值」。事實上只要知道實際結構,就能輕鬆地建構出 Functor/Contravariant 的實作,而且這個實作一定是唯一的,這代表 Functor/Contravariant 並不像一般的 typeclass/interface,它描述的不是某種能力,而是一種與語意無關的理論。這個理論告訴你何謂「值」與「洞」,和你可以怎麼「取代」作為值/洞的類型參數。