第八章 Haskell構(gòu)造我們自己的類型和類型類

2022-08-08 14:25 更新
  • 數(shù)據(jù)類型入門
  • Record Syntax
  • 類型參數(shù)
  • 派生實(shí)例
  • 類型別名

數(shù)據(jù)類型入門

在前面的章節(jié)中,我們談了一些Haskell內(nèi)置的類型和類型類。而在本章,我們將學(xué)習(xí)構(gòu)造類型和類型類的方法。

我們以已經(jīng)見識(shí)了許多數(shù)據(jù)類型,如Bool、Int、Char、Maybe等等,不過該怎樣構(gòu)造自己的數(shù)據(jù)類型呢?好問題,使用data關(guān)鍵字是一種方法。我們看看Bool在標(biāo)準(zhǔn)庫(kù)中的定義:

data Bool = False | True

data表示我們要定義一個(gè)新的數(shù)據(jù)類型。=的左端標(biāo)明類型的名稱即Bool,=的右端就是值構(gòu)造子Value Constructor),它們明確了該類型可能的值。|讀作“或”,所以可以這樣閱讀該聲明:Bool類型的值可以是True或False。類型名和值構(gòu)造子的首字母必大寫。

相似,我們可以假想Int類型的聲明:

data Int = -2147483648 | -2147483647 | ... | -1 | 0 | 1 | 2 | ... | 2147483647

首位兩個(gè)值構(gòu)造子分別表示了Int類型可能的最小值和最大值,這些省略號(hào)表示我們省去了中間大段的數(shù)字。當(dāng)然,真實(shí)的聲明不是這個(gè)樣子的,這樣寫只是為了便于理解。

我們想想Haskell中圖形的表示方法。表示圓可以用一個(gè)元組,如(43.1,55.0,10.4),前兩項(xiàng)表示圓心的位置,末項(xiàng)表示半徑。聽著不錯(cuò),不過三維向量或其它什么東西也可能是這種形式!更好的方法就是自己構(gòu)造一個(gè)表示圖形的類型。假定圖形可以是圓(Circle)或長(zhǎng)方形(Rectangle):

data Shape = Circle Float Float Float | Rectangle Float Float Float Float

這是啥,想想?Circle的值構(gòu)造子有三個(gè)項(xiàng),都是Float??梢娢覀?cè)诙x值構(gòu)造子時(shí),可以在后面跟幾個(gè)類型表示它包含值的類型。在這里,前兩項(xiàng)表示圓心的坐標(biāo),尾項(xiàng)表示半徑。Rectangle的值構(gòu)造子取四個(gè)Float項(xiàng),前兩項(xiàng)表示其左上角的坐標(biāo),后兩項(xiàng)表示右下角的坐標(biāo)。

談到“項(xiàng)”(field),其實(shí)應(yīng)為“參數(shù)”(parameters)。值構(gòu)造子的本質(zhì)是個(gè)函數(shù),可以返回一個(gè)類型的值。我們看下這兩個(gè)值構(gòu)造子的類型聲明:

ghci> :t Circle   
Circle :: Float -> Float -> Float -> Shape   
ghci> :t Rectangle   
Rectangle :: Float -> Float -> Float -> Float -> Shape

Cool,這么說值構(gòu)造子就跟普通函數(shù)并無(wú)二致咯,誰(shuí)想得到?我們寫個(gè)函數(shù)計(jì)算圖形面積:

surface :: Shape -> Float   
surface (Circle _ _ r) = pi * r ^ 2   
surface (Rectangle x1 y1 x2 y2) = (abs $ x2 - x1) * (abs $ y2 - y1)

值得一提的是,它的類型聲明表示了該函數(shù)取一個(gè)Shape值并返回一個(gè)Float值。寫Circle -> Float是不可以的,因?yàn)镃ircle并非類型,真正的類型應(yīng)該是Shape。這與不能寫True->False的道理是一樣的。再就是,我們使用的模式匹配針對(duì)的都是值構(gòu)造子。之前我們匹配過[]、False5,它們都是不包含參數(shù)的值構(gòu)造子。

我們只關(guān)心圓的半徑,因此不需理會(huì)表示坐標(biāo)的前兩項(xiàng):

ghci> surface $ Circle 10 20 10   
314.15927   
ghci> surface $ Rectangle 0 0 100 100   
10000.0

Yay,it works!不過我們?nèi)魢L試輸出Circle 10 20到控制臺(tái),就會(huì)得到一個(gè)錯(cuò)誤。這是因?yàn)镠askell還不知道該類型的字符串表示方法。想想,當(dāng)我們往控制臺(tái)輸出值的時(shí)候,Haskell會(huì)先調(diào)用show函數(shù)得到這個(gè)值的字符串表示才會(huì)輸出。因此要讓我們的Shape類型成為Show類型類的成員??梢赃@樣修改:

data Shape = Circle Float Float Float | Rectangle Float Float Float Float deriving (Show)

先不去深究deriving(派生),可以先這樣理解:若在data聲明的后面加上deriving (Show),那Haskell就會(huì)自動(dòng)將該類型至于Show類型類之中。好了,由于值構(gòu)造子是個(gè)函數(shù),因此我們可以拿它交給map,拿它不全調(diào)用,以及普通函數(shù)能做的一切。

ghci> Circle 10 20 5   
Circle 10.0 20.0 5.0   
ghci> Rectangle 50 230 60 90   
Rectangle 50.0 230.0 60.0 90.0

我們?nèi)粢∫唤M不同半徑的同心圓,可以這樣:

ghci> map (Circle 10 20) [4,5,6,6]   
[Circle 10.0 20.0 4.0,Circle 10.0 20.0 5.0,Circle 10.0 20.0 6.0,Circle 10.0 20.0 6.0]

我們的類型還可以更好。增加加一個(gè)表示二維空間中點(diǎn)的類型,可以讓我們的Shape更加容易理解:

data Point = Point Float Float deriving (Show)   
data Shape = Circle Point Float | Rectangle Point Point deriving (Show)

注意下Point的定義,它的類型與值構(gòu)造子用了相同的名字。沒啥特殊含義,實(shí)際上,在一個(gè)類型含有唯一值構(gòu)造子時(shí)這種重名是很常見的。好的,如今我們的Circle含有兩個(gè)項(xiàng),一個(gè)是Point類型,一個(gè)是Float類型,好作區(qū)分。Rectangle也是同樣,我們得修改surface函數(shù)以適應(yīng)類型定義的變動(dòng)。

surface :: Shape -> Float   
surface (Circle _ r) = pi * r ^ 2   
surface (Rectangle (Point x1 y1) (Point x2 y2)) = (abs $ x2 - x1) * (abs $ y2 - y1)

唯一需要修改的地方就是模式。在Circle的模式中,我們無(wú)視了整個(gè)Point。而在Rectangle的模式中,我們用了一個(gè)嵌套的模式來取得Point中的項(xiàng)。若出于某原因而需要整個(gè)Point,那么直接匹配就是了。

ghci> surface (Rectangle (Point 0 0) (Point 100 100))   
10000.0   
ghci> surface (Circle (Point 0 0) 24)   
1809.5574

表示移動(dòng)一個(gè)圖形的函數(shù)該怎么寫? 它應(yīng)當(dāng)取一個(gè)Shape和表示位移的兩個(gè)數(shù),返回一個(gè)位于新位置的圖形。

nudge :: Shape -> Float -> Float -> Shape   
nudge (Circle (Point x y) r) a b = Circle (Point (x+a) (y+b)) r   
nudge (Rectangle (Point x1 y1) (Point x2 y2)) a b = Rectangle (Point (x1+a) (y1+b)) (Point (x2+a) (y2+b))

很直白,我們給這一Shape的點(diǎn)加上位移的量。

ghci> nudge (Circle (Point 34 34) 10) 5 10   
Circle (Point 39.0 44.0) 10.0

如果不想直接處理Point,我們可以搞個(gè)輔助函數(shù)(auxilliary function),初始從原點(diǎn)創(chuàng)建圖形,再移動(dòng)它們。

baseCircle :: Float -> Shape   
baseCircle r = Circle (Point 0 0) r   

baseRect :: Float -> Float -> Shape   
baseRect width height = Rectangle (Point 0 0) (Point width height)
ghci> nudge (baseRect 40 100) 60 23   
Rectangle (Point 60.0 23.0) (Point 100.0 123.0)

毫無(wú)疑問,你可以把你的數(shù)據(jù)類型導(dǎo)出到模塊中。只要把你的類型與要導(dǎo)出的函數(shù)寫到一起就是了。再在后面跟個(gè)括號(hào),列出要導(dǎo)出的值構(gòu)造子,用逗號(hào)隔開。如要導(dǎo)出所有的值構(gòu)造子,那就寫個(gè)..。

若要將這里定義的所有函數(shù)和類型都導(dǎo)出到一個(gè)模塊中,可以這樣:

module Shapes    
( Point(..)   
, Shape(..)   
, surface   
, nudge   
, baseCircle   
, baseRect   
) where

一個(gè)Shape (..),我們就導(dǎo)出了Shape的所有值構(gòu)造子。這一來無(wú)論誰(shuí)導(dǎo)入我們的模塊,都可以用RectangleCircle值構(gòu)造子來構(gòu)造Shape了。這與寫Shape(Rectangle,Circle)等價(jià)。

我們可以選擇不導(dǎo)出任何Shape的值構(gòu)造子,這一來使用我們模塊的人就只能用輔助函數(shù)baseCirclebaseRect來得到Shape了。Data.Map就是這一套,沒有Map.Map [(1,2),(3,4)],因?yàn)樗鼪]有導(dǎo)出任何一個(gè)值構(gòu)造子。但你可以用,像Map.fromList這樣的輔助函數(shù)得到map。應(yīng)該記住,值構(gòu)造子只是函數(shù)而已,如果不導(dǎo)出它們,就拒絕了使用我們模塊的人調(diào)用它們。但可以使用其他返回該類型的函數(shù),來取得這一類型的值。

不導(dǎo)出數(shù)據(jù)類型的值構(gòu)造子隱藏了他們的內(nèi)部實(shí)現(xiàn),令類型的抽象度更高。同時(shí),我們模塊的使用者也就無(wú)法使用該值構(gòu)造子進(jìn)行模式匹配了。

Record Syntax

OK,我們需要一個(gè)數(shù)據(jù)類型來描述一個(gè)人,得包含他的姓、名、年齡、身高、體重、電話號(hào)碼以及最愛的冰激淋。我不知你的想法,不過我覺得要了解一個(gè)人,這些資料就夠了。就這樣,實(shí)現(xiàn)出來!

data Person = Person String String Int Float String String deriving (Show)

O~Kay,第一項(xiàng)是名,第二項(xiàng)是姓,第三項(xiàng)是年齡,等等。我們?cè)煲粋€(gè)人:

ghci> let guy = Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"   
ghci> guy   
Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"

貌似很酷,就是難讀了點(diǎn)兒。弄個(gè)函數(shù)得人的某項(xiàng)資料又該如何?如姓的函數(shù),名的函數(shù),等等。好吧,我們只能這樣:

firstName :: Person -> String   
firstName (Person firstname _ _ _ _ _) = firstname   

lastName :: Person -> String   
lastName (Person _ lastname _ _ _ _) = lastname   

age :: Person -> Int   
age (Person _ _ age _ _ _) = age   

height :: Person -> Float   
height (Person _ _ _ height _ _) = height   

phoneNumber :: Person -> String   
phoneNumber (Person _ _ _ _ number _) = number   

flavor :: Person -> String   
flavor (Person _ _ _ _ _ flavor) = flavor

唔,我可不愿寫這樣的代碼!雖然it works,但也太無(wú)聊了哇。

ghci> let guy = Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"   
ghci> firstName guy   
"Buddy"   
ghci> height guy   
184.2   
ghci> flavor guy   
"Chocolate"

你可能會(huì)說,一定有更好的方法!呃,抱歉,沒有。

開個(gè)玩笑,其實(shí)有的,哈哈哈~Haskell的發(fā)明者都是天才,早就料到了此類情形。他們引入了一個(gè)特殊的類型,也就是剛才提到的更好的方法--Record Syntax。

data Person = Person { firstName :: String   
                     , lastName :: String   
                     , age :: Int   
                     , height :: Float   
                     , phoneNumber :: String   
                     , flavor :: String   
                     } deriving (Show)

與原先讓那些項(xiàng)一個(gè)挨一個(gè)的空格隔開不同,這里用了花括號(hào){}。先寫出項(xiàng)的名字,如firstName,后跟兩個(gè)冒號(hào)(也叫Raamayim Nekudotayim,哈哈~(譯者不知道什么意思~囧)),標(biāo)明其類型,返回的數(shù)據(jù)類型仍與以前相同。這樣的好處就是,可以用函數(shù)從中直接按項(xiàng)取值。通過Record Syntax,haskell就自動(dòng)生成了這些函數(shù):firstName,lastName,age,height,phoneNumberflavor

ghci> :t flavor   
flavor :: Person -> String   
ghci> :t firstName   
firstName :: Person -> String

還有個(gè)好處,就是若派生(deriving)到Show類型類,它的顯示是不同的。假如我們有個(gè)類型表示一輛車,要包含生產(chǎn)商、型號(hào)以及出場(chǎng)年份:

data Car = Car String String Int deriving (Show)
ghci> Car "Ford" "Mustang" 1967   
Car "Ford" "Mustang" 1967

若用Record Syntax,就可以得到像這樣的新車:

data Car = Car {company :: String, model :: String, year :: Int} deriving (Show)
ghci> Car {company="Ford", model="Mustang", year=1967}   
Car {company = "Ford", model = "Mustang", year = 1967}

這一來在造車時(shí)我們就不必關(guān)心各項(xiàng)的順序了。

表示三維向量之類簡(jiǎn)單數(shù)據(jù),Vector = Vector Int Int Int就足夠明白了。但一個(gè)值構(gòu)造子中若含有很多個(gè)項(xiàng)且不易區(qū)分,如一個(gè)人或者一輛車啥的,就應(yīng)該使用Record Syntax。

類型參數(shù)

值構(gòu)造子可以取幾個(gè)參數(shù)產(chǎn)生一個(gè)新值,如Car的構(gòu)造子是取三個(gè)參數(shù)返回一個(gè)Car。與之相似,類型構(gòu)造子可以取類型作參數(shù),產(chǎn)生新的類型。這咋一聽貌似有點(diǎn)深?yuàn)W,不過實(shí)際上并不復(fù)雜。如果你對(duì)C++的模板有了解,就會(huì)看到很多相似的地方。我們看一個(gè)熟悉的類型,好對(duì)類型參數(shù)有個(gè)大致印象:

data Maybe a = Nothing | Just a

這里的a就是個(gè)類型參數(shù)。也正因?yàn)橛辛怂?,Maybe就成為了一個(gè)類型構(gòu)造子。在它的值不是Nothing時(shí),它的類型構(gòu)造子可以搞出Maybe Int,Maybe String等等諸多類型。但只一個(gè)Maybe是不行的,因?yàn)樗皇穷愋?,而是類型?gòu)造子。要成為真正的類型,必須得把它需要的類型參數(shù)全部填滿。

所以,如果拿Char作參數(shù)交給Maybe,就可以得到一個(gè)Maybe Char的類型。如,Just 'a'的類型就是Maybe Char。

你可能并未察覺,在遇見Maybe之前我們?cè)缇徒佑|到類型參數(shù)了。它便是List類型。這里面有點(diǎn)語(yǔ)法糖,List類型實(shí)際上就是取一個(gè)參數(shù)來生成一個(gè)特定類型,這類型可以是Int,Char也可以是String,但不會(huì)跟在[]的后面。

把玩一下Maybe

ghci> Just "Haha"   
Just "Haha"   
ghci> Just 84   
Just 84   
ghci> :t Just "Haha"   
Just "Haha" :: Maybe [Char]   
ghci> :t Just 84   
Just 84 :: (Num t) => Maybe t   
ghci> :t Nothing   
Nothing :: Maybe a   
ghci> Just 10 :: Maybe Double   
Just 10.0

類型參數(shù)很實(shí)用。有了它,我們就可以按照我們的需要構(gòu)造出不同的類型。若執(zhí)行:t Just "Haha",類型推導(dǎo)引擎就會(huì)認(rèn)出它是個(gè)Maybe [Char],由于Just a里的a是個(gè)字符串,那么Maybe a里的a一定也是個(gè)字符串。

注意下,Nothing的類型為Maybe a。它是多態(tài)的,若有函數(shù)取Maybe Int類型的參數(shù),就一概可以傳給它一個(gè)Nothing,因?yàn)镹othing中不包含任何值。Maybe a類型可以有Maybe Int的行為,正如5可以是Int也可以是Double。與之相似,空List的類型是[a],可以與一切List打交道。因此,我們可以[1,2,3]++[],也可以["ha","ha,","ha"]++[]。

類型參數(shù)有很多好處,但前提是用對(duì)了地方才行。一般都是不關(guān)心類型里面的內(nèi)容,如Maybe a。一個(gè)類型的行為若有點(diǎn)像是容器,那么使用類型參數(shù)會(huì)是個(gè)不錯(cuò)的選擇。我們完全可以把我們的Car類型從

data Car = Car { company :: String 
                 , model :: String 
                 , year :: Int 
                 } deriving (Show)

改成:

data Car a b c = Car { company :: a 
                       , model :: b 
                       , year :: c 
                        } deriving (Show)

但是,這樣我們又得到了什么好處?回答很可能是,一無(wú)所得。因?yàn)槲覀冎欢x了處理Car String String Int類型的函數(shù),像以前,我們還可以弄個(gè)簡(jiǎn)單函數(shù)來描述車的屬性。

tellCar :: Car -> String 
tellCar (Car {company = c, model = m, year = y}) = "This " ++ c ++ " " ++ m ++ " was made in " ++ show y
ghci> let stang = Car {company="Ford", model="Mustang", year=1967}   
ghci> tellCar stang  "This Ford Mustang was made in 1967"

可愛的小函數(shù)!它的類型聲明得很漂亮,而且工作良好。好,如果改成Car a b c又會(huì)怎樣?

tellCar :: (Show a) => Car String String a -> String   
tellCar (Car {company = c, model = m, year = y}) = "This " ++ c ++ " " ++ m ++ " was made in " ++ show y

我們只能強(qiáng)制性地給這個(gè)函數(shù)安一個(gè)(Show a) => Car String String a 的類型約束。看得出來,這要繁復(fù)得多。而唯一的好處貌似就是,我們可以使用Show類型類的實(shí)例來作a的類型。

ghci> tellCar (Car "Ford" "Mustang" 1967)   
"This Ford Mustang was made in 1967"   
ghci> tellCar (Car "Ford" "Mustang" "nineteen sixty seven")   
"This Ford Mustang was made in \"nineteen sixty seven\""   
ghci> :t Car "Ford" "Mustang" 1967   
Car "Ford" "Mustang" 1967 :: (Num t) => Car [Char] [Char] t   
ghci> :t Car "Ford" "Mustang" "nineteen sixty seven"   
Car "Ford" "Mustang" "nineteen sixty seven" :: Car [Char] [Char] [Char]

其實(shí)在現(xiàn)實(shí)生活中,使用Car String String Int在大多數(shù)情況下已經(jīng)滿夠了。所以給Car類型加類型參數(shù)貌似并沒有什么必要。通常我們都是都是在一個(gè)類型中包含的類型并不影響它的行為時(shí)才引入類型參數(shù)。一組什么東西組成的List就是一個(gè)List,它不關(guān)心里面東西的類型是啥,然而總是工作良好。若取一組數(shù)字的和,我們可以在后面的函數(shù)體中明確是一組數(shù)字的List。Maybe與之相似,它表示可以有什么東西可以沒有,而不必關(guān)心這東西是啥。

我們之前還遇見過一個(gè)類型參數(shù)的應(yīng)用,就是Data.Map中的Map k v。k表示Map中鍵的類型,v表示值的類型。這是個(gè)好例子,map中類型參數(shù)的使用允許我們能夠用一個(gè)類型索引另一個(gè)類型,只要鍵的類型在Ord類型類就行。如果叫我們自己定義一個(gè)map類型,可以在data聲明中加上一個(gè)類型類的約束。

data (Ord k) => Map k v = ...

然而haskell中有一個(gè)嚴(yán)格的約定,那就是永遠(yuǎn)不要在data聲明中添加類型約束。為啥?嗯,因?yàn)檫@樣沒好處,反而得寫更多不必要的類型約束。Map k v要是有Ord k的約束,那就相當(dāng)于假定每個(gè)map的相關(guān)函數(shù)都認(rèn)為k是可排序的。若不給數(shù)據(jù)類型加約束,我們就不必給那些不關(guān)心鍵是否可排序的函數(shù)另加約束了。這類函數(shù)的一個(gè)例子就是toList,它只是把一個(gè)map轉(zhuǎn)換為關(guān)聯(lián)List罷了,類型聲明為toList :: Map k v -> [(k, v)]。要是加上類型約束,就只能是toList :: (Ord k) =>Map k a -> [(k,v)],明顯沒必要嘛。

所以說,永遠(yuǎn)不要在data聲明中加類型約束---即便看起來沒問題。免得在函數(shù)聲明中寫出過多無(wú)畏的類型約束。

我們實(shí)現(xiàn)個(gè)表示三維向量的類型,再給它加幾個(gè)處理函數(shù)。我么那就給它個(gè)類型參數(shù),雖然大多數(shù)情況都是數(shù)值型,不過這一來它就支持了多種數(shù)值類型。

data Vector a = Vector a a a deriving (Show)     
vplus :: (Num t) => Vector t -> Vector t -> Vector t   
(Vector i j k) `vplus` (Vector l m n) = Vector (i+l) (j+m) (k+n)     
vectMult :: (Num t) => Vector t -> t -> Vector t   
(Vector i j k) `vectMult` m = Vector (i*m) (j*m) (k*m)     
scalarMult :: (Num t) => Vector t -> Vector t -> t   
(Vector i j k) `scalarMult` (Vector l m n) = i*l + j*m + k*n

vplus用來相加兩個(gè)向量,即將其所有對(duì)應(yīng)的項(xiàng)相加。scalarMult用來求兩個(gè)向量的標(biāo)量積,vectMult求一個(gè)向量和一個(gè)標(biāo)量的積。這些函數(shù)可以處理Vector IntVector Integer,Vector Float等等類型,只要Vector a里的這個(gè)a在Num類型類中就行。同樣,如果你看下這些函數(shù)的類型聲明就會(huì)發(fā)現(xiàn),它們只能處理相同類型的向量,其中包含的數(shù)字類型必須與另一個(gè)向量一致。注意,我們并沒有在data聲明中添加Num的類約束。反正無(wú)論怎么著都是給函數(shù)加約束。

再度重申,類型構(gòu)造子和值構(gòu)造子的區(qū)分是相當(dāng)重要的。在聲明數(shù)據(jù)類型時(shí),等號(hào)=左端的那個(gè)是類型構(gòu)造子,右端的(中間可能有|分隔)都是值構(gòu)造子。拿Vector t t t -> Vector t t t -> t作函數(shù)的類型就會(huì)產(chǎn)生一個(gè)錯(cuò)誤,因?yàn)樵陬愋吐暶髦兄荒軐戭愋?,而Vector的類型構(gòu)造子只有個(gè)參數(shù),它的值構(gòu)造子才是有三個(gè)。我們就慢慢耍:

ghci> Vector 3 5 8 `vplus` Vector 9 2 8   
Vector 12 7 16   
ghci> Vector 3 5 8 `vplus` Vector 9 2 8 `vplus` Vector 0 2 3   
Vector 12 9 19   
ghci> Vector 3 9 7 `vectMult` 10   
Vector 30 90 70   
ghci> Vector 4 9 5 `scalarMult` Vector 9.0 2.0 4.0   
74.0   
ghci> Vector 2 9 3 `vectMult` (Vector 4 9 5 `scalarMult` Vector 9 2 4)   
Vector 148 666 222

派生實(shí)例

在typeclass 101那節(jié)里面,我們了解了typeclass的基礎(chǔ)內(nèi)容。里面提到,類型類就是定義了某些行為的接口。例如,Int類型是Eq類型類的一個(gè)實(shí)例,Eq類就定義了判定相等性的行為。Int值可以判斷相等性,所以Int就是Eq類型類的成員。它的真正威力體現(xiàn)在作為Eq接口的函數(shù)中,即==和/=。只要一個(gè)類型是Eq類型類的成員,我們就可以使用==函數(shù)來處理這一類型。這便是為何4==4"foo"/="bar"這樣的表達(dá)式都需要作類型檢查。

我們也曾提到,人們很容易把類型類與Java,python,C++等語(yǔ)言的類混淆。很多人對(duì)此都倍感不解,在原先那些語(yǔ)言中,類就像是藍(lán)圖,我們可以根據(jù)它來創(chuàng)造對(duì)象、保存狀態(tài)并執(zhí)行操作。而類型類更像是接口,我們不是靠它構(gòu)造數(shù)據(jù),而是給既有的數(shù)據(jù)類型描述行為。什么東西若可以判定相等性,我們就可以讓它成為Eq類型類的實(shí)例。什么東西若可以比較大小,那就可以讓它成為Ord類型類的實(shí)例。

在下一節(jié),我們將看一下如何手工實(shí)現(xiàn)類型類中定義函數(shù)來構(gòu)造實(shí)例?,F(xiàn)在呢,我們先了解下Haskell是如何自動(dòng)生成這幾個(gè)類型類的實(shí)例,Eq,Ord,Enum,Bounded,Show,Read。只要我們?cè)跇?gòu)造類型時(shí)在后面加個(gè)deriving(派生)關(guān)鍵字,Haskell就可以自動(dòng)地給我們的類型加上這些行為。

看這個(gè)數(shù)據(jù)類型:

data Person = Person { firstName :: String   
                     , lastName :: String   
                     , age :: Int   
                     }

這描述了一個(gè)人。我們先假定世界上沒有重名重姓又同齡的人存在,好,假如有兩個(gè)record,有沒有可能是描述同一個(gè)人呢?當(dāng)然可能,我么可以判定姓名年齡的相等性,來判斷它倆是否相等。這一來,讓這個(gè)類型成為Eq的成員就很靠譜了。直接derive這個(gè)實(shí)例:

data Person = Person { firstName :: String   
                     , lastName :: String   
                     , age :: Int   
                     } deriving (Eq)

在一個(gè)類型派生為Eq的實(shí)例后,就可以直接使用==或/=來判斷它們的相等性了。Haskell會(huì)先看下這兩個(gè)值的值構(gòu)造子是否一致(這里只是單值構(gòu)造子),再用==來檢查其中的所有數(shù)據(jù)(必須都是Eq的成員)是否一致。在這里只有String和Int,所以是沒有問題的。測(cè)試下我們的Eq實(shí)例:

ghci> let mikeD = Person {firstName = "Michael", lastName = "Diamond", age = 43}   
ghci> let adRock = Person {firstName = "Adam", lastName = "Horovitz", age = 41}   
ghci> let mca = Person {firstName = "Adam", lastName = "Yauch", age = 44}   
ghci> mca == adRock   
False   
ghci> mikeD == adRock   
False   
ghci> mikeD == mikeD   
True   
ghci> mikeD == Person {firstName = "Michael", lastName = "Diamond", age = 43}   
True

自然,Person如今已經(jīng)成為了Eq的成員,我們就可以將其應(yīng)用于所有在類型聲明中用到Eq類約束的函數(shù)了,如elem。

ghci> let beastieBoys = [mca, adRock, mikeD]   
ghci> mikeD `elem` beastieBoys   
True

Show和Read類型類處理可與字符串相互轉(zhuǎn)換的東西。同Eq相似,如果一個(gè)類型的構(gòu)造子含有參數(shù),那所有參數(shù)的類型必須都得屬于Show或Read才能讓該類型成為其實(shí)例。就讓我們的Person也成為Read和Show的一員吧。

data Person = Person { firstName :: String   
                     , lastName :: String   
                     , age :: Int   
                     } deriving (Eq, Show, Read)

然后就可以輸出一個(gè)Person到控制臺(tái)了。

ghci> let mikeD = Person {firstName = "Michael", lastName = "Diamond", age = 43}   
ghci> mikeD   
Person {firstName = "Michael", lastName = "Diamond", age = 43}   
ghci> "mikeD is: " ++ show mikeD   
"mikeD is: Person {firstName = \"Michael\", lastName = \"Diamond\", age = 43}"

如果我們還沒讓Person類型作為Show的成員就嘗試輸出它,haskell就會(huì)向我們抱怨,說它不知道該怎么把它表示成一個(gè)字符串。不過現(xiàn)在既然已經(jīng)派生成為了Show的一個(gè)實(shí)例,它就知道了。

Read幾乎就是與Show相對(duì)的類型類,show是將一個(gè)值轉(zhuǎn)換成字符串,而read則是將一個(gè)字符串轉(zhuǎn)成某類型的值。還記得,使用read函數(shù)時(shí)我們必須得用類型注釋注明想要的類型,否則haskell就不會(huì)知道如何轉(zhuǎn)換。

ghci> read "Person {firstName =\"Michael\", lastName =\"Diamond\", age = 43}" :: Person   
Person {firstName = "Michael", lastName = "Diamond", age = 43}

如果我們r(jià)ead的結(jié)果會(huì)在后面用到參與計(jì)算,Haskell就可以推導(dǎo)出是一個(gè)Person的行為,不加注釋也是可以的。

ghci> read "Person {firstName =\"Michael\", lastName =\"Diamond\", age = 43}" == mikeD   
True

也可以read帶參數(shù)的類型,但必須填滿所有的參數(shù)。因此read "Just 't'" :: Maybe a是不可以的,read "Just 't'" :: Maybe Char才對(duì)。

很容易想象Ord類派生實(shí)例的行為。首先,判斷兩個(gè)值構(gòu)造子是否一致,如果是,再判斷它們的參數(shù),前提是它們的參數(shù)都得是Ord的實(shí)例。Bool類型可以有兩種值,F(xiàn)alse和True。為了了解在比較中程序的行為,我們可以這樣想象:

data Bool = False | True deriving (Ord)

由于值構(gòu)造子False安排在True的前面,我們可以認(rèn)為True比False大。

ghci> True `compare` False   
GT   
ghci> True > False   
True   
ghci> True  
False

在Maybe a數(shù)據(jù)類型中,值構(gòu)造子Nothing在Just值構(gòu)造子前面,所以一個(gè)Nothing總要比Just something的值小。即便這個(gè)something100000000也是如此。

ghci> Nothing  
True   
ghci> Nothing > Just (-49999)   
False   
ghci> Just 3 `compare` Just 2   
GT   
ghci> Just 100 > Just 50   
True

不過類似Just (3), Just(2)之類的代碼是不可以的。因?yàn)?3)和(2)都是函數(shù),而函數(shù)不是Ord類的成員。

作枚舉,使用數(shù)字類型就能輕易做到。不過使用Enmu和Bounded類型類會(huì)更好,看下這個(gè)類型:

data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday

所有的值構(gòu)造子都是nullary的(也就是沒有參數(shù)),每個(gè)東西都有前置子和后繼子,我們可以讓它成為Enmu類型類的成員。同樣,每個(gè)東西都有可能的最小值和最大值,我們也可以讓它成為Bounded類型類的成員。在這里,我們就同時(shí)將它搞成其它可派生類型類的實(shí)例。再看看我們能拿它做啥:

data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday    
           deriving (Eq, Ord, Show, Read, Bounded, Enum)

由于它是Show和Read類型類的成員,我們可以將這個(gè)類型的值與字符串相互轉(zhuǎn)換。

ghci> Wednesday   
Wednesday   
ghci> show Wednesday   
"Wednesday"   
ghci> read "Saturday" :: Day   
Saturday

由于它是Eq與Ord的成員,因此我們可以拿Day作比較。

ghci> Saturday == Sunday   
False   
ghci> Saturday == Saturday   
True   
ghci> Saturday > Friday   
True   
ghci> Monday `compare` Wednesday   
LT

它也是Bounded的成員,因此有最早和最晚的一天。

ghci> minBound :: Day   
Monday   
ghci> maxBound :: Day   
Sunday

它也是Enmu的實(shí)例,可以得到前一天和后一天,并且可以對(duì)此使用List的區(qū)間。

ghci> succ Monday   
Tuesday   
ghci> pred Saturday   
Friday   
ghci> [Thursday .. Sunday]   
[Thursday,Friday,Saturday,Sunday]   
ghci> [minBound .. maxBound] :: [Day]   
[Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday]

那是相當(dāng)?shù)陌簟?/p>

類型別名

在前面我們提到在寫類型名的時(shí)候,[Char]String等價(jià),可以互換。這就是由類型別名實(shí)現(xiàn)的。類型別名實(shí)際上什么也沒做,只是給類型提供了不同的名字,讓我們的代碼更容易理解。這就是[Char]的別名String的由來。

type String = [Char]

我們已經(jīng)介紹過了type關(guān)鍵字,這個(gè)關(guān)鍵字有一定誤導(dǎo)性,它并不是用來創(chuàng)造新類(這是data關(guān)鍵字做的事情),而是給一個(gè)既有類型提供一個(gè)別名。

如果我們隨便搞個(gè)函數(shù)toUpperString或其他什么名字,將一個(gè)字符串變成大寫,可以用這樣的類型聲明toUpperString :: [Char] -> [Char], 也可以這樣toUpperString :: String -> String,二者在本質(zhì)上是完全相同的。后者要更易讀些。

在前面Data.Map那部分,我們用了一個(gè)關(guān)聯(lián)List來表示phoneBook,之后才改成的Map。我們已經(jīng)發(fā)現(xiàn)了,一個(gè)關(guān)聯(lián)List就是一組鍵值對(duì)組成的List。再看下我們phoneBook的樣子:

phoneBook :: [(String,String)]   
phoneBook =       
    [("betty","555-2938")      
    ,("bonnie","452-2928")      
    ,("patsy","493-2928")      
    ,("lucille","205-2928")      
    ,("wendy","939-8282")      
    ,("penny","853-2492")      
    ]

可以看出,phoneBook的類型就是[(String,String)],這表示一個(gè)關(guān)聯(lián)List僅是String到String的映射關(guān)系。我們就弄個(gè)類型別名,好讓它類型聲明中能夠表達(dá)更多信息。

type PhoneBook = [(String,String)]

現(xiàn)在我們phoneBook的類型聲明就可以是phoneBook :: PhoneBook了。再給字符串加上別名:

type PhoneNumber = String   
type Name = String   
type PhoneBook = [(Name,PhoneNumber)]

Haskell程序員給String加別名是為了讓函數(shù)中字符串的表達(dá)方式及用途更加明確。

好的,我們實(shí)現(xiàn)了一個(gè)函數(shù),它可以取一名字和號(hào)碼檢查它是否存在于電話本?,F(xiàn)在可以給它加一個(gè)相當(dāng)好看明了的類型聲明:

inPhoneBook :: Name -> PhoneNumber -> PhoneBook -> Bool   
inPhoneBook name pnumber pbook = (name,pnumber) `elem` pbook

如果不用類型別名,我們函數(shù)的類型聲明就只能是String -> String -> [(String ,String)] -> Bool了。在這里使用類型別名是為了讓類型聲明更加易讀,但你也不必拘泥于它。引入類型別名的動(dòng)機(jī)既非單純表示我們函數(shù)中的既有類型,也不是為了替換掉那些重復(fù)率高的長(zhǎng)名字類型(如[(String,String)]),而是為了讓類型對(duì)事物的描述更加明確。

類型別名也是可以有參數(shù)的,如果你想搞個(gè)類型來表示關(guān)聯(lián)List,但依然要它保持通用,好讓它可以使用任意類型作key和value,我們可以這樣:

type AssocList k v = [(k,v)]

好的,現(xiàn)在一個(gè)從關(guān)聯(lián)List中按鍵索值的函數(shù)類型可以定義為(Eq k) => k -> AssocList k v -> Maybe v. AssocList i。AssocList是個(gè)取兩個(gè)類型做參數(shù)生成一個(gè)具體類型的類型構(gòu)造子,如Assoc Int String等等。

Fronzie說:Hey!當(dāng)我提到具體類型,那我就是說它是完全調(diào)用的,就像Map Int String。要不就是多態(tài)函數(shù)中的[a](Ord a) => Maybe a之類。有時(shí)我和孩子們會(huì)說“Maybe類型”,但我們的意思并不是按字面來,傻瓜都知道Maybe是類型構(gòu)造子嘛。只要用一個(gè)明確的類型調(diào)用Maybe,如Maybe String可得一個(gè)具體類型。你知道,只有具體類型才可以儲(chǔ)存值。

我們可以用不全調(diào)用來得到新的函數(shù),同樣也可以使用不全調(diào)用得到新的類型構(gòu)造子。同函數(shù)一樣,用不全的類型參數(shù)調(diào)用類型構(gòu)造子就可以得到一個(gè)不全調(diào)用的類型構(gòu)造子,如果我們要一個(gè)表示從整數(shù)到某東西間映射關(guān)系的類型,我們可以這樣:

type IntMap v = Map Int v

也可以這樣:

type IntMap = Map Int

無(wú)論怎樣,IntMap的類型構(gòu)造子都是取一個(gè)參數(shù),而它就是這整數(shù)指向的類型。

Oh yeah,如果要你去實(shí)現(xiàn)它,很可能會(huì)用個(gè)qualified import來導(dǎo)入Data.Map。這時(shí),類型構(gòu)造子前面必須得加上模塊名。所以應(yīng)該寫個(gè)type IntMap = Map.Map Int

你得保證真正弄明白了類型構(gòu)造子和值構(gòu)造子的區(qū)別。我們有了個(gè)叫IntMap或者AssocList的別名并不意味著我們可以執(zhí)行類似AssocList [(1,2),(4,5),(7,9)]的代碼,而是可以用不同的名字來表示原先的List,就像[(1,2),(4,5),(7,9)] :: AssocList Int Int讓它里面的類型都是Int。而像處理普通的二元組構(gòu)成的那種List處理它也是可以的。類型別名(類型依然不變),只可以在Haskell的類型部分中使用,像定義新類型或類型聲明或類型注釋中跟在::后面的部分。

另一個(gè)很酷的二參類型就是Either a b了,它大約是這樣定義的:

data Either a b = Left a | Right b deriving (Eq, Ord, Read, Show)

它有兩個(gè)值構(gòu)造子。如果用了Left,那它內(nèi)容的類型就是a;用了Right,那它內(nèi)容的類型就是b。我們可以用它來將可能是兩種類型的值封裝起來,從里面取值時(shí)就同時(shí)提供Left和Right的模式匹配。

ghci> Right 20   
Right 20   
ghci> Left "w00t"   
Left "w00t"   
ghci> :t Right 'a'   
Right 'a' :: Either a Char   
ghci> :t Left True   
Left True :: Either Bool b

到現(xiàn)在為止,Maybe是最常見的表示可能失敗的計(jì)算的類型了。但有時(shí)Maybe也并不是十分的好用,因?yàn)镹othing中包含的信息還是太少。要是我們不關(guān)心函數(shù)失敗的原因,它還是不錯(cuò)的。就像Data.Map的lookup只有在搜尋的項(xiàng)不在map時(shí)才會(huì)失敗,對(duì)此我們一清二楚。但我們?nèi)粝胫篮瘮?shù)失敗的原因,那還得使用Either a b,用a來表示可能的錯(cuò)誤的類型,用b來表示一個(gè)成功運(yùn)算的類型。從現(xiàn)在開始,錯(cuò)誤一律用Left值構(gòu)造子,而結(jié)果一律用Right。

一個(gè)例子:有個(gè)學(xué)校提供了不少壁櫥,好給學(xué)生們地方放他們的Gun'N'Rose海報(bào)。每個(gè)壁櫥都有個(gè)密碼,哪個(gè)學(xué)生想用個(gè)壁櫥,就告訴管理員壁櫥的號(hào)碼,管理員就會(huì)告訴他壁櫥的密碼。但如果這個(gè)壁櫥已經(jīng)讓別人用了,管理員就不能告訴他密碼了,得換一個(gè)壁櫥。我們就用Data.Map的一個(gè)map來表示這些壁櫥,把一個(gè)號(hào)碼映射到一個(gè)表示壁櫥占用情況及密碼的二元組里。

import qualified Data.Map as Map   

data LockerState = Taken | Free deriving (Show, Eq)   

type Code = String   

type LockerMap = Map.Map Int (LockerState, Code)

很簡(jiǎn)單,我們引入了一個(gè)新的類型來表示壁櫥的占用情況。并為壁櫥密碼及按號(hào)碼找壁櫥的map分別設(shè)置了一個(gè)別名。好,現(xiàn)在我們實(shí)現(xiàn)這個(gè)按號(hào)碼找壁櫥的函數(shù),就用Either String Code類型表示我們的結(jié)果,因?yàn)閘ookup可能會(huì)以兩種原因失敗。廚子已經(jīng)讓別人用了或者壓根就沒有這個(gè)櫥子。如果lookup失敗,就用字符串表明失敗的原因。

lockerLookup :: Int -> LockerMap -> Either String Code   
lockerLookup lockerNumber map =    
    case Map.lookup lockerNumber map of    
        Nothing -> Left $ "Locker number " ++ show lockerNumber ++ " doesn't exist!"   
        Just (state, code) -> if state /= Taken    
                                then Right code   
                                else Left $ "Locker " ++ show lockerNumber ++ " is already taken!"

我們?cè)谶@里個(gè)map中執(zhí)行一次普通的lookup,如果得到一個(gè)Nothing,就返回一個(gè)Left String的值,告訴他壓根就沒這個(gè)號(hào)碼的櫥子。如果找到了,就再檢查下,看這櫥子是不是已經(jīng)讓別人用了,如果是,就返回個(gè)Left String說它已經(jīng)讓別人用了。否則就返回個(gè)Right Code的值,通過它來告訴學(xué)生壁櫥的密碼。它實(shí)際上就是個(gè)Right String,我們引入了個(gè)類型別名讓它這類型聲明更好看。

如下是個(gè)map的例子:

lockers :: LockerMap   
lockers = Map.fromList    
    [(100,(Taken,"ZD39I"))   
    ,(101,(Free,"JAH3I"))   
    ,(103,(Free,"IQSA9"))   
    ,(105,(Free,"QOTSA"))   
    ,(109,(Taken,"893JJ"))   
    ,(110,(Taken,"99292"))   
    ]

現(xiàn)在從里面lookup某個(gè)櫥子號(hào)..

ghci> lockerLookup 101 lockers   
Right "JAH3I"   
ghci> lockerLookup 100 lockers   
Left "Locker 100 is already taken!"   
ghci> lockerLookup 102 lockers   
Left "Locker number 102 doesn't exist!"   
ghci> lockerLookup 110 lockers   
Left "Locker 110 is already taken!"   
ghci> lockerLookup 105 lockers   
Right "QOTSA"

我們完全可以用Maybe a來表示它的結(jié)果,但這樣一來我們就對(duì)得不到密碼的原因不得而知了。而在這里,我們的新類型可以告訴我們失敗的原因。


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

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)