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

2018-02-24 15:49 更新

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

類型是干什么用的?

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

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

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

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

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

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

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

Haskell 的類型系統(tǒng)

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

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

強類型

Haskell 的強類型系統(tǒng)會拒絕執(zhí)行任何無意義的表達式,保證程序不會因為這些表達式而引起錯誤:比如將整數(shù)當作函數(shù)來使用,或者將一個字符串傳給一個只接受整數(shù)參數(shù)的函數(shù),等等。

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

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

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

有些時候,強類型會讓某種類型代碼的編寫變得困難。比如說,一種編寫底層 C 代碼的典型方式就是將一系列字節(jié)數(shù)組當作復雜的數(shù)據(jù)結(jié)構(gòu)來操作。這種做法的效率非常高,因為它避免了對字節(jié)的復制操作。因為 Haskell 不允許這種形式的轉(zhuǎn)換,所以要獲得同等結(jié)構(gòu)形式的數(shù)據(jù),可能需要進行一些復制操作,這可能會對性能造成細微影響。

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

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

靜態(tài)類型

靜態(tài)類型系統(tǒng)指的是,編譯器可以在編譯期(而不是執(zhí)行期)知道每個值和表達式的類型。Haskell 編譯器或解釋器會察覺出類型不正確的表達式,并拒絕這些表達式的執(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"

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

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

Prelude> 'a'            -- 自動推導
'a'

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

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

Prelude> 'a' :: Int     -- 試圖將一個字符值標識為 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)用一個函數(shù),先寫出它的名字,后接函數(shù)的參數(shù):

Prelude> odd 3
True

Prelude> odd 6
False

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

Prelude> compare 2 3
LT

Prelude> compare 3 3
EQ

Prelude> compare 3 2
GT

Haskell 函數(shù)的應用方式和其他語言差不多,但是格式要來得更簡單。

因為函數(shù)應用的優(yōu)先級比操作符要高,因此以下兩個表達式是相等的:

Prelude> (compare 2 3) == LT
True

Prelude> compare 2 3 == LT
True

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

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

這個表達式將 sqrt3 和 sqrt6 的計算結(jié)果分別傳給 compare 函數(shù)。如果將括號移走, Haskell 編譯器就會產(chǎn)生一個編譯錯誤,因為它認為我們將四個參數(shù)傳給了只需要兩個參數(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

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

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

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

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

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

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

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

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

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 應用于 [Char] 類型的列表,結(jié)果為一個 Char 類型的值,而將它應用于 [Bool] 類型的值,結(jié)果為一個 Bool 類型的值。 head 函數(shù)并不關(guān)心它處理的是何種類型的列表。

因為列表中的值可以是任意類型,所以我們可以稱列表為類型多態(tài)(polymorphic)的。當需要編寫帶有多態(tài)類型的代碼時,需要使用類型變量。這些類型變量以小寫字母開頭,作為一個占位符,最終被一個具體的類型替換。

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

當需要一個帶有具體類型的列表時,就需要用一個具體的類型去替換類型變量。比如說, [Int] 表示一個包含 Int 類型值的列表,它用 Int 類型替換了類型變量 a 。又比如, [MyPersonalType] 表示一個包含 MyPersonalType 類型值的列表,它用 MyPersonalType 替換了類型變量 a 。

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

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

假設現(xiàn)在要用一個數(shù)據(jù)結(jié)構(gòu),分別保存一本書的出版年份 —— 一個整數(shù),以及這本書的書名 —— 一個字符串。很明顯,列表不能保存這樣的信息,因為列表只能接受類型相同的值。這時,我們就需要使用元組:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

元組通常用于以下兩個地方:

  • 如果一個函數(shù)需要返回多個值,那么可以將這些值都包裝到一個元組中,然后返回元組作為函數(shù)的值。
  • 當需要使用定長容器,但又沒有必要使用自定義類型的時候,就可以使用元組來對值進行包裝。

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

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

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

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

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

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

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

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

Prelude> fst (1, 'a')
1

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

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

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

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

drop4"azety" 這個表達式被一對括號顯式地包圍,作為參數(shù)傳入 head 函數(shù)。

如果將括號移走,那么編譯器就會認為我們試圖將三個參數(shù)傳給 head 函數(shù),于是它引發(fā)一個錯誤:

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]

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

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

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

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

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

純度

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

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

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

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

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

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

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

既然我們已經(jīng)學會了如何應用函數(shù),那么是時候回過頭來,學習怎樣去編寫函數(shù)。

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

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

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

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

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

將 add.hs 保存之后,就可以在 ghci 里通過 :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 的提示符會發(fā)生變化
3

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

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

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

變量

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

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

對變量的這兩種不同的處理方式產(chǎn)生了巨大的差別:在 Haskell 程序里面,當變量和表達式綁定之后,我們總能將變量替換成相應的表達式。但是在聲明式語言里面就沒有辦法做這樣的替換,因為變量的值可能無時不刻都處在改變當中。

舉個例子,以下 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.

條件求值

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

我們通過編寫一個個人版本的 drop 函數(shù)來熟悉 if 表達式。先來回顧一下 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"

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

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

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

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

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

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

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

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

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

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

我們將跟在 then 和 else 之后的表達式稱為“分支”。不同分支之間的類型必須相同。[譯注:這里原文還有一句“the if expression will also have this type”,這是錯誤的,因為條件部分的表達式只要是 Bool 類型就可以了,沒有必要和分支的類型相同。]像是 ifTruethen1else"foo" 這樣的表達式會產(chǎn)生錯誤,因為兩個分支的類型并不相同:

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 是一門以表達式為主導(expression-oriented)的語言。在命令式語言中,代碼由陳述(statement)而不是表達式組成,因此在省略 if 語句的 else 分支的情況下,程序仍是有意義的。但是,當代碼由表達式組成時,一個缺少 else 分支的 if 語句,在條件部分為 False 時,是沒有辦法給出一個結(jié)果的,當然這個 else 分支也不會有任何類型,因此,省略 else 分支對于 Haskell 是無意義的,編譯器也不會允許這么做。

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

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

Prelude> null []
True

Prelude> null [1, 2, 3]
False

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

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

Prelude> True || False
True

Prelude> True || True
True

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

最后,整個 if 表達式被分成了多行,而實際上,它也可以寫成一行:

-- 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ū)別開來,這里修改文件名,讓它和函數(shù)名 myDropX 保持一致。]

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

*Main> myDropX 2 "foobar"
"obar"

這個一行版本的 myDrop 比起之前的定義要難讀得多,為了可讀性考慮,一般來說,總是應該通過分行來隔開條件部分和兩個分支。

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

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

通過示例了解求值

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

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

惰性求值

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

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

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

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

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

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

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

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

一個更復雜的例子

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

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

*Main> myDrop 2 "abcd"
"cd"

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

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

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

*Main> 2 <= 0
False

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

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

如果 (||) 左操作符的值為 True ,那么 (||) 就不需要對右操作符進行求值,因為整個 (||) 表達式的值已經(jīng)由左操作符決定了。[譯注:在邏輯或計算中,只要有一個變量的值為真,那么結(jié)果就為真。]另一方面,因為這里左操作符的值為 False ,那么 (||) 表達式的值由右操作符的值來決定:

*Main> null "abcd"
False

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

*Main> False || False
False

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

遞歸

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

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

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

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

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

*Main> 2 - 1
1

*Main> 1 <= 0
False

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

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

*Main> tail "abcd"
"bcd"

*Main> null "bcd"
False

因為條件判斷表達式的最終結(jié)果為 False ,所以這次執(zhí)行的也是 else 分支,而被執(zhí)行的表達式為 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

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

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

最終,我們得出了一個 True 值!

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

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

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

從遞歸中返回

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

因此,表達式 tail"bcd" 作為結(jié)果值,被返回給對 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

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

*Main> myDrop 2 "abcd"
"cd"

*Main> tail "bcd"
"cd"

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

學到了什么?

這一節(jié)介紹了三個重要的知識點:

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

Haskell 里的多態(tài)

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

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

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

Prelude> last "baz"
'z'

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

延伸閱讀

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

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

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

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

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

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

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

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

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

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

為什么要對純度斤斤計較?

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

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

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

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

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

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

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

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

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

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

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

回顧

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

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

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

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號