類型類(typeclass)是 Haskell 最強大的功能之一:它用于定義通用接口,為各種不同的類型提供一組公共特性集。
類型類是某些基本語言特性的核心,比如相等性測試和數(shù)值操作符。
在討論如何使用類型類之前,先來看看它能做什么。
假設這樣一個場景:我們想對 Color 類型的值進行對比,但 Haskell 的語言設計者卻沒有實現(xiàn) == 操作。
要解決這個問題,必須親自實現(xiàn)一個相等性測試函數(shù):
-- file: ch06/colorEq.hs
data Color = Red | Green | Blue
colorEq :: Color -> Color -> Bool
colorEq Red Red = True
colorEq Green Green = True
colorEq Blue Blue = True
colorEq _ _ = False
在 ghci 里測試:
Prelude> :load colorEq.hs
[1 of 1] Compiling Main ( colorEq.hs, interpreted )
Ok, modules loaded: Main.
*Main> colorEq Green Green
True
*Main> colorEq Blue Red
False
過了一會,程序又添加了一個新類型 —— 職位:它對公司中的各個員工進行分類。
在執(zhí)行像是工資計算這類任務是,又需要用到相等性測試,所以又需要再次為職位類型定義相等性測試函數(shù):
-- file: ch06/roleEq.hs
data Role = Boss | Manager | Employee
roleEq :: Role -> Role -> Bool
roleEq Employee Employee = True
roleEq Manager Manager = True
roleEq Boss Boss = True
roleEq _ _ = False
測試:
Prelude> :load roleEq.hs
[1 of 1] Compiling Main ( roleEq.hs, interpreted )
Ok, modules loaded: Main.
*Main> roleEq Boss Boss
True
*Main> roleEq Boss Employee
False
colorEq 和 roleEq 的定義揭示了一個問題:對于每個不同的類型,我們都需要為它們專門定義一個對比函數(shù)。
這種做法非常低效,而且煩人。如果同一個對比函數(shù)(比如 == )可以用于對比任何類型的值,這樣就會方便得多。
另一方面,一般來說,如果定義了相等測試函數(shù)(比如 == ),那么不等測試函數(shù)(比如 /= )的值就可以直接對相等測試函數(shù)取反(使用 not )來計算得出。因此,如果可以通過相等測試函數(shù)來定義不等測試函數(shù),那么會更方便。
通用函數(shù)還可以讓代碼變得更通用:如果同一段代碼可以用于不同類型的輸入值,那么程序的代碼量將大大減少。
還有很重要的一點是,如果在之后添加通用函數(shù)對新類型的支持,那么原來的代碼應該不需要進行修改。
Haskell 的類型類可以滿足以上提到的所有要求。
類型類定義了一系列函數(shù),這些函數(shù)對于不同類型的值使用不同的函數(shù)實現(xiàn)。它和其他語言的接口和多態(tài)方法有些類似。
[譯注:這里原文是將“面向?qū)ο缶幊讨械膶ο蟆焙?Haskell 的類型類進行類比,但實際上這種類比并不太恰當,類比成接口和多態(tài)方法更適合一點。]
我們定義一個類型類來解決前面提到的相等性測試問題:
class BasicEq a where
isEqual :: a -> a -> Bool
類型類使用 class 關鍵字來定義,跟在 class 之后的 BasicEq 是這個類型類的名字,之后的 a 是這個類型類的實例類型(instance type)。
BasicEq 使用類型變量 a 來表示實例類型,說明它并不將這個類型類限定于某個類型:任何一個類型,只要它實現(xiàn)了這個類型類中定義的函數(shù),那么它就是這個類型類的實例類型。
實例類型所使用的名字可以隨意選擇,但是它和類型類中定義函數(shù)簽名時所使用的名字應該保持一致。比如說,我們使用 a 來表示實例類型,那么函數(shù)簽名中也必須使用 a 來代表這個實例類型。
BasicEq 類型類只定義了 isEqual 一個函數(shù) —— 它接受兩個參數(shù)作為輸入,并且這兩個參數(shù)都指向同一種實例類型:
Prelude> :load BasicEq_1.hs
[1 of 1] Compiling Main ( BasicEq_1.hs, interpreted )
Ok, modules loaded: Main.
*Main> :type isEqual
isEqual :: BasicEq a => a -> a -> Bool
作為演示,以下代碼將 Bool 類型作為 BasicEq 的實例類型,實現(xiàn)了 isEqual 函數(shù):
instance BasicEq Bool where
isEqual True True = True
isEqual False False = True
isEqual _ _ = False
在 ghci 里驗證這個程序:
*Main> isEqual True True
True
*Main> isEqual False True
False
如果試圖將不是 BasicEq 實例類型的值作為輸入調(diào)用 isEqual 函數(shù),那么就會引發(fā)錯誤:
*Main> isEqual "hello" "moto"
<interactive>:5:1:
No instance for (BasicEq [Char])
arising from a use of `isEqual'
Possible fix: add an instance declaration for (BasicEq [Char])
In the expression: isEqual "hello" "moto"
In an equation for `it': it = isEqual "hello" "moto"
錯誤信息提醒我們, [Char] 并不是 BasicEq 的實例類型。
稍后的一節(jié)會介紹更多關于類型類實例的定義方式,這里先繼續(xù)前面的例子。這一次,除了 isEqual 之外,我們還想定義不等測試函數(shù) isNotEqual :
class BasicEq a where
isEqual :: a -> a -> Bool
isNotEqual :: a -> a -> Bool
同時定義 isEqual 和 isNotEqual 兩個函數(shù)產(chǎn)生了一些不必要的工作:從邏輯上講,對于任何類型,只要知道 isEqual 或 isNotEqual 的任意一個,就可以計算出另外一個。因此,一種更省事的辦法是,為 isEqual 和 isNotEqual 兩個函數(shù)提供默認值,這樣 BasicEq 的實例類型只要實現(xiàn)這兩個函數(shù)中的一個,就可以順利使用這兩個函數(shù):
class BasicEq a where
isEqual :: a -> a -> Bool
isEqual x y = not (isNotEqual x y)
isNotEqual :: a -> a -> Bool
isNotEqual x y = not (isEqual x y)
以下是將 Bool 作為 BasicEq 實例類型的例子:
instance BasicEq Bool where
isEqual False False = True
isEqual True True = True
isEqual _ _ = False
我們只要定義 isEqual 函數(shù),就可以“免費”得到 isNotEqual :
Prelude> :load BasicEq_3.hs
[1 of 1] Compiling Main ( BasicEq_3.hs, interpreted )
Ok, modules loaded: Main.
*Main> isEqual True True
True
*Main> isEqual False False
True
*Main> isNotEqual False True
True
當然,如果閑著沒事,你仍然可以自己親手定義這兩個函數(shù)。但是,你至少要定義兩個函數(shù)中的一個,否則兩個默認的函數(shù)就會互相調(diào)用,直到程序崩潰。
定義一個類型為某個類型類的實例,指的就是,為某個類型實現(xiàn)給定類型類所聲明的全部函數(shù)。
比如在前面, BasicEq 類型類定義了兩個函數(shù) isEqual 和 isNotEqual :
class BasicEq a where
isEqual :: a -> a -> Bool
isEqual x y = not (isNotEqual x y)
isNotEqual :: a -> a -> Bool
isNotEqual x y = not (isEqual x y)
在前一節(jié),我們成功將 Bool 類型實現(xiàn)為 BasicEq 的實例類型,要使 Color 類型也成為 BasicEq 類型類的實例,就需要另外為 Color 類型實現(xiàn) isEqual 和 isNotEqual :
instance BasicEq Color where
isEqual Red Red = True
isEqual Blue Blue = True
isEqual Green Green = True
isEqual _ _ = True
注意,這里的函數(shù)定義和之前的 colorEq 函數(shù)定義實際上沒有什么不同,唯一的區(qū)別是,它使得 isEqual 不僅可以對 Bool 類型進行對比測試,還可以對 Color 類型進行對比測試。
更一般地說,只要為相應的類型實現(xiàn) BasicEq 類型類中的定義,那么 isEqual 就可以用于對比任何我們想對比的類型。
不過在實際中,通常并不使用 BasicEq 類型類,而是使用 Haskell Report 中定義的 Eq 類型類:它定義了 == 和 /= 操作符,這兩個操作符才是 Haskell 中最常用的測試函數(shù)。
以下是 Eq 類型類的定義:
class Eq a where
(==), (/=) :: a -> a -> Bool
-- Minimal complete definition:
-- (==) or (/=)
x /= y = not (x == y)
x == y = not (x /= y)
稍后會介紹更多使用 Eq 類型類的信息。
前面兩節(jié)分別介紹了類型類的定義,以及如何讓某個類型成為給定類型類的實例類型。
正本節(jié)會介紹幾個 Prelude 庫中包含的類型類。如本章開始時所說的,類型類是 Haskell 語言某些特性的奠基石,本節(jié)就會介紹幾個這方面的例子。
更多信息可以參考 Haskell 的函數(shù)參考,那里一般都給出了類型類的詳細介紹,并且說明,要成為這個類型類的實例,需要實現(xiàn)那些函數(shù)。
Show 類型類用于將值轉(zhuǎn)換為字符串,它最重要的函數(shù)是 show 。
show 函數(shù)使用單個參數(shù)接收輸入數(shù)據(jù),并返回一個表示該輸入數(shù)據(jù)的字符串:
Main> :type show
show :: Show a => a -> String
以下是一些 show 函數(shù)調(diào)用的例子:
Main> show 1
"1"
Main> show [1, 2, 3]
"[1,2,3]"
Main> show (1, 2)
"(1,2)"
Ghci 輸出一個值,實際上就是對這個值調(diào)用 putStrLn 和 show :
Main> 1
1
Main> show 1
"1"
Main> putStrLn (show 1)
1
因此,如果你定義了一種新的數(shù)據(jù)類型,并且希望通過 ghci 來顯示它,那么你就應該將這個類型實現(xiàn)為 Show 類型類的實例,否則 ghci 就會向你抱怨,說它不知道該怎樣用字符串的形式表示這種數(shù)據(jù)類型:
Main> data Color = Red | Green | Blue;
Main> show Red
<interactive>:10:1:
No instance for (Show Color)
arising from a use of `show'
Possible fix: add an instance declaration for (Show Color)
In the expression: show Red
In an equation for `it': it = show Red
Prelude> Red
<interactive>:5:1:
No instance for (Show Color)
arising from a use of `print'
Possible fix: add an instance declaration for (Show Color)
In a stmt of an interactive GHCi command: print it
通過實現(xiàn) Color 類型的 show 函數(shù),讓 Color 類型成為 Show 的類型實例,可以解決以上問題:
instance Show Color where
show Red = "Red"
show Green = "Green"
show Blue = "Blue"
當然, show 函數(shù)的打印值并不是非要和類型構造器一樣不可,比如 Red 值并不是非要表示為 "Red" 不可,以下是另一種實例化 Show 類型類的方式:
instance Show Color where
show Red = "Color 1: Red"
show Green = "Color 2: Green"
show Blue = "Color 3: Blue"
Read 和 Show 類型類的作用正好相反,它將字符串轉(zhuǎn)換為值。
Read 最有用的函數(shù)是 read :它接受一個字符串作為參數(shù),對這個字符串進行處理,并返回一個值,這個值的類型為 Read 實例類型的成員(所有實例類型中的一種)。
Prelude> :type read
read :: Read a => String -> a
以下代碼展示了 read 的用法:
Prelude> read "3"
<interactive>:5:1:
Ambiguous type variable `a0' in the constraint:
(Read a0) arising from a use of `read'
Probable fix: add a type signature that fixes these type variable(s)
In the expression: read "3"
In an equation for `it': it = read "3"
Prelude> (read "3")::Int
3
Prelude> :type it
it :: Int
Prelude> (read "3")::Double
3.0
Prelude> :type it
it :: Double
注意在第一次調(diào)用 read 的時候,我們并沒有顯式地給定類型簽名,這時對 read"3" 的求值會引發(fā)錯誤。這是因為有非常多的類型都是 Read 的實例,而編譯器在 read 函數(shù)讀入 "3" 之后,不知道應該將這個值轉(zhuǎn)換成什么類型,于是編譯器就會向我們發(fā)牢騷。
因此,為了讓 read 函數(shù)返回正確類型的值,必須給它指示正確的類型。
很多時候,程序需要將內(nèi)存中的數(shù)據(jù)保存為文件,又或者,反過來,需要將文件中的數(shù)據(jù)轉(zhuǎn)換為內(nèi)存中的數(shù)據(jù)實體。這種轉(zhuǎn)換過程稱為序列化和反序列化 .
通過將類型實現(xiàn)為 Read 和 Show 的實例類型, read 和 show 兩個函數(shù)可以成為非常好的序列化工具。
作為例子,以下代碼將一個內(nèi)存中的列表序列化到文件中:
Prelude> let years = [1999, 2010, 2012]
Prelude> show years
"[1999,2010,2012]"
Prelude> writeFile "years.txt" (show years)
writeFile 將給定內(nèi)容寫入到文件當中,它接受兩個參數(shù),第一個參數(shù)是文件路徑,第二個參數(shù)是寫入到文件的字符串內(nèi)容。
觀察文件 years.txt 可以看到, (showyears) 所產(chǎn)生的文本被成功保存到了文件當中:
$ cat years.txt
[1999,2010,2012]
使用以下代碼可以對 years.txt 進行反序列化操作:
Prelude> input <- readFile "years.txt"
Prelude> input -- 讀入的字符串
"[1999,2010,2012]"
Prelude> (read input)::[Int] -- 將字符串轉(zhuǎn)換成列表
[1999,2010,2012]
readFile 讀入給定的 years.txt ,并將它的內(nèi)存?zhèn)鹘o input 變量,最后,通過使用 read ,我們成功將字符串反序列化成一個列表。
Haskell 有一集非常強大的數(shù)字類型:從速度飛快的 32 位或 64 位整數(shù),到任意精度的有理數(shù),包羅萬有。
除此之外,Haskell 還有一系列通用算術操作符,這些操作符可以用于幾乎所有數(shù)字類型。而對數(shù)字類型的這種強有力的支持就是建立在類型類的基礎上的。
作為一個額外的好處(side benefit),用戶可以定義自己的數(shù)字類型,并且獲得和內(nèi)置數(shù)字類型完全平等的權利。
以下表格顯示了 Haskell 中最常用的一些數(shù)字類型:
表格 6.1 : 部分數(shù)字類型
類型 | 介紹 |
---|---|
Double | 雙精度浮點數(shù)。表示浮點數(shù)的常見選擇。 |
Float | 單精度浮點數(shù)。通常在對接 C 程序時使用。 |
Int | 固定精度帶符號整數(shù);最小范圍在 -2^29 至 2^29-1 。相當常用。 |
Int8 | 8 位帶符號整數(shù) |
Int16 | 16 位帶符號整數(shù) |
Int32 | 32 位帶符號整數(shù) |
Int64 | 64 位帶符號整數(shù) |
Integer | 任意精度帶符號整數(shù);范圍由機器的內(nèi)存限制。相當常用。 |
Rational | 任意精度有理數(shù)。保存為兩個整數(shù)之比(ratio)。 |
Word | 固定精度無符號整數(shù)。占用的內(nèi)存大小和 Int 相同 |
Word8 | 8 位無符號整數(shù) |
Word16 | 16 位無符號整數(shù) |
Word32 | 32 位無符號整數(shù) |
Word64 | 64 位無符號整數(shù) |
大部分算術操作都可以用于任意數(shù)字類型,少數(shù)的一部分函數(shù),比如 asin ,只能用于浮點數(shù)類型。
以下表格列舉了操作各種數(shù)字類型的常見函數(shù)和操作符:
表格 6.2 : 部分數(shù)字函數(shù)和
項 | 類型 | 模塊 | 描述 | |
---|---|---|---|---|
(+) | Num a => a -> a -> a | Prelude | 加法 | |
(-) | Num a => a -> a -> a | Prelude | 減法 | |
(*) | Num a => a -> a -> a | Prelude | 乘法 | |
(/) | Fractional a => a -> a -> a | Prelude | 份數(shù)除法 | |
(**) | Floating a => a -> a -> a | Prelude | 乘冪 | |
(^) | (Num a, Integral b) => a -> b -> a | Prelude | 計算某個數(shù)的非負整數(shù)次方 | |
(^^) | (Fractional a, Integral b) => a -> b -> a | Prelude | 分數(shù)的任意整數(shù)次方 | |
(%) | Integral a => a -> a -> Ratio a | Data.Ratio | 構成比率 | |
(.&.) | Bits a => a -> a -> a | Data.Bits | 二進制并操作 | |
(. | .) | Bits a => a -> a -> a | Data.Bits | 二進制或操作 |
abs | Num a => a -> a | Prelude | 絕對值操作 | |
approxRational | RealFrac a => a -> a -> Rational | Data.Ratio | 通過分數(shù)的分子和分母計算出近似有理數(shù) | |
cos | Floating a => a -> a | Prelude | 余弦函數(shù)。另外還有 acos 、 cosh 和 acosh ,類型和 cos 一樣。 | |
div | Integral a => a -> a -> a | Prelude | 整數(shù)除法,總是截斷小數(shù)位。 | |
fromInteger | Num a => Integer -> a | Prelude | 將一個 Integer 值轉(zhuǎn)換為任意數(shù)字類型。 | |
fromIntegral | (Integral a, Num b) => a -> b | Prelude | 一個更通用的轉(zhuǎn)換函數(shù),將任意 Integral 值轉(zhuǎn)為任意數(shù)字類型。 | |
fromRational | Fractional a => Rational -> a | Prelude | 將一個有理數(shù)轉(zhuǎn)換為分數(shù)??赡軙芯葥p失。 | |
log | Floating a => a -> a | Prelude | 自然對數(shù)算法。 | |
logBase | Floating a => a -> a -> a | Prelude | 計算指定底數(shù)對數(shù)。 | |
maxBound | Bounded a => a | Prelude | 有限長度數(shù)字類型的最大值。 | |
minBound | Bounded a => a | Prelude | 有限長度數(shù)字類型的最小值。 | |
mod | Integral a => a -> a -> a | Prelude | 整數(shù)取模。 | |
pi | Floating a => a | Prelude | 圓周率常量。 | |
quot | Integral a => a -> a -> a | Prelude | 整數(shù)除法;商數(shù)的分數(shù)部分截斷為 0 。 | |
recip | Fractional a => a -> a | Prelude | 分數(shù)的倒數(shù)。 | |
rem | Integral a => a -> a -> a | Prelude | 整數(shù)除法的余數(shù)。 | |
round | (RealFrac a, Integral b) => a -> b | Prelude | 四舍五入到最近的整數(shù)。 | |
shift | Bits a => a -> Int -> a | Bits | 輸入為正整數(shù),就進行左移。如果為負數(shù),進行右移。 | |
sin | Floating a => a -> a | Prelude | 正弦函數(shù)。還提供了 asin 、 sinh 和 asinh ,和 sin 類型一樣。 | |
sqrt | Floating a => a -> a | Prelude | 平方根 | |
tan | Floating a => a -> a | Prelude | 正切函數(shù)。還提供了 atan 、 tanh 和 atanh ,和 tan 類型一樣。 | |
toInteger | Integral a => a -> Integer | Prelude | 將任意 Integral 值轉(zhuǎn)換為 Integer | |
toRational | Real a => a -> Rational | Prelude | 從實數(shù)到有理數(shù)的有損轉(zhuǎn)換 | |
truncate | (RealFrac a, Integral b) => a -> b | Prelude | 向下取整 | |
xor | Bits a => a -> a -> a | Data.Bits | 二進制異或操作 |
數(shù)字類型及其對應的類型類列舉在下表:
表格 6.3 : 數(shù)字類型的類型類實例
類型 | Bits | Bounded | Floating | Fractional | Integral | Num | Real | RealFrac |
---|---|---|---|---|---|---|---|---|
Double | ? | ? | X | X | ? | X | X | X |
Float | ? | ? | X | X | ? | X | X | X |
Int | X | X | ? | ? | X | X | X | ? |
Int16 | X | X | ? | ? | X | X | X | ? |
Int32 | X | X | ? | ? | X | X | X | ? |
Int64 | X | X | ? | ? | X | X | X | ? |
Integer | X | ? | ? | ? | X | X | X | ? |
Rational or any Ratio | ? | ? | ? | X | ? | X | X | X |
Word | X | X | ? | ? | X | X | X | ? |
Word16 | X | X | ? | ? | X | X | X | ? |
Word32 | X | X | ? | ? | X | X | X | ? |
Word64 | X | X | ? | ? | X | X | X | ? |
表格 6.2 列舉了一些數(shù)字類型之間進行轉(zhuǎn)換的函數(shù),以下表格是一個匯總:
表格 6.4 : 數(shù)字類型之間的轉(zhuǎn)換
源類型 | 目標類型 | |||
Double, Float | Int, Word | Integer | Rational | |
Double, FloatInt, WordIntegerRational | fromRational . toRationalfromIntegralfromIntegralfromRational | truncate *fromIntegralfromIntegraltruncate * | truncate *fromIntegralN/Atruncate * | toRationalfromIntegralfromIntegralN/A |
第十三章會說明,怎樣用自定義數(shù)據(jù)類型來擴展數(shù)字類型。
除了前面介紹的通用算術符號之外,相等測試、不等測試、大于和小于等對比操作也是非常常見的。
其中, Eq 類型類定義了 == 和 /= 操作,而 >= 和 <= 等對比操作,則由 Ord 類型類定義。
需要將對比操作和相等性測試分開用兩個類型類來定義的原因是,對于某些類型,它們只對相等性測試和不等測試有興趣,比如 Handle 類型,而部分有序操作(particular ordering, 大于、小于等)對它來說是沒有意義的。
所有 Ord 實例都可以使用 Data.List.sort 來排序。
幾乎所有 Haskell 內(nèi)置類型都是 Eq 類型類的實例,而 Ord 實例的類型也不在少數(shù)。
對于簡單的數(shù)據(jù)類型, Haskell 編譯器可以自動將類型派生(derivation)為 Read 、 Show 、 Bounded 、 Enum 、 Eq 和 Ord 的實例。
以下代碼將 Color 類型派生為 Read 、 Show 、 Eq 和 Ord 的實例:
data Color = Red | Green | Blue
deriving (Read, Show, Eq, Ord)
測試:
*Main> show Red
"Red"
*Main> (read "Red")::Color
Red
*Main> (read "[Red, Red, Blue]")::[Color]
[Red,Red,Blue]
*Main> Red == Red
True
*Main> Data.List.sort [Blue, Green, Blue, Red]
[Red,Green,Blue,Blue]
*Main> Red < Blue
True
注意 Color 類型的排序位置由定義類型時值構造器的排序決定。
自動派生并不總是可用的。比如說,如果定義類型 dataMyType=MyType(Int->Bool) ,那么編譯器就沒辦法派生 MyType 為 Show 的實例,因為它不知道該怎么將 MyType 函數(shù)的輸出轉(zhuǎn)換成字符串,這會造成編譯錯誤。
除此之外,當使用自動推導將某個類型設置為給定類型類的實例時,定義這個類型時所使用的其他類型,也必須是給定類型類的實例(通過自動推導或手動添加的都可以)。
舉個例子,以下代碼不能使用自動推導:
data Book = Book
data BookInfo = BookInfo Book
deriving (Show)
Ghci 會給出提示,說明 Book 類型也必須是 Show 的實例, BookInfo 才能對 Show 進行自動推導:
Prelude> :load cant_ad.hs
[1 of 1] Compiling Main ( cant_ad.hs, interpreted )
ad.hs:4:27:
No instance for (Show Book)
arising from the 'deriving' clause of a data type declaration
Possible fix:
add an instance declaration for (Show Book)
or use a standalone 'deriving instance' declaration,
so you can specify the instance context yourself
When deriving the instance for (Show BookInfo)
Failed, modules loaded: none.
相反,以下代碼可以使用自動推導,因為它對 Book 類型也使用了自動推導,使得 Book 類型變成了 Show 的實例:
data Book = Book
deriving (Show)
data BookInfo = BookInfo Book
deriving (Show)
使用 :info 命令在 ghci 中確認兩種類型都是 Show 的實例:
Prelude> :load ad.hs
[1 of 1] Compiling Main ( ad.hs, interpreted )
Ok, modules loaded: Main.
*Main> :info Book
data Book = Book -- Defined at ad.hs:1:6
instance Show Book -- Defined at ad.hs:2:23
*Main> :info BookInfo
data BookInfo = BookInfo Book -- Defined at ad.hs:4:6
instance Show BookInfo -- Defined at ad.hs:5:27
我們在 在 Haskell 中表示 JSON 數(shù)據(jù) 一節(jié)介紹的 JValue 用起來還不夠簡便。這里是一段由搜索引擎返回的實際 JSON 數(shù)據(jù)。刪除重整之后:
{
"query": "awkward squad haskell",
"estimatedCount": 3920,
"moreResults": true,
"results":
[{
"title": "Simon Peyton Jones: papers",
"snippet": "Tackling the awkward squad: monadic input/output ...",
"url": "http://research.microsoft.com/~simonpj/papers/marktoberdorf/",
},
{
"title": "Haskell for C Programmers | Lambda the Ultimate",
"snippet": "... the best job of all the tutorials I've read ...",
"url": "http://lambda-the-ultimate.org/node/724",
}]
}
進一步簡化之,并用 Haskell 表示:
-- file: ch06/SimpleResult.hs
import SimpleJSON
result :: JValue
result = JObject [
("query", JString "awkward squad haskell"),
("estimatedCount", JNumber 3920),
("moreResults", JBool True),
("results", JArray [
JObject [
("title", JString "Simon Peyton Jones: papers"),
("snippet", JString "Tackling the awkward ..."),
("url", JString "http://.../marktoberdorf/")
]])
]
由于 Haskell 不原生支持包含不同類型值的列表,我們不能直接表示包含不同類型值的 JSON 對象。我們需要把每個值都用 JValue 構造器包裝起來。但這樣我們的靈活性就受到了限制:如果我們想把數(shù)字 3920 轉(zhuǎn)換成字符串 "3,920",我們就必須把 JNumber 構造器換成 JString 構造器。
Haskell 的類型類提供了一個誘人的解決方案:
-- file: ch06/JSONClass.hs
type JSONError = String
class JSON a where
toJValue :: a -> JValue
fromJValue :: JValue -> Either JSONError a
instance JSON JValue where
toJValue = id
fromJValue = Right
現(xiàn)在,我們無需再用 JNumber 等構造器去包裝值了,直接使用 toJValue 函數(shù)即可。如果我們更改值的類型,編譯器會自動選擇相應的 toJValue 實現(xiàn)。
我們也提供了 fromJValue 函數(shù),它把 JValue 值轉(zhuǎn)換成我們希望的類型。
fromJValue 函數(shù)的返回類型為 Either。跟 Maybe 一樣,這個類型是預定義的。我們經(jīng)常用它來表示可能會失敗的計算。
雖然 Maybe 也用作這個目的,但它在錯誤發(fā)生時沒有給我們足夠有用的信息:我們只得到一個 Nothing。Either 類型的結構相同,但它在錯誤發(fā)生時會調(diào)用 Left 構造器,并且還接受一個參數(shù)。
-- file: ch06/DataEither.hs
data Maybe a = Nothing
| Just a
deriving (Eq, Ord, Read, Show)
data Either a b = Left a
| Right b
deriving (Eq, Ord, Read, Show)
我們經(jīng)常使用 String 作為 a 參數(shù)的類型,以便在出錯時提供有用的描述。為了說明在實際中怎么使用 Either 類型,我們來看一個簡單實例。
-- file: ch06/JSONClass.hs
instance JSON Bool where
toJValue = JBool
fromJValue (JBool b) = Right b
fromJValue _ = Left "not a JSON boolean"
[譯注:讀者若想在 ghci 中嘗試 fromJValue,需要為其提供類型標注,例如 (fromJValue(toJValueTrue))::EitherJSONErrorBool。]
Haskell 98標準不允許我們用下面的形式聲明實例,盡管它看起來沒什么問題:
-- file: ch06/JSONClass.hs
instance JSON String where
toJValue = JString
fromJValue (JString s) = Right s
fromJValue _ = Left "not a JSON string"
String 是 [Char] 的別名,因此它的類型是 [a],并用 Char 替換了類型變量 a。根據(jù) Haskell 98的規(guī)則,我們在聲明實例的時候不能用具體類型替代類型變量。也就是說,我們可以給 [a] 聲明實例,但給 [Char] 不行。
盡管 GHC 默認遵守 Haskell 98標準,但是我們可以在文件頂部添加特殊格式的注釋來解除這個限制。
-- file: ch06/JSONClass.hs
{-# LANGUAGE TypeSynonymInstances #-}
這條注釋是一條編譯器指令,稱為編譯選項(pragma),它告訴編譯器允許這項語言擴展。上面的代碼因為TypeSynonymInstances
這項語言擴展而合法。我們在本章(本書)還會碰到更多的語言擴展。
[譯注:作者舉的這個例子實際上牽涉到了兩個問題。第一,Haskell 98不允許類型別名,這個問題可以通過上述方法解決。第二,Haskell 98不允許 [Char] 這種形式的類型,這個問題需要通過增加另外一條編譯選項 {-#LANGUAGEFlexibleInstances#-} 來解決。]
Haskell 的設計允許我們?nèi)我鈩?chuàng)建類型類實例。
-- file: ch06/JSONClass.hs
doubleToJValue :: (Double -> a) -> JValue -> Either JSONError a
doubleToJValue f (JNumber v) = Right (f v)
doubleToJValue _ _ = Left "not a JSON number"
instance JSON Int where
toJValue = JNumber . realToFrac
fromJValue = doubleToJValue round
instance JSON Integer where
toJValue = JNumber . realToFrac
fromJValue = doubleToJValue round
instance JSON Double where
toJValue = JNumber
fromJValue = doubleToJValue id
我們可以在任意地方創(chuàng)建新實例,而不僅限于在定義了類型類的模塊中。類型類系統(tǒng)的這個特性被稱為開放世界假設(open world assumption)。如果有方法表示“這個類型類只存在這些實例”,那我們將得到一個封閉的世界。
我們希望把列表轉(zhuǎn)為 JSON 數(shù)組。現(xiàn)在先不用關心實現(xiàn)細節(jié),暫時用 undefined 替代函數(shù)內(nèi)容即可。
-- file: ch06/BrokenClass.hs
instance (JSON a) => JSON [a] where
toJValue = undefined
fromJValue = undefined
我們也希望能將鍵/值對列表轉(zhuǎn)為 JSON 對象。
-- file: ch06/BrokenClass.hs
instance (JSON a) => JSON [(String, a)] where
toJValue = undefined
fromJValue = undefined
如果我們把這些定義放進文件中并在 ghci 里載入,初看起來沒什么問題。
*JSONClass> :l BrokenClass.hs
[1 of 2] Compiling JSONClass ( JSONClass.hs, interpreted )
[2 of 2] Compiling BrokenClass ( BrokenClass.hs, interpreted )
Ok, modules loaded: JSONClass, BrokenClass
然而,當我們使用序?qū)α斜韺嵗龝r,麻煩來了。
*BrokenClass> toJValue [("foo","bar")]
<interactive>:10:1:
Overlapping instances for JSON [([Char], [Char])]
arising from a use of ‘toJValue’
Matching instances:
instance JSON a => JSON [(String, a)]
-- Defined at BrokenClass.hs:13:10
instance JSON a => JSON [a] -- Defined at BrokenClass.hs:8:10
In the expression: toJValue [("foo", "bar")]
In an equation for ‘it’: it = toJValue [("foo", "bar")]
重疊實例問題是由 Haskell 的開放世界假設造成的。 這里有一個更簡單的例子來說明發(fā)生了什么。
-- file: ch06/Overlap.hs
class Borked a where
bork :: a -> String
instance Borked Int where
bork = show
instance Borked (Int, Int) where
bork (a, b) = bork a ++ ", " ++ bork b
instance (Borked a, Borked b) => Borked (a, b) where
bork (a, b) = ">>" ++ bork a ++ " " ++ bork b ++ "<<"
對于序?qū)Γ覀冇袃蓚€ Borked 類型類實例:一個是 Int 序?qū)?,另一個是任意類型的序?qū)Γ灰@個類型是 Borked 類型類的實例。
假設我們想把 bork 應用于 Int 序?qū)Α>幾g器必須選擇一個實例來用。由于這兩個實例都能用,所以看上去它好像只要選那個更相關(specific)的實例就可以了。
但是,GHC 默認是保守的。它堅持只能有一個可用實例。這樣,當我們試圖使用 bork 時,它就會報錯。
Note
重疊實例什么時候會出問題?
之前我們提到,我們可以把某個類型類的實例分散在幾個模塊中。GHC 并不會在意重疊實例的存在。相反,只有當我們使用受影響的類型類的函數(shù),GHC 被迫要選擇使用哪個實例時,它才會報錯。
通常,我們不能給多態(tài)類型(polymorphic type)的特化版本(specialized version)寫類型類實例。[Char] 類型就是多態(tài)類型 [a] 特化成 Char 的結果。因此我們禁止聲明 [Char] 為某個類型類的實例。這非常不方便,因為字符串在代碼中無處不在。
FlexibleInstances 語言擴展取消了這個限制,它允許我們寫這樣的實例。
GHC 支持另外一個有用的語言擴展,OverlappingInstances,它解決了重疊實例帶來的問題。如果存在重疊實例,編譯器會選擇最相關的(specific)那一個。
我們經(jīng)常把這個擴展和 TypeSynonymInstances 放在一起使用。下面是一個例子。
-- file: ch06/SimpleClass.hs
{-# LANGUAGE TypeSynonymInstances, OverlappingInstances #-}
import Data.List
class Foo a where
foo :: a -> String
instance Foo a => Foo [a] where
foo = concat . intersperse ", " . map foo
instance Foo Char where
foo c = [c]
instance Foo String where
foo = id
如果我們對 String 應用 foo,編譯器會選擇 String 的特定實現(xiàn)。即使 [a] 和 Char 都是 Foo 的實例,但由于 String 實例更相關,因此 GHC 選擇了它。
即使開了 OverlappingInstances 擴展,如果 GHC 發(fā)現(xiàn)了多個同樣相(equally specific)關的實例,它仍然會拒絕代碼。
何時使用 OverlappingInstances 擴展(to be added)
OverlappingInstances 和 TypeSynonymInstances 語言擴展是 GHC 特有的,Haskell 98 并不支持。然而,Haskell 98 中的 Show 類型類在轉(zhuǎn)化 Char 列表和 Int 列表時卻用了不同的方法。它用了一個聰明但簡單的小技巧。
Show 類型類定義了轉(zhuǎn)換單個值的 show 方法和轉(zhuǎn)換列表的 showList 方法。showList 默認使用中括號和逗號轉(zhuǎn)換列表。
[a] 的 Show 實例使用 showList 實現(xiàn)。Char 的 Show 實例提供了一個特殊的 showList 實現(xiàn),它使用雙引號,并轉(zhuǎn)義非 ASCII 打印字符。
結果是,如果有人想對 [Char] 應用 show,編譯器會選擇 showList 的實現(xiàn),并使用雙引號正確轉(zhuǎn)換這個字符串。
這樣,換個角度看問題,我們就能避免 OverlappingInstances 擴展了。
除了熟悉的 data 關鍵字外,Haskell 還允許我們用 newtype 關鍵字來創(chuàng)建新類型。
-- file: ch06/Newtype.hs
data DataInt = D Int
deriving (Eq, Ord, Show)
newtype NewtypeInt = N Int
deriving (Eq, Ord, Show)
newtype 聲明的作用是重命名現(xiàn)有類型,并給它一個新身份??梢钥闯觯挠梅ê褪褂?data 關鍵字進行類型聲明看起來很相似。
Note
type 和 newtype 關鍵字
盡管名字類似,type 和 newtype 關鍵字的作用卻完全不同。type 關鍵字給了我們另一種指代某個類型的方法,類似于給朋友起的綽號。我們和編譯器都知道 [Char] 和 String 指的是同一個類型。
相反,newtype 關鍵字的存在是為了隱藏類型的本性??紤]這個 UniqueID 類型。
-- file: ch06/Newtype.hs
newtype UniqueID = UniqueID Int
deriving (Eq)
編譯器會把 UniqueID 當成和 Int 不同的類型。作為 UniqueID 的用戶,我們只知道它是一個唯一標識符;我們并不知道它是用 Int 來實現(xiàn)的。
在聲明 newtype 時,我們必須決定暴露被重命名類型的哪些類型類實例。這里,我們讓 NewtypeInt 提供 Int 類型的 Eq, Ord 和 Show 實例。這樣,我們就可以比較和打印 NewtypeInt 類型的值了。
*Main> N 1 < N 2
True
由于我們沒有暴露 Int 的 Num 或 Integral 實例,NewtypeInt 類型的值并不是數(shù)字。例如,我們不能做加法。
*Main> N 313 + N 37
<interactive>:9:7:
No instance for (Num NewtypeInt) arising from a use of ‘+’
In the expression: N 313 + N 37
In an equation for ‘it’: it = N 313 + N 37
跟用 data 關鍵字一樣,我們可以用 newtype 的值構造器創(chuàng)建新值,或者對現(xiàn)有值進行模式匹配。 如果 newtype 沒用自動派生來暴露對應類型的類型類實現(xiàn)的話,我們可以自己寫一個新實例或者干脆不實現(xiàn)那個類型類。 data 和 newtype 的區(qū)別 newtype 關鍵字給現(xiàn)有類型一個不同的身份,相比起 data,它使用時的限制更多。具體來講,newtype 只能有一個值構造器, 并且這個構造器只能有一個字段。
-- file: ch06/NewtypeDiff.hs
-- 可以:任意數(shù)量的構造器和字段
data TwoFields = TwoFields Int Int
-- 可以:一個字段
newtype Okay = ExactlyOne Int
-- 可以:使用類型變量
newtype Param a b = Param (Either a b)
-- 可以:使用記錄語法
newtype Record = Record {
getInt :: Int
}
-- 不可以:沒有字段
newtype TooFew = TooFew
-- 不可以:多于一個字段
newtype TooManyFields = Fields Int Int
-- 不可以:多于一個構造器
newtype TooManyCtors = Bad Int
| Worse Int
除此之外,data 和 newtype 還有一個重要區(qū)別。由 data 關鍵字創(chuàng)建的類型在運行時有一個簿記開銷,如記錄某個值是用哪個構造器創(chuàng)建的。而 newtype 只有一個構造器,所以不需要這個額外開銷。這使得它在運行時更省時間和空間。
由于 newtype 的構造器只在編譯時使用,運行時甚至不存在,用 newtype 定義的類型和用 data 定義的類型在匹配 undefined 時會有不同的行為。
為了理解它們的不同點,我們首先回顧一下普通數(shù)據(jù)類型的行為。我們已經(jīng)非常熟悉,在運行時對 undefined 求值會導致崩潰。
Prelude> undefined
*** Exception: Prelude.undefined
我們把 undefined 放進 D 構造器創(chuàng)建一個 DataInt,然后對它進行模式匹配。
*Main> case (D undefined) of D _ -> 1
1
由于我們的模式匹配只匹配構造器而不管里面的值,undefined 未被求值,因而不會拋出異常。
下面的例子沒有使用 D 構造器,因而模式匹配時 undefined 被求值,異常拋出。
*Main> case undefined of D _ -> 1
*** Exception: Prelude.undefined
當我們用 N 構造器創(chuàng)建 NewtypeInt 值時,它的行為與使用 DataInt 類型的 D 構造器相同:沒有異常。
*Main> case (N undefined) of N _ -> 1
1
但當我們把表達式中的 N 去掉,并對 undefined 進行模式匹配時,關鍵的不同點來了。
*Main> case undefined of N _ -> 1
1
沒有崩潰!由于運行時不存在構造器,匹配 N 實際上就是在匹配通配符 :由于通配符總可以被匹配,所以表達式是不需要被求值的。
這里簡要回顧一下 haskell 引入新類型名的三種方式。
Haskell 98 有一個微妙的特性可能會在某些意想不到的情況下“咬”到我們。下面這個簡單的函數(shù)展示了這個問題。
-- file: ch06/Monomorphism.hs
myShow = show
如果我們試圖把它載入 ghci,會產(chǎn)生一個奇怪的錯誤:
Prelude> :l Monomorphism.hs
[1 of 1] Compiling Main ( Monomorphism.hs, interpreted )
Monomorphism.hs:2:10:
No instance for (Show a0) arising from a use of ‘show’
The type variable ‘a(chǎn)0’ is ambiguous
Relevant bindings include
myShow :: a0 -> String (bound at Monomorphism.hs:2:1)
Note: there are several potential instances:
instance Show a => Show (Maybe a) -- Defined in ‘GHC.Show’
instance Show Ordering -- Defined in ‘GHC.Show’
instance Show Integer -- Defined in ‘GHC.Show’
...plus 22 others
In the expression: show
In an equation for ‘myShow’: myShow = show
Failed, modules loaded: none.
[譯注:譯者得到的輸出和原文有出入,這里提供的是使用最新版本 GHC 得到的輸出。] 錯誤信息中提到的 “monomorphism” 是 Haskell 98 的一部分。 單一同態(tài)是多態(tài)(polymorphism)的反義詞:它表明某個表達式只有一種類型。 Haskell 有時會強制使某些聲明不像我們預想的那么多態(tài)。 我們在這里提單一同態(tài)是因為盡管它和類型類沒有直接關系,但類型類給它提供了產(chǎn)生的環(huán)境。 Note 在實際代碼中可能很久都不會碰到單一同態(tài),因此我們覺得你沒必要記住這部分的細節(jié), 只要在心里知道有這么回事就可以了,除非 GHC 真的報告了跟上面類似的錯誤。 如果真的發(fā)生了,記得在這兒曾讀過這個錯誤,然后回過頭來看就行了。 我們不會試圖去解釋單一同態(tài)限制。Haskell 社區(qū)一致同意它并不經(jīng)常出現(xiàn);它解釋起來很棘手(tricky); 它幾乎沒什么實際用處;它唯一的作用就是坑人。舉個例子來說明它為什么棘手:盡管上面的例子違反了這個限制, 下面的兩個編譯起來卻毫無問題。
-- file: ch06/Monomorphism.hs
myShow2 value = show value
myShow3 :: (Show a) => a -> String
myShow3 = show
上面的定義表明,如果 GHC 報告單一同態(tài)限制錯誤,我們有三個簡單的方法來處理。
沒人喜歡單一同態(tài)限制,因此幾乎可以肯定的是下一個版本的 Haskell 會去掉它。但這并不是說加上 NoMonomorphismRestriction 就可以一勞永逸:有些編譯器(包括一些老版本的 GHC)識別不了這個擴展,但用另外兩種方法就可以解決問題。如果這種可移植性對你不是問題,那么請務必打開這個擴展。
在這章,你學到了類型類有什么用以及怎么用它們。我們討論了如何定義自己的類型類,然后又討論了一些 Haskell 庫里定義的類型類。最后,我們展示了怎么讓 Haskell 編譯器給你的類型自動派生出某些類型類實例。
更多建議: