第三章:Defining Types, Streamlining Functions

2018-02-24 15:49 更新

第三章:Defining Types, Streamlining Functions

定義新的數(shù)據(jù)類型

盡管列表和元組都非常有用,但是,定義新的數(shù)據(jù)類型也是一種常見的需求,這種能力使得我們可以為程序中的值添加結(jié)構(gòu)。

而且比起使用元組,對一簇相關(guān)的值賦予一個(gè)名字和一個(gè)獨(dú)一無二的類型顯得更有用一些。

定義新的數(shù)據(jù)類型也提升了代碼的安全性:Haskell 不會允許我們混用兩個(gè)結(jié)構(gòu)相同但類型不同的值。

本章將以一個(gè)在線書店為例子,展示如何去進(jìn)行類型定義。

使用 data 關(guān)鍵字可以定義新的數(shù)據(jù)類型:

-- file: ch03/BookStore.hs
data BookInfo = Book Int String [String]
                deriving (Show)

跟在 data 關(guān)鍵字之后的 BookInfo 就是新類型的名字,我們稱 BookInfo 為類型構(gòu)造器。類型構(gòu)造器用于指代(refer)類型。正如前面提到過的,類型名字的首字母必須大寫,因此,類型構(gòu)造器的首字母也必須大寫。

接下來的 Book 是值構(gòu)造器(有時(shí)候也稱為數(shù)據(jù)構(gòu)造器)的名字。類型的值就是由值構(gòu)造器創(chuàng)建的。值構(gòu)造器名字的首字母也必須大寫。

在 Book 之后的 Int , String 和 [String] 是類型的組成部分。組成部分的作用,和面向?qū)ο笳Z言的類中的域作用一致:它是一個(gè)儲存值的槽。(為了方便起見,我們通常也將組成部分稱為域。)

在這個(gè)例子中, Int 表示一本書的 ID ,而 String 表示書名,而 [String] 則代表作者。

BookInfo 類型包含的成分和一個(gè) (Int,String,[String]) 類型的三元組一樣,它們唯一不相同的是類型。[譯注:這里指的是整個(gè)值的類型,不是成分的類型。]我們不能混用結(jié)構(gòu)相同但類型不同的值。

舉個(gè)例子,以下的 MagzineInfo 類型的成分和 BookInfo 一模一樣,但 Haskell 會將它們作為不同的類型來區(qū)別對待,因?yàn)樗鼈兊念愋蜆?gòu)構(gòu)造器和值構(gòu)造器并不相同:

-- file: ch03/BookStore.hs
data MagzineInfo = Magzine Int String [String]
                   deriving (Show)

可以將值構(gòu)造器看作是一個(gè)函數(shù) —— 它創(chuàng)建并返回某個(gè)類型值。在這個(gè)書店的例子里,我們將 Int 、 String 和 [String] 三個(gè)類型的值應(yīng)用到 Book ,從而創(chuàng)建一個(gè) 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 里面當(dāng)然也可以創(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 命令來查看表達(dá)式的值:

*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 命令可以查看更多關(guān)于給定表達(dá)式的信息:

*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 命令,可以查看值構(gòu)造器 Book 的類型簽名,了解它是如何創(chuàng)建出 BookInfo 類型的值的:

*Main> :type Book
Book :: Int -> String -> [String] -> BookInfo

類型構(gòu)造器和值構(gòu)造器的命名

在前面介紹 BookInfo 類型的時(shí)候,我們專門為類型構(gòu)造器和值構(gòu)造器設(shè)置了不同的名字( BookInfo 和 Book ),這樣區(qū)分起來比較容易。

在 Haskell 里,類型的名字(類型構(gòu)造器)和值構(gòu)造器的名字是相互獨(dú)立的。類型構(gòu)造器只能出現(xiàn)在類型的定義,或者類型簽名當(dāng)中。而值構(gòu)造器只能出現(xiàn)在實(shí)際的代碼中。因?yàn)榇嬖谶@種差別,給類型構(gòu)造器和值構(gòu)造器賦予一個(gè)相同的名字實(shí)際上并不會產(chǎn)生任何問題。

以下是這種用法的一個(gè)例子:

-- file: ch03/BookStore.hs
-- 稍后就會介紹 CustomerID 的定義

data BookReview = BookReview BookInfo CustomerID String

以上代碼定義了一個(gè) BookReview 類型,并且它的值構(gòu)造器的名字也同樣是 BookReview 。

類型別名

可以使用類型別名,來為一個(gè)已存在的類型設(shè)置一個(gè)更具描述性的名字。

比如說,在前面 BookReview 類型的定義里,并沒有說明 String 成分是干什么用的,通過類型別名,可以解決這個(gè)問題:

-- file: ch03/BookStore.hs
type CustomerID = Int
type ReviewBody = String

data BetterReview = BetterReview BookInfo CustomerID ReviewBody

type 關(guān)鍵字用于設(shè)置類型別名,其中新的類型名字放在 = 號的左邊,而已有的類型名字放在 = 號的右邊。這兩個(gè)名字都標(biāo)識同一個(gè)類型,因此,類型別名完全是為了提高可讀性而存在的。

類型別名也可以用來為啰嗦的類型設(shè)置一個(gè)更短的名字:

-- file: ch03/BookStore.hs
type BookRecord = (BookInfo, BookReview)

需要注意的是,類型別名只是為已有類型提供了一個(gè)新名字,創(chuàng)建值的工作還是由原來類型的值構(gòu)造器進(jìn)行。[注:如果你熟悉 C 或者 C++ ,可以將 Haskell 的類型別名看作是 typedef 。]

代數(shù)數(shù)據(jù)類型

Bool 類型是代數(shù)數(shù)據(jù)類型(algebraic data type)的最簡單也是最常見的例子。一個(gè)代數(shù)類型可以有多于一個(gè)值構(gòu)造器:

-- file: ch03/Bool.hs
data Bool = False | True

上面代碼定義的 Bool 類型擁有兩個(gè)值構(gòu)造器,一個(gè)是 True ,另一個(gè)是 False 。每個(gè)值構(gòu)造器使用 | 符號分割,讀作“或者” —— 以 Bool 類型為例子,我們可以說, Bool 類型由 True 值或者 False 值構(gòu)成。

當(dāng)一個(gè)類型擁有一個(gè)以上的值構(gòu)造器時(shí),這些值構(gòu)造器通常被稱為“備選”(alternatives)或“分支”(case)。同一類型的所有備選,創(chuàng)建出的的值的類型都是相同的。

代數(shù)數(shù)據(jù)類型的各個(gè)值構(gòu)造器都可以接受任意個(gè)數(shù)的參數(shù)。[譯注:不同備選之間接受的參數(shù)個(gè)數(shù)不必相同,參數(shù)的類型也可以不一樣。]以下是一個(gè)賬單數(shù)據(jù)的例子:

-- file: ch03/BookStore.hs
type CardHolder = String
type CardNumber = String
type Address = [String]
data BillingInfo = CreditCard CardNumber CardHolder Address
                 | CashOnDelivery
                 | Invoice CustomerID
                   deriving (Show)

這個(gè)程序提供了三種付款的方式。如果使用信用卡付款,就要使用 CreditCard 作為值構(gòu)造器,并輸入信用卡卡號、信用卡持有人和地址作為參數(shù)。如果即時(shí)支付現(xiàn)金,就不用接受任何參數(shù)。最后,可以通過貨到付款的方式來收款,在這種情況下,只需要填寫客戶的 ID 就可以了。

當(dāng)使用值構(gòu)造器來創(chuàng)建 BillingInfo 類型的值時(shí),必須提供這個(gè)值構(gòu)造器所需的參數(shù):

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

如果輸入?yún)?shù)的類型不對或者數(shù)量不對,那么引發(fā)一個(gè)錯(cuò)誤:

*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 值構(gòu)造器足夠的參數(shù)。

[譯注:原文這里的代碼示例有錯(cuò),譯文已改正。]

什么情況下該用元組,而什么情況下又該用代數(shù)數(shù)據(jù)類型?

元組和自定域代數(shù)數(shù)據(jù)類型有一些相似的地方。比如說,可以使用一個(gè) (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"])

代數(shù)數(shù)據(jù)類型使得我們可以在結(jié)構(gòu)相同但類型不同的數(shù)據(jù)之間進(jìn)行區(qū)分。然而,對于元組來說,只要元素的結(jié)構(gòu)和類型都一致,那么元組的類型就是相同的:

-- 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])

對于兩個(gè)不同的代數(shù)數(shù)據(jù)類型來說,即使值構(gòu)造器成分的結(jié)構(gòu)和類型都相同,它們也是不同的類型:

-- 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

以下是一個(gè)更細(xì)致的例子,它用兩種不同的方式表示二維向量:

-- 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 類型,但是,這些成分表達(dá)的是不同的意思。因?yàn)?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

錯(cuò)誤信息顯示, (==) 操作符只接受類型相同的值作為它的參數(shù),在類型簽名里也可以看出這一點(diǎn):

*Main> :type (==)
(==) :: Eq a => a -> a -> Bool

另一方面,如果使用類型為 (Double,Double) 的元組來表示二維向量的兩種表示方式,那么我們就有麻煩了:

Prelude> -- 第一個(gè)元組使用 Cartesian 表示,第二個(gè)元組使用 Polar 表示
Prelude> (1, 2) == (1, 2)
True

類型系統(tǒng)不會察覺到,我們正錯(cuò)誤地對比兩種不同表示方式的值,因?yàn)閷蓚€(gè)類型相同的元組進(jìn)行對比是完全合法的!

關(guān)于該使用元組還是該使用代數(shù)數(shù)據(jù)類型,沒有一勞永逸的辦法。但是,有一個(gè)經(jīng)驗(yàn)法則可以參考:如果程序大量使用復(fù)合數(shù)據(jù),那么使用 data 進(jìn)行類型自定義對于類型安全和可讀性都有好處。而對于小規(guī)模的內(nèi)部應(yīng)用,那么通常使用元組就足夠了。

其他語言里類似代數(shù)數(shù)據(jù)類型的東西

代數(shù)數(shù)據(jù)類型為描述數(shù)據(jù)類型提供了一種單一且強(qiáng)大的方式。很多其他語言,要達(dá)到相當(dāng)于代數(shù)數(shù)據(jù)類型的表達(dá)能力,需要同時(shí)使用多種特性。

以下是一些 C 和 C++ 方面的例子,說明怎樣在這些語言里,怎么樣實(shí)現(xiàn)類似于代數(shù)數(shù)據(jù)類型的功能。

結(jié)構(gòu)

當(dāng)只有一個(gè)值構(gòu)造器時(shí),代數(shù)數(shù)據(jù)類型和元組很相似:它將一系列相關(guān)的值打包成一個(gè)復(fù)合值。這種做法相當(dāng)于 C 和 C++ 里的 struct ,而代數(shù)數(shù)據(jù)類型的成分則相當(dāng)于 struct 里的域。

以下是一個(gè) C 結(jié)構(gòu),它等同于我們前面定義的 BookInfo 類型:

struct book_info {
    int id;
    char *name;
    char **authors;
};

目前來說, C 結(jié)構(gòu)和 Haskell 的代數(shù)數(shù)據(jù)類型最大的差別是,代數(shù)數(shù)據(jù)類型的成分是匿名且按位置排序的:

--file: ch03/BookStore.hs
data BookInfo = Book Int String [String]
                deriving (Show)

按位置排序指的是,對成分的訪問是通過位置來實(shí)行的,而不是像 C 那樣,通過名字:比如 book_info->id 。

稍后的“模式匹配”小節(jié)會介紹如何訪代數(shù)數(shù)據(jù)類型里的成分。在“記錄”一節(jié)會介紹定義數(shù)據(jù)的新語法,通過這種語法,可以像 C 結(jié)構(gòu)那樣,使用名字來訪問相應(yīng)的成分。

枚舉

C 和 C++ 里的 enum 通常用于表示一系列符號值排列。代數(shù)數(shù)據(jù)類型里面也有相似的東西,一般稱之為枚舉類型

以下是一個(gè) enum 例子:

enum roygbiv {
    red,
    orange,
    yellow,
    green,
    blue,
    indigo,
    violet,
};

以下是等價(jià)的 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 的問題是,它使用整數(shù)值去代表元素:在一些接受 enum 的場景里,可以將整數(shù)傳進(jìn)去,C 編譯器會自動進(jìn)行類型轉(zhuǎn)換。同樣,在使用整數(shù)的場景里,也可以將一個(gè) enum 元素傳進(jìn)去。這種用法可能會造成一些令人不爽的 bug 。

另一方面,在 Haskell 里就沒有這樣的問題。比如說,不可能使用 Roygbiv 里的某個(gè)值來代替 Int 值[譯注:因?yàn)槊杜e類型的每個(gè)元素都由一個(gè)唯一的值構(gòu)造器生成,而不是使用整數(shù)表示。]:

*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"

聯(lián)合

如果一個(gè)代數(shù)數(shù)據(jù)類型有多個(gè)備選,那么可以將它看作是 C 或 C++ 里的 union 。

以上兩者的一個(gè)主要區(qū)別是, union 并不告訴用戶,當(dāng)前使用的是哪一個(gè)備選, union 的使用者必須自己記錄這方面的信息(通常使用一個(gè)額外的域來保存),這意味著,如果搞錯(cuò)了備選的信息,那么對 union 的使用就會出錯(cuò)。

以下是一個(gè) 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 域的值可以是一個(gè) circle 結(jié)構(gòu),也可以是一個(gè) poly 結(jié)構(gòu)。 shape_type 用于記錄目前 shape 正在使用的結(jié)構(gòu)類型。

另一方面,Haskell 版本不僅簡單,而且更為安全:

-- file: ch03/ShapeUnion.hs
type Vector = (Double, Double)

data Shape = Circle Vector Double
           | Poly [Vector]
             deriving (Show)

[譯注:原文的代碼少了 deriving(Show) 一行,在 ghci 測試時(shí)會出錯(cuò)。]

注意,我們不必像 C 語言那樣,使用 shape_type 域來手動記錄 Shape 類型的值是由 Circle 構(gòu)造器生成的,還是由 Poly 構(gòu)造器生成, Haskell 自己有辦法弄清楚一點(diǎn),它不會弄混兩種不同的值。其中的原因,下一節(jié)《模式匹配》就會講到。

[譯注:原文這里將 Poly 寫成了 Square 。]

模式匹配

前面的章節(jié)介紹了代數(shù)數(shù)據(jù)類型的定義方法,本節(jié)將說明怎樣去處理這些類型的值。

對于某個(gè)類型的值來說,應(yīng)該可以做到以下兩點(diǎn):

  • 如果這個(gè)類型有一個(gè)以上的值構(gòu)造器,那么應(yīng)該可以知道,這個(gè)值是由哪個(gè)構(gòu)造器創(chuàng)建的。
  • 如果一個(gè)值構(gòu)造器包含不同的成分,那么應(yīng)該有辦法提取這些成分。

對于以上兩個(gè)問題, Haskell 有一個(gè)簡單且有效的解決方式,那就是類型匹配

模式匹配允許我們查看值的內(nèi)部,并將值所包含的數(shù)據(jù)綁定到變量上。以下是一個(gè)對 Bool 類型值進(jìn)行模式匹配的例子,它的作用和 not 函數(shù)一樣:

-- file: myNot.hs
myNot True = False
myNot False = True

[譯注:原文的文件名為 add.hs ,這里修改成 myNot.hs ,和函數(shù)名保持一致。]

初看上去,代碼似乎同時(shí)定義了兩個(gè) myNot 函數(shù),但實(shí)際情況并不是這樣 —— Haskell 允許將函數(shù)定義為一系列等式: myNot 的兩個(gè)等式分別定義了函數(shù)對于輸入?yún)?shù)在不同模式之下的行為。對于每行等式,模式定義放在函數(shù)名之后, = 符號之前。

為了理解模式匹配是如何工作的,來研究一下 myNotFalse 是如何執(zhí)行的:首先調(diào)用 myNot , Haskell 運(yùn)行時(shí)檢查輸入?yún)?shù) False 是否和第一個(gè)模式的值構(gòu)造器匹配 —— 答案是不匹配,于是它繼續(xù)嘗試匹配第二個(gè)模式 —— 這次匹配成功了,于是第二個(gè)等式右邊的值被作為結(jié)果返回。

以下是一個(gè)復(fù)雜一點(diǎn)的例子,這個(gè)函數(shù)計(jì)算出列表所有元素之和:

-- file:: ch03/sumList.hs
sumList (x:xs) = x + sumList xs
sumList []  = 0

[譯注:原文代碼的文件名為 add.hs 這里改為 sumList.hs ,和函數(shù)名保持一致。]

需要說明的一點(diǎn)是,在 Haskell 里,列表 [1,2] 實(shí)際上只是 (1:(2:[])) 的一種簡單的表示方式,其中 (:) 用于構(gòu)造列表:

Prelude> []
[]

Prelude> 1:[]
[1]

Prelude> 1:2:[]
[1,2]

因此,當(dāng)需要對一個(gè)列表進(jìn)行匹配時(shí),也可以使用 (:) 操作符,只不過這次不是用來構(gòu)造列表,而是用來分解列表。

作為例子,考慮求值 sumList[1,2] 時(shí)會發(fā)生什么:首先, [1,2] 嘗試對第一個(gè)等式的模式 (x:xs) 進(jìn)行匹配,結(jié)果是模式匹配成功,并將 x 綁定為 1 , xs 綁定為 [2] 。

計(jì)算進(jìn)行到這一步,表達(dá)式就變成了 1+(sumList[2]) ,于是遞歸調(diào)用 sumList ,對 [2] 進(jìn)行模式匹配。

這一次也是在第一個(gè)等式匹配成功,變量 x 被綁定為 2 ,而 xs 被綁定為 [] 。表達(dá)式變?yōu)?1+(2+sumList[]) 。

再次遞歸調(diào)用 sumList ,輸入為 [] ,這一次,第二個(gè)等式的 [] 模式匹配成功,返回 0 ,整個(gè)表達(dá)式為 1+(2+(0)) ,計(jì)算結(jié)果為 3 。

最后要說的一點(diǎn)是,標(biāo)準(zhǔn)函數(shù)庫里已經(jīng)有 sum 函數(shù),它和我們定以的 sumList 一樣,都可以用于計(jì)算表元素的和:

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

組成和解構(gòu)

讓我們稍微慢下探索新特性的腳步,花些時(shí)間,了解構(gòu)造一個(gè)值、和對這個(gè)值進(jìn)行模式匹配之間的關(guān)系。

我們通過應(yīng)用值構(gòu)造器來構(gòu)建值:表達(dá)式 Book9"CloseCalls"["JohnLong"] 應(yīng)用 Book 構(gòu)造器到值 9 、 "CloseCalls" 和 ["JohnLong"] 上面,從而產(chǎn)生一個(gè)新的 BookInfo 類型的值。

另一方面,當(dāng)對 Book 構(gòu)造器進(jìn)行模式匹配時(shí),我們逆轉(zhuǎn)(reverse)它的構(gòu)造過程:首先,檢查這個(gè)值是否由 Book 構(gòu)造器生成 —— 如果是的話,那么就對這個(gè)值進(jìn)行探查(inspect),并取出創(chuàng)建這個(gè)值時(shí),提供給構(gòu)造器的各個(gè)值。

考慮一下表達(dá)式 Book9"CloseCalls"["JohnLong"] 對模式 (Bookidnameauthors) 的匹配是如何進(jìn)行的:

  • 因?yàn)橹档臉?gòu)造器和模式里的構(gòu)造器相同,因此匹配成功。
  • 變量 id 被綁定為 9 。
  • 變量 name 被綁定為 CloseCalls 。
  • 變量 authors 被綁定為 ["JohnLong"] 。

因?yàn)槟J狡ヅ涞倪^程就像是逆轉(zhuǎn)一個(gè)值的構(gòu)造(construction)過程,因此它有時(shí)候也被稱為解構(gòu)(deconstruction)。

[譯注:上一節(jié)的《聯(lián)合》小節(jié)里提到, Haskell 有辦法分辨同一類型由不同值構(gòu)造器創(chuàng)建的值,說的就是模式匹配。

比如 Circle... 和 Poly... 兩個(gè)表達(dá)式創(chuàng)建的都是 Shape 類型的值,但第一個(gè)表達(dá)式只有在匹配 (Circlevectordouble) 模式時(shí)才會成功,而第二個(gè)表達(dá)式只有在 (Polyvectors) 時(shí)才會成功。這就是它們不會被混淆的原因。]

更進(jìn)一步

對元組進(jìn)行模式匹配的語法,和構(gòu)造元組的語法很相似。

以下是一個(gè)可以返回三元組中最后一個(gè)元素的函數(shù):

-- file: ch03/third.hs
third (a, b, c) = c

[譯注:原文的源碼文件名為 Tuple.hs ,這里改為 third.hs ,和函數(shù)的名字保持一致。]

在 ghci 里測試這個(gè)函數(shù):

Prelude> :load third.hs
[1 of 1] Compiling Main             ( third.hs, interpreted )
Ok, modules loaded: Main.

*Main> third (1, 2, 3)
3

模式匹配的“深度”并沒有限制。以下模式會同時(shí)對元組和元組里的列表進(jìn)行匹配:

-- file: ch03/complicated.hs
complicated (True, a, x:xs, 5) = (a, xs)

[譯注:原文的源碼文件名為 Tuple.hs ,這里改為 complicated.hs ,和函數(shù)的名字保持一致。]

在 ghci 里測試這個(gè)函數(shù):

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])

對于出現(xiàn)在模式里的字面(literal)值(比如前面元組例子里的 True 和 5 ),輸入里的各個(gè)值必須和這些字面值相等,匹配才有可能成功。以下代碼顯示,因?yàn)檩斎朐M和模式的第一個(gè)字面值 True 不匹配,所以匹配失敗了:

*Main> complicated (False, 1, [1, 2, 3], 5)
*** Exception: complicated.hs:2:1-40: Non-exhaustive patterns in function complicated

這個(gè)例子也顯示了,如果所有給定等式的模式都匹配失敗,那么返回一個(gè)運(yùn)行時(shí)錯(cuò)誤。

對代數(shù)數(shù)據(jù)類型的匹配,可以通過這個(gè)類型的值構(gòu)造器來進(jìn)行。拿之前我們定義的 BookInfo 類型為例子,對它的模式匹配可以使用它的 Book 構(gòu)造器來進(jìn)行:

-- 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ī)則對于列表和值構(gòu)造器的匹配也適用: (3:xs) 模式只匹配那些不為空,并且第一個(gè)元素為 3 的列表;而 (Book3titleauthors) 只匹配 ID 值為 3 的那本書。

模式匹配中的變量名命名

當(dāng)你閱讀那些進(jìn)行模式匹配的函數(shù)時(shí),經(jīng)常會發(fā)現(xiàn)像是 (x:xs) 或是 (d:ds) 這種類型的名字。這是一個(gè)流行的命名規(guī)則,其中的 s 表示“元素的復(fù)數(shù)”。以 (x:xs) 來說,它用 x 來表示列表的第一個(gè)元素,剩余的列表元素則用 xs 表示。

通配符模式匹配

如果在匹配模式中我們不在乎某個(gè)值的類型,那么可以用下劃線字符 “_” 作為符號來進(jìn)行標(biāo)識,它也叫做通配符。它的用法如下。

-- file: ch03/BookStore.hs
nicerID      (Book id _     _      ) = id
nicerTitle   (Book _  title _      ) = title
nicerAuthors (Book _  _     authors) = authors

于是,我們將之前介紹過的訪問器函數(shù)改得更加簡明了。現(xiàn)在能很清晰的看出各個(gè)函數(shù)究竟使用到了哪些元素。

在模式匹配里,通配符的作用和變量類似,但是它并不會綁定成一個(gè)新的變量。就像上面的例子展示的那樣,在一個(gè)模式匹配里可以使用一個(gè)或多個(gè)通配符。

使用通配符還有另一個(gè)好處。如果我們在一個(gè)匹配模式中引入了一個(gè)變量,但沒有在函數(shù)體中用到它的話,Haskell 編譯器會發(fā)出一個(gè)警告。定義一個(gè)變量但忘了使用通常意味著存在潛在的 bug,因此這是個(gè)有用的功能。假如我們不準(zhǔn)備使用一個(gè)變量,那就不要用變量,而是用通配符,這樣編譯器就不會報(bào)錯(cuò)。

窮舉匹配模式和通配符

在給一個(gè)類型寫一組匹配模式時(shí),很重要的一點(diǎn)就是一定要涵蓋構(gòu)造器的所有可能情況。例如,如果我們需要探查一個(gè)列表,就應(yīng)該寫一個(gè)匹配非空構(gòu)造器 (:) 的方程和一個(gè)匹配空構(gòu)造器 [] 的方程。

假如我們沒有涵蓋所有情況會發(fā)生什么呢。下面,我們故意漏寫對 [] 構(gòu)造器的檢查。

-- file: ch03/BadPattern.hs
badExample (x:xs) = x + badExample xs

如果我們將其作用于一個(gè)不能匹配的值,運(yùn)行時(shí)就會報(bào)錯(cuò):我們的軟件有 bug!

ghci> badExample []
*** Exception: BadPattern.hs:4:0-36: Non-exhaustive patterns in function badExample

在上面的例子中,函數(shù)定義時(shí)的方程里沒有一個(gè)可以匹配 [] 這個(gè)值。

如果在某些情況下,我們并不在乎某些特定的構(gòu)造器,我們就可以用通配符匹配模式來定義一個(gè)默認(rèn)的行為。

-- file: ch03/BadPattern.hs
goodExample (x:xs) = x + goodExample xs
goodExample _      = 0

上面例子中的通配符可以匹配 [] 構(gòu)造器,因此應(yīng)用這個(gè)函數(shù)不會導(dǎo)致程序崩潰。

ghci> goodExample []
0
ghci> goodExample [1,2]
3

記錄語法

給一個(gè)數(shù)據(jù)類型的每個(gè)成分寫訪問器函數(shù)是令人感覺重復(fù)而且乏味的事情。

-- file: ch03/BookStore.hs
nicerID      (Book id _     _      ) = id
nicerTitle   (Book _  title _      ) = title
nicerAuthors (Book _  _     authors) = authors

我們把這種代碼叫做“樣板代碼(boilerplate code)”:盡管是必需的,但是又長又煩。Haskell 程序員不喜歡樣板代碼。幸運(yùn)的是,語言的設(shè)計(jì)者提供了避免這個(gè)問題的方法:我們在定義一種數(shù)據(jù)類型的同時(shí),就可以定義好每個(gè)成分的訪問器。(逗號的位置是一個(gè)風(fēng)格問題,如果你喜歡的話,也可以把它放在每行的最后。)

-- 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 會使用我們在定義類型的每個(gè)字段時(shí)的命名,相應(yīng)生成與該命名相同的該字段的訪問器函數(shù)。

ghci> :type customerID
customerID :: Customer -> CustomerID

我們?nèi)匀豢梢匀缤R粯邮褂脩?yīng)用語法來新建一個(gè)此類型的值。

-- file: ch03/BookStore.hs
customer1 = Customer 271828 "J.R. Hacker"
            ["255 Syntax Ct",
             "Milpitas, CA 95134",
             "USA"]

記錄語法還新增了一種更詳細(xì)的標(biāo)識法來新建一個(gè)值。這種標(biāo)識法通常都會提升代碼的可讀性。

-- file: ch03/BookStore.hs
customer2 = Customer {
              customerID = 271828
            , customerAddress = ["1048576 Disk Drive",
                                 "Milpitas, CA 95134",
                                 "USA"]
            , customerName = "Jane Q. Citizen"
            }

如果使用這種形式,我們還可以調(diào)換字段列表的順序。比如在上面的例子里,name 和 address 字段的順序就被移動過,和定義類型時(shí)的順序不一樣了。

當(dāng)我們使用記錄語法來定義類型時(shí),還會影響到該類型的打印格式。

ghci> customer1
Customer {customerID = 271828, customerName = "J.R. Hacker", customerAddress = ["255 Syntax Ct","Milpitas, CA 95134","USA"]}

讓我們打印一個(gè) BookInfo 類型的值來做個(gè)比較;這是沒有使用記錄語法時(shí)的打印格式。

ghci> cities
Book 173 "Use of Weapons" ["Iain M. Banks"]

我們在使用記錄語法的時(shí)候“免費(fèi)”得到的訪問器函數(shù),實(shí)際上都是普通的 Haskell 函數(shù)。

ghci> :type customerName
customerName :: Customer -> String
ghci> customerName customer1
"J.R. Hacker"

標(biāo)準(zhǔn)庫里的 System.Time 模塊就是一個(gè)使用記錄語法的好例子。例如其中定義了這樣一個(gè)類型:

data CalendarTime = CalendarTime {
  ctYear                      :: Int,
  ctMonth                     :: Month,
  ctDay, ctHour, ctMin, ctSec :: Int,
  ctPicosec                   :: Integer,
  ctWDay                      :: Day,
  ctYDay                      :: Int,
  ctTZName                    :: String,
  ctTZ                        :: Int,
  ctIsDST                     :: Bool
}

假如沒有記錄語法,從一個(gè)如此復(fù)雜的類型中抽取某個(gè)字段將是一件非常痛苦的事情。這種標(biāo)識法使我們在使用大型結(jié)構(gòu)的過程中更方便了。

參數(shù)化類型

我們曾不止一次地提到列表類型是多態(tài)的:列表中的元素可以是任何類型。我們也可以給自定義的類型添加多態(tài)性。只要在類型定義中使用類型變量就可以做到這一點(diǎn)。Prelude 中定義了一種叫做 Maybe 的類型:它用來表示這樣一種值——既可以有值也可能空缺,比如數(shù)據(jù)庫中某行的某字段就可能為空。

-- file: ch03/Nullable.hs
data Maybe a = Just a
             | Nothing
譯注:Maybe,Just,Nothing 都是 Prelude 中已經(jīng)定義好的類型
這段代碼是不能在 ghci 里面執(zhí)行的,它簡單地展示了標(biāo)準(zhǔn)庫是怎么定義 Maybe 這種類型的

這里的變量 a 不是普通的變量:它是一個(gè)類型變量。它意味著 `Maybe 類型使用另一種類型作為它的參數(shù)。從而使得 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 是一個(gè)多態(tài),或者稱作泛型的類型。我們向 Maybe 的類型構(gòu)造器傳入某種類型作為參數(shù),例如 MaybeInt 或Maybe[Bool]。 如我們所希望的那樣,這些都是不同的類型(譯注:可能省略了“但是都可以成功傳入作為參數(shù)”)。

我們可以嵌套使用參數(shù)化的類型,但要記得使用括號標(biāo)識嵌套的順序,以便 Haskell 編譯器知道如何解析這樣的表達(dá)式。

-- file: ch03/Nullable.hs
wrapped = Just (Just "wrapped")

再補(bǔ)充說明一下,如果和其它更常見的語言做個(gè)類比,參數(shù)化類型就相當(dāng)于 C++ 中的模板(template),和 Java 中的泛型(generics)。請注意這僅僅是個(gè)大概的比喻。這些語言都是在被發(fā)明之后很久再加上模板和泛型的,因此在使用時(shí)會感到有些別扭。Haskell 則是從誕生之日起就有了參數(shù)化類型,因此更簡單易用。

遞歸類型

列表這種常見的類型就是遞歸的:即它用自己來定義自己。為了深入了解其中的含義,讓我們自己來設(shè)計(jì)一個(gè)與列表相仿的類型。我們將用 Cons 替換 (:) 構(gòu)造器,用 Nil 替換 [] 構(gòu)造器。

-- file: ch03/ListADT.hs
data List a = Cons a (List a)
            | Nil
              deriving (Show)

Lista 在 = 符號的左右兩側(cè)都有出現(xiàn),我們可以說該類型的定義引用了它自己。當(dāng)我們使用 Cons 構(gòu)造器創(chuàng)建一個(gè)值的時(shí)候,我們必須提供一個(gè) a 的值作為參數(shù)一,以及一個(gè) Lista 類型的值作為參數(shù)二。接下來我們看一個(gè)實(shí)例。

我們能創(chuàng)建的 Lista 類型的最簡單的值就是 Nil。請將上面的代碼保存為一個(gè)文件,然后打開 ghci 并加載它。

ghci> Nil
Nil

由于 Nil 是一個(gè) Lista 類型(譯注:原文是 List 類型,可能是漏寫了 a),因此我們可以將它作為 Cons 的第二個(gè)參數(shù)。

ghci> Cons 0 Nil
Cons 0 Nil

然后 Cons0Nil 也是一個(gè) Lista 類型,我們也可以將它作為 Cons 的第二個(gè)參數(shù)。

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)))

我們可以一直這樣寫下去,得到一個(gè)很長的 Cons 鏈,其中每個(gè)子鏈的末位元素都是一個(gè) Nil。

Tip

List 可以被當(dāng)作是 list 嗎?

讓我們來簡單的證明一下 Lista 類型和內(nèi)置的 list 類型 [a] 擁有相同的構(gòu)型。讓我們設(shè)計(jì)一個(gè)函數(shù)能夠接受任何一個(gè) [a] 類型的值作為輸入?yún)?shù),并返回 Lista 類型的一個(gè)值。

-- file: ch03/ListADT.hs
fromList (x:xs) = Cons x (fromList xs)
fromList []     = Nil

通過查看上述實(shí)現(xiàn),能清楚的看到它將每個(gè) (:) 替換成 Cons,將每個(gè) [] 替換成 Nil。這樣就涵蓋了內(nèi)置 list 類型的全部構(gòu)造器。因此我們可以說二者是同構(gòu)的,它們有著相同的構(gòu)型。

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))

為了說明什么是遞歸類型,我們再來看第三個(gè)例子——定義一個(gè)二叉樹類型。

-- file: ch03/Tree.hs
data Tree a = Node a (Tree a) (Tree a)
            | Empty
              deriving (Show)

二叉樹是指這樣一種節(jié)點(diǎn):該節(jié)點(diǎn)有兩個(gè)子節(jié)點(diǎn),這兩個(gè)子節(jié)點(diǎn)要么也是二叉樹節(jié)點(diǎn),要么是空節(jié)點(diǎn)。

這次我們將和另一種常見的語言進(jìn)行比較來尋找靈感。以下是在 Java 中實(shí)現(xiàn)類似數(shù)據(jù)結(jié)構(gòu)的類定義。

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 來表示一個(gè)節(jié)點(diǎn)沒有左子節(jié)點(diǎn)或沒有右子節(jié)點(diǎn)。下面這個(gè)簡單的函數(shù)能夠構(gòu)建一個(gè)有兩個(gè)葉節(jié)點(diǎn)的樹(葉節(jié)點(diǎn)這個(gè)詞習(xí)慣上是指沒有子節(jié)點(diǎn)的節(jié)點(diǎn))。

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 對應(yīng)的概念。盡管我們可以使用 Maybe 達(dá)到類似的效果,但后果是模式匹配將變得十分臃腫。因此我們決定使用一個(gè)沒有參數(shù)的 Empty 構(gòu)造器。在上述 Tree 類型的 Java 實(shí)現(xiàn)中使用到 null 的地方,在 Haskell 中都改用 Empty。

-- file: ch03/Tree.hs
simpleTree = Node "parent" (Node "left child" Empty Empty)
                           (Node "right child" Empty Empty)

練習(xí)

  1. 請給 List 類型寫一個(gè)與 fromList 作用相反的函數(shù):傳入一個(gè) Lista 類型的值,返回一個(gè) [a]。
  2. 請仿造 Java 示例,定義一種只需要一個(gè)構(gòu)造器的樹類型。不要使用 Empty 構(gòu)造器,而是用 Maybe 表示節(jié)點(diǎn)的子節(jié)點(diǎn)。

報(bào)告錯(cuò)誤

當(dāng)我們的代碼中出現(xiàn)嚴(yán)重錯(cuò)誤時(shí)可以調(diào)用 Haskell 提供的標(biāo)準(zhǔn)函數(shù) error::String->a。我們將希望打印出來的錯(cuò)誤信息作為一個(gè)字符串參數(shù)傳入。而該函數(shù)的類型簽名看上去有些特別:它是怎么做到僅從一個(gè)字符串類型的值就生成任意類型 a 的返回值的呢?

由于它的結(jié)果是返回類型 a,因此無論我們在哪里調(diào)用它都能得到正確類型的返回值。然而,它并不像普通函數(shù)那樣返回一個(gè)值,而是立即中止求值過程,并將我們提供的錯(cuò)誤信息打印出來。

mySecond 函數(shù)返回輸入列表參數(shù)的第二個(gè)元素,假如輸入列表長度不夠則失敗。

-- file: ch03/MySecond.hs
mySecond :: [a] -> a

mySecond xs = if null (tail xs)
              then error "list too short"
              else head (tail xs)

和之前一樣,我們來看看這個(gè)函數(shù)在 ghci 中的使用效果如何。

ghci> mySecond "xi"
'i'
ghci> mySecond [2]
*** Exception: list too short
ghci> head (mySecond [[9]])
*** Exception: list too short

注意上面的第三種情況,我們試圖將調(diào)用 mySecond 的結(jié)果作為參數(shù)傳入另一個(gè)函數(shù)。求值過程也同樣中止了,并返回到 ghci 提示符。這就是使用 error 的最主要的問題:它并不允許調(diào)用者根據(jù)錯(cuò)誤是可修復(fù)的還是嚴(yán)重到必須中止的來區(qū)別對待。

正如我們之前所看到的,模式匹配失敗也會造成類似的不可修復(fù)錯(cuò)誤。

ghci> mySecond []
*** Exception: Prelude.tail: empty list

讓過程更可控的方法

我們可以使用 Maybe 類型來表示有可能出現(xiàn)錯(cuò)誤的情況。

如果我們想指出某個(gè)操作可能會失敗,可以使用 Nothing 構(gòu)造器。反之則使用 Just 構(gòu)造器將值包裹起來。

讓我們看看如果返回 Maybe 類型的值而不是調(diào)用 error,這樣會給 mySecond 函數(shù)帶來怎樣的變化。

-- file: ch03/MySecond.hs
safeSecond :: [a] -> Maybe a

safeSecond [] = Nothing
safeSecond xs = if null (tail xs)
                then Nothing
                else Just (head (tail xs))

當(dāng)傳入的列表太短時(shí),我們將 Nothing 返回給調(diào)用者。然后由他們來決定接下來做什么,假如調(diào)用 error 的話則會強(qiáng)制程序崩潰。

ghci> safeSecond []
Nothing
ghci> safeSecond [1]
Nothing
ghci> safeSecond [1,2]
Just 2
ghci> safeSecond [1,2,3]
Just 2

復(fù)習(xí)一下前面的章節(jié),我們還可以使用模式匹配繼續(xù)增強(qiáng)這個(gè)函數(shù)的可讀性。

-- file: ch03/MySecond.hs
tidySecond :: [a] -> Maybe a

tidySecond (_:x:_) = Just x
tidySecond _       = Nothing
譯注:(_:x:_) 相當(dāng)于 (_:(x:_)),考慮到列表的元素只能是同一種類型
     假想第一個(gè) _ 是 a 類型,那么這個(gè)模式匹配的是 (a:(a:[a, a, ...])) 或 (a:(a:[]))
     即元素是 a 類型的值的一個(gè)列表,并且至少有 2 個(gè)元素
     那么如果第一個(gè) _ 匹配到了 [],有沒有可能使最終匹配到得列表只有一個(gè)元素呢?
     ([]:(x:_)) 說明 a 是列表類型,那么 x 也必須是列表類型,x 至少是 []
     而 ([]:([]:[])) -> ([]:[[]]) -> [[], []],還是 2 個(gè)元素

第一個(gè)模式僅僅匹配那些至少有兩個(gè)元素的列表(因?yàn)樗袃蓚€(gè)列表構(gòu)造器),并將列表的第二個(gè)元素的值綁定給 變量 x。如果第一個(gè)模式匹配失敗了,則匹配第二個(gè)模式。
引入局部變量 在函數(shù)體內(nèi)部,我們可以在任何地方使用 let 表達(dá)式引入新的局部變量。請看下面這個(gè)簡單的函數(shù),它用來檢查我們是否可以向顧客出借現(xiàn)金。我們需要確保剩余的保證金不少于 100 元的情況下,才能出借現(xiàn)金,并返回減去出借金額后的余額。

-- file: ch03/Lending.hs
lend amount balance = let reserve    = 100
                          newBalance = balance - amount
                      in if balance < reserve
                         then Nothing
                         else Just newBalance

這段代碼中使用了 let 關(guān)鍵字標(biāo)識一個(gè)變量聲明區(qū)塊的開始,用 in 關(guān)鍵字標(biāo)識這個(gè)區(qū)塊的結(jié)束。每行引入了一個(gè)局部變量。變量名在 = 的左側(cè),右側(cè)則是該變量所綁定的表達(dá)式。

Note

特別提示

請?zhí)貏e注意我們的用詞:在 let 區(qū)塊中,變量名被綁定到了一個(gè)表達(dá)式而不是一個(gè)。由于 Haskell 是一門惰性求值的語言,變量名所對應(yīng)的表達(dá)式一直到被用到時(shí)才會求值。在上面的例子里,如果沒有滿足保證金的要求,就不會計(jì)算 newBalance 的值。

當(dāng)我們在一個(gè) let 區(qū)塊中定義一個(gè)變量時(shí),我們稱之為let 范圍內(nèi)的變量。顧名思義即是:我們將這個(gè)變量限制在這個(gè) let 區(qū)塊內(nèi)。

另外,上面這個(gè)例子中對空白和縮進(jìn)的使用也值得特別注意。在下一節(jié) “The offside rule and white space in an expression” 中我們會著重講解其中的奧妙。

在 let 區(qū)塊內(nèi)定義的變量,既可以在定義區(qū)內(nèi)使用,也可以在緊跟著 in 關(guān)鍵字的表達(dá)式中使用。

一般來說,我們將代碼中可以使用一個(gè)變量名的地方稱作這個(gè)變量名的作用域(scope)。如果我們能使用,則說明在 作用域內(nèi),反之則說明在作用域外 。如果一個(gè)變量名在整個(gè)源代碼的任意處都可以使用,則說明它位于最高層的作用域。

屏蔽

我們可以在表達(dá)式中使用嵌套的 let 區(qū)塊。

-- file: ch03/NestedLets.hs
foo = let a = 1
      in let b = 2
         in a + b

上面的寫法是完全合法的;但是在嵌套的 let 表達(dá)式里重復(fù)使用相同的變量名并不明智。

-- file: ch03/NestedLets.hs
bar = let x = 1
      in ((let x = "foo" in x), x)

如上,內(nèi)部的 x 隱藏了,或稱作屏蔽(shadowing), 外部的 x。它們的變量名一樣,但后者擁有完全不同的類型和值。

ghci> bar
("foo",1)

我們同樣也可以屏蔽一個(gè)函數(shù)的參數(shù),并導(dǎo)致更加奇怪的結(jié)果。你認(rèn)為下面這個(gè)函數(shù)的類型是什么?

-- file: ch03/NestedLets.hs
quux a = let a = "foo"
         in a ++ "eek!"

在函數(shù)的內(nèi)部,由于 let-綁定的變量名 a 屏蔽了函數(shù)的參數(shù),使得參數(shù) a 沒有起到任何作用,因此該參數(shù)可以是任何類型的。

ghci> :type quux
quux :: t -> [Char]

Tip

編譯器警告是你的朋友

顯然屏蔽會導(dǎo)致混亂和惡心的 bug,因此 GHC 設(shè)置了一個(gè)有用的選項(xiàng) -fwarn-name-shadowing。如果你開啟了這個(gè)功能,每當(dāng)屏蔽某個(gè)變量名時(shí),GHC 就會打印出一條警告。

where 從句

還有另一種方法也可以用來引入局部變量:where 從句。where 從句中的定義在其所跟隨的主句中有效。下面是和 lend 函數(shù)類似的一個(gè)例子,不同之處是使用了 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 從句通常會有異樣的感覺,但它對于提升可讀性有著巨大的幫助。它使得讀者的注意力首先能集中在表達(dá)式的一些重要的細(xì)節(jié)上,而之后再補(bǔ)上支持性的定義。經(jīng)過一段時(shí)間以后,如果再用回那些沒有 where 從句的語言,你就會懷念它的存在了。

與 let 表達(dá)式一樣,where 從句中的空白和縮進(jìn)也十分重要。 在下一節(jié) “The offside rule and white space in an expression” 中我們會著重講解其中的奧妙。

局部函數(shù)與全局變量

你可能已經(jīng)注意到了,在 Haskell 的語法里,定義變量和定義函數(shù)的方式非常相似。這種相似性也存在于 let 和 where 區(qū)塊里:定義局部函數(shù)就像定義局部變量那樣簡單。

-- 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"

我們定義了一個(gè)由多個(gè)等式構(gòu)成的局部函數(shù) plural。局部函數(shù)可以自由地使用其被封裝在內(nèi)的作用域內(nèi)的任意變量:在本例中,我們使用了在外部函數(shù) pluralise 中定義的變量 word。在 pluralise 的定義里,map 函數(shù)(我們將在下一章里再來講解它的用法)將局部函數(shù) plural 逐一應(yīng)用于 counts 列表的每個(gè)元素。

我們也可以在代碼的一開始就定義變量,語法和定義函數(shù)是一樣的。

-- file: ch03/GlobalVariable.hs
itemName = "Weighted Companion Cube"

The Offside Rule and Whitespace in an Expression

The Case Expression

Common Beginner Mistakes with Patterns

Conditional Evaluation with Guards

Exercises

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

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號