第六章:類型類

2018-02-24 15:49 更新

第六章:類型類

類型類(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 類型類的信息。

幾個重要的內(nèi)置類型類

前面兩節(jié)分別介紹了類型類的定義,以及如何讓某個類型成為給定類型類的實例類型。

正本節(jié)會介紹幾個 Prelude 庫中包含的類型類。如本章開始時所說的,類型類是 Haskell 語言某些特性的奠基石,本節(jié)就會介紹幾個這方面的例子。

更多信息可以參考 Haskell 的函數(shù)參考,那里一般都給出了類型類的詳細介紹,并且說明,要成為這個類型類的實例,需要實現(xiàn)那些函數(shù)。

Show

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

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ù)返回正確類型的值,必須給它指示正確的類型。

使用 Read 和 Show 進行序列化

很多時候,程序需要將內(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 ,我們成功將字符串反序列化成一個列表。

數(shù)字類型

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, FloatInt, WordIntegerRational
Double, FloatInt, WordIntegerRationalfromRational . toRationalfromIntegralfromIntegralfromRationaltruncate *fromIntegralfromIntegraltruncate *truncate *fromIntegralN/Atruncate *toRationalfromIntegralfromIntegralN/A
  • 除了 truncate 之外,還可以使用 round 、 ceiling 或者 float 。

第十三章會說明,怎樣用自定義數(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

類型類實戰(zhàn):讓 JSON 更好用

我們在 在 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。]

使用類型別名創(chuàng)建實例

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

什么時候重疊實例(Overlapping instances)會出問題?

如果我們把這些定義放進文件中并在 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)

字符串的 show 是如何工作的?

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 擴展了。

如何給類型定義新身份(Identity)

除了熟悉的 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 引入新類型名的三種方式。

  • data 關鍵字定義一個真正的代數(shù)數(shù)據(jù)類型。
  • type 關鍵字給現(xiàn)有類型定義別名。類型和別名可以通用。
  • newtype 關鍵字給現(xiàn)有類型定義一個不同的身份(distinct identity)。原類型和新類型不能通用。

JSON typeclasses without overlapping instances

可怕的單一同態(tài)限定(monomorphism restriction)

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)限制錯誤,我們有三個簡單的方法來處理。

  • 顯式聲明函數(shù)參數(shù),而不是隱性。
  • 顯式定義類型簽名,而不是依靠編譯器去推導。
  • 不改代碼,編譯模塊的時候用上 NoMonomorphismRestriction 語言擴展。它取消了單一同態(tài)限制。

沒人喜歡單一同態(tài)限制,因此幾乎可以肯定的是下一個版本的 Haskell 會去掉它。但這并不是說加上 NoMonomorphismRestriction 就可以一勞永逸:有些編譯器(包括一些老版本的 GHC)識別不了這個擴展,但用另外兩種方法就可以解決問題。如果這種可移植性對你不是問題,那么請務必打開這個擴展。

結論

在這章,你學到了類型類有什么用以及怎么用它們。我們討論了如何定義自己的類型類,然后又討論了一些 Haskell 庫里定義的類型類。最后,我們展示了怎么讓 Haskell 編譯器給你的類型自動派生出某些類型類實例。

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號