原文見 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
是由 a
與 b
參數化的類型的名字;p
是數據構造子的名字。要定義一個序對類型,只需向 Pair
類型構造子傳遞兩個類型。要構造一個序對類型的值,只需向數據構造子 P
傳遞兩個合適類型的值。例如,定義一個序對類型的值 stmt
,其對應的序對類型爲 String
與 Bool
的積:
stmt :: Pair String Bool stmt = P "This statements is" False
上述代碼的第一行是類型聲明,即便用 String
與 Bool
類型替換了 Pair
泛型定義中的 a
與 b
。第二行向數據構造子 P
傳遞了具體的字符串與布爾值,從而定義了一個序對類型的值 stmt
。類型構造子用於構造類型;數據構造子用於構造值。
由於類型構造子與數據構造子的命名空間是彼此獨立的,所以兩者能夠同名:
data Pair a b = Pair a b
若是你足夠學究,就能夠發現 Haskell 內建的序對類型只是這種聲明的一種變體,前者將 數據構造子 Pair
替換爲一個二元運算符 (,)
。若是將這個二元運算符像正常的數據構造子那樣用,照樣能建立序對類型的值,例如:
stmt = (,) "This statement is" False
相似的,能夠用 (,,)
來建立三元組,以此類推。
若是不用泛型序對或元組,也能夠定義特定的有名字的積類型,例如:
data Stmt = Stmt String Bool
這個類型雖然也是 String
與 Bool
的積,可是它有本身的名字與構造子。這種風格的類型聲明,其優點在於能夠爲相同的內容定義不一樣的類型,使之在名稱上具有不一樣的意義與功能,以免彼此混淆。
在編程中,只依賴元組以及多參數的構造子會讓代碼混亂,容易出錯,由於只能靠咱們的腦殼來記憶各個數據成員的含義。如果能對數據成員進行命名,結果會好一些。支持數據成員命名的積類型也是有的,在 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 e
從 e
中檢出 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 代碼中的構造子 Nil
與 Cons
,在 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
同構麼?例如,是否存在一個 Int
與 Void
構成的序對?
要建立序對,須要兩個值。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
的值,所以只要兩者有一個有值便可。邏輯運算 and 與 or 也能造成半環,它們也能映射到類型理論:
這是更深入的類比,也是邏輯與類型理論之間的 Curry-Howard 同構的基礎。之後在討論函數類型時,對此再做探討。
1.
證實 Maybe a
與 Either () 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
,而後建立兩個類 Circle
與 Rect
,並以虛函數的形式實現 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 審閱此文並提出了寶貴意見。
-> 下一篇:『函子』