<譯> 函子性

原文見 http://bartoszmilewski.com/20...c++

-> 上一篇:『函子segmentfault

如今,你已經知悉函子是什麼了,而且也見識了它的一些例子。本文要作的事,是用一些小函子構造出大函子。更有趣的是,你能夠看到哪一種類型構造子(至關於一個範疇內部對象之間的映射)可以被擴展爲函子(態射之間的映射)。數據結構

二元函子

函子是 Cat 範疇(範疇的範疇)中的態射,所以你對態射(亦即函數)所造成的的大部分直覺也適用於函子。例如,若是一個函數可以有兩個參數,那麼函子也能夠有兩個參數,這種函子叫二元函子(Bifuntor)。對於對象而言,若一個對象來自範疇 C,另外一個對象來自範疇 D,那麼二元函子能夠將這兩個對象映射爲範疇 E 中的某個對象。也就是說,二元函子是將範疇 C 與範疇 D 的笛卡爾積 C×D 映射爲 E。ide

二元函子

這是至關直觀的。可是函子性意味着一個二元函子也必須可以映射態射。也就是說,二元函子必須將一對態射——其中一個來自 C,另外一個來自 D,映射爲 E 中的態射。函數

注意,上述的一對態射,只不過是範疇 C×D 中的一個態射。若一個態射是在範疇的笛卡爾積中定義的,那麼它的行爲就是將一對對象映射爲另外一對對象。這樣的態射對能夠複合:spa

(f, g) ∘ (f', g') = (f ∘ f', g ∘ g')

這種複合是符合結合律的,而且它也有一個恆等態射,即恆等態射對 (id, id)。所以,範疇的笛卡爾積實際上也是一個範疇。3d

將二元函子想象爲具備兩個參數的函數會更直觀一些。要證實二元函子是不是函子,沒必要藉助函子定律,只需獨立的考察它的參數便可。若是有一個映射,它將兩個範疇映射爲第三個範疇,只需證實這個映射相對於每一個參數(例如,讓另外一個參數變成常量)具備函子性,那麼這個映射就天然是一個二元函子。對於態射,也能夠這樣來證實二元函子具備函子性。指針

下面用 Haskell 定義一個二元函子。在這個例子中,三個範疇都是同一個:Haskell 類型的範疇。一個二元函子是一個類型構造子,它接受兩個類型參數。下面是直接從 Control.Bifunctor 庫中提取出來的 Bifunctor 類型類的定義:code

class Bifunctor f where
    bimap :: (a -> c) -> (b -> d) -> f a b -> f c d
    bimap g h = first g . second h
    first :: (a -> c) -> f a b -> f c b
    first g = bimap g id
    second :: (b -> d) -> f a b -> f a d
    second = bimap id

類型變量 f 表示二元函子,能夠看到有關它的全部類型簽名都是做用於兩個類型參數。第一個類型簽名定義了 bimap 函數,它將兩個函數映射爲一個被提高了的函數 (f a b -> f c d),後者做用於二元函子的類型構造子所產生的類型。 bimap 有一個默認的實現,即 firstsecond 的複合,這代表只要 bimap 分別對兩個參數都具有函子性,就意味着它是一個二元函子。對象

二元映射

其餘兩個類型簽名是 firstsecond,他們分別做用於 bimap 的第一個與第二個參數,所以它們是 f 具備函子性的兩個 fmap 證據。

first

second

上述類型類的定義以 bimap 的形式提供了 firstsecond 的默認實現。

當聲明 Bifunctor 的一個實例時,你能夠去實現 bimap,這樣 firstsecond 就不用再實現了;也能夠去實現 firstsecond,這樣就不用再實現 bimap 了。固然,你也能夠三個都實現了,可是你須要肯定它們之間要知足類型類的定義中的那些關係。

積與餘積二元函子

二元函子的一個重要的例子是範疇積——由泛構造(Universal Construction)定義的兩個對象的積。若是任意一對對象之間存在積,那麼從這些對象到積的映射就具有二元函子性,這一般是正確的,特別是在 Haskell 中。序對構造子就是一個 Bifunctor 實例——最簡單的積類型:

instance Bifunctor (,) where
    bimap f g (x, y) = (f x, g y)

不會有其餘選擇,bimap 就是簡單的將第一個函數做用於第一個序對成員,將第二個函數做用於第二個序對成員。它的代碼是不言自明的,其類型爲:

bimap :: (a -> c) -> (b -> d) -> (a, b) -> (c, d)

這個二元函子的用途就是產生類型序對,例如:

(,) a b = (a, b)

餘積做爲對偶,若是它做用於範疇中的每一對對象,那麼它也是一個二元函子。在 Haskell 中,餘積二元函子的例子是 Either 類型構造子,它是 Bifunctor 的一個實例:

instance Bifunctor Either where
    bimap f _ (Left x)  = Left (f x)
    bimap _ g (Right y) = Right (g y)

這段代碼也是不言自明的。

還記得咱們討論過幺半羣範疇嗎?一個幺半羣範疇定義了一個做用於對象的二元運算,以及一個 unit 對象。我曾說過,Set 是一個與笛卡爾積相關的幺半羣範疇,其 unit 是單例。Set 也是一個與不交併(Disjoint Union)相關的幺半羣範疇,其 unit 是空集。我沒有提到的是,幺半羣範疇還須要一個二元函子。若是咱們要讓幺半羣的積與態射所定義的範疇結構相兼容,這個二元函子是必須的。咱們如今距離幺半羣範疇的完整定義更近了一步,下一步是瞭解天然性(Naturality)。

具備函子性的代數數據類型

咱們所看到的參數化的數據類型的幾個例子,結果它們都是函子——能夠爲它們定義 fmap。複雜的數據類型是由簡單的數據類型構造出來的。特別是代數數據類型(ADT),它是由和與積建立的。咱們已經見識了和與積的函子性,也瞭解了函子的複合。所以,若是咱們能揭示代數數據類型的基本構造塊是具有函子性的,那麼就能夠肯定代數數據類型也具有函子性。

那麼,參數化的代數數據類型的基本構造塊是什麼?首先,有些構造塊是不依賴於函子所接受的類型參數的,例如 Maybe 中的 NothingList 中的 Nil。它們等價與 Const 函子。記住,Const 函子忽略它的類型參數(其實是忽略第二個類型參數,第一個被保留做爲常量)。

其次,有些構造塊簡單的將類型參數封裝爲自身的一部分,例如 Maybe 中的 Just,它們等價於恆等函子。以前我提到過恆等函子,它是 Cat 範疇中的恆等態射,不過 Haskell 未對它進行定義。咱們給出它的定義:

data Identity a = Identity a

instance Functor Identity where
    fmap f (Identity x) = Identity (f x)

可將 Indentity 視爲最簡單的容器,它只存儲類型 a 的一個(不變)的值。

其餘的代數數據結構都是使用這兩種基本類型的和與積構建而成。

運用這個新知識,咱們從一個新的角度來看 Maybe 類型構造子:

data Maybe a = Nothing | Just a

它是兩種類型的和,咱們如今知道求和運算是具有函子性的。第一部分,Nothing 能夠表示爲做用於類型 aConst ()Const 的第一個類型參數是 unit——後面咱們會看到 Const 更多有趣的應用),而第二部分不過是恆等函子的化名而已。在同構的意義下,咱們能夠將 Maybe 定義爲:

type Maybe a = Either (Const () a) (Identity a)

所以,MaybeConst () 函子與 Indentity 函子被二元函子 Either 複合後的結果。Const 自己也是一個二元函子,只不過在這裏用的是它的偏應用形式。

你應該看到了,兩個函子的複合後,其結果是一個函子——這一點不難肯定。咱們還須要作的就是描述兩個函子被一個二元函子複合後如何做用於態射。對於給定的兩個態射,咱們能夠分別用這兩個函子對其進行提高,而後再用二元函子去提高這兩個被提高後的態射所構成的序對。

咱們能夠在 Haskell 中表示這種複合。先定義一個由二元函子 bf 參數化的數據類型,兩個函子 fugu,以及兩個常規類型 ab。咱們將 fu 做用於 a,將 gu 做用於 b,而後將 bf 做用於 fu afu b

ewtype BiComp bf fu gu a b = BiComp (bf (fu a) (gu b))

這是對象的複合,在 Haskell 中也就是類型的複合。注意,在 Haskell 中,類型構造子做用於類型,就像函數做用於它的參數同樣。語法是相同的。

若是你有點迷惑,能夠試試將 BiComp 做用於 EitherConst ()Indentitya,以及 b。你獲得的是一個裸奔版本的 Maybe ba 被忽略了)。

若是 bf 是一個二元函子,fugu 都是函子,那麼這個新的數據類型 BiComp 就是 ab 之間的二元函子。編譯器必須知道與 bf 匹配的 bimap 的定義,以及分別與 fugu 匹配的 fmap 的定義。在 Haskell 中,這個條件能夠預先給出:一個類約束集合後面尾隨一個粗箭頭:

instance (Bifunctor bf, Functor fu, Functor gu) =>
  Bifunctor (BiComp bf fu gu) where
    bimap f1 f2 (BiComp x) = BiComp ((bimap (fmap f1) (fmap f2)) x)

面向 BiCompbimap 實現是以面向 bfbimap 以及兩個分別面向 fugufmap 的形式給出的。在使用 Bimap 時,編譯器會自動推斷出全部類型,並選擇正確的重載函數。

bimap 的定義中,x 的類型爲:

bf (fu a) (gu b)

它的個頭很大。外圍的 bimap 脫去它的 bf 層,而後兩個 fmap 分別脫去它的 fugu 層。若是 f1f2 的類型是:

f1 :: a -> a'
f2 :: b -> b'

那麼,最終結果是類型 bf (fu a') (gu b')

bimap (fu a -> fu a') -> (gu b -> gu b') 
  -> bf (fu a) (gu b) -> bf (fu a') (gu b')

若是你喜歡拼圖遊戲,諸如此類的類型操做夠你娛樂幾個小時的了。

沒有必要去證實 Maybe 是一個函子,因爲它是兩個基本的函子求和後的結果,所以 Maybe 天然也就具有了函子性。

敏銳的讀者可能會問,對於代數數據類型而言,Functor 實例的繼承至關繁瑣,這個過程有無可能由編譯器自動完成?的確,編譯器能作到這一點。你須要在代碼的首部中啓用 Haskell 的擴展:

{-# LANGUAGE DeriveFunctor #-}

而後在數據結構中添加 deriving Functor

data Maybe a = Nothing | Just a
  deriving Functor

而後你就會獲得相應的 fmap 的實現。

代數數據結構的規律性不只適用於 Functor 的自動繼承,也適合其它的類型類,例如以前提到的 Eq 類型類。也能夠訓導編譯器自動繼承你自定義的類型類,可是技術上要難一點。不過思想是相同的:你須要爲你的類型類描述基本構造塊、求和以及求積的行爲,而後讓編譯器來描述其餘部分。

C++ 中的函子

若是你是 C++ 程序猿,顯然你會不知不覺的就實現了一些函子。不過,如今你應該可以認識到 C++ 中存在一些代數數據結構類型。若是這樣的數據結構是以泛型模板的方式實現的,那麼就能夠爲它實現 fmap

看一下樹的數據結構,它在 Haskell 中是一種遞歸求和類型:

data Tree a = Leaf a | Node (Tree a) (Tree a)
    deriving Functor

以前曾提到過,C++ 中是經過類繼承的方式實現類型求和的。所以很天然的會想到,在一種面嚮對象語言中,可將 fmap 實現爲 Functor 基類的虛函數,而後在子類中對其進行重載。不幸的是,這是沒法實現的,由於 fmap 是一個模板,它的類型參數不只是它所做用的對象的類型,也包括它所做用的函數的返回類型。在 C++ 中,類的虛函數沒法模板化。所以咱們只能將 fmap 實現爲一個泛型的自由函數,並採用 dynamic_cast 替代 Haskell 中的模式匹配。

爲了支持動態類型轉換,基類至少須要定義一個虛函數,所以咱們將析構函數定義爲虛函數(在任何狀況下這都是各好主意):

template<class T>
struct Tree {
    virtual ~Tree() {};
};

Leaf 只不過是一個帶着面具的恆等(Identity)函子:

template<class T>
struct Leaf : public Tree<T> {
    T _label;
    Leaf(T l) : _label(l) {}
};

Node 是一個積類型:

template<class T>
struct Node : public Tree<T> {
    Tree<T> * _left;
    Tree<T> * _right;
    Node(Tree<T> * l, Tree<T> * r) : _left(l), _right(r) {}
};

在實現 fmap 時,咱們須要藉助 Tree 類型的動態分配的優點。Leaf 對應的狀況是 fmapIdentity 版本,Node 對應的狀況是一個二元函子,它複合了兩個 Tree 函子的複本。做爲 C++ 程序猿,你可能不習慣這樣子分析代碼,可是要創建範疇化思考,這是一個很好的練習。

template<class A, class B>
Tree<B> * fmap(std::function<B(A)> f, Tree<A> * t)
{
    Leaf<A> * pl = dynamic_cast <Leaf<A>*>(t);
    if (pl)
        return new Leaf<B>(f (pl->_label));
    Node<A> * pn = dynamic_cast<Node<A>*>(t);
    if (pn)
        return new Node<B>( fmap<A>(f, pn->_left)
                          , fmap<A>(f, pn->_right));
    return nullptr;
}

爲了簡單起見,我決定忽略內存與資源管理問題,可是在產品級代碼中,你可能會考慮使用智能指針(獨佔仍是共享,取決於你的決策)。

將上述 C++ 的 fmap 與 Haskell 的 fmap 對比一下:

instance Functor Tree where
    fmap f (Leaf a) = Leaf (f a)
    fmap f (Node t t') = Node (fmap f t) (fmap f t')

Haskell 中的 fmap 也能夠由編譯器經過自動繼承實現。

Writer 函子

我曾許諾,將會重提 Kleisli 範疇。在 Kleisli 範疇中,態射被表示爲被裝幀過的函數,他們返回 Writer 數據結構。

type Writer a = (a, String)

我說過,這種裝幀與自函子有一些關係,而且 Writer 類型構造子對於 a 具備函子性。咱們不須要去爲它實現 fmap,由於它只是一種簡單的積類型。

可是,Kleisli 範疇與一個函子之間存在什麼關係呢?一個 Kleisli 範疇,做爲一個範疇,它定義了複合與恆等。複合是經過小魚運算符實現的:

(>=>) :: (a -> Writer b) -> (b -> Writer c) -> (a -> Writer c)
m1 >=> m2 = \x -> 
    let (y, s1) = m1 x
        (z, s2) = m2 y
    in (z, s1 ++ s2)

恆等態射是一個叫作 return 的函數:

return :: a -> Writer a
return x = (x, "")

若是你仔細審度這兩個函數的類型,結果會發現,能夠將它們組合成一個函數,這個函數就是 fmap

fmap f = id >=> (\x -> return (f x))

這裏,小魚運算符組合了兩個函數:一個是咱們熟悉的 id,另外一個是一個匿名函數,它將 return 做用於 f x。最難理解的地方可能就是 id 的用途。小魚運算符難道不是接受一個『常規』類型,返回一個通過裝幀的類型嗎?實際上並不是如此。沒人說 a -> Writer b 中的 a 必須是一個『常規』類型。它是一個類型變量,所以它能夠是任何東西,特別是它能夠是一個被裝幀的類型,例如 Writer b

所以,id 將會接受 Writer a,而後返回 Writer a。小魚運算符就會拿到 a 的值,將它做爲 x 傳給那個匿名函數。在匿名函數中,f 會將 x 變成 b,而後 return 會對 b 進行裝幀,從而獲得 Writer b。把這些放到一塊兒,最終就獲得了一個函數,它接受 Writer a,返回 Writer b,這正是 fmap 想要的結果。

注意,上述討論是能夠推廣的:你能夠將 Writer 替換爲任何一個類型構造子。只要這個類型構造子支持一個小魚運算符以及 return,那麼你就能夠定義 fmap。所以,Kleisli 範疇中的這種裝幀,其實是一個函子。(儘管並不是每一個函子都能產生一個 Kleisli 範疇)

你可能會感到奇怪,咱們剛纔定義的 fmap 是否與編譯器使用 deriving Functor 自動繼承來的 fmap 相同?至關有趣,它們是相同的。這是 Haskell 實現多態函數的方式所決定的。這種多態函數的實現方式叫作參數化多態,它是所謂的免費定理(Theorems for free)之源。這些免費的定理中有一個是這麼說的,若是一個給定的類型構造子具備一個 fmap 的實現,它能維持恆等(將一個範疇中的恆等態射映射爲另外一個範疇中的恆等態射),那麼它一定具有惟一性。

協變與逆變函子

剛纔回顧了一番 Writer 函子,如今來回顧 Reader 函子。Reader 函子是『函數箭頭』類型構造子的的偏應用(譯註:函數箭頭 -> 自己就是一個類型構造子,它接受兩個類型參數)。

(->) r

咱們能夠給它取一個類型別名:

type Reader r a = r -> a

將它聲明爲 Functor 的實例,跟以前咱們見過的相似:

instance Functor (Reader r) where
    fmap f g = f . g

可是,函數類型構造子接受兩個類型參數,這一點與序對或 Either 類型構造子類似。序對與 Either 對於它們所接受的參數具有函子性,所以它們二元函子。函數類型構造子也是一個二元函子嗎?

咱們試試讓函數類型構造子對於第一個參數具有函子性。爲此須要再定義一個類型別名——與 Reader 類似,只是參數次序顛倒了一下:

type Op r a = a -> r

這樣,咱們將返回類型 r 固定了下來,只讓參數類型是 a 可變的。與它相匹配的 fmap 的類型簽名以下:

fmap :: (a -> b) -> (a -> r) -> (b -> r)

只憑借 a -> ba -> r 這兩個函數,顯然沒法構造 b -> r!若是咱們以某種方式將第一個函數的參數翻轉一下,讓它變成 b -> a,這樣就能夠構造 b -> r 了。雖然咱們不能隨便反轉一個函數的參數,可是在相反的範疇中能夠這樣作。

快速回顧一下:對於每一個範疇 $C$,存在一個對偶範疇 $C^{OP}$,後者所包含的對象與前者相同,可是後者全部的箭頭都與前者相反。

假設 $C^{op}$ 與另外一個範疇 $D$ 之間存在一個函子:

$$F::C^{OP} \rightarrow D$$

這種函子將 $C^{OP}$ 中的一個態射 $f^{OP}::a \rightarrow b$ 映射爲 $D$ 中的一個態射 $F\;f^{OP}::F\;a\rightarrow F\;b$. 可是,態射 $f^{OP}$ 在原範疇 $C$ 中與某個態射 $f::b\rightarrow a$ 相對應,它們的方向是相反的。

如今,$F$ 是一個常規的函子,可是咱們能夠基於 $F$ 定義一個映射,這個映射不是函子,姑且稱之爲 $G$. 這個映射從 $C$ 到 $D$. 它在映射對象方面的功能與 $F$ 相同,可是當它做用於態射時,它會將態射的方向反轉。它接受 $C$ 中的一個態射 $f::b\rightarrow a$,將其映射爲相反的態射 $f^{OP}::a\rightarrow b$,而後用函子 $F$ 做用於這個被反轉的態射,結果獲得 $F\;f^{OP}::F\;a\rightarrow F\;b$.

逆變

假設 $F\;a$ 與 $G\;a$ 相同,$F\;b$ 與 $G\;b$ 相同,那麼整個旅程能夠描述爲:

$$G\;f::(b\rightarrow a)\rightarrow (G\;a\rightarrow G\;b)$$

這是一個『帶有一個扭結的函子』。範疇的一個映射,它反轉了態射的方向,這種映射被稱爲逆變函子。注意,逆變函子只不過來自相反範疇的一個常規函子。順便說一下,這種常規函子——咱們已經碰到不少了——被稱爲協變函子。

下面是 Haskell 中逆變函子的類型類的定義(實際上,是逆變自函子):

class Contravariant f where
    contramap :: (b -> a) -> (f a -> f b)

類型構造子 Op 是它的一個實例:

instance Contravariant (Op r) where
    -- (b -> a) -> Op r a -> Op r b
    contramap f g = g . f

注意,函數 f 將在 Op 的內容——函數g 以前(也就是右邊)插入其自身。

若是你注意到面向 Opcontramap 只是參數顛倒了的函數複合運算符,那麼它定義能夠搞的更簡潔一些。有一個特定的函數能夠顛倒參數,它叫 flip

flip :: (a -> b -> c) -> (b -> a -> c)
flip f y x = f x y

使用這個函數定義 contramap

contramap = flip (.)

副函子

咱們已經看到了函數箭頭運算符對於它的第一個參數是具備逆變函子性,而對於它的第二個參數則具備協變函子性。像這樣的的怪獸,該叫它什麼?若是是集合範疇,這種怪獸叫 副函子(Profunctor)。因爲一個逆變函子等價於相反範疇的協變函子,所以能夠這樣定義一個副函子:

$$C^{OP}\times D\rightarrow Set$$

由於 Haskell 的類型與集合差很少,因此咱們可將 Profunctor 這個名字應用於一個類型構造子 p,它接受兩個參數,它對於第一個參數具備逆變函子性,對於第二個參數則具備協變函子性。下面是從 Data.Profunctor 庫中抽取出來的相應的類型類:

class Profunctor p where
  dimap :: (a -> b) -> (c -> d) -> p b c -> p a d
  dimap f g = lmap f . rmap g
  lmap :: (a -> b) -> p b c -> p a c
  lmap f = dimap f id
  rmap :: (b -> c) -> p a b -> p a c
  rmap = dimap id

這三個函數只是默認的實現。就像 Bifunctor 那樣,當聲明 Profunctor 的一個實例時,你要麼去實現 dimap,要麼去實現 lmap

dimap

如今,咱們宣稱函數箭頭運算符是 Profunctor 的一個實例了:

instance Profunctor (->) where
  dimap ab cd bc = cd . bc . ab
  lmap = flip (.)
  rmap = (.)

副函子在 Haskell 的 lens 庫中被用到了。之後講到端(End)與餘端(Coend)時再來回顧它(譯註:不知 End 與 Coend 對應的數學中文名詞是誰)。

挑戰

1. 證實數據類型

data Pair a b = Pair a b

是一個二元函子,而後實現 Bifunctor 的所有方法,並使用等式推導去證實這些方法在使用時與它們的默認實現是兼容的。

2. 證實 Maybe 的標準定義與下面的 Maybe' 同構。

type Maybe' a = Either (Const () a) (Identity a)

提示:爲這兩種數據類型定義兩個映射,而後使用等式推導去證實這兩個映射互逆。

3. 試試我稱之爲 PreList 的數據結構,它是 List 的前身。這個數據結構用一個類型參數 b 替換了遞歸:

data PreList a b = Nil | Cons a b

若是用 PreList 代替 b,就能夠獲得 List(在講不動點的時候就知道如何實現)。

證實 PreListBifunctor 的一個實例。

4. 證實一下數據類型是 ab 上的二元函子:

data K2 c a b = K2 c

data Fst a b = Fst a

data Snd a b = Snd b

附加題:檢查一下你的答案是否與 Conor McBride 的論文『Clowns to the Left of me, Jokers to the Right』相符。

5. 用非 Haskell 語言定義一個二元函子,爲那種語言提供的泛型序對實現 bimap

6. 應當將 std:map 視爲做用於兩個模板參數 KeyT 的二元函子或副函子嗎?若是不能夠,該怎麼讓它是?

-> 下一篇:『函數類型

相關文章
相關標籤/搜索