盡管列表和元組都非常有用,但是,定義新的數據類型也是一種常見的需求,這種能力使得我們可以為程序中的值添加結構。
而且比起使用元組,對一簇相關的值賦予一個名字和一個獨一無二的類型顯得更有用一些。
定義新的數據類型也提升了代碼的安全性:Haskell 不會允許我們混用兩個結構相同但類型不同的值。
本章將以一個在線書店為例子,展示如何去進行類型定義。
使用 data 關鍵字可以定義新的數據類型:
-- file: ch03/BookStore.hs
data BookInfo = Book Int String [String]
deriving (Show)
跟在 data 關鍵字之后的 BookInfo 就是新類型的名字,我們稱 BookInfo 為類型構造器。類型構造器用于指代(refer)類型。正如前面提到過的,類型名字的首字母必須大寫,因此,類型構造器的首字母也必須大寫。
接下來的 Book 是值構造器(有時候也稱為數據構造器)的名字。類型的值就是由值構造器創(chuàng)建的。值構造器名字的首字母也必須大寫。
在 Book 之后的 Int , String 和 [String] 是類型的組成部分。組成部分的作用,和面向對象語言的類中的域作用一致:它是一個儲存值的槽。(為了方便起見,我們通常也將組成部分稱為域。)
在這個例子中, Int 表示一本書的 ID ,而 String 表示書名,而 [String] 則代表作者。
BookInfo 類型包含的成分和一個 (Int,String,[String]) 類型的三元組一樣,它們唯一不相同的是類型。[譯注:這里指的是整個值的類型,不是成分的類型。]我們不能混用結構相同但類型不同的值。
舉個例子,以下的 MagzineInfo 類型的成分和 BookInfo 一模一樣,但 Haskell 會將它們作為不同的類型來區(qū)別對待,因為它們的類型構構造器和值構造器并不相同:
-- file: ch03/BookStore.hs
data MagzineInfo = Magzine Int String [String]
deriving (Show)
可以將值構造器看作是一個函數 —— 它創(chuàng)建并返回某個類型值。在這個書店的例子里,我們將 Int 、 String 和 [String] 三個類型的值應用到 Book ,從而創(chuàng)建一個 BookInfo 類型的值:
-- file: ch03/BookStore.hs
myInfo = Book 9780135072455 "Algebra of Programming"
["Richard Bird", "Oege de Moor"]
定義類型的工作完成之后,可以到 ghci 里載入并測試這些新類型:
Prelude> :load BookStore.hs
[1 of 1] Compiling Main ( BookStore.hs, interpreted )
Ok, modules loaded: Main.
再看看前面在文件里定義的 myInfo 變量:
*Main> myInfo
Book 494539463 "Algebra of Programming" ["Richard Bird","Oege de Moor"]
在 ghci 里面當然也可以創(chuàng)建新的 BookInfo 值:
*Main> Book 0 "The Book of Imaginary Beings" ["Jorge Luis Borges"]
Book 0 "The Book of Imaginary Beings" ["Jorge Luis Borges"]
可以用 :type 命令來查看表達式的值:
*Main> :type Book 1 "Cosmicomics" ["Italo Calvino"]
Book 1 "Cosmicomics" ["Italo Calvino"] :: BookInfo
請記住,在 ghci 里定義變量的語法和在源碼文件里定義變量的語法并不相同。在 ghci 里,變量通過 let 定義:
*Main> let cities = Book 173 "Use of Weapons" ["Iain M. Banks"]
使用 :info 命令可以查看更多關于給定表達式的信息:
*Main> :info BookInfo
data BookInfo = Book Int String [String]
-- Defined at BookStore.hs:2:6
instance Show BookInfo -- Defined at BookStore.hs:3:27
使用 :type 命令,可以查看值構造器 Book 的類型簽名,了解它是如何創(chuàng)建出 BookInfo 類型的值的:
*Main> :type Book
Book :: Int -> String -> [String] -> BookInfo
在前面介紹 BookInfo 類型的時候,我們專門為類型構造器和值構造器設置了不同的名字( BookInfo 和 Book ),這樣區(qū)分起來比較容易。
在 Haskell 里,類型的名字(類型構造器)和值構造器的名字是相互獨立的。類型構造器只能出現在類型的定義,或者類型簽名當中。而值構造器只能出現在實際的代碼中。因為存在這種差別,給類型構造器和值構造器賦予一個相同的名字實際上并不會產生任何問題。
以下是這種用法的一個例子:
-- file: ch03/BookStore.hs
-- 稍后就會介紹 CustomerID 的定義
data BookReview = BookReview BookInfo CustomerID String
以上代碼定義了一個 BookReview 類型,并且它的值構造器的名字也同樣是 BookReview 。
可以使用類型別名,來為一個已存在的類型設置一個更具描述性的名字。
比如說,在前面 BookReview 類型的定義里,并沒有說明 String 成分是干什么用的,通過類型別名,可以解決這個問題:
-- file: ch03/BookStore.hs
type CustomerID = Int
type ReviewBody = String
data BetterReview = BetterReview BookInfo CustomerID ReviewBody
type 關鍵字用于設置類型別名,其中新的類型名字放在 = 號的左邊,而已有的類型名字放在 = 號的右邊。這兩個名字都標識同一個類型,因此,類型別名完全是為了提高可讀性而存在的。
類型別名也可以用來為啰嗦的類型設置一個更短的名字:
-- file: ch03/BookStore.hs
type BookRecord = (BookInfo, BookReview)
需要注意的是,類型別名只是為已有類型提供了一個新名字,創(chuàng)建值的工作還是由原來類型的值構造器進行。[注:如果你熟悉 C 或者 C++ ,可以將 Haskell 的類型別名看作是 typedef 。]
Bool 類型是代數數據類型(algebraic data type)的最簡單也是最常見的例子。一個代數類型可以有多于一個值構造器:
-- file: ch03/Bool.hs
data Bool = False | True
上面代碼定義的 Bool 類型擁有兩個值構造器,一個是 True ,另一個是 False 。每個值構造器使用 | 符號分割,讀作“或者” —— 以 Bool 類型為例子,我們可以說, Bool 類型由 True 值或者 False 值構成。
當一個類型擁有一個以上的值構造器時,這些值構造器通常被稱為“備選”(alternatives)或“分支”(case)。同一類型的所有備選,創(chuàng)建出的的值的類型都是相同的。
代數數據類型的各個值構造器都可以接受任意個數的參數。[譯注:不同備選之間接受的參數個數不必相同,參數的類型也可以不一樣。]以下是一個賬單數據的例子:
-- file: ch03/BookStore.hs
type CardHolder = String
type CardNumber = String
type Address = [String]
data BillingInfo = CreditCard CardNumber CardHolder Address
| CashOnDelivery
| Invoice CustomerID
deriving (Show)
這個程序提供了三種付款的方式。如果使用信用卡付款,就要使用 CreditCard 作為值構造器,并輸入信用卡卡號、信用卡持有人和地址作為參數。如果即時支付現金,就不用接受任何參數。最后,可以通過貨到付款的方式來收款,在這種情況下,只需要填寫客戶的 ID 就可以了。
當使用值構造器來創(chuàng)建 BillingInfo 類型的值時,必須提供這個值構造器所需的參數:
Prelude> :load BookStore.hs
[1 of 1] Compiling Main ( BookStore.hs, interpreted )
Ok, modules loaded: Main.
*Main> :type CreditCard
CreditCard :: CardNumber -> CardHolder -> Address -> BillingInfo
*Main> CreditCard "2901650221064486" "Thomas Gradgrind" ["Dickens", "England"]
CreditCard "2901650221064486" "Thomas Gradgrind" ["Dickens","England"]
*Main> :type it
it :: BillingInfo
如果輸入參數的類型不對或者數量不對,那么引發(fā)一個錯誤:
*Main> Invoice
<interactive>:7:1:
No instance for (Show (CustomerID -> BillingInfo))
arising from a use of `print'
Possible fix:
add an instance declaration for (Show (CustomerID -> BillingInfo))
In a stmt of an interactive GHCi command: print it
ghci 抱怨我們沒有給 Invoice 值構造器足夠的參數。
[譯注:原文這里的代碼示例有錯,譯文已改正。]
元組和自定域代數數據類型有一些相似的地方。比如說,可以使用一個 (Int,String,[String]) 類型的元組來代替 BookInfo 類型:
*Main> Book 2 "The Wealth of Networks" ["Yochai Benkler"]
Book 2 "The Wealth of Networks" ["Yochai Benkler"]
*Main> (2, "The Wealth of Networks", ["Yochai Benkler"])
(2,"The Wealth of Networks",["Yochai Benkler"])
代數數據類型使得我們可以在結構相同但類型不同的數據之間進行區(qū)分。然而,對于元組來說,只要元素的結構和類型都一致,那么元組的類型就是相同的:
-- file: ch03/Distinction.hs
a = ("Porpoise", "Grey")
b = ("Table", "Oak")
其中 a 和 b 的類型相同:
Prelude> :load Distinction.hs
[1 of 1] Compiling Main ( Distinction.hs, interpreted )
Ok, modules loaded: Main.
*Main> :type a
a :: ([Char], [Char])
*Main> :type b
b :: ([Char], [Char])
對于兩個不同的代數數據類型來說,即使值構造器成分的結構和類型都相同,它們也是不同的類型:
-- file: ch03/Distinction.hs
data Cetacean = Cetacean String String
data Furniture = Furniture String String
c = Cetacean "Porpoise" "Grey"
d = Furniture "Table" "Oak"
其中 c 和 d 的類型并不相同:
Prelude> :load Distinction.hs
[1 of 1] Compiling Main ( Distinction.hs, interpreted )
Ok, modules loaded: Main.
*Main> :type c
c :: Cetacean
*Main> :type d
d :: Furniture
以下是一個更細致的例子,它用兩種不同的方式表示二維向量:
-- file: ch03/AlgebraicVector.hs
-- x and y coordinates or lengths.
data Cartesian2D = Cartesian2D Double Double
deriving (Eq, Show)
-- Angle and distance (magnitude).
data Polar2D = Polar2D Double Double
deriving (Eq, Show)
Cartesian2D 和 Polar2D 兩種類型的成分都是 Double 類型,但是,這些成分表達的是不同的意思。因為 Cartesian2D 和 Polar2D 是不同的類型,因此 Haskell 不會允許混淆使用這兩種類型:
Prelude> :load AlgebraicVector.hs
[1 of 1] Compiling Main ( AlgebraicVector.hs, interpreted )
Ok, modules loaded: Main.
*Main> Cartesian2D (sqrt 2) (sqrt 2) == Polar2D (pi / 4) 2
<interactive>:3:34:
Couldn't match expected type `Cartesian2D'
with actual type `Polar2D'
In the return type of a call of `Polar2D'
In the second argument of `(==)', namely `Polar2D (pi / 4) 2'
In the expression:
Cartesian2D (sqrt 2) (sqrt 2) == Polar2D (pi / 4) 2
錯誤信息顯示, (==) 操作符只接受類型相同的值作為它的參數,在類型簽名里也可以看出這一點:
*Main> :type (==)
(==) :: Eq a => a -> a -> Bool
另一方面,如果使用類型為 (Double,Double) 的元組來表示二維向量的兩種表示方式,那么我們就有麻煩了:
Prelude> -- 第一個元組使用 Cartesian 表示,第二個元組使用 Polar 表示
Prelude> (1, 2) == (1, 2)
True
類型系統(tǒng)不會察覺到,我們正錯誤地對比兩種不同表示方式的值,因為對兩個類型相同的元組進行對比是完全合法的!
關于該使用元組還是該使用代數數據類型,沒有一勞永逸的辦法。但是,有一個經驗法則可以參考:如果程序大量使用復合數據,那么使用 data 進行類型自定義對于類型安全和可讀性都有好處。而對于小規(guī)模的內部應用,那么通常使用元組就足夠了。
代數數據類型為描述數據類型提供了一種單一且強大的方式。很多其他語言,要達到相當于代數數據類型的表達能力,需要同時使用多種特性。
以下是一些 C 和 C++ 方面的例子,說明怎樣在這些語言里,怎么樣實現類似于代數數據類型的功能。
當只有一個值構造器時,代數數據類型和元組很相似:它將一系列相關的值打包成一個復合值。這種做法相當于 C 和 C++ 里的 struct ,而代數數據類型的成分則相當于 struct 里的域。
以下是一個 C 結構,它等同于我們前面定義的 BookInfo 類型:
struct book_info {
int id;
char *name;
char **authors;
};
目前來說, C 結構和 Haskell 的代數數據類型最大的差別是,代數數據類型的成分是匿名且按位置排序的:
--file: ch03/BookStore.hs
data BookInfo = Book Int String [String]
deriving (Show)
按位置排序指的是,對成分的訪問是通過位置來實行的,而不是像 C 那樣,通過名字:比如 book_info->id 。
稍后的“模式匹配”小節(jié)會介紹如何訪代數數據類型里的成分。在“記錄”一節(jié)會介紹定義數據的新語法,通過這種語法,可以像 C 結構那樣,使用名字來訪問相應的成分。
C 和 C++ 里的 enum 通常用于表示一系列符號值排列。代數數據類型里面也有相似的東西,一般稱之為枚舉類型。
以下是一個 enum 例子:
enum roygbiv {
red,
orange,
yellow,
green,
blue,
indigo,
violet,
};
以下是等價的 Haskell 代碼:
-- file: ch03/Roygbiv.hs
data Roygbiv = Red
| Orange
| Yellow
| Green
| Blue
| Indigo
| Violet
deriving (Eq, Show)
在 ghci 里面測試:
Prelude> :load Roygbiv.hs
[1 of 1] Compiling Main ( Roygbiv.hs, interpreted )
Ok, modules loaded: Main.
*Main> :type Yellow
Yellow :: Roygbiv
*Main> :type Red
Red :: Roygbiv
*Main> Red == Yellow
False
*Main> Green == Green
True
enum 的問題是,它使用整數值去代表元素:在一些接受 enum 的場景里,可以將整數傳進去,C 編譯器會自動進行類型轉換。同樣,在使用整數的場景里,也可以將一個 enum 元素傳進去。這種用法可能會造成一些令人不爽的 bug 。
另一方面,在 Haskell 里就沒有這樣的問題。比如說,不可能使用 Roygbiv 里的某個值來代替 Int 值[譯注:因為枚舉類型的每個元素都由一個唯一的值構造器生成,而不是使用整數表示。]:
*Main> take 3 "foobar"
"foo"
*Main> take Red "foobar"
<interactive>:9:6:
Couldn't match expected type `Int' with actual type `Roygbiv'
In the first argument of `take', namely `Red'
In the expression: take Red "foobar"
In an equation for `it': it = take Red "foobar"
如果一個代數數據類型有多個備選,那么可以將它看作是 C 或 C++ 里的 union 。
以上兩者的一個主要區(qū)別是, union 并不告訴用戶,當前使用的是哪一個備選, union 的使用者必須自己記錄這方面的信息(通常使用一個額外的域來保存),這意味著,如果搞錯了備選的信息,那么對 union 的使用就會出錯。
以下是一個 union 例子:
enum shape_type {
shape_circle,
shape_poly,
};
struct circle {
struct vector centre;
float radius;
};
struct poly {
size_t num_vertices;
struct vector *vertices;
};
struct shape
{
enum shape_type type;
union {
struct circle circle;
struct poly poly;
} shape;
};
在上面的代碼里, shape 域的值可以是一個 circle 結構,也可以是一個 poly 結構。 shape_type 用于記錄目前 shape 正在使用的結構類型。
另一方面,Haskell 版本不僅簡單,而且更為安全:
-- file: ch03/ShapeUnion.hs
type Vector = (Double, Double)
data Shape = Circle Vector Double
| Poly [Vector]
deriving (Show)
[譯注:原文的代碼少了 deriving(Show) 一行,在 ghci 測試時會出錯。]
注意,我們不必像 C 語言那樣,使用 shape_type 域來手動記錄 Shape 類型的值是由 Circle 構造器生成的,還是由 Poly 構造器生成, Haskell 自己有辦法弄清楚一點,它不會弄混兩種不同的值。其中的原因,下一節(jié)《模式匹配》就會講到。
[譯注:原文這里將 Poly 寫成了 Square 。]
前面的章節(jié)介紹了代數數據類型的定義方法,本節(jié)將說明怎樣去處理這些類型的值。
對于某個類型的值來說,應該可以做到以下兩點:
對于以上兩個問題, Haskell 有一個簡單且有效的解決方式,那就是類型匹配。
模式匹配允許我們查看值的內部,并將值所包含的數據綁定到變量上。以下是一個對 Bool 類型值進行模式匹配的例子,它的作用和 not 函數一樣:
-- file: myNot.hs
myNot True = False
myNot False = True
[譯注:原文的文件名為 add.hs ,這里修改成 myNot.hs ,和函數名保持一致。]
初看上去,代碼似乎同時定義了兩個 myNot 函數,但實際情況并不是這樣 —— Haskell 允許將函數定義為一系列等式: myNot 的兩個等式分別定義了函數對于輸入參數在不同模式之下的行為。對于每行等式,模式定義放在函數名之后, = 符號之前。
為了理解模式匹配是如何工作的,來研究一下 myNotFalse 是如何執(zhí)行的:首先調用 myNot , Haskell 運行時檢查輸入參數 False 是否和第一個模式的值構造器匹配 —— 答案是不匹配,于是它繼續(xù)嘗試匹配第二個模式 —— 這次匹配成功了,于是第二個等式右邊的值被作為結果返回。
以下是一個復雜一點的例子,這個函數計算出列表所有元素之和:
-- file:: ch03/sumList.hs
sumList (x:xs) = x + sumList xs
sumList [] = 0
[譯注:原文代碼的文件名為 add.hs 這里改為 sumList.hs ,和函數名保持一致。]
需要說明的一點是,在 Haskell 里,列表 [1,2] 實際上只是 (1:(2:[])) 的一種簡單的表示方式,其中 (:) 用于構造列表:
Prelude> []
[]
Prelude> 1:[]
[1]
Prelude> 1:2:[]
[1,2]
因此,當需要對一個列表進行匹配時,也可以使用 (:) 操作符,只不過這次不是用來構造列表,而是用來分解列表。
作為例子,考慮求值 sumList[1,2] 時會發(fā)生什么:首先, [1,2] 嘗試對第一個等式的模式 (x:xs) 進行匹配,結果是模式匹配成功,并將 x 綁定為 1 , xs 綁定為 [2] 。
計算進行到這一步,表達式就變成了 1+(sumList[2]) ,于是遞歸調用 sumList ,對 [2] 進行模式匹配。
這一次也是在第一個等式匹配成功,變量 x 被綁定為 2 ,而 xs 被綁定為 [] 。表達式變?yōu)?1+(2+sumList[]) 。
再次遞歸調用 sumList ,輸入為 [] ,這一次,第二個等式的 [] 模式匹配成功,返回 0 ,整個表達式為 1+(2+(0)) ,計算結果為 3 。
最后要說的一點是,標準函數庫里已經有 sum 函數,它和我們定以的 sumList 一樣,都可以用于計算表元素的和:
Prelude> :load sumList.hs
[1 of 1] Compiling Main ( sumList.hs, interpreted )
Ok, modules loaded: Main.
*Main> sumList [1, 2]
3
*Main> sum [1, 2]
3
讓我們稍微慢下探索新特性的腳步,花些時間,了解構造一個值、和對這個值進行模式匹配之間的關系。
我們通過應用值構造器來構建值:表達式 Book9"CloseCalls"["JohnLong"] 應用 Book 構造器到值 9 、 "CloseCalls" 和 ["JohnLong"] 上面,從而產生一個新的 BookInfo 類型的值。
另一方面,當對 Book 構造器進行模式匹配時,我們逆轉(reverse)它的構造過程:首先,檢查這個值是否由 Book 構造器生成 —— 如果是的話,那么就對這個值進行探查(inspect),并取出創(chuàng)建這個值時,提供給構造器的各個值。
考慮一下表達式 Book9"CloseCalls"["JohnLong"] 對模式 (Bookidnameauthors) 的匹配是如何進行的:
因為模式匹配的過程就像是逆轉一個值的構造(construction)過程,因此它有時候也被稱為解構(deconstruction)。
[譯注:上一節(jié)的《聯合》小節(jié)里提到, Haskell 有辦法分辨同一類型由不同值構造器創(chuàng)建的值,說的就是模式匹配。
比如 Circle... 和 Poly... 兩個表達式創(chuàng)建的都是 Shape 類型的值,但第一個表達式只有在匹配 (Circlevectordouble) 模式時才會成功,而第二個表達式只有在 (Polyvectors) 時才會成功。這就是它們不會被混淆的原因。]
對元組進行模式匹配的語法,和構造元組的語法很相似。
以下是一個可以返回三元組中最后一個元素的函數:
-- file: ch03/third.hs
third (a, b, c) = c
[譯注:原文的源碼文件名為 Tuple.hs ,這里改為 third.hs ,和函數的名字保持一致。]
在 ghci 里測試這個函數:
Prelude> :load third.hs
[1 of 1] Compiling Main ( third.hs, interpreted )
Ok, modules loaded: Main.
*Main> third (1, 2, 3)
3
模式匹配的“深度”并沒有限制。以下模式會同時對元組和元組里的列表進行匹配:
-- file: ch03/complicated.hs
complicated (True, a, x:xs, 5) = (a, xs)
[譯注:原文的源碼文件名為 Tuple.hs ,這里改為 complicated.hs ,和函數的名字保持一致。]
在 ghci 里測試這個函數:
Prelude> :load complicated.hs
[1 of 1] Compiling Main ( complicated.hs, interpreted )
Ok, modules loaded: Main.
*Main> complicated (True, 1, [1, 2, 3], 5)
(1,[2,3])
對于出現在模式里的字面(literal)值(比如前面元組例子里的 True 和 5 ),輸入里的各個值必須和這些字面值相等,匹配才有可能成功。以下代碼顯示,因為輸入元組和模式的第一個字面值 True 不匹配,所以匹配失敗了:
*Main> complicated (False, 1, [1, 2, 3], 5)
*** Exception: complicated.hs:2:1-40: Non-exhaustive patterns in function complicated
這個例子也顯示了,如果所有給定等式的模式都匹配失敗,那么返回一個運行時錯誤。
對代數數據類型的匹配,可以通過這個類型的值構造器來進行。拿之前我們定義的 BookInfo 類型為例子,對它的模式匹配可以使用它的 Book 構造器來進行:
-- file: ch03/BookStore.hs
bookID (Book id title authors) = id
bookTitle (Book id title authors) = title
bookAuthors (Book id title authors) = authors
在 ghci 里試試:
Prelude> :load BookStore.hs
[1 of 1] Compiling Main ( BookStore.hs, interpreted )
Ok, modules loaded: Main.
*Main> let book = (Book 3 "Probability Theory" ["E.T.H. Jaynes"])
*Main> bookID book
3
*Main> bookTitle book
"Probability Theory"
*Main> bookAuthors book
["E.T.H. Jaynes"]
字面值的比對規(guī)則對于列表和值構造器的匹配也適用: (3:xs) 模式只匹配那些不為空,并且第一個元素為 3 的列表;而 (Book3titleauthors) 只匹配 ID 值為 3 的那本書。
當你閱讀那些進行模式匹配的函數時,經常會發(fā)現像是 (x:xs) 或是 (d:ds) 這種類型的名字。這是一個流行的命名規(guī)則,其中的 s 表示“元素的復數”。以 (x:xs) 來說,它用 x 來表示列表的第一個元素,剩余的列表元素則用 xs 表示。
如果在匹配模式中我們不在乎某個值的類型,那么可以用下劃線字符 “_” 作為符號來進行標識,它也叫做通配符。它的用法如下。
-- file: ch03/BookStore.hs
nicerID (Book id _ _ ) = id
nicerTitle (Book _ title _ ) = title
nicerAuthors (Book _ _ authors) = authors
于是,我們將之前介紹過的訪問器函數改得更加簡明了?,F在能很清晰的看出各個函數究竟使用到了哪些元素。
在模式匹配里,通配符的作用和變量類似,但是它并不會綁定成一個新的變量。就像上面的例子展示的那樣,在一個模式匹配里可以使用一個或多個通配符。
使用通配符還有另一個好處。如果我們在一個匹配模式中引入了一個變量,但沒有在函數體中用到它的話,Haskell 編譯器會發(fā)出一個警告。定義一個變量但忘了使用通常意味著存在潛在的 bug,因此這是個有用的功能。假如我們不準備使用一個變量,那就不要用變量,而是用通配符,這樣編譯器就不會報錯。
在給一個類型寫一組匹配模式時,很重要的一點就是一定要涵蓋構造器的所有可能情況。例如,如果我們需要探查一個列表,就應該寫一個匹配非空構造器 (:) 的方程和一個匹配空構造器 [] 的方程。
假如我們沒有涵蓋所有情況會發(fā)生什么呢。下面,我們故意漏寫對 [] 構造器的檢查。
-- file: ch03/BadPattern.hs
badExample (x:xs) = x + badExample xs
如果我們將其作用于一個不能匹配的值,運行時就會報錯:我們的軟件有 bug!
ghci> badExample []
*** Exception: BadPattern.hs:4:0-36: Non-exhaustive patterns in function badExample
在上面的例子中,函數定義時的方程里沒有一個可以匹配 [] 這個值。
如果在某些情況下,我們并不在乎某些特定的構造器,我們就可以用通配符匹配模式來定義一個默認的行為。
-- file: ch03/BadPattern.hs
goodExample (x:xs) = x + goodExample xs
goodExample _ = 0
上面例子中的通配符可以匹配 [] 構造器,因此應用這個函數不會導致程序崩潰。
ghci> goodExample []
0
ghci> goodExample [1,2]
3
給一個數據類型的每個成分寫訪問器函數是令人感覺重復而且乏味的事情。
-- file: ch03/BookStore.hs
nicerID (Book id _ _ ) = id
nicerTitle (Book _ title _ ) = title
nicerAuthors (Book _ _ authors) = authors
我們把這種代碼叫做“樣板代碼(boilerplate code)”:盡管是必需的,但是又長又煩。Haskell 程序員不喜歡樣板代碼。幸運的是,語言的設計者提供了避免這個問題的方法:我們在定義一種數據類型的同時,就可以定義好每個成分的訪問器。(逗號的位置是一個風格問題,如果你喜歡的話,也可以把它放在每行的最后。)
-- file: ch03/BookStore.hs
data Customer = Customer {
customerID :: CustomerID
, customerName :: String
, customerAddress :: Address
} deriving (Show)
以上代碼和下面這段我們更熟悉的代碼的意義幾乎是完全一致的。
-- file: ch03/AltCustomer.hs
data Customer = Customer Int String [String]
deriving (Show)
customerID :: Customer -> Int
customerID (Customer id _ _) = id
customerName :: Customer -> String
customerName (Customer _ name _) = name
customerAddress :: Customer -> [String]
customerAddress (Customer _ _ address) = address
Haskell 會使用我們在定義類型的每個字段時的命名,相應生成與該命名相同的該字段的訪問器函數。
ghci> :type customerID
customerID :: Customer -> CustomerID
我們仍然可以如往常一樣使用應用語法來新建一個此類型的值。
-- file: ch03/BookStore.hs
customer1 = Customer 271828 "J.R. Hacker"
["255 Syntax Ct",
"Milpitas, CA 95134",
"USA"]
記錄語法還新增了一種更詳細的標識法來新建一個值。這種標識法通常都會提升代碼的可讀性。
-- file: ch03/BookStore.hs
customer2 = Customer {
customerID = 271828
, customerAddress = ["1048576 Disk Drive",
"Milpitas, CA 95134",
"USA"]
, customerName = "Jane Q. Citizen"
}
如果使用這種形式,我們還可以調換字段列表的順序。比如在上面的例子里,name 和 address 字段的順序就被移動過,和定義類型時的順序不一樣了。
當我們使用記錄語法來定義類型時,還會影響到該類型的打印格式。
ghci> customer1
Customer {customerID = 271828, customerName = "J.R. Hacker", customerAddress = ["255 Syntax Ct","Milpitas, CA 95134","USA"]}
讓我們打印一個 BookInfo 類型的值來做個比較;這是沒有使用記錄語法時的打印格式。
ghci> cities
Book 173 "Use of Weapons" ["Iain M. Banks"]
我們在使用記錄語法的時候“免費”得到的訪問器函數,實際上都是普通的 Haskell 函數。
ghci> :type customerName
customerName :: Customer -> String
ghci> customerName customer1
"J.R. Hacker"
標準庫里的 System.Time 模塊就是一個使用記錄語法的好例子。例如其中定義了這樣一個類型:
data CalendarTime = CalendarTime {
ctYear :: Int,
ctMonth :: Month,
ctDay, ctHour, ctMin, ctSec :: Int,
ctPicosec :: Integer,
ctWDay :: Day,
ctYDay :: Int,
ctTZName :: String,
ctTZ :: Int,
ctIsDST :: Bool
}
假如沒有記錄語法,從一個如此復雜的類型中抽取某個字段將是一件非常痛苦的事情。這種標識法使我們在使用大型結構的過程中更方便了。
我們曾不止一次地提到列表類型是多態(tài)的:列表中的元素可以是任何類型。我們也可以給自定義的類型添加多態(tài)性。只要在類型定義中使用類型變量就可以做到這一點。Prelude 中定義了一種叫做 Maybe 的類型:它用來表示這樣一種值——既可以有值也可能空缺,比如數據庫中某行的某字段就可能為空。
-- file: ch03/Nullable.hs
data Maybe a = Just a
| Nothing
譯注:Maybe,Just,Nothing 都是 Prelude 中已經定義好的類型
這段代碼是不能在 ghci 里面執(zhí)行的,它簡單地展示了標準庫是怎么定義 Maybe 這種類型的
這里的變量 a 不是普通的變量:它是一個類型變量。它意味著 `Maybe 類型使用另一種類型作為它的參數。從而使得 Maybe 可以作用于任何類型的值。
-- file: ch03/Nullable.hs
someBool = Just True
someString = Just "something"
和往常一樣,我們可以在 ghci 里試著用一下這種類型。
ghci> Just 1.5
Just 1.5
ghci> Nothing
Nothing
ghci> :type Just "invisible bike"
Just "invisible bike" :: Maybe [Char]
Maybe 是一個多態(tài),或者稱作泛型的類型。我們向 Maybe 的類型構造器傳入某種類型作為參數,例如 MaybeInt 或
Maybe[Bool]。 如我們所希望的那樣,這些都是不同的類型(譯注:可能省略了“但是都可以成功傳入作為參數”)。
我們可以嵌套使用參數化的類型,但要記得使用括號標識嵌套的順序,以便 Haskell 編譯器知道如何解析這樣的表達式。
-- file: ch03/Nullable.hs
wrapped = Just (Just "wrapped")
再補充說明一下,如果和其它更常見的語言做個類比,參數化類型就相當于 C++ 中的模板(template),和 Java 中的泛型(generics)。請注意這僅僅是個大概的比喻。這些語言都是在被發(fā)明之后很久再加上模板和泛型的,因此在使用時會感到有些別扭。Haskell 則是從誕生之日起就有了參數化類型,因此更簡單易用。
列表這種常見的類型就是遞歸的:即它用自己來定義自己。為了深入了解其中的含義,讓我們自己來設計一個與列表相仿的類型。我們將用 Cons 替換 (:) 構造器,用 Nil 替換 [] 構造器。
-- file: ch03/ListADT.hs
data List a = Cons a (List a)
| Nil
deriving (Show)
Lista 在 = 符號的左右兩側都有出現,我們可以說該類型的定義引用了它自己。當我們使用 Cons 構造器創(chuàng)建一個值的時候,我們必須提供一個 a 的值作為參數一,以及一個 Lista 類型的值作為參數二。接下來我們看一個實例。
我們能創(chuàng)建的 Lista 類型的最簡單的值就是 Nil。請將上面的代碼保存為一個文件,然后打開 ghci 并加載它。
ghci> Nil
Nil
由于 Nil 是一個 Lista 類型(譯注:原文是 List 類型,可能是漏寫了 a),因此我們可以將它作為 Cons 的第二個參數。
ghci> Cons 0 Nil
Cons 0 Nil
然后 Cons0Nil 也是一個 Lista 類型,我們也可以將它作為 Cons 的第二個參數。
ghci> Cons 1 it
Cons 1 (Cons 0 Nil)
ghci> Cons 2 it
Cons 2 (Cons 1 (Cons 0 Nil))
ghci> Cons 3 it
Cons 3 (Cons 2 (Cons 1 (Cons 0 Nil)))
我們可以一直這樣寫下去,得到一個很長的 Cons 鏈,其中每個子鏈的末位元素都是一個 Nil。
Tip
List 可以被當作是 list 嗎?
讓我們來簡單的證明一下 Lista 類型和內置的 list 類型 [a] 擁有相同的構型。讓我們設計一個函數能夠接受任何一個 [a] 類型的值作為輸入參數,并返回 Lista 類型的一個值。
-- file: ch03/ListADT.hs
fromList (x:xs) = Cons x (fromList xs)
fromList [] = Nil
通過查看上述實現,能清楚的看到它將每個 (:) 替換成 Cons,將每個 [] 替換成 Nil。這樣就涵蓋了內置 list 類型的全部構造器。因此我們可以說二者是同構的,它們有著相同的構型。
ghci> fromList "durian"
Cons 'd' (Cons 'u' (Cons 'r' (Cons 'i' (Cons 'a' (Cons 'n' Nil)))))
ghci> fromList [Just True, Nothing, Just False]
Cons (Just True) (Cons Nothing (Cons (Just False) Nil))
為了說明什么是遞歸類型,我們再來看第三個例子——定義一個二叉樹類型。
-- file: ch03/Tree.hs
data Tree a = Node a (Tree a) (Tree a)
| Empty
deriving (Show)
二叉樹是指這樣一種節(jié)點:該節(jié)點有兩個子節(jié)點,這兩個子節(jié)點要么也是二叉樹節(jié)點,要么是空節(jié)點。
這次我們將和另一種常見的語言進行比較來尋找靈感。以下是在 Java 中實現類似數據結構的類定義。
class Tree<A>
{
A value;
Tree<A> left;
Tree<A> right;
public Tree(A v, Tree<A> l, Tree<A> r)
{
value = v;
left = l;
right = r;
}
}
稍有不同的是,Java 中使用特殊值 null 表示各種“沒有值”, 因此我們可以使用 null 來表示一個節(jié)點沒有左子節(jié)點或沒有右子節(jié)點。下面這個簡單的函數能夠構建一個有兩個葉節(jié)點的樹(葉節(jié)點這個詞習慣上是指沒有子節(jié)點的節(jié)點)。
class Example
{
static Tree<String> simpleTree()
{
return new Tree<String>(
"parent",
new Tree<String>("left leaf", null, null),
new Tree<String>("right leaf", null, null));
}
}
Haskell 沒有與 null 對應的概念。盡管我們可以使用 Maybe 達到類似的效果,但后果是模式匹配將變得十分臃腫。因此我們決定使用一個沒有參數的 Empty 構造器。在上述 Tree 類型的 Java 實現中使用到 null 的地方,在 Haskell 中都改用 Empty。
-- file: ch03/Tree.hs
simpleTree = Node "parent" (Node "left child" Empty Empty)
(Node "right child" Empty Empty)
當我們的代碼中出現嚴重錯誤時可以調用 Haskell 提供的標準函數 error::String->a。我們將希望打印出來的錯誤信息作為一個字符串參數傳入。而該函數的類型簽名看上去有些特別:它是怎么做到僅從一個字符串類型的值就生成任意類型 a 的返回值的呢?
由于它的結果是返回類型 a,因此無論我們在哪里調用它都能得到正確類型的返回值。然而,它并不像普通函數那樣返回一個值,而是立即中止求值過程,并將我們提供的錯誤信息打印出來。
mySecond 函數返回輸入列表參數的第二個元素,假如輸入列表長度不夠則失敗。
-- file: ch03/MySecond.hs
mySecond :: [a] -> a
mySecond xs = if null (tail xs)
then error "list too short"
else head (tail xs)
和之前一樣,我們來看看這個函數在 ghci 中的使用效果如何。
ghci> mySecond "xi"
'i'
ghci> mySecond [2]
*** Exception: list too short
ghci> head (mySecond [[9]])
*** Exception: list too short
注意上面的第三種情況,我們試圖將調用 mySecond 的結果作為參數傳入另一個函數。求值過程也同樣中止了,并返回到 ghci 提示符。這就是使用 error 的最主要的問題:它并不允許調用者根據錯誤是可修復的還是嚴重到必須中止的來區(qū)別對待。
正如我們之前所看到的,模式匹配失敗也會造成類似的不可修復錯誤。
ghci> mySecond []
*** Exception: Prelude.tail: empty list
我們可以使用 Maybe 類型來表示有可能出現錯誤的情況。
如果我們想指出某個操作可能會失敗,可以使用 Nothing 構造器。反之則使用 Just 構造器將值包裹起來。
讓我們看看如果返回 Maybe 類型的值而不是調用 error,這樣會給 mySecond 函數帶來怎樣的變化。
-- file: ch03/MySecond.hs
safeSecond :: [a] -> Maybe a
safeSecond [] = Nothing
safeSecond xs = if null (tail xs)
then Nothing
else Just (head (tail xs))
當傳入的列表太短時,我們將 Nothing 返回給調用者。然后由他們來決定接下來做什么,假如調用 error 的話則會強制程序崩潰。
ghci> safeSecond []
Nothing
ghci> safeSecond [1]
Nothing
ghci> safeSecond [1,2]
Just 2
ghci> safeSecond [1,2,3]
Just 2
復習一下前面的章節(jié),我們還可以使用模式匹配繼續(xù)增強這個函數的可讀性。
-- file: ch03/MySecond.hs
tidySecond :: [a] -> Maybe a
tidySecond (_:x:_) = Just x
tidySecond _ = Nothing
譯注:(_:x:_) 相當于 (_:(x:_)),考慮到列表的元素只能是同一種類型
假想第一個 _ 是 a 類型,那么這個模式匹配的是 (a:(a:[a, a, ...])) 或 (a:(a:[]))
即元素是 a 類型的值的一個列表,并且至少有 2 個元素
那么如果第一個 _ 匹配到了 [],有沒有可能使最終匹配到得列表只有一個元素呢?
([]:(x:_)) 說明 a 是列表類型,那么 x 也必須是列表類型,x 至少是 []
而 ([]:([]:[])) -> ([]:[[]]) -> [[], []],還是 2 個元素
第一個模式僅僅匹配那些至少有兩個元素的列表(因為它有兩個列表構造器),并將列表的第二個元素的值綁定給 變量 x。如果第一個模式匹配失敗了,則匹配第二個模式。
引入局部變量 在函數體內部,我們可以在任何地方使用 let 表達式引入新的局部變量。請看下面這個簡單的函數,它用來檢查我們是否可以向顧客出借現金。我們需要確保剩余的保證金不少于 100 元的情況下,才能出借現金,并返回減去出借金額后的余額。
-- file: ch03/Lending.hs
lend amount balance = let reserve = 100
newBalance = balance - amount
in if balance < reserve
then Nothing
else Just newBalance
這段代碼中使用了 let 關鍵字標識一個變量聲明區(qū)塊的開始,用 in 關鍵字標識這個區(qū)塊的結束。每行引入了一個局部變量。變量名在 = 的左側,右側則是該變量所綁定的表達式。
Note
特別提示
請?zhí)貏e注意我們的用詞:在 let 區(qū)塊中,變量名被綁定到了一個表達式而不是一個值。由于 Haskell 是一門惰性求值的語言,變量名所對應的表達式一直到被用到時才會求值。在上面的例子里,如果沒有滿足保證金的要求,就不會計算 newBalance 的值。
當我們在一個 let 區(qū)塊中定義一個變量時,我們稱之為let
范圍內的變量。顧名思義即是:我們將這個變量限制在這個 let 區(qū)塊內。
另外,上面這個例子中對空白和縮進的使用也值得特別注意。在下一節(jié) “The offside rule and white space in an expression” 中我們會著重講解其中的奧妙。
在 let 區(qū)塊內定義的變量,既可以在定義區(qū)內使用,也可以在緊跟著 in 關鍵字的表達式中使用。
一般來說,我們將代碼中可以使用一個變量名的地方稱作這個變量名的作用域(scope)。如果我們能使用,則說明在 作用域內,反之則說明在作用域外 。如果一個變量名在整個源代碼的任意處都可以使用,則說明它位于最高層的作用域。
我們可以在表達式中使用嵌套的 let 區(qū)塊。
-- file: ch03/NestedLets.hs
foo = let a = 1
in let b = 2
in a + b
上面的寫法是完全合法的;但是在嵌套的 let 表達式里重復使用相同的變量名并不明智。
-- file: ch03/NestedLets.hs
bar = let x = 1
in ((let x = "foo" in x), x)
如上,內部的 x 隱藏了,或稱作屏蔽(shadowing), 外部的 x。它們的變量名一樣,但后者擁有完全不同的類型和值。
ghci> bar
("foo",1)
我們同樣也可以屏蔽一個函數的參數,并導致更加奇怪的結果。你認為下面這個函數的類型是什么?
-- file: ch03/NestedLets.hs
quux a = let a = "foo"
in a ++ "eek!"
在函數的內部,由于 let-綁定的變量名 a 屏蔽了函數的參數,使得參數 a 沒有起到任何作用,因此該參數可以是任何類型的。
ghci> :type quux
quux :: t -> [Char]
Tip
編譯器警告是你的朋友
顯然屏蔽會導致混亂和惡心的 bug,因此 GHC 設置了一個有用的選項 -fwarn-name-shadowing。如果你開啟了這個功能,每當屏蔽某個變量名時,GHC 就會打印出一條警告。
還有另一種方法也可以用來引入局部變量:where 從句。where 從句中的定義在其所跟隨的主句中有效。下面是和 lend 函數類似的一個例子,不同之處是使用了 where 而不是 let。
-- file: ch03/Lending.hs
lend2 amount balance = if amount < reserve * 0.5
then Just newBalance
else Nothing
where reserve = 100
newBalance = balance - amount
盡管剛開始使用 where 從句通常會有異樣的感覺,但它對于提升可讀性有著巨大的幫助。它使得讀者的注意力首先能集中在表達式的一些重要的細節(jié)上,而之后再補上支持性的定義。經過一段時間以后,如果再用回那些沒有 where 從句的語言,你就會懷念它的存在了。
與 let 表達式一樣,where 從句中的空白和縮進也十分重要。 在下一節(jié) “The offside rule and white space in an expression” 中我們會著重講解其中的奧妙。
你可能已經注意到了,在 Haskell 的語法里,定義變量和定義函數的方式非常相似。這種相似性也存在于 let 和 where 區(qū)塊里:定義局部函數就像定義局部變量那樣簡單。
-- file: ch03/LocalFunction.hs
pluralise :: String -> [Int] -> [String]
pluralise word counts = map plural counts
where plural 0 = "no " ++ word ++ "s"
plural 1 = "one " ++ word
plural n = show n ++ " " ++ word ++ "s"
我們定義了一個由多個等式構成的局部函數 plural。局部函數可以自由地使用其被封裝在內的作用域內的任意變量:在本例中,我們使用了在外部函數 pluralise 中定義的變量 word。在 pluralise 的定義里,map 函數(我們將在下一章里再來講解它的用法)將局部函數 plural 逐一應用于 counts 列表的每個元素。
我們也可以在代碼的一開始就定義變量,語法和定義函數是一樣的。
-- file: ch03/GlobalVariable.hs
itemName = "Weighted Companion Cube"
更多建議: