<譯> 簡單的代數數據類型

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

-> 上一篇:『積與餘積編程

咱們已經瞭解了兩種類型組合方式:積與餘積。編程中,僅經過這兩種類型就能夠構造出大部分數據結構。正是由於這一點,保證了數據結構的許多性質都是可複合的。例如,若是知道如何比較兩種基本類型的值是否相等,而且知道如何將這種比較方法推廣到積與餘積類型,那麼你就可以自動派生出支持相等運算的複合類型。在 Haskell 中,大部分複合類型可以自動繼承相等比較、大小比較以及與字符串的相互轉換等運算能力。segmentfault

如今,咱們先近距離的看看編程中的積類型與和類型。數據結構

積類型

編程語言中,對於兩個類型的積,官方實現是序對(Pair)。在 Haskell 中,序對是基本的類型構造子;在 C++ 中,積表現爲標準庫中所定義的相對複雜一些的模板。編程語言

序對

序對並不是嚴格可交換的:不能用序對 (Bool, Int) 去替換序對 (Int, Bool),即便它們承載相同的信息。然而,在同構意義上,序對是可交換的——swap 可給出這種同構關係:函數

swap :: (a, b) -> (b, a)
swqp (x, y) = (y, x)

你能夠將這兩個序對看做是採用了不一樣的格式存儲了相同的數據,這有點像大根堆 vs 小根堆。atom

能夠經過序對的嵌套,將任意個數的類型組合到積內,可是更簡單的方法是利用:嵌套的序對與元組(Tuple)相等。由於嵌套的序對與元組是同構的。若是你想將 a, b, 與 c 這三種類型組合爲積,有兩種辦法能夠作到:spa

((a, b), c)

翻譯

(a, (b, c))

這兩種類型是不一樣的——你不能將其中一種類型傳遞給一個須要另外一種類型的函數——可是它們所包含的元素是壹壹對應的。有一個函數能夠將其中一種類型映射爲另外一種:指針

alpha :: ((a, b), c) -> (a, (b, c))
alpha ((x, y), z) = (x, (y, z))

而且,這個函數是可逆的:

alpha_inv :: (a, (b, c)) -> ((a, b), c)
alpha_inv  (x, (y, z)) = ((x, y), z)

所以,這是一種同構,即用了不一樣的方式封裝了相同的數據。

能夠將積類型的構造解釋爲做用於類型的一種二元運算。從這個角度來看,上述的同構關係看上去有些像幺半羣中的結合律:

(a * b) * c = a * (b * c)

只不過,在幺半羣的狀況中,這兩種複合爲積的方法是等價的,而上述積類型的構造方法只是在『同構條件下』等價。

若是是在同構的條件下,再也不堅持嚴格的相等性的話,咱們能夠走的更遠,並且還能揭示 unit 類型 () 相似於數字乘法中的 1。類型 a 的值與一個 unit 值所構成的序對,除了 a 值以外,什麼也沒有包含。所以,這種類型

(a, ())

a 是同構的。若是不相信,咱們能夠爲它們構造出同構關係:

rho :: (a, ()) -> a
rho (x, ()) = x

rho_inv :: a -> (a, ())
rho_inv x = (x, ())

若是用形式化語言描述這些現象,能夠說 Set(集合的範疇)是一個幺半羣範疇,亦即這種範疇也是一個幺半羣,這意味着你可讓對象相乘(在此,就是笛卡爾積)。之後我會再多講講幺半羣範疇,並給出完整的定義。

在 Haskell 中有一種更通用的辦法來定義積類型——特別是,過一會就能夠看到,這種積類型可由加類型複合而成。這種積類型能夠用帶有多個參數的構造子來定義,例如可將序對定義爲:

data Pair a b = P a b

在此,Pair a b 是由 ab 參數化的類型的名字;p 是數據構造子的名字。要定義一個序對類型,只需向 Pair 類型構造子傳遞兩個類型。要構造一個序對類型的值,只需向數據構造子 P 傳遞兩個合適類型的值。例如,定義一個序對類型的值 stmt,其對應的序對類型爲 StringBool 的積:

stmt :: Pair String Bool
stmt = P "This statements is" False

上述代碼的第一行是類型聲明,即便用 StringBool 類型替換了 Pair 泛型定義中的 ab。第二行向數據構造子 P 傳遞了具體的字符串與布爾值,從而定義了一個序對類型的值 stmt。類型構造子用於構造類型;數據構造子用於構造值。

由於類型構造子與數據構造子的命名空間是彼此獨立的,所以兩者能夠同名:

data Pair a b = Pair a b

若是你足夠學究,就能夠發現 Haskell 內建的序對類型只是這種聲明的一種變體,前者將 數據構造子 Pair 替換爲一個二元運算符 (,)。若是將這個二元運算符像正常的數據構造子那樣用,照樣能建立序對類型的值,例如:

stmt = (,) "This statement is" False

相似的,能夠用 (,,) 來建立三元組,以此類推。

若是不用泛型序對或元組,也能夠定義特定的有名字的積類型,例如:

data Stmt = Stmt String Bool

這個類型雖然也是 StringBool 的積,可是它有本身的名字與構造子。這種風格的類型聲明,其優點在於能夠爲相同的內容定義不一樣的類型,使之在名稱上具有不一樣的意義與功能,以免彼此混淆。

在編程中,只依賴元組以及多參數的構造子會讓代碼混亂,容易出錯,由於只能靠咱們的腦殼來記憶各個數據成員的含義。如果能對數據成員進行命名,結果會好一些。支持數據成員命名的積類型也是有的,在 Haskell 中稱爲記錄,在 C 語言中稱爲 struct

記錄

來看一個簡單的例子:用兩個字符串表示化學元素的名稱與符號,用一個整型數表示原子量,將這些信息組合到一個數據結構中。能夠用元組 (String, String, Int) 來表示這個數據結構,只不過須要咱們記住每一個數據成員的含義。如今要用一個函數檢查化學元素符號是否爲其名稱的前綴(例如 He 是否爲 Helium 的前綴),這須要經過模式匹配從元組中抽取數據成員:

startsWithSymbol :: (String, String, Int) -> Bool
startsWithSymbol (name, symbol, _) = isPrefixOf symbol name

這樣的代碼很容易出錯,而且也難於理解與維護。更好的辦法是用記錄:

data Element = Element { name         :: String
                       , symbol       :: String
                       , atomicNumber :: Int }

上述的元組與記錄是同構的,由於兩者之間存在可逆的轉換函數:

tupleToElem :: (String, String, Int) -> Element
tupleToElem (n, s, a) = Element { name = n
                                , symbol = s
                                , atomicNumber = a }

elemToTuple :: Element -> (String, String, Int)
elemToTuple e = (name e, symbol e, atomicNumber e)

注意,記錄的數據成員的名字自己也是訪問這些數據成員的函數。例如,atomicNumber ee 中檢出 atomicNumber 的值。也就是說,atomicNumber 是個函數:

atomicNumber :: Eelement -> Int

使用 Element 的記錄語法去實現 startsWithSymbol 函數,可讀性更好:

startsWithSymbol :: Element -> Bool
startsWithSymbol e = isPrefixOf (symbol e) (name e)

還能夠用一下 Haskell 的小花招,將 isPrefixOf 做爲中綴運算符,可讓 startsWithSymbol 的實現幾乎接近人類的語言:

startsWithSymbol e = symbol e `isPrefixOf` name e

原來的兩對括號被省略了,由於中綴運算符的優先級低於函數調用。

和類型

就像集合的範疇中的積能產生積類型那樣,餘積能產生和類型。Haskell 官方實現的和類型以下:

data Either a b = Left a | Right b

相似序對,Either 在同構意義下是可交換的,也是可嵌套的,並且在同構意義下,嵌套的順序不重要。所以,咱們能夠定義與三元組相等的和類型:

data OneOfThree a b c = Sinistral a | Medial b | Dextral c

能夠證實 Set 對於餘積而言也是個(對稱的)幺半羣範疇。二元運算由不相交和(Disjoint Sum)來承擔,unit 元素由初始對象來扮演。用類型術語來講,咱們能夠將 Either 做爲幺半羣運算符,將 Void 做爲中立元素。也就是說,能夠認爲 Either 相似於運算符 +,而 Void 相似於 0。這與事實是相符的,將 Void 加到和類型上,不會對和類型有任何影響。例如:

Either a Void

a 同構。這是由於沒法爲這種類型構造 Right 版本的值——不存在類型爲 Void 的值。Either a Void 只能經過 Left 構造子產生值,這個值只是簡單的封裝了一個類型爲 a 的值。這就相似於 a + 0 = a

在 Haskell 中,和類型至關廣泛,而 C++ 中的與之相對應的聯合(Union)與變體(Variant)類型則不是那麼常見。這是有緣由的。

首先,最簡單的和類型是枚舉,在 C++ 中能夠用 enum 來實現,在 Haskell 中與之等價的和類型是:

data Color = Red | Green | Blue

在 C++ 中相應的枚舉類型爲:

enum {Red, Green, Blue};

其實 Haskell 中還有更簡單的和類型:

data Bool = True | False

它與 C++ 中內建的 bool 類型等價。

在 C++ 中,要控制一個值在和類型中出現與否,有各類各樣的實現,須要藉助一些特殊技巧以及一些『不可能』的值,例如空字串、負數、空指針等等。在 Haskell 只需使用 Maybe 類型便可解決這一問題:

data Maybe a = Nothing | Just a

Maybe 類型是兩個類型的和。能夠將兩個構造子分開來看。只觀察第一個構造子,其形態以下:

data NothingType = Nothing

它是一個叫作 Nothing 的單值的枚舉。換句話說,它是一個單例,與 unit 類型 () 等價。

第二個構造子

data JustType a = Just a

的做用就是封裝類型 a

因而,咱們能夠將 Maybe 表示爲:

data Maybe a = Either () a

在 C++ 中,更復雜一些的和類型每每要使用指針。一個指針能夠爲空,也能夠指向某種特定類型。例如,Haskell 中的列表類型,它被定義爲一個(遞歸)的和類型:

List a = Nil | Cons a (List a)

要將它翻譯爲 C++ 代碼,須要使用空指針來模擬空列表:

template<class A> 
class List {
    Node<A> * _head;
public:
    List() : _head(nullptr) {}  // Nil
    List(A a, List<A> l)        // Cons
      : _head(new Node<A>(a, l))
    {}
};

注意,上述 Haskell 代碼中的構造子 NilCons,在 C++ 代碼中對應於 List 構造函數的重載。雖然 List 類不須要用標籤來區分和類型的兩個成員,可是它須要用 nullptr_head 弄成相似 Nil 這樣的東西。

Haskell 與 C++ 類型之間主要的區別是 Haskell 的數據結構不可變。若是你使用特定的構造子建立了一個對象,這個對象會永遠記住它是由哪一個構造子建立的,以及這個構造子接受的參數是什麼。所以由 Just "energy" 建立的 Maybe 對象,永遠也不會變成 Nothing。相似的,一個空的列表永遠都是空的,一個有三個元素的列表永遠有相同的三個元素。

正是這種數據的不變性使得數據的構造是可逆的。給定一個對象,你老是可以使用它的構造函數將它大卸八塊。這種解構過程能夠經過模式匹配來實現。

List 類型有兩個構造子,所以任意一個 List 的解構可使用與這兩個構造子相對應的模式匹配來實現。一個模式能夠匹配 Nil 列表,另外一個模式可匹配 Cons 構造的列表。例如,下面是一個做用於 List 的簡單函數:

maybeTail :: List a -> Maybe (List a)
maybeTail Nil = Nothing
maybeTail (Cons _ t) = Just t

maybeTail 定義的第一部分使用了 Nil 構造子做爲模式,以 Nothing 做爲結果返回。第二部分使用了 Cons 構造子做爲模式,並使用通配符 _ 對其第一個參數進行匹配,之因此如此,是由於咱們對第一個參數不感興趣。Cons 的第二個參數值會被綁定到變量 t 上(雖然稱之爲變量,但事實上它們是永遠都不變的:一旦它們被綁定到表達式,它們就不會再變化了),返回結果是 Just t。你的 List 是如何建立的,maybeTail 總有一個『從句』可以與之相匹配。若是 List 是由 Cons 建立,那麼 Cons 所接受的兩個參數就會被 maybeTail 檢出(第一個參數會被忽略)。

C++ 中能夠經過多態類的繼承方式實現更復雜一些的和類型。有共同祖先的類族能夠看做是一個可變的類型,虛函數表的扮演了一個隱含的標籤的角色。C++ 多態類是經過虛函數表查找要被調用的虛函數來實現多態,而 Haskell 老是能夠基於構造子的模式匹配來完成一樣的事。

不多看到 C++ 代碼中會使用聯合類型做爲和類型,由於聯合類型存在不少限制。譬如,你不能將 std::string 放入聯合中,由於字符串具備 copy 構造函數。

類型代數

積類型與和類型,單獨使用其中一個就能夠定義一些有用的數據結構,可是兩者組合起來或更增強大。咱們再度祭出複合的能量。

先總結一下到如今爲止已經見識過的東西。咱們已經見識了類型系統中兩種可交換的幺半羣結構:用 Void 做爲中性元素的和類型,用 () 做爲中性元素的積類型。能夠將這兩種類型比喻爲加法與乘法。在這個比喻中,Void 至關於 0,而 () 至關於 1

如今來思考如何加強這個比喻。例如,與 0 相乘的結果爲 0 麼?換句話說,一個積類型中的一個成員是 Void,那麼這個積類型與 Void 同構麼?例如,是否存在一個 IntVoid 構成的序對?

要建立序對,須要兩個值。Int 值很容易得到,可是 Void 卻沒有值。所以,對於任意類型 a 而言,(a, Void) 是不存在的——它沒有值——所以它等價於 Void。換句話說,a * 0 = 0

另一件事,加法與乘法在一塊兒的時候,存在着分配律:

a * (b + c) = a * b + a * c

那麼對於積類型與和類型而言,是否也存在分配律?是的,不過通常是在同構意義下存在。上式的左半部分至關於:

(a, Either b c)

右半部分至關於:

Either (a, b) (a, c)

下面給出一個方向的轉換函數:

prodToSum :: (a, Either b c) -> Either (a, b) (a, c)
prodToSum (x, e) = 
    case e of
      Left  y -> Left  (x, y)
      Right z -> Right (x, z)

另外一個方向的轉換函數爲:

sumToProd :: Either (a, b) (a, c) -> (a, Either b c)
sumToProd e = 
    case e of
      Left  (x, y) -> (x, Left  y)
      Right (x, z) -> (x, Right z)

case of 語句用於函數內部的模式匹配。每一個模式後面是箭頭所指向的可求值的表達式。例如,若是將下面的值做爲 proToSum 的參數:

prod1 :: (Int, Either String Float)
prod1 = (2, Left "Hi!")

case e of 中的 e 就等於 Left "Hi!",它會匹配到模式 Left y,因此 y 就會被綁定到 "Hi!"。由於 x 已經匹配到 2 了,所以 case of 從句的結果,也就是整個函數的結果將會是 Left (2, "Hi!")

我不打算證實上面的這兩個函數互逆,不過若是你對此有所疑慮,能夠確定的說,它們確定互逆!由於它們只不過是將相同的數據用了不一樣的形式進行了封裝。

數學家們爲這種相互糾纏的幺半羣取了個名字:半環(Semiring)。之因此不叫它們全環,是由於咱們沒法定義類型的減法。這就是爲什麼半環有時也被稱爲 rig 的緣由,由於『Ring without an n(negative,負數)』。不過,在此不關心這些問題,咱們關心的是怎麼描述天然數運算與類型運算之間的對應關係。這裏有一個表,給出了一些有趣的對應關係:

天然數運算對應的類型運算

列表類型至關有趣,由於它被定義爲一個方程的解,由於咱們要定義的類型出如今方程兩側:

List a = Nil | Cons a (List a)

若是將 List a 換成 x,就能夠獲得這樣的方程:

x = 1 + a * x

不過,咱們不能使用傳統的代數方法去求解這個方程,由於對於類型,咱們沒有相應的減法與除法運算。不過,能夠用一系列的替換,即不斷的用 (1 + a*x) 來替換方程右側的 x,並使用分配律,這樣就有了下面的結果:

x = 1 + a*x
x = 1 + a*(1 + a*x) = 1 + a + a*a*x
x = 1 + a + a*a*(1 + a*x) = 1 + a + a*a + a*a*a*x
...
x = 1 + a + a*a + a*a*a + a*a*a*a...

最終會是一個積(元組)的無限和,這個結果可被解釋爲:一個列表,要麼是空的,即 1;要麼是一個單例 a;要麼是一個序對 a*a;要麼是一個三元組 a*a*a;……以此類推,結果就是一個由 a 構成的字符串。

對於列表而言,還有更有趣的東西,等咱們瞭解函子與不動點以後,再來觀察它們以及其餘的遞歸數據結構。

用符號變量來解方程,這就是代數!所以上面出現的這些數據類型被稱爲:代數數據類型。

最後,我應該談一下類型代數的一個很是重要的解釋。注意,類型 a 與類型 b 的積必須包含類型 a 的值與類型 b 的值,這意味着這兩種類型都是有值的;兩種類型的和則要麼包含類型 a 的值,要麼包含類型 b 的值,所以只要兩者有一個有值便可。邏輯運算 andor 也能造成半環,它們也能映射到類型理論:

邏輯運算對應的類型運算

這是更深入的類比,也是邏輯與類型理論之間的 Curry-Howard 同構的基礎。之後在討論函數類型時,對此再做探討。

挑戰

1. 證實 Maybe aEither () a 同構。

2. 用 Haskell 定義一個和類型:

data Shape = Circle Float
           | Rect Float Float

要爲 Shape 定義一個 area 函數,能夠用 Shape 的兩個構造子造成的模式匹配:

area :: Shape -> Float
area (Circle r) = pi * r * r
area (Rect d h) = d * h

請在 C++ 或 Java 中以接口的形式實現 Shape,而後建立兩個類 CircleRect,並以虛函數的形式實現 area

3. 繼續上一題,在不改變 Shape 定義的條件下,很容易增長一個新函數 circ,用於計算 Shape 的周長:

circ :: Shape -> Float
circ (Circle r) = 2.0 * pi * r
circ (Rect d h) = 2.0 * (d + h)

請爲你的 C++ 或 Java 實現也增長 circ 函數。原來的代碼有哪部分不得不做修改?

4. 繼續上一題,在 Shape 中增長一個新的形狀 Square,並對代碼做相應的更新。在 Haskell vs C++ 或 Java 的實現中,有哪些代碼須要變更?(即便你並不是 Haskell 程序猿,代碼的變更應該也是至關明顯的。)

5. 證實 a + a = 2 * a 在類型系統中(在同構意義下)也成立。記住,在上面給出的表中,2 至關於 Bool

致謝

感謝 Gershom Bazerman 審閱此文並提出了寶貴意見。

-> 下一篇:『函子

相關文章
相關標籤/搜索