第二章:類型和函數(shù)

2018-02-24 15:49 更新

第二章:類型和函數(shù)

類型是干什么用的?

Haskell 中的每個(gè)函數(shù)和表達(dá)式都帶有各自的類型,通常稱一個(gè)表達(dá)式擁有類型 T ,或者說(shuō)這個(gè)表達(dá)式的類型為 T 。舉個(gè)例子,布爾值 True 的類型為 Bool ,而字符串 "foo" 的類型為 String 。一個(gè)值的類型標(biāo)識(shí)了它和該類型的其他值所共有的一簇屬性(property),比如我們可以對(duì)數(shù)字進(jìn)行相加,對(duì)列表進(jìn)行拼接,諸如此類。

在對(duì) Haskell 的類型系統(tǒng)進(jìn)行更深入的探討之前,不妨先來(lái)了解下,我們?yōu)槭裁匆P(guān)心類型 —— 也即是,它們是干什么用的?

在計(jì)算機(jī)的最底層,處理的都是沒(méi)有任何附加結(jié)構(gòu)的字節(jié)(byte)。而類型系統(tǒng)在這個(gè)基礎(chǔ)上提供了抽象:它為那些單純的字節(jié)加上了意義,使得我們可以說(shuō)“這些字節(jié)是文本”,“那些字節(jié)是機(jī)票預(yù)約數(shù)據(jù)”,等等。

通常情況下,類型系統(tǒng)還會(huì)在標(biāo)識(shí)類型的基礎(chǔ)上更進(jìn)一步:它會(huì)阻止我們混合使用不同的類型,避免程序錯(cuò)誤。比如說(shuō),類型系統(tǒng)通常不會(huì)允許將一個(gè)酒店預(yù)約數(shù)據(jù)當(dāng)作汽車租憑數(shù)據(jù)來(lái)使用。

引入抽象的使得我們可以忽略底層細(xì)節(jié)。舉個(gè)例子,如果程序中的某個(gè)值是一個(gè)字符串,那么我不必考慮這個(gè)字符串在內(nèi)部是如何實(shí)現(xiàn)的,只要像操作其他字符串一樣,操作這個(gè)字符串就可以了。

類型系統(tǒng)的一個(gè)有趣的地方是,不同的類型系統(tǒng)的表現(xiàn)并不完全相同。實(shí)際上,不同類型系統(tǒng)有時(shí)候處理的還是不同種類的問(wèn)題。

除此之外,一門語(yǔ)言的類型系統(tǒng),還會(huì)深切地影響這門語(yǔ)言的使用者思考和編寫程序的方式。而 Haskell 的類型系統(tǒng)則允許程序員以非常抽象的層次思考,并寫出簡(jiǎn)潔、高效、健壯的代碼。

Haskell 的類型系統(tǒng)

Haskell 中的類型有三個(gè)有趣的方面:首先,它們是強(qiáng)(strong)類型的;其次,它們是靜態(tài)(static)的;第三,它們可以通過(guò)自動(dòng)推導(dǎo)(automatically inferred)得出。

后面的三個(gè)小節(jié)會(huì)分別討論這三個(gè)方面,介紹它們的長(zhǎng)處和短處,并列舉 Haskell 類型系統(tǒng)的概念和其他語(yǔ)言里相關(guān)構(gòu)思之間的相似性。

強(qiáng)類型

Haskell 的強(qiáng)類型系統(tǒng)會(huì)拒絕執(zhí)行任何無(wú)意義的表達(dá)式,保證程序不會(huì)因?yàn)檫@些表達(dá)式而引起錯(cuò)誤:比如將整數(shù)當(dāng)作函數(shù)來(lái)使用,或者將一個(gè)字符串傳給一個(gè)只接受整數(shù)參數(shù)的函數(shù),等等。

遵守類型規(guī)則的表達(dá)式被稱為是“類型正確的”(well typed),而不遵守類型規(guī)則、會(huì)引起類型錯(cuò)誤的表達(dá)式被稱為是“類型不正確的”(ill typed)。

Haskell 強(qiáng)類型系統(tǒng)的另一個(gè)作用是,它不會(huì)自動(dòng)地將值從一個(gè)類型轉(zhuǎn)換到另一個(gè)類型(轉(zhuǎn)換有時(shí)又稱為強(qiáng)制或變換)。舉個(gè)例子,如果將一個(gè)整數(shù)值作為參數(shù)傳給了一個(gè)接受浮點(diǎn)數(shù)的函數(shù),C 編譯器會(huì)自動(dòng)且靜默(silently)地將參數(shù)從整數(shù)類型轉(zhuǎn)換為浮點(diǎn)類型,而 Haskell 編譯器則會(huì)引發(fā)一個(gè)編譯錯(cuò)誤。

要在 Haskell 中進(jìn)行類型轉(zhuǎn)換,必須顯式地使用類型轉(zhuǎn)換函數(shù)。

有些時(shí)候,強(qiáng)類型會(huì)讓某種類型代碼的編寫變得困難。比如說(shuō),一種編寫底層 C 代碼的典型方式就是將一系列字節(jié)數(shù)組當(dāng)作復(fù)雜的數(shù)據(jù)結(jié)構(gòu)來(lái)操作。這種做法的效率非常高,因?yàn)樗苊饬藢?duì)字節(jié)的復(fù)制操作。因?yàn)?Haskell 不允許這種形式的轉(zhuǎn)換,所以要獲得同等結(jié)構(gòu)形式的數(shù)據(jù),可能需要進(jìn)行一些復(fù)制操作,這可能會(huì)對(duì)性能造成細(xì)微影響。

強(qiáng)類型的最大好處是可以讓 bug 在代碼實(shí)際運(yùn)行之前浮現(xiàn)出來(lái)。比如說(shuō),在強(qiáng)類型的語(yǔ)言中,“不小心將整數(shù)當(dāng)成了字符串來(lái)使用”這樣的情況不可能出現(xiàn)。

[注意:這里說(shuō)的“bug”指的是類型錯(cuò)誤,和我們常說(shuō)的、通常意義上的 bug 有一些區(qū)別。]

靜態(tài)類型

靜態(tài)類型系統(tǒng)指的是,編譯器可以在編譯期(而不是執(zhí)行期)知道每個(gè)值和表達(dá)式的類型。Haskell 編譯器或解釋器會(huì)察覺出類型不正確的表達(dá)式,并拒絕這些表達(dá)式的執(zhí)行:

Prelude> True && "False"

<interactive>:2:9:
    Couldn't match expected type `Bool' with actual type `[Char]'
    In the second argument of `(&&)', namely `"False"'
    In the expression: True && "False"
    In an equation for `it': it = True && "False"

類似的類型錯(cuò)誤在之前已經(jīng)看過(guò)了:編譯器發(fā)現(xiàn)值 "False" 的類型為 [Char] ,而 (&&) 操作符要求兩個(gè)操作對(duì)象的類型都為 Bool ,雖然左邊的操作對(duì)象 True 滿足類型要求,但右邊的操作對(duì)象 "False" 卻不能匹配指定的類型,因此編譯器以“類型不正確”為由,拒絕執(zhí)行這個(gè)表達(dá)式。 靜態(tài)類型有時(shí)候會(huì)讓某種有用代碼的編寫變得困難。在 Python 這類語(yǔ)言里, duck typing 非常流行, 只要兩個(gè)對(duì)象的行為足夠相似,那么就可以在它們之間進(jìn)行互換。 幸運(yùn)的是, Haskell 提供的 typeclass 機(jī)制以一種安全、方便、實(shí)用的方式提供了大部分動(dòng)態(tài)類型的優(yōu)點(diǎn)。Haskell 也提供了一部分對(duì)全動(dòng)態(tài)類型(truly dynamic types)編程的支持,盡管用起來(lái)沒(méi)有專門支持這種功能的語(yǔ)言那么方便。 Haskell 對(duì)強(qiáng)類型和靜態(tài)類型的雙重支持使得程序不可能發(fā)生運(yùn)行時(shí)類型錯(cuò)誤,這也有助于捕捉那些輕微但難以發(fā)現(xiàn)的小錯(cuò)誤,作為代價(jià),在編程的時(shí)候就要付出更多的努力[譯注:比如糾正類型錯(cuò)誤和編寫類型簽名]。Haskell 社區(qū)有一種說(shuō)法,一旦程序編譯通過(guò),那么這個(gè)程序的正確性就會(huì)比用其他語(yǔ)言來(lái)寫要好得多。(一種更現(xiàn)實(shí)的說(shuō)法是,Haskell 程序的小錯(cuò)誤一般都很少。) 使用動(dòng)態(tài)類型語(yǔ)言編寫的程序,常常需要通過(guò)大量的測(cè)試來(lái)預(yù)防類型錯(cuò)誤的發(fā)生,然而,測(cè)試通常很難做到巨細(xì)無(wú)遺:一些常見的任務(wù),比如重構(gòu),非常容易引入一些測(cè)試沒(méi)覆蓋到的新類型錯(cuò)誤。 另一方面,在 Haskell 里,編譯器負(fù)責(zé)檢查類型錯(cuò)誤:編譯通過(guò)的 Haskell 程序是不可能帶有類型錯(cuò)誤的。而重構(gòu) Haskell 程序通常只是移動(dòng)一些代碼塊,編譯,修復(fù)編譯錯(cuò)誤,并重復(fù)以上步驟直到編譯無(wú)錯(cuò)為止。 要理解靜態(tài)類型的好處,可以用玩拼圖的例子來(lái)打比方:在 Haskell 里,如果一塊拼圖的形狀不正確,那么它就不能被使用。另一方面,動(dòng)態(tài)類型的拼圖全部都是 1 x 1 大小的正方形,這些拼圖無(wú)論放在那里都可以匹配,為了驗(yàn)證這些拼圖被放到了正確的地方,必須使用測(cè)試來(lái)進(jìn)行檢查。
類型推導(dǎo) 關(guān)于類型系統(tǒng),最后要說(shuō)的是,Haskell 編譯器可以自動(dòng)推斷出程序中幾乎所有表達(dá)式的類型[注:有時(shí)候要提供一些信息,幫助編譯器理解程序代碼]。這個(gè)過(guò)程被稱為類型推導(dǎo)(type inference)。 雖然 Haskell 允許我們顯式地為任何值指定類型,但類型推導(dǎo)使得這種工作通常是可選的,而不是非做不可的事。
正確理解類型系統(tǒng) 對(duì) Haskell 類型系統(tǒng)能力和好處的探索會(huì)花費(fèi)好幾個(gè)章節(jié)。在剛開始的時(shí)候,處理 Haskell 的類型可能會(huì)讓你覺得有些麻煩。 比如說(shuō),在 Python 和 Ruby 里,你只要寫下程序,然后測(cè)試一下程序的執(zhí)行結(jié)果是否正確就夠了,但是在 Haskell ,你還要先確保程序能通過(guò)類型檢查。那么,為什么要多走這些彎路呢? 答案是,靜態(tài)、強(qiáng)類型檢查使得 Haskell 更安全,而類型推導(dǎo)則讓它更精煉、簡(jiǎn)潔。這樣得出的的結(jié)果是,比起其他流行的靜態(tài)語(yǔ)言,Haskell 要來(lái)得更安全,而比起其他流行的動(dòng)態(tài)語(yǔ)言, Haskell 的表現(xiàn)力又更勝一籌。 這并不是吹牛,等你看完這本書之后就會(huì)了解這一點(diǎn)。 修復(fù)編譯時(shí)的類型錯(cuò)誤剛開始會(huì)讓人覺得增加了不必要的工作量,但是,換個(gè)角度來(lái)看,這不過(guò)是提前完成了調(diào)試工作:編譯器在處理程序時(shí),會(huì)將代碼中的邏輯錯(cuò)誤一一展示出來(lái),而不是一聲不吭,任由代碼在運(yùn)行時(shí)出錯(cuò)。 更進(jìn)一步來(lái)說(shuō),因?yàn)?Haskell 里值和函數(shù)的類型都可以通過(guò)自動(dòng)推導(dǎo)得出,所以 Haskell 程序既可以獲得靜態(tài)類型帶來(lái)的所有好處,而又不必像傳統(tǒng)的靜態(tài)類型語(yǔ)言那樣,忙于添加各種各樣的類型簽名[譯注:比如 C 語(yǔ)言的函數(shù)原型聲明] —— 在其他語(yǔ)言里,類型系統(tǒng)為編譯器服務(wù);而在 Haskell 里,類型系統(tǒng)為你服務(wù)。唯一的要求是,你需要學(xué)習(xí)如何在類型系統(tǒng)提供的框架下工作。 對(duì) Haskell 類型的運(yùn)用將遍布整本書,這些技術(shù)將幫助我們編寫和測(cè)試實(shí)用的代碼。
一些常用的基本類型 以下是 Haskell 里最常用的一些基本類型,其中有些在之前的章節(jié)里已經(jīng)看過(guò)了: Char 單個(gè) Unicode 字符。 Bool 表示一個(gè)布爾邏輯值。這個(gè)類型只有兩個(gè)值: True 和 False 。 Int 帶符號(hào)的定長(zhǎng)(fixed-width)整數(shù)。這個(gè)值的準(zhǔn)確范圍由機(jī)器決定:在 32 位機(jī)器里, Int 為 32 位寬,在 64 位機(jī)器里, Int 為 64 位寬。Haskell 保證 Int 的寬度不少于 28 位。(數(shù)值類型還可以是 8 位、16 位,等等,也可以是帶符號(hào)和無(wú)符號(hào)的,以后會(huì)介紹。) Integer 不限長(zhǎng)度的帶符號(hào)整數(shù)。 Integer 并不像 Int 那么常用,因?yàn)樗鼈冃枰嗟膬?nèi)存和更大的計(jì)算量。另一方面,對(duì) Integer 的計(jì)算不會(huì)造成溢出,因此使用 Integer 的計(jì)算結(jié)果更可靠。 Double 用于表示浮點(diǎn)數(shù)。長(zhǎng)度由機(jī)器決定,通常是 64 位。(Haskell 也有 Float 類型,但是并不推薦使用,因?yàn)榫幾g器都是針對(duì) Double 來(lái)進(jìn)行優(yōu)化的,而 Float 類型值的計(jì)算要慢得多。) 在前面的章節(jié)里,我們已經(jīng)見到過(guò) :: 符號(hào)。除了用來(lái)表示類型之外,它還可以用于進(jìn)行類型簽名。比如說(shuō), exp :: T 就是向 Haskell 表示, exp 的類型是 T ,而 :: T 就是表達(dá)式 exp 的類型簽名。如果一個(gè)表達(dá)式?jīng)]有顯式地指名類型的話,那么它的類型就通過(guò)自動(dòng)推導(dǎo)來(lái)決定:

Prelude> :type 'a'
'a' :: Char

Prelude> 'a'            -- 自動(dòng)推導(dǎo)
'a'

Prelude> 'a' :: Char    -- 顯式簽名
'a'

當(dāng)然了,類型簽名必須正確,否則 Haskell 編譯器就會(huì)產(chǎn)生錯(cuò)誤:

Prelude> 'a' :: Int     -- 試圖將一個(gè)字符值標(biāo)識(shí)為 Int 類型

<interactive>:7:1:
    Couldn't match expected type `Int' with actual type `Char'
    In the expression: 'a' :: Int
    In an equation for `it': it = 'a' :: Int

調(diào)用函數(shù)

要調(diào)用一個(gè)函數(shù),先寫出它的名字,后接函數(shù)的參數(shù):

Prelude> odd 3
True

Prelude> odd 6
False

注意,函數(shù)的參數(shù)不需要用括號(hào)來(lái)包圍,參數(shù)和參數(shù)之間也不需要用逗號(hào)來(lái)隔開[譯注:使用空格就可以了]:

Prelude> compare 2 3
LT

Prelude> compare 3 3
EQ

Prelude> compare 3 2
GT

Haskell 函數(shù)的應(yīng)用方式和其他語(yǔ)言差不多,但是格式要來(lái)得更簡(jiǎn)單。

因?yàn)楹瘮?shù)應(yīng)用的優(yōu)先級(jí)比操作符要高,因此以下兩個(gè)表達(dá)式是相等的:

Prelude> (compare 2 3) == LT
True

Prelude> compare 2 3 == LT
True

有時(shí)候,為了可讀性考慮,添加一些額外的括號(hào)也是可以理解的,上面代碼的第一個(gè)表達(dá)式就是這樣一個(gè)例子。另一方面,在某些情況下,我們必須使用括號(hào)來(lái)讓編譯器知道,該如何處理一個(gè)復(fù)雜的表達(dá)式:

Prelude> compare (sqrt 3) (sqrt 6)
LT

這個(gè)表達(dá)式將 sqrt3 和 sqrt6 的計(jì)算結(jié)果分別傳給 compare 函數(shù)。如果將括號(hào)移走, Haskell 編譯器就會(huì)產(chǎn)生一個(gè)編譯錯(cuò)誤,因?yàn)樗J(rèn)為我們將四個(gè)參數(shù)傳給了只需要兩個(gè)參數(shù)的 compare 函數(shù):

Prelude> compare sqrt 3 sqrt 6

<interactive>:17:1:
    The function `compare' is applied to four arguments,
    but its type `a0 -> a0 -> Ordering' has only two
    In the expression: compare sqrt 3 sqrt 6
    In an equation for `it': it = compare sqrt 3 sqrt 6

復(fù)合數(shù)據(jù)類型:列表和元組

復(fù)合類型通過(guò)其他類型構(gòu)建得出。列表和元組是 Haskell 中最常用的復(fù)合數(shù)據(jù)類型。

在前面介紹字符串的時(shí)候,我們就已經(jīng)見到過(guò)列表類型了: String 是 [Char] 的別名,而 [Char] 則表示由 Char 類型組成的列表。

head 函數(shù)取出列表的第一個(gè)元素:

Prelude> head [1, 2, 3, 4]
1

Prelude> head ['a', 'b', 'c']
'a'

Prelude> head []
*** Exception: Prelude.head: empty list

和 head 相反, tail 取出列表里除了第一個(gè)元素之外的其他元素:

Prelude> tail [1, 2, 3, 4]
[2,3,4]

Prelude> tail [2, 3, 4]
[3,4]

Prelude> tail [True, False]
[False]

Prelude> tail "list"
"ist"

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

正如前面的例子所示, head 和 tail 函數(shù)可以處理不同類型的列表。將 head 應(yīng)用于 [Char] 類型的列表,結(jié)果為一個(gè) Char 類型的值,而將它應(yīng)用于 [Bool] 類型的值,結(jié)果為一個(gè) Bool 類型的值。 head 函數(shù)并不關(guān)心它處理的是何種類型的列表。

因?yàn)榱斜碇械闹悼梢允侨我忸愋?,所以我們可以稱列表為類型多態(tài)(polymorphic)的。當(dāng)需要編寫帶有多態(tài)類型的代碼時(shí),需要使用類型變量。這些類型變量以小寫字母開頭,作為一個(gè)占位符,最終被一個(gè)具體的類型替換。

比如說(shuō), [a] 用一個(gè)方括號(hào)包圍一個(gè)類型變量 a ,表示一個(gè)“類型為 a 的列表”。這也就是說(shuō)“我不在乎列表是什么類型,盡管給我一個(gè)列表就是了”。

當(dāng)需要一個(gè)帶有具體類型的列表時(shí),就需要用一個(gè)具體的類型去替換類型變量。比如說(shuō), [Int] 表示一個(gè)包含 Int 類型值的列表,它用 Int 類型替換了類型變量 a 。又比如, [MyPersonalType] 表示一個(gè)包含 MyPersonalType 類型值的列表,它用 MyPersonalType 替換了類型變量 a 。

這種替換還還可以遞歸地進(jìn)行: [[Int]] 是一個(gè)包含 [Int] 類型值的列表,而 [Int] 又是一個(gè)包含 Int 類型值的列表。以下例子展示了一個(gè)包含 Bool 類型的列表的列表:

Prelude> :type [[True], [False, False]]
[[True], [False, False]] :: [[Bool]]

假設(shè)現(xiàn)在要用一個(gè)數(shù)據(jù)結(jié)構(gòu),分別保存一本書的出版年份 —— 一個(gè)整數(shù),以及這本書的書名 —— 一個(gè)字符串。很明顯,列表不能保存這樣的信息,因?yàn)榱斜碇荒芙邮茴愋拖嗤闹怠_@時(shí),我們就需要使用元組:

Prelude> (1964, "Labyrinths")
(1964,"Labyrinths")

元組和列表非常不同,它們的兩個(gè)屬性剛剛相反:列表可以任意長(zhǎng),且只能包含類型相同的值;元組的長(zhǎng)度是固定的,但可以包含不同類型的值。

元組的兩邊用括號(hào)包圍,元素之間用逗號(hào)分割。元組的類型信息也使用同樣的格式:

Prelude> :type (True, "hello")
(True, "hello") :: (Bool, [Char])

Prelude> (4, ['a', 'm'], (16, True))
(4,"am",(16,True))

Haskell 有一個(gè)特殊的類型 () ,這種類型只有一個(gè)值 () ,它的作用相當(dāng)于包含零個(gè)元素的元組,類似于 C 語(yǔ)言中的 void :

Prelude> :t ()
() :: ()

通常用元組中元素的數(shù)量作為稱呼元組的前綴,比如“2-元組”用于稱呼包含兩個(gè)元素的元組,“5-元組”用于稱呼包含五個(gè)元素的元組,諸如此類。Haskell 不能創(chuàng)建 1-元組,因?yàn)?Haskell 沒(méi)有相應(yīng)的創(chuàng)建 1-元組的語(yǔ)法(notion)。另外,在實(shí)際編程中,元組的元素太多會(huì)讓代碼變得混亂,因此元組通常只包含幾個(gè)元素。

元組的類型由它所包含元素的數(shù)量、位置和類型決定。這意味著,如果兩個(gè)元組里都包含著同樣類型的元素,而這些元素的擺放位置不同,那么它們的類型就不相等,就像這樣:

Prelude> :type (False, 'a')
(False, 'a') :: (Bool, Char)

Prelude> :type ('a', False)
('a', False) :: (Char, Bool)

除此之外,即使兩個(gè)元組之間有一部分元素的類型相同,位置也一致,但是,如果它們的元素?cái)?shù)量不同,那么它們的類型也不相等:

Prelude> :type (False, 'a')
(False, 'a') :: (Bool, Char)

Prelude> :type (False, 'a', 'b')
(False, 'a', 'b') :: (Bool, Char, Char)

只有元組中的數(shù)量、位置和類型都完全相同,這兩個(gè)元組的類型才是相同的:

Prelude> :t (False, 'a')
(False, 'a') :: (Bool, Char)

Prelude> :t (True, 'b')
(True, 'b') :: (Bool, Char)

元組通常用于以下兩個(gè)地方:

  • 如果一個(gè)函數(shù)需要返回多個(gè)值,那么可以將這些值都包裝到一個(gè)元組中,然后返回元組作為函數(shù)的值。
  • 當(dāng)需要使用定長(zhǎng)容器,但又沒(méi)有必要使用自定義類型的時(shí)候,就可以使用元組來(lái)對(duì)值進(jìn)行包裝。

處理列表和元組的函數(shù)

前面的內(nèi)容介紹了如何構(gòu)造列表和元組,現(xiàn)在來(lái)看看處理這兩種數(shù)據(jù)結(jié)構(gòu)的函數(shù)。

函數(shù) take 和 drop 接受兩個(gè)參數(shù),一個(gè)數(shù)字 n 和一個(gè)列表 l 。

take 返回一個(gè)包含 l 前 n 個(gè)元素的列表:

Prelude> take 2 [1, 2, 3, 4, 5]
[1,2]

drop 則返回一個(gè)包含 l 丟棄了前 n 個(gè)元素之后,剩余元素的列表:

Prelude> drop 2 [1, 2, 3, 4, 5]
[3,4,5]

函數(shù) fst 和 snd 接受一個(gè)元組作為參數(shù),返回該元組的第一個(gè)元素和第二個(gè)元素:

Prelude> fst (1, 'a')
1

Prelude> snd (1, 'a')
'a'

將表達(dá)式傳給函數(shù)

Haskell 的函數(shù)應(yīng)用是左關(guān)聯(lián)的。比如說(shuō),表達(dá)式 abcd 等同于 (((ab)c)d) 。要將一個(gè)表達(dá)式用作另一個(gè)表達(dá)式的參數(shù),那么就必須顯式地使用括號(hào)來(lái)包圍它,這樣編譯器才會(huì)知道我們的真正意思:

Prelude> head (drop 4 "azety")
'y'

drop4"azety" 這個(gè)表達(dá)式被一對(duì)括號(hào)顯式地包圍,作為參數(shù)傳入 head 函數(shù)。

如果將括號(hào)移走,那么編譯器就會(huì)認(rèn)為我們?cè)噲D將三個(gè)參數(shù)傳給 head 函數(shù),于是它引發(fā)一個(gè)錯(cuò)誤:

Prelude> head drop 4 "azety"

<interactive>:26:6:
    Couldn't match expected type `[t1 -> t2 -> t0]'
    with actual type `Int -> [a0] -> [a0]'
    In the first argument of `head', namely `drop'
    In the expression: head drop 4 "azety"
    In an equation for `it': it = head drop 4 "azety"

函數(shù)類型

使用 :type 命令可以查看函數(shù)的類型[譯注:縮寫形式為 :t ]:

Prelude> :type lines
lines :: String -> [String]

符號(hào) -> 可以讀作“映射到”,或者(稍微不太精確地),讀作“返回”。函數(shù)的類型簽名顯示, lines 函數(shù)接受單個(gè)字符串,并返回包含字符串值的列表:

Prelude> lines "the quick\nbrown fox\njumps"
["the quick","brown fox","jumps"]

結(jié)果表示, lines 函數(shù)接受一個(gè)字符串作為輸入,并將這個(gè)字符串按行轉(zhuǎn)義符號(hào)分割成多個(gè)字符串。

從 lines 函數(shù)的這個(gè)例子可以看出:函數(shù)的類型簽名對(duì)于函數(shù)自身的功能有很大的提示作用,這種屬性對(duì)于函數(shù)式語(yǔ)言的類型來(lái)說(shuō),意義重大。

[譯注: String->[String] 的實(shí)際意思是指 lines 函數(shù)定義了一個(gè)從 String 到 [String] 的函數(shù)映射,因此,這里將 -> 的讀法 to 翻譯成“映射到”。]

純度

副作用指的是,函數(shù)的行為受系統(tǒng)的全局狀態(tài)所影響。

舉個(gè)命令式語(yǔ)言的例子:假設(shè)有某個(gè)函數(shù),它讀取并返回某個(gè)全局變量,如果程序中的其他代碼可以修改這個(gè)全局變量的話,那么這個(gè)函數(shù)的返回值就取決于這個(gè)全局變量在某一時(shí)刻的值。我們說(shuō)這個(gè)函數(shù)帶有副作用,盡管它并不親自修改全局變量。

副作用本質(zhì)上是函數(shù)的一種不可見的(invisible)輸入或輸出。Haskell 的函數(shù)在默認(rèn)情況下都是無(wú)副作用的:函數(shù)的結(jié)果只取決于顯式傳入的參數(shù)。

我們將帶副作用的函數(shù)稱為“不純(impure)函數(shù)”,而將不帶副作用的函數(shù)稱為“純(pure)函數(shù)”。

從類型簽名可以看出一個(gè) Haskell 函數(shù)是否帶有副作用 —— 不純函數(shù)的類型簽名都以 IO 開頭:

Prelude> :type readFile
readFile :: FilePath -> IO String

Haskell 源碼,以及簡(jiǎn)單函數(shù)的定義

既然我們已經(jīng)學(xué)會(huì)了如何應(yīng)用函數(shù),那么是時(shí)候回過(guò)頭來(lái),學(xué)習(xí)怎樣去編寫函數(shù)。

因?yàn)?ghci 只支持 Haskell 特性的一個(gè)非常受限的子集,因此,盡管可以在 ghci 里面定義函數(shù),但那里并不是編寫函數(shù)最適當(dāng)?shù)沫h(huán)境。更關(guān)鍵的是, ghci 里面定義函數(shù)的語(yǔ)法和 Haskell 源碼里定義函數(shù)的語(yǔ)法并不相同。綜上所述,我們選擇將代碼寫在源碼文件里。

Haskell 源碼通常以 .hs 作為后綴。我們創(chuàng)建一個(gè) add.hs 文件,并將以下定義添加到文件中:

-- file: ch02/add.hs
add a b = a + b

[譯注:原書代碼里的路徑為 ch03/add.hs ,是錯(cuò)誤的。]

= 號(hào)左邊的 addab 是函數(shù)名和函數(shù)參數(shù),而右邊的 a+b 則是函數(shù)體,符號(hào) = 表示將左邊的名字(函數(shù)名和函數(shù)參數(shù))定義為右邊的表達(dá)式(函數(shù)體)。

將 add.hs 保存之后,就可以在 ghci 里通過(guò) :load 命令(縮寫為 :l )載入它,接著就可以像使用其他函數(shù)一樣,調(diào)用 add 函數(shù)了:

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

*Main> add 1 2  -- 包載入成功之后 ghci 的提示符會(huì)發(fā)生變化
3

[譯注:你的當(dāng)前文件夾(CWD)必須是 ch02 文件夾,否則直接載入 add.hs 會(huì)失敗]

當(dāng)以 1 和 2 作為參數(shù)應(yīng)用 add 函數(shù)的時(shí)候,它們分別被賦值給(或者說(shuō),綁定到)函數(shù)定義中的變量 a 和 b ,因此得出的結(jié)果表達(dá)式為 1+2 ,而這個(gè)表達(dá)式的值 3 就是本次函數(shù)應(yīng)用的結(jié)果。

Haskell 不使用 return 關(guān)鍵字來(lái)返回函數(shù)值:因?yàn)橐粋€(gè)函數(shù)就是一個(gè)單獨(dú)的表達(dá)式(expression),而不是一組陳述(statement),求值表達(dá)式所得的結(jié)果就是函數(shù)的返回值。(實(shí)際上,Haskell 有一個(gè)名為 return 的函數(shù),但它和命令式語(yǔ)言里的 return 不是同一回事。)

變量

在 Haskell 里,可以使用變量來(lái)賦予表達(dá)式名字:一旦變量綁定了(也即是,關(guān)聯(lián)起)某個(gè)表達(dá)式,那么這個(gè)變量的值就不會(huì)改變 —— 我們總能用這個(gè)變量來(lái)指代它所關(guān)聯(lián)的表達(dá)式,并且每次都會(huì)得到同樣的結(jié)果。

如果你曾經(jīng)用過(guò)命令式語(yǔ)言,就會(huì)發(fā)現(xiàn) Haskell 的變量和命令式語(yǔ)言的變量很不同:在命令式語(yǔ)言里,一個(gè)變量通常用于標(biāo)識(shí)一個(gè)內(nèi)存位置(或者其他類似的東西),并且在任何時(shí)候,都可以隨意修改這個(gè)變量的值。因此在不同時(shí)間點(diǎn)上,訪問(wèn)這個(gè)變量得出的值可能是完全不同的。

對(duì)變量的這兩種不同的處理方式產(chǎn)生了巨大的差別:在 Haskell 程序里面,當(dāng)變量和表達(dá)式綁定之后,我們總能將變量替換成相應(yīng)的表達(dá)式。但是在聲明式語(yǔ)言里面就沒(méi)有辦法做這樣的替換,因?yàn)樽兞康闹悼赡軣o(wú)時(shí)不刻都處在改變當(dāng)中。

舉個(gè)例子,以下 Python 腳本打印出值 11 :

x = 10
x = 11
print(x)

[譯注:這里將原書的代碼從 printx 改為 print(x) ,確保代碼在 Python 2 和 Python 3 都可以順利執(zhí)行。]

然后,試著在 Haskell 里做同樣的事:

-- file: ch02/Assign.hs
x = 10
x = 11

但是 Haskell 并不允許做這樣的多次賦值:

Prelude> :load Assign
[1 of 1] Compiling Main             ( Assign.hs, interpreted )

Assign.hs:3:1:
    Multiple declarations of `x'
    Declared at: Assign.hs:2:1
                 Assign.hs:3:1
Failed, modules loaded: none.

條件求值

和很多語(yǔ)言一樣,Haskell 也有自己的 if 表達(dá)式。本節(jié)先說(shuō)明怎么用這個(gè)表達(dá)式,然后再慢慢介紹它的詳細(xì)特性。

我們通過(guò)編寫一個(gè)個(gè)人版本的 drop 函數(shù)來(lái)熟悉 if 表達(dá)式。先來(lái)回顧一下 drop 的行為:

Prelude> drop 2 "foobar"
"obar"

Prelude> drop 4 "foobar"
"ar"

Prelude> drop 4 [1, 2]
[]

Prelude> drop 0 [1, 2]
[1,2]

Prelude> drop 7 []
[]

Prelude> drop (-2) "foo"
"foo"

從測(cè)試代碼的反饋可以看到。當(dāng) drop 函數(shù)的第一個(gè)參數(shù)小于或等于 0 時(shí), drop 函數(shù)返回整個(gè)輸入列表。否則,它就從列表左邊開始移除元素,一直到移除元素的數(shù)量足夠,或者輸入列表被清空為止。

以下是帶有同樣行為的 myDrop 函數(shù),它使用 if 表達(dá)來(lái)決定該做什么。而代碼中的 null 函數(shù)則用于檢查列表是否為空:

-- file: ch02/myDrop.hs
myDrop n xs = if n <= 0 || null xs
              then xs
              else myDrop (n - 1) (tail xs)

在 Haskell 里,代碼的縮進(jìn)非常重要:它會(huì)延續(xù)(continue)一個(gè)已存在的定義,而不是新創(chuàng)建一個(gè)。所以,不要省略縮進(jìn)!

變量 xs 展示了一個(gè)命名列表的常見模式: s 可以視為后綴,而 xs 則表示“復(fù)數(shù)個(gè) x ”。

先保存文件,試試 myDrop 函數(shù)是否如我們所預(yù)期的那樣工作:

[1 of 1] Compiling Main             ( myDrop.hs, interpreted )
Ok, modules loaded: Main.

*Main> myDrop 2 "foobar"
"obar"

*Main> myDrop 4 "foobar"
"ar"

*Main> myDrop 4 [1, 2]
[]

*Main> myDrop 0 [1, 2]
[1,2]

*Main> myDrop 7 []
[]

*Main> myDrop (-2) "foo"
"foo"

好的,代碼正如我們所想的那樣運(yùn)行,現(xiàn)在是時(shí)候回過(guò)頭來(lái),說(shuō)明一下 myDrop 的函數(shù)體里都干了些什么:

if 關(guān)鍵字引入了一個(gè)帶有三個(gè)部分的表達(dá)式:

  • 跟在 if 之后的是一個(gè) Bool 類型的表達(dá)式,它是 if 的條件部分。
  • 跟在 then 關(guān)鍵字之后的是另一個(gè)表達(dá)式,這個(gè)表達(dá)式在條件部分的值為 True 時(shí)被執(zhí)行。
  • 跟在 else 關(guān)鍵字之后的又是另一個(gè)表達(dá)式,這個(gè)表達(dá)式在條件部分的值為 False 時(shí)被執(zhí)行。

我們將跟在 then 和 else 之后的表達(dá)式稱為“分支”。不同分支之間的類型必須相同。[譯注:這里原文還有一句“the if expression will also have this type”,這是錯(cuò)誤的,因?yàn)闂l件部分的表達(dá)式只要是 Bool 類型就可以了,沒(méi)有必要和分支的類型相同。]像是 ifTruethen1else"foo" 這樣的表達(dá)式會(huì)產(chǎn)生錯(cuò)誤,因?yàn)閮蓚€(gè)分支的類型并不相同:

Prelude> if True then 1 else "foo"

<interactive>:2:14:
    No instance for (Num [Char])
        arising from the literal `1'
    Possible fix: add an instance declaration for (Num [Char])
    In the expression: 1
    In the expression: if True then 1 else "foo"
    In an equation for `it': it = if True then 1 else "foo"

記住,Haskell 是一門以表達(dá)式為主導(dǎo)(expression-oriented)的語(yǔ)言。在命令式語(yǔ)言中,代碼由陳述(statement)而不是表達(dá)式組成,因此在省略 if 語(yǔ)句的 else 分支的情況下,程序仍是有意義的。但是,當(dāng)代碼由表達(dá)式組成時(shí),一個(gè)缺少 else 分支的 if 語(yǔ)句,在條件部分為 False 時(shí),是沒(méi)有辦法給出一個(gè)結(jié)果的,當(dāng)然這個(gè) else 分支也不會(huì)有任何類型,因此,省略 else 分支對(duì)于 Haskell 是無(wú)意義的,編譯器也不會(huì)允許這么做。

程序里還有幾個(gè)新東西需要解釋。其中, null 函數(shù)檢查一個(gè)列表是否為空:

Prelude> :type null
null :: [a] -> Bool

Prelude> null []
True

Prelude> null [1, 2, 3]
False

而 (||) 操作符對(duì)它的 Bool 類型參數(shù)執(zhí)行一個(gè)邏輯或(logical or)操作:

Prelude> :type (||)
(||) :: Bool -> Bool -> Bool

Prelude> True || False
True

Prelude> True || True
True

另外需要注意的是, myDrop 函數(shù)是一個(gè)遞歸函數(shù):它通過(guò)調(diào)用自身來(lái)解決問(wèn)題。關(guān)于遞歸,書本稍后會(huì)做更詳細(xì)的介紹。

最后,整個(gè) if 表達(dá)式被分成了多行,而實(shí)際上,它也可以寫成一行:

-- file: ch02/myDropX.hs
myDropX n xs = if n <= 0 || null xs then xs else myDropX (n - 1) (tail xs)

[譯注:原文這里的文件名稱為 myDrop.hs ,為了和之前的 myDrop.hs 區(qū)別開來(lái),這里修改文件名,讓它和函數(shù)名 myDropX 保持一致。]

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

*Main> myDropX 2 "foobar"
"obar"

這個(gè)一行版本的 myDrop 比起之前的定義要難讀得多,為了可讀性考慮,一般來(lái)說(shuō),總是應(yīng)該通過(guò)分行來(lái)隔開條件部分和兩個(gè)分支。

作為對(duì)比,以下是一個(gè) Python 版本的 myDrop ,它的結(jié)構(gòu)和 Haskell 版本差不多:

def myDrop(n, elts):
    while n > 0 and elts:
        n = n -1
        elts = elts[1:]
    return elts

通過(guò)示例了解求值

前面對(duì) myDrop 的描述關(guān)注的都是表面上的特性。我們需要更進(jìn)一步,開發(fā)一個(gè)關(guān)于函數(shù)是如何被應(yīng)用的心智模型:為此,我們先從一些簡(jiǎn)單的示例出發(fā),逐步深入,直到搞清楚 myDrop2"abcd" 到底是怎樣求值為止。

在前面的章節(jié)里多次談到,可以使用一個(gè)表達(dá)式去代換一個(gè)變量。在這部分的內(nèi)容里,我們也會(huì)看到這種替換能力:計(jì)算過(guò)程需要多次對(duì)表達(dá)式進(jìn)行重寫,并將變量替換為表達(dá)式,直到產(chǎn)生最終結(jié)果為止。為了幫助理解,最好準(zhǔn)備一些紙和筆,跟著書本的說(shuō)明,自己計(jì)算一次。

惰性求值

先從一個(gè)簡(jiǎn)單的、非遞歸例子開始,其中 mod 函數(shù)是典型的取模函數(shù):

-- file: ch02/isOdd.hs
isOdd n = mod n 2 == 1

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

我們的第一個(gè)任務(wù)是,弄清楚 isOdd(1+2) 的結(jié)果是如何求值出的。

在使用嚴(yán)格求值的語(yǔ)言里,函數(shù)的參數(shù)總是在應(yīng)用函數(shù)之前被求值。以 isOdd 為例子:子表達(dá)式 (1+2) 會(huì)首先被求值,得出結(jié)果 3 。接著,將 3 綁定到變量 n ,應(yīng)用到函數(shù) isOdd 。最后, mod32 返回 1 ,而 1==1 返回 True 。

Haskell 使用了另外一種求值方式 —— 非嚴(yán)格求值。在這種情況下,求值 isOdd(1+2)并不會(huì)即刻使得子表達(dá)式 1+2 被求值為 3 ,相反,編譯器做出了一個(gè)“承諾”,說(shuō),“當(dāng)真正有需要的時(shí)候,我有辦法計(jì)算出 isOdd(1+2) 的值”。

用于追蹤未求值表達(dá)式的記錄被稱為塊(chunk)。這就是事情發(fā)生的經(jīng)過(guò):編譯器通過(guò)創(chuàng)建塊來(lái)延遲表達(dá)式的求值,直到這個(gè)表達(dá)式的值真正被需要為止。如果某個(gè)表達(dá)式的值不被需要,那么從始至終,這個(gè)表達(dá)式都不會(huì)被求值。

非嚴(yán)格求值通常也被稱為惰性求值。[注:實(shí)際上,“非嚴(yán)格”和“惰性”在技術(shù)上有些細(xì)微的差別,但這里不討論這些細(xì)節(jié)。]

一個(gè)更復(fù)雜的例子

現(xiàn)在,將注意力放回 myDrop2"abcd" 上面,考察它的結(jié)果是如何計(jì)算出來(lái)的:

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

*Main> myDrop 2 "abcd"
"cd"

當(dāng)執(zhí)行表達(dá)式 myDrop2"abcd" 時(shí),函數(shù) myDrop 應(yīng)用于值 2 和 "abcd" ,變量 n 被綁定為 2 ,而變量 xs 被綁定為 "abcd" 。將這兩個(gè)變量代換到 myDrop 的條件判斷部分,就得出了以下表達(dá)式:

*Main> :type 2 <= 0 || null "abcd"
2 <= 0 || null "abcd" :: Bool

編譯器需要對(duì)表達(dá)式 2<=0||null"abcd" 進(jìn)行求值,從而決定 if 該執(zhí)行哪一個(gè)分支。這需要對(duì) (||) 表達(dá)式進(jìn)行求值,而要求值這個(gè)表達(dá)式,又需要對(duì)它的左操作符進(jìn)行求值:

*Main> 2 <= 0
False

將值 False 代換到 (||) 表達(dá)式當(dāng)中,得出以下表達(dá)式:

*Main> :type False || null "abcd"
False || null "abcd" :: Bool

如果 (||) 左操作符的值為 True ,那么 (||) 就不需要對(duì)右操作符進(jìn)行求值,因?yàn)檎麄€(gè) (||) 表達(dá)式的值已經(jīng)由左操作符決定了。[譯注:在邏輯或計(jì)算中,只要有一個(gè)變量的值為真,那么結(jié)果就為真。]另一方面,因?yàn)檫@里左操作符的值為 False ,那么 (||) 表達(dá)式的值由右操作符的值來(lái)決定:

*Main> null "abcd"
False

最后,將左右兩個(gè)操作對(duì)象的值分別替換回 (||) 表達(dá)式,得出以下表達(dá)式:

*Main> False || False
False

這個(gè)結(jié)果表明,下一步要求值的應(yīng)該是 if 表達(dá)式的 else 分支,而這個(gè)分支包含一個(gè)對(duì) myDrop 函數(shù)自身的遞歸調(diào)用: myDrop(2-1)(tail"abcd") 。

遞歸

當(dāng)遞歸地調(diào)用 myDrop 的時(shí)候, n 被綁定為塊 2-1 ,而 xs 被綁定為 tail"abcd" 。

于是再次對(duì) myDrop 函數(shù)進(jìn)行求值,這次將新的值替換到 if 的條件判斷部分:

*Main> :type (2 - 1) <= 0 || null (tail "abcd")
(2 - 1) <= 0 || null (tail "abcd") :: Bool

對(duì) (||) 的左操作符的求值過(guò)程如下:

*Main> :type (2 - 1)
(2 - 1) :: Num a => a

*Main> 2 - 1
1

*Main> 1 <= 0
False

正如前面“惰性求值”一節(jié)所說(shuō)的那樣, (2-1) 只有在真正需要的時(shí)候才會(huì)被求值。同樣,對(duì)右操作符 (tail"abcd") 的求值也會(huì)被延遲,直到真正有需要時(shí)才被執(zhí)行:

*Main> :type null (tail "abcd")
null (tail "abcd") :: Bool

*Main> tail "abcd"
"bcd"

*Main> null "bcd"
False

因?yàn)闂l件判斷表達(dá)式的最終結(jié)果為 False ,所以這次執(zhí)行的也是 else 分支,而被執(zhí)行的表達(dá)式為 myDrop(1-1)(tail"bcd") 。

終止遞歸

這次遞歸調(diào)用將 1-1 綁定到 n ,而 xs 被綁定為 tail"bcd" :

*Main> :type (1 - 1) <= 0 || null (tail "bcd")
(1 - 1) <= 0 || null (tail "bcd") :: Bool

再次對(duì) (||) 操作符的右操作對(duì)象求值:

*Main> :type (1 - 1) <= 0
(1 - 1) <= 0 :: Bool

最終,我們得出了一個(gè) True 值!

*Main> True || null (tail "bcd")
True

因?yàn)?(||) 的右操作符 null(tail"bcd") 并不影響表達(dá)式的計(jì)算結(jié)果,因此它沒(méi)有被求值,而整個(gè)條件判斷部分的最終值為 True 。于是 then 分支被求值:

*Main> :type tail "bcd"
tail "bcd" :: [Char]

從遞歸中返回

請(qǐng)注意,在求值的最后一步,結(jié)果表達(dá)式 tail"bcd" 處于兩次對(duì) myDrop 的遞歸調(diào)用當(dāng)中。

因此,表達(dá)式 tail"bcd" 作為結(jié)果值,被返回給對(duì) myDrop 的第二次遞歸調(diào)用:

*Main> myDrop (1 - 1) (tail "bcd") == tail "bcd"
True

接著,第二次遞歸調(diào)用所得的值(還是 tail"bcd" ),它被返回給第一次遞歸調(diào)用:

*Main> myDrop (2 - 1) (tail "abcd") == tail "bcd"
True

然后,第一次遞歸調(diào)用也將 tail"bcd" 作為結(jié)果值,返回給最開始的 myDrop 調(diào)用:

*Main> myDrop 2 "abcd" == tail "bcd"
True

最終計(jì)算出結(jié)果 "cd" :

*Main> myDrop 2 "abcd"
"cd"

*Main> tail "bcd"
"cd"

注意,在從遞歸調(diào)用中退出并傳遞結(jié)果值的過(guò)程中, tail"bcd" 并不會(huì)被求值,只有當(dāng)它返回到最開始的 myDrop 之后, ghci 需要打印這個(gè)值時(shí), tail"bcd" 才會(huì)被求值。

學(xué)到了什么?

這一節(jié)介紹了三個(gè)重要的知識(shí)點(diǎn):

  • 可以通過(guò)代換(substitution)和重寫(rewriting)去了解 Haskell 求值表達(dá)式的方式。
  • 惰性求值可以延遲計(jì)算直到真正需要一個(gè)值為止,并且在求值時(shí),也只執(zhí)行可以給出(establish)值的那部分表達(dá)式。[譯注:比如之前提到的, (||) 的左操作符的值為 True 時(shí)的情況。]
  • 函數(shù)的返回值可能是一個(gè)塊(一個(gè)被延遲計(jì)算的表達(dá)式)。

Haskell 里的多態(tài)

之前介紹列表的時(shí)候提到過(guò),列表是類型多態(tài)的,這一節(jié)會(huì)說(shuō)明更多這方面的細(xì)節(jié)。

如果想要取出一個(gè)列表的最后一個(gè)元素,那么可以使用 last 函數(shù)。 last 函數(shù)的返回值和列表中的元素的類型是相同的,但是, last 函數(shù)并不介意輸入的列表是什么類型,它對(duì)于任何類型的列表都可以產(chǎn)生同樣的效果:

Prelude> last [1, 2, 3, 4, 5]
5

Prelude> last "baz"
'z'

last 的秘密就隱藏在類型簽名里面:

Prelude> :type last
last :: [a] -> a

這個(gè)類型簽名可以讀作“ last 接受一個(gè)列表,這個(gè)列表里的所有元素的類型都為 a ,并返回一個(gè)類型為 a 的元素作為返回值”,其中 a 是類型變量。

如果函數(shù)的類型簽名里包含類型變量,那么就表示這個(gè)函數(shù)的某些參數(shù)可以是任意類型,我們稱這些函數(shù)是多態(tài)的。

如果將一個(gè)類型為 [Char] 的列表傳給 last ,那么編譯器就會(huì)用 Char 代換 last 函數(shù)類型簽名中的所有 a ,從而得出一個(gè)類型為 [Char]->Char 的 last 函數(shù)。而對(duì)于 [Int] 類型的列表,編譯器則產(chǎn)生一個(gè)類型為 [Int]->Int 類型的 last 函數(shù),諸如此類。

這種類型的多態(tài)被稱為參數(shù)多態(tài)??梢杂靡粋€(gè)類比來(lái)幫助理解這個(gè)名字:就像函數(shù)的參數(shù)可以被其他實(shí)際的值綁定一樣,Haskell 的類型也可以帶有參數(shù),并且這些參數(shù)也可以被其他實(shí)際的類型綁定。

當(dāng)看見一個(gè)參數(shù)化類型(parameterized type)時(shí),這表示代碼并不在乎實(shí)際的類型是什么。另外,我們還可以給出一個(gè)更強(qiáng)的陳述:沒(méi)有辦法知道參數(shù)化類型的實(shí)際類型是什么,也不能操作這種類型的值;不能創(chuàng)建這種類型的值,也不能對(duì)這種類型的值進(jìn)行探查(inspect)。

參數(shù)化類型唯一能做的事,就是作為一個(gè)完全抽象的“黑箱”而存在。稍后的內(nèi)容會(huì)解釋為什么這個(gè)性質(zhì)對(duì)參數(shù)化類型來(lái)說(shuō)至關(guān)重要。

參數(shù)多態(tài)是 Haskell 支持的多態(tài)中最明顯的一個(gè)。Haskell 的參數(shù)多態(tài)直接影響了 Java 和 C# 等語(yǔ)言的泛型(generic)功能的設(shè)計(jì)。Java 泛型中的類型變量和 Haskell 的參數(shù)化類型非常相似。而 C++ 的模板也和參數(shù)多態(tài)相去不遠(yuǎn)。

為了弄清楚 Haskell 的多態(tài)和其他語(yǔ)言的多態(tài)之間的區(qū)別,以下是一些被流行語(yǔ)言所使用的多態(tài)形式,這些形式的多態(tài)都沒(méi)有在 Haskell 里出現(xiàn):

在主流的面向?qū)ο笳Z(yǔ)言中,子類多態(tài)是應(yīng)用得最廣泛的一種。C++ 和 Java 的繼承機(jī)制實(shí)現(xiàn)了子類多態(tài),使得子類可以修改或擴(kuò)展父類所定義的行為。Haskell 不是面向?qū)ο笳Z(yǔ)言,因此它沒(méi)有提供子類多態(tài)。

另一個(gè)常見的多態(tài)形式是強(qiáng)制多態(tài)(coercion polymorphism),它允許值在類型之間進(jìn)行隱式的轉(zhuǎn)換。很多語(yǔ)言都提供了對(duì)強(qiáng)制多態(tài)的某種形式的支持,其中一個(gè)例子就是:自動(dòng)將整數(shù)類型值轉(zhuǎn)換成浮點(diǎn)數(shù)類型值。既然 Haskell 堅(jiān)決反對(duì)自動(dòng)類型轉(zhuǎn)換,那么這種多態(tài)自然也不會(huì)出現(xiàn)在 Haskell 里面。

關(guān)于多態(tài)還有很多東西要說(shuō),本書第六章會(huì)再次回到這個(gè)主題。

對(duì)多態(tài)函數(shù)進(jìn)行推理

前面的《函數(shù)類型》小節(jié)介紹過(guò),可以通過(guò)查看函數(shù)的類型簽名來(lái)了解函數(shù)的行為。這種方法同樣適用于對(duì)多態(tài)類型進(jìn)行推理。

以 fst 函數(shù)為例子:

Prelude> :type fst
fst :: (a, b) -> a

首先,函數(shù)簽名包含兩個(gè)類型變量 a 和 b ,表明元組可以包含不同類型的值。

其次, fst 函數(shù)的結(jié)果值的類型為 a 。前面提到過(guò),參數(shù)多態(tài)沒(méi)有辦法知道輸入?yún)?shù)的實(shí)際類型,并且它也沒(méi)有足夠的信息構(gòu)造一個(gè) a 類型的值,當(dāng)然,它也不可以將 a 轉(zhuǎn)換為 b 。因此,這個(gè)函數(shù)唯一合法的行為,就是返回元組的第一個(gè)元素。

延伸閱讀

前一節(jié)所說(shuō)的 fst 函數(shù)的類型推導(dǎo)行為背后隱藏著非常高深的數(shù)學(xué)知識(shí),并且可以延伸出一系列復(fù)雜的多態(tài)函數(shù)。有興趣的話,可以參考 Philip Wadler 的 Theorems for free 論文。

多參數(shù)函數(shù)的類型

截至目前為止,我們已經(jīng)見到過(guò)一些函數(shù),比如 take ,它們接受一個(gè)以上的參數(shù):

Prelude> :type take
take :: Int -> [a] -> [a]

通過(guò)類型簽名可以看到, take 函數(shù)和一個(gè) Int 值以及兩個(gè)列表有關(guān)。類型簽名中的 -> 符號(hào)是右關(guān)聯(lián)的: Haskell 從右到左地串聯(lián)起這些箭頭,使用括號(hào)可以清晰地標(biāo)示這個(gè)類型簽名是怎樣被解釋的:

-- file: ch02/Take.hs
take :: Int -> ([a] -> [a])

從這個(gè)新的類型簽名可以看出, take 函數(shù)實(shí)際上只接受一個(gè) Int 類型的參數(shù),并返回另一個(gè)函數(shù),這個(gè)新函數(shù)接受一個(gè)列表作為參數(shù),并返回一個(gè)同類型的列表作為這個(gè)函數(shù)的結(jié)果。

以上的說(shuō)明都是正確的,但要說(shuō)清楚隱藏在這種變換背后的重要性并不容易,在《部分函數(shù)應(yīng)用和柯里化》一節(jié),我們會(huì)再次回到這個(gè)主題上。目前來(lái)說(shuō),可以簡(jiǎn)單地將類型簽名中最后一個(gè) -> 右邊的類型看作是函數(shù)結(jié)果的類型,而將前面的其他類型看作是函數(shù)參數(shù)的類型。

了解了這些之后,現(xiàn)在可以為前面定義的 myDrop 函數(shù)編寫類型簽名了:

myDrop :: Int -> [a] -> [a]

為什么要對(duì)純度斤斤計(jì)較?

很少有語(yǔ)言像 Haskell 那樣,默認(rèn)使用純函數(shù)。這個(gè)選擇不僅意義深遠(yuǎn),而且至關(guān)重要。

因?yàn)榧兒瘮?shù)的值只取決于輸入的參數(shù),所以通常只要看看函數(shù)的名字,還有它的類型簽名,就能大概知道函數(shù)是干什么用的。

以 not 函數(shù)為例子:

Prelude> :type not
not :: Bool -> Bool

即使拋開函數(shù)名不說(shuō),單單函數(shù)簽名就極大地限制了這個(gè)函數(shù)可能有的合法行為:

  • 函數(shù)要么返回 True ,要么返回 False
  • 函數(shù)直接將輸入?yún)?shù)當(dāng)作返回值返回
  • 函數(shù)對(duì)它的輸入值求反

除此之外,我們還能肯定,這個(gè)函數(shù)不會(huì)干以下這些事情:讀取文件,訪問(wèn)網(wǎng)絡(luò),或者返回當(dāng)前時(shí)間。

純度減輕了理解一個(gè)函數(shù)所需的工作量。一個(gè)純函數(shù)的行為并不取決于全局變量、數(shù)據(jù)庫(kù)的內(nèi)容或者網(wǎng)絡(luò)連接狀態(tài)。純代碼(pure code)從一開始就是模塊化的:每個(gè)函數(shù)都是自包容的,并且都帶有定義良好的接口。

將純函數(shù)作為默認(rèn)的另一個(gè)不太明顯的好處是,它使得與不純代碼之間的交互變得簡(jiǎn)單。一種常見的 Haskell 風(fēng)格就是,將帶有副作用的代碼和不帶副作用的代碼分開處理。在這種情況下,不純函數(shù)需要盡可能地簡(jiǎn)單,而復(fù)雜的任務(wù)則交給純函數(shù)去做。

軟件的大部分風(fēng)險(xiǎn),都來(lái)自于與外部世界進(jìn)行交互:它需要程序去應(yīng)付錯(cuò)誤的、不完整的數(shù)據(jù),并且處理惡意的攻擊,諸如此類。Haskell 的類型系統(tǒng)明確地告訴我們,哪一部分的代碼帶有副作用,讓我們可以對(duì)這部分代碼添加適當(dāng)?shù)谋Wo(hù)措施。

通過(guò)這種將不純函數(shù)隔離、并盡可能簡(jiǎn)單化的編程風(fēng)格,程序的漏洞將變得非常少。

回顧

這一章對(duì) Haskell 的類型系統(tǒng)以及類型語(yǔ)法進(jìn)行了快速的概覽,了解了基本類型,并學(xué)習(xí)了如何去編寫簡(jiǎn)單的函數(shù)。這章還介紹了多態(tài)、條件表達(dá)式、純度和惰性求值。

這些知識(shí)必須被充分理解。在第三章,我們就會(huì)在這些基本知識(shí)的基礎(chǔ)上,進(jìn)一步加深對(duì) Haskell 的理解。

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

掃描二維碼

下載編程獅App

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

編程獅公眾號(hào)