Haskell與範疇論

http://www.yi-programmer.com/blog/2010-04-06_haskell_and_category_translate.html#id24

 


 

Haskell與範疇論

用haskell的概念解釋範疇論。翻譯自 wikibook : http://en.wikibooks.org/wiki/Haskell/Category_theory

本文將對範疇論進行簡單的介紹,最終目的是爲了將它應用到Haskell語言。爲了達到這個目的,我們會一邊介紹數學上的定義,一邊給出對應的 Haskell代碼。我們並不追求絕對的精確對應,只期望讀者在讀完以後,能對範疇論的基本概念以及跟Haskell之間的聯繫有一個直觀的感受。

範疇論簡介

images/Simple-cat.png

一個簡單的範疇,由三個對象 A , BC 組成,有三個單位態射 id_A , id_Bid_C ,還有另外兩個態射 f : C /to Bg : A /to B 。第三個組成元素(即如何對態射進行組合)沒有展示出來。

本質上講,範疇就是一個組合,它包括三部分:

  1. 一組 對象
  2. 一組 態射 。每一個態射捆綁兩個對象(一個源對象,一個目標對象)。(也有人把它們叫做箭頭,我們這裏不這麼叫它,因爲這個詞語在Haskell裏面有其他含義。譯註:其他含義指的是 Control.Arrow 。)如果 f 是一個從源對象 A 到目標對象 B 的態射,我們把它記作 f:A /to B
  3. 一個叫做 態射組合 的概念。如果 h 是態射 fg 的組合,我們記作: h=f /circ g

許多事物都構成範疇。比如所有集合就能構成一個範疇 Set ,函數(譯註:這裏說的函數是指集合論中的函數)就是它的態射,態射的組合就是函數的組合。所有羣也構成範疇 Grp ,保持羣結構的函數就是它的態射(羣同態),比如任意兩個羣 GHG 的操作符爲 :m:* , H 的操作符是 :m:circ ,那麼函數 f:G /to H 只要滿足如下條件就是一個態射:

f(u*v) = f(u)  /circ  f(v)

初看起來似乎所有態射都是函數,實際上也不一定,比如下面這個例子,任何偏序結構 (P , /leq ) 都構成範疇,該範疇中的對象就是 P 中的元素,任意兩個元素 ab 只要滿足 a /leq b ,那麼 a /to a 就是一個態射。另外,在相同的源對象和目的對象之間可以存在多個態射。我們拿 Set 範疇爲例, /sin/cos 都是從 R[-1,1] 的函數,但是他們不是相同的態射。

範疇公理

範疇需要滿足三個公理。第一個就是態射的組合操作需要滿足結合律。記作:

f /circ (g /circ h) = (g /circ h) /circ h

第二,態射在組合操作下是閉合的。所以如果存在態射 f:A /to Bg:B /to C ,那麼範疇中必定存在態射 h:A /to C 使得 h=f /circ g 。比如以下面這個範疇爲例:

images/Composition-ex.png

fg 都是態射,所以我們一定能夠對他們進行組合並得到範疇中的另一個態射。那麼哪一個是態射 f /circ g 呢?唯一的選擇就是 id_A 。類似地, g /circ f=id_B

第三個公理,對任何一個範疇 C ,其中任何一個對象 A 一定存在一個單位態射, id_A:A /to A 。這個態射是組合操作的單位元。

g /circ id_A = id_B /circ g = g

Hask ,Haskell範疇

本文重點關注一個叫 Hask 的範疇,該範疇由Haskell中所有類型組成,Haskell的函數就是它的態射, (.) 操作符便是態射組合,函數 f::A->B 就是 Hask 中的一個從類型 A 到類型 B 的態射。第一和第三個公理很容易驗證通過,因爲 (.) 操作符本身就是滿足結合律的函數,而且很顯然,對於任何函數 fgf.g 還是一個函數。在 Hask 中,單位態射就是函數 id ,而且顯然也滿足第三個公理:

id

.

f

 =

 f

.

id

 =

 f


函子

images/Functor.png

這是從範疇 /mathbf{C} 到範疇 /mathbf{D} 的函子。圖中的文字描述了對象 AB 被轉換到了範疇 D 中同一個對象,因此,態射 g 就被轉換成了一個源對象和目標對象相同的態射(不一定是單位態射),而且 id_Aid_B 變成了相同的態射。對象之間的轉換是用淺黃色的虛線箭頭表示,態射之間的轉換是用淺綠色的箭頭表示。

OK,我們已經介紹了一些範疇,這些範疇裏面都有些對象,還有一些態射能神奇地把對象關聯在一起。下面我們要介紹一個範疇論中相當重要的概念,那就是 函子 ,它甚至能把兩個範疇關聯在一起。函子本質上說其實就是範疇之間的轉換。比如對於範疇 CD ,函子 F : C /to D 能夠:

  • C 中任意對象 a 轉換爲 D 中的 F(A)
  • C 中的態射 f : A /to B 轉換爲 D 中的 F(f) : F(A) /to F(B)

“健忘”函子就是典型的一個函子: /mathbf{Grp} /to /mathbf{Set} ,它能將羣轉換成它底層的集合,並將羣的態射轉換成集合上的相同行爲的函數。另一個例子就是冪集函子: /mathbf{Set} /to /mathbf{Set} ,它能將集合轉換成他們的冪集,並將函數 f : X /to Y 轉換成函數 /mathcal{P}(X) /to /mathcal{P}(Y) ,後面這個函數接收所有 U /subset X 組成的集合(譯註:也就是集合 X 的冪集),將它轉換成 f(U) ,其中 f(U) = /{ /, f(u) : u /in U /, /} 。對所有的範疇 C 都可以定義一個所謂的單位函子,也叫做 1_C : C /to C ,它將對象和態射直接轉換成它們自己。在後面要講的 monad公理和他們的重要性 一節中,我們將會看到單位函子的作用。

同樣的,函子也需要遵守一些公理。第一,給定一個對象 A 上的單位態射 id_AF(id_A) 必須也是 F(A) 上的單位態射,也就是說:

F(id_A) = id_{F(A)}

第二,函子在態射組合上必須滿足分配率,也就是說:

F(f /circ g) = F(f) /circ F(g)

Hask 上的函子

也許你已經看出來了, typeclass Functor 確實和範疇論中的函子概念關係緊密。首先函子包括兩部分:首先它將一個範疇中的對象轉換成另一個範疇中的對象,其次它還將一個範疇中的態射轉換成另一個範疇中的態射。Haskell中的函子其實是從 HaskHask 的子範疇 Func 的,範疇 Func 是在定義該函子的類型上面的一個範疇。比如函子 list 就是從範疇 Hask 到範疇 Lst 的函子, Lst 範疇只包含一個類型 list ,也就是說函子 list 能將任意類型 T 轉換爲 [T] 。範疇 Lst 中的態射就是定義在 list 類型上的函數,即: [T]->[U] 。那麼所有這些東西又如何跟Haskell的 Functor typeclass 聯繫在一起呢?我們回憶一下 Functor 的定義:

class

 Functor

 (

f

 ::

 *

 ->

 *

)

 where


    fmap

 ::

 (

a

 ->

 b

)

 ->

 (

f

 a

 ->

 f

 b

)


我們再定義一個實例:

instance

 Functor

 Maybe

 where


    fmap

 f

 (

Just

 x

)

 =

 Just

 (

f

 x

)


    fmap

 _

 Nothing

  =

 Nothing


關鍵的來了:type constructor Maybe 將任意類型 T 轉換成新類型 Maybe T ,同時定義在 Maybe 上的 fmap 能將函數 a->b 轉換爲函數 Maybe a->Maybe b 。這樣,我們就已經把函子的兩個組成部分都定義了,將 Hask 中的對象轉換到另一個範疇中的對象,並將 Hask 中的態射轉換到該範疇的態射。所以 Maybe 是一個函子。

對於Haskell的 Functor 一個直觀的感覺就是,他們代表了一類可以被map的類型。它可以是 list 或是 Maybe ,也可以是樹這樣複雜的結構。利用它我們可以編寫一個執行實際map操作的函數,和 fmap 組合起來,然後就可以傳遞任意 Functor 結構給它。比如你可以寫一個通用函數可以取代 Data.List.mapData.Map.mapData.Array.IArray.amap ,等等。

我們繼續來看函子公理,多態函數 id 可以替代任意的 id_A ,所以第一條公理是滿足的:

fmap

 id

 =

 id


直觀地看,這句代碼的含義是說map一個結構,然後對其中每一個元素啥也不做,和從一開始就啥也不做是等價的。

第二,因爲態射組合就是 (.) ,那麼:

fmap

 (

f

 .

 g

)

 =

 fmap

 f

 .

 fmap

 g


這條公理還挺實用。這裏我們可以把函子想象成類似 list 這樣的容器,等號右邊就是一個要遍歷容器兩遍的算法:首先map這個容器,對其中元素執行函數 g ,產生一個新容器,然後map該新容器,執行 f 。而這條公理告訴我們,這個算法可以換成一個只需遍歷一遍的算法,並對其中每一個元素執行 f . g 。這個過程叫做 fusion (譯註:fusion啥意思不懂)。

將範疇論的概念對應到Haskell

現在我們來總結一下範疇論的概念要如何轉換到Haskell上面呢? 在這方面 Functor 提供了一個很好的例子。關鍵在於記住以下幾點:

  • 我們只探討 Hask 範疇和它的子範疇
  • 範疇的對象就是Haskell的類型
  • 範疇的態射就是Haskell的函數
  • 那些接受類型作爲參數並返回另一個類型的東西叫類型構造子
  • 那些接受函數作爲參數並返回另一個函數的東西叫高階函數
  • typeclass 以及它們提供的多態特性,正好反映了這樣一個事實,那就是在範疇論中,其實很多概念都是在一組對象上定義的。

Monads

images/Unit-join.png

unitjoin ,對monad中每一個對象都必須存在的兩個態射

Monad是Haskell中一個相當重要的概念,實際上,它們最開始就是來自範疇論。monad 是一類特別的函子,它們擁有一些獨特的結構。monad都是從一個範疇映射到其自身的函子。下面我們來看詳細定義,Monad是一個函子 M : C /to C ,並且對於 C 中每一個對象 x 都存在如下兩個態射:

  • /mathit{unit}^M_X : X /to M(X)
  • /mathit{join}^M_X : M(M(X)) /to M(X)

在隨後的討論中,只要不產生混淆,我們就去掉上標 M ,只說 /mathit{unit}_X/mathit{join}_X

現在我們來看看它是如何對應到Haskell的typeclass Monad 上的:

class

 Functor

 m

 =>

 Monad

 m

 where


    return

 ::

 a

 ->

 m

 a


    (

>>=

)

  ::

 m

 a

 ->

 (

a

 ->

 m

 b

)

 ->

 m

 b


類型約束 Functor m 可以確保我們已經擁有了函子結構:即對象和態射的一組轉換關係。 return 就是對應的 /mathit{unit}_X 。不過下面我們就遇到問題了,雖然 return 的類型酷似 unit ,但 (>>=) 的類型卻很難跟 join 聯繫起來。反而下面這樣的函數: join :: Monad m => m (m a) -> m a 看起來倒是跟 join 挺像的。實際上它們之間是可以互相轉換的:

join

 ::

 Monad

 m

 =>

 m

 (

m

 a

)

 ->

 m

 a


join

 x

 =

 x

 >>=

 id



(

>>=

)

 ::

 Monad

 m

 =>

 m

 a

 ->

 (

a

 ->

 m

 b

)

 ->

 m

 b


x

 >>=

 f

 =

 join

 (

fmap

 f

 x

)


所以給出 returnjoin 和給出 return>>= 是等價的。只不過在範疇論中通常用 unitjoin 來定義monad,而Haskell程序員則更喜歡用 return(>>=) _[3] 。範疇論的方式通常要更合理一點,因爲對於一個結構 M 來說,如果存在一種自然的方式將任意對象 X 轉換爲 M(X) ,並能將 M(M(X)) 轉換爲 M(X) ,那麼該結構很可能就是一個monad。這一點可以從下面的示例中看出。

示例:冪集函子同時也是monad

前面描述過的冪集函子 P : /mathbf{Set} /to /mathbf{Set} 可以形成一個monad。對每一個集合 S 都有 /mathit{unit}_S(x) = /{x/} ,將其中 S 的每一個元素映射到只含有該元素的一個集合。注意到這些只有一個元素的集合都是 S 的子集,所以 /mathit{unit}_S 返回的正是 S 的冪集中的元素,這樣就滿足了 monad 對 unit 的要求。我們再來定義函數 /mathit{join}_S ,輸入爲 L /in /mathcal{P}(/mathcal{P}(S)) ,它是:

  • S 的冪集的冪集的元素.
  • S 所有子集組成的集合的所有子集組成的集合的元素 .
  • S 的部分子集組成的集合

然後我們返回這些集合的並集, 這樣就得到了 S 的另一個子集(譯註:即 P(S) 的一個元素,也即成功地從 P(P(X)) 映射到了 P(X) ),公式如下。

/mathit{join}_S(L) = /bigcup L

由此可見 P 確實是一個 monad 。

其實 P`list` 幾乎是等價的;除了後者處理的是列表而前者是集合,他們在其他地方基本一致。見表:

集合上的冪集函子 Haskell中的List Monad
類型 定義 類型 定義
給定集合 S 和態射 A /to B 給定類型 T 和函數 f :: A -> B
P(f):P(A)->P(B) (P(f))(S)={f(a):a /in S} fmap f :: [A] -> [B] fmap f xs = [ f a | a <- xs ]
unit_S:S /to P(S) unit_S(X)={X} return :: T -> [T] return x = [x]
P(P(S)) /to P(S) join_S(L)= /bigcup L join :: [[T]] -> [T] join xs = concat xs

monad公理和他們的重要性

正如函子需要滿足函子的公理,monad也有他們的公理要去滿足。我們先把這些公理簡單列舉一下,並轉換成haskell代碼,最後再來探討這些公理的重要性。

給定一個monad M:C /to C 和態射 f:A /to B 其中 A,B /in C ,有公理如下:

  1. join /circ M(join)=join /circ join
  2. join /circ M(unit)=join /circ unit=id
  3. unit /circ f=M(f) /circ unit
  4. join /circ M(M(f))=M(f) /circ join

現在,大家應該可以很自然地把他們轉換成下面這樣的haskell代碼了吧:

  1. join . fmap join = join . join
  2. join . fmap return = join . return = id
  3. return . f = fmap f . return
  4. join . fmap (fmap f) = fmap f . join

(記住,fmap是函子定義中負責轉換態射的那一部分。)乍看起來,這些公理似乎看不出存在什麼深意。這幾條公理究竟有啥鳥含義,憑什麼非要monad遵守這幾條規定?下面便讓我們來探索一二。

公理一

images/Monad-law-1-lists.png

list 對公理一進行的演示. 記住在 list monad中 join 就是 concatfmap 就是普通的 map

爲了方便理解這條公理,我們先用 list 作爲例子。首先這條公理涉及兩個函數, join . fmap join (等式左邊)和 join . join (等式右邊)。這兩個函數的類型是什麼呢?因爲我們只探討 list ,所以我們知道 join 的類型是 [[a]] -> [a] ,然後可以推出他們的類型都是: [[[a]]] -> [a] 。所以我們的參數是一個 listlistlist ,然後對這個三層 list 執行 fmap join ,然後再在返回結果上應用 join 。對 list 來說 fmap 就是我們熟悉的普通 map ,所以我們首先對最外層列表的每一個元素進行 join 操作,也就是將其中每一個元素坍縮成爲單層 list 。這個時候我們就得到一個 listlist ,我們再在其上應用 join ,最終坍縮成爲一個 list 。簡單地說,我們先進入外層 list ,將第二層和第三層 list 坍縮成一層,然後再將這一層和最外層坍縮成一層。

等式右邊又是怎麼樣一個情況呢?我們首先對我們的三層 list 進行 join ,雖然是三層 list ,但實際上 join 操作的還是兩層 list ,因爲 [[[a]]] 也可以當作是一個 [[b]] ,其中 b = [a] ,所以,某種意義上說,三層 list 只是內部元素也是 list 的一個兩層 list 。所以如果我們將這個 listlist (的 list )應用到 join ,它會將外面兩層坍縮成一層,而因爲第二層的元素本身還是 list ,所以我們得到的還是一個 listlist ,然後我們再應用一次 join ,最終坍縮成爲一個 list ,總結起來就是說,等式左邊是先坍縮裏面兩層,然後坍縮外面一層,而等式右邊則是先坍縮外面兩層,然後裏面一層。而這條公理告訴我們,這兩個操作應該是等價的。其實也有點像是在說 join 操作需要滿足結合律。

Maybe 也是一個 monad,因爲:

return

 ::

 a

 ->

 Maybe

 a


return

 x

 =

 Just

 x



join

 ::

 Maybe

 (

Maybe

 a

)

 ->

 Maybe

 a


join

 Nothing

         =

 Nothing


join

 (

Just

 Nothing

)

  =

 Nothing


join

 (

Just

 (

Just

 x

))

 =

 Just

 x


所以如果我們有一個三層的 Maybe 類型(舉例來說,它可以是 Nothing , Just Nothing , Just (Just Nothing) or Just (Just (Just x)) ),公理一就告訴我們,先坍縮裏面兩層還是先坍縮外面兩層是完全等價的。

公理二

我們再來看看第二條公理, 同樣我們還是用 list 做例子。第二條公理提到兩個函數的類型都是: [a] -> [a] 。等式左邊表達的是對一個 list 進行map的函數,將每一個元素 x 轉換成者有這一個元素的列表 [x],這樣我們最終就得到一個單元素列表組成的列表。然後這個兩層 list 通過join函數又重新坍縮回單層 list ,右邊部分,接收整個 list [x, y, z, ...],將它轉換成單元素 list [[x, y, z, ...]] ,然後又坍縮成爲單層列表。這條公理的含義沒那麼容易一下子說清楚,不過大概就是說,當你在一個monadic值上面應用return,然後再對見過使用 join另它坍縮,不管你是在外層應用return還是在內部應用return,其效果是一樣的。

公理三和公理四

最後兩條公理就更加不言而喻了,要展現他們的真實性最簡單的方法就是將他們擴展開來:

  1. x -> return (f x) = x -> fmap f (return x)
  2. x -> join (fmap (fmap f) x) = x -> fmap f (join x)

應用到do語法

OK,前面我們已經對monad必須遵守的一些公理進行了一些直觀的陳述,但是這些公理爲什麼如此重要?這個問題的答案當我們看到do語法的時候就清除了。我們知道do只是一個語法糖,它其實就是多個 (>>=) 操作的組合:

do

 {

 x

 }

                 -->

  x

 -- test


do

 {

 let

 {

 y

 =

 v

 };

 x

 }

  -->

  let

 y

 =

 v

 in

 do

 {

 x

 }


do

 {

 v

 <-

 y

;

 x

 }

         -->

  y

 >>=

 /

v

 ->

 do

 {

 x

 }


do

 {

 y

;

 x

 }

              -->

  y

 >>=

 /

_

 ->

 do

 {

 x

 }


另外,我們其實可以通過上面提到的這些公理和 (>>=) 的定義,對 haskell 中的 monad 公理進行證明(證明過程有的地方比較複雜,如果沒有興趣也可以直接跳過):

  1. return x >>= f = f x 。證明:
  return

 x

 >>=

 f


=

 join

 (

fmap

 f

 (

return

 x

))

 -- 根據 (>>=) 的定義


=

 join

 (

return

 (

f

 x

))

      -- 根據公理三


=

 (

join

 .

 return

)

 (

f

 x

)


=

 id

 (

f

 x

)

                 -- 根據公理二


=

 f

 x


  1. m >>= return = m 。證明:
  m

 >>=

 return


=

 join

 (

fmap

 return

 m

)

    -- 根據 (>>=) 的定義


=

 (

join

 .

 fmap

 return

)

 m


=

 id

 m

                    -- 根據公理二


=

 m


  1. (m >>= f) >>= g = m >>= (x -> f x >>= g) 。證明(聯想 fmap f . fmap g = fmap (f . g) ):
  (

m

 >>=

 f

)

 >>=

 g


=

 (

join

 (

fmap

 f

 m

))

 >>=

 g

                          -- 根據 (>>=) 的定義


=

 join

 (

fmap

 g

 (

join

 (

fmap

 f

 m

)))

                  -- 根據 (>>=) 的定義


=

 (

join

 .

 fmap

 g

)

 (

join

 (

fmap

 f

 m

))


=

 (

join

 .

 fmap

 g

 .

 join

)

 (

fmap

 f

 m

)


=

 (

join

 .

 join

 .

 fmap

 (

fmap

 g

))

 (

fmap

 f

 m

)

         -- 根據公理四


=

 (

join

 .

 join

 .

 fmap

 (

fmap

 g

)

 .

 fmap

 f

)

 m


=

 (

join

 .

 join

 .

 fmap

 (

fmap

 g

 .

 f

))

 m

              -- 根據函子的分配率


=

 (

join

 .

 join

 .

 fmap

 (

/

x

 ->

 fmap

 g

 (

f

 x

)))

 m


=

 (

join

 .

 fmap

 join

 .

 fmap

 (

/

x

 ->

 fmap

 g

 (

f

 x

)))

 m

 -- 根據公理一


=

 (

join

 .

 fmap

 (

join

 .

 (

/

x

 ->

 fmap

 g

 (

f

 x

))))

 m

    -- 根據函子的分配率


=

 (

join

 .

 fmap

 (

/

x

 ->

 join

 (

fmap

 g

 (

f

 x

))))

 m


=

 (

join

 .

 fmap

 (

/

x

 ->

 f

 x

 >>=

 g

))

 m

                -- 根據 (>>=) 的定義


=

 join

 (

fmap

 (

/

x

 ->

 f

 x

 >>=

 g

)

 m

)


=

 m

 >>=

 (

/

x

 ->

 f

 x

 >>=

 g

)

                          -- 根據 (>>=) 的定義


這幾條使用 return 和 (>>=) 的monad公理,可以翻譯成如下的do語法糖:

無參(points-free)風格 do語句塊
return x >>= f = f x do { v <- return x; f v } = do { f x }
m >>= return = m do { v <- m; return v } = do { m }
(m >>= f) >>= g = m >>= (x -> f x >>= g)
   do

 {

 y

 <-

 do

 {

 x

 <-

 m

;

 f

 x

 };


       g

 y

 }


=


  do

 {

 x

 <-

 m

;


       y

 <-

 f

 x

;


       g

 y

 }


現在,monad公理就變成了關於do語句塊如何才能正常運轉的稱述了,如果有一個公理不滿足,都會導致用戶的困惑,因爲do語句塊的行爲將和你期待的不一樣。所以本質上說,monad公理其實是一份關於monad的可用性指南。

總結

在這一章中,我們一路走到現在,我們知道了範疇是什麼,他們對應了haskell的哪些概念。我們介紹了包括函子在內的許多範疇論中的重要概念,同 時還介紹了monad這樣的高級話題,並且看到了他們對於haskell來說是多麼的關鍵。我們沒有介紹範疇論中其他一些基本概念,比如自然轉換,因爲就 我們的目標來說並不需要。我們希望讓你能對haskell背後的範疇論概念有一些直觀的感受。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章