第二章:類型和函數

2018-02-24 15:49 更新

第二章:類型和函數

類型是干什么用的?

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

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

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

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

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

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

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

Haskell 的類型系統

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

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

強類型

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

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

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

要在 Haskell 中進行類型轉換,必須顯式地使用類型轉換函數。

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

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

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

靜態(tài)類型

靜態(tài)類型系統指的是,編譯器可以在編譯期(而不是執(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"

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

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

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

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

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

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

調用函數

要調用一個函數,先寫出它的名字,后接函數的參數:

Prelude> odd 3
True

Prelude> odd 6
False

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

Prelude> compare 2 3
LT

Prelude> compare 3 3
EQ

Prelude> compare 3 2
GT

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

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

Prelude> (compare 2 3) == LT
True

Prelude> compare 2 3 == LT
True

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

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

這個表達式將 sqrt3 和 sqrt6 的計算結果分別傳給 compare 函數。如果將括號移走, Haskell 編譯器就會產生一個編譯錯誤,因為它認為我們將四個參數傳給了只需要兩個參數的 compare 函數:

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

復合數據類型:列表和元組

復合類型通過其他類型構建得出。列表和元組是 Haskell 中最常用的復合數據類型。

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

head 函數取出列表的第一個元素:

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 函數可以處理不同類型的列表。將 head 應用于 [Char] 類型的列表,結果為一個 Char 類型的值,而將它應用于 [Bool] 類型的值,結果為一個 Bool 類型的值。 head 函數并不關心它處理的是何種類型的列表。

因為列表中的值可以是任意類型,所以我們可以稱列表為類型多態(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]]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

處理列表和元組的函數

前面的內容介紹了如何構造列表和元組,現在來看看處理這兩種數據結構的函數。

函數 take 和 drop 接受兩個參數,一個數字 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]

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

Prelude> fst (1, 'a')
1

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

將表達式傳給函數

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

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

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

如果將括號移走,那么編譯器就會認為我們試圖將三個參數傳給 head 函數,于是它引發(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"

函數類型

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

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

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

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

結果表示, lines 函數接受一個字符串作為輸入,并將這個字符串按行轉義符號分割成多個字符串。

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

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

純度

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

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

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

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

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

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

Haskell 源碼,以及簡單函數的定義

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

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

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

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

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

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

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

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

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

變量

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

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

對變量的這兩種不同的處理方式產生了巨大的差別:在 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 函數來熟悉 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 函數的第一個參數小于或等于 0 時, drop 函數返回整個輸入列表。否則,它就從列表左邊開始移除元素,一直到移除元素的數量足夠,或者輸入列表被清空為止。

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

-- 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 則表示“復數個 x ”。

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

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

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

if 關鍵字引入了一個帶有三個部分的表達式:

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

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

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 時,是沒有辦法給出一個結果的,當然這個 else 分支也不會有任何類型,因此,省略 else 分支對于 Haskell 是無意義的,編譯器也不會允許這么做。

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

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

Prelude> null []
True

Prelude> null [1, 2, 3]
False

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

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

Prelude> True || False
True

Prelude> True || True
True

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

最后,整個 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ū)別開來,這里修改文件名,讓它和函數名 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 ,它的結構和 Haskell 版本差不多:

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

通過示例了解求值

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

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

惰性求值

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

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

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

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

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

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

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

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

一個更復雜的例子

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

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

*Main> myDrop 2 "abcd"
"cd"

當執(zhí)行表達式 myDrop2"abcd" 時,函數 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 ,那么 (||) 就不需要對右操作符進行求值,因為整個 (||) 表達式的值已經由左操作符決定了。[譯注:在邏輯或計算中,只要有一個變量的值為真,那么結果就為真。]另一方面,因為這里左操作符的值為 False ,那么 (||) 表達式的值由右操作符的值來決定:

*Main> null "abcd"
False

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

*Main> False || False
False

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

遞歸

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

于是再次對 myDrop 函數進行求值,這次將新的值替換到 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

因為條件判斷表達式的最終結果為 False ,所以這次執(zhí)行的也是 else 分支,而被執(zhí)行的表達式為 myDrop(1-1)(tail"bcd") 。

終止遞歸

這次遞歸調用將 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") 并不影響表達式的計算結果,因此它沒有被求值,而整個條件判斷部分的最終值為 True 。于是 then 分支被求值:

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

從遞歸中返回

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

因此,表達式 tail"bcd" 作為結果值,被返回給對 myDrop 的第二次遞歸調用:

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

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

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

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

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

最終計算出結果 "cd" :

*Main> myDrop 2 "abcd"
"cd"

*Main> tail "bcd"
"cd"

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

學到了什么?

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

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

Haskell 里的多態(tài)

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

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

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

Prelude> last "baz"
'z'

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

以 fst 函數為例子:

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

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

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

延伸閱讀

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

多參數函數的類型

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

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

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

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

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

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

了解了這些之后,現在可以為前面定義的 myDrop 函數編寫類型簽名了:

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

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

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

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

以 not 函數為例子:

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

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

  • 函數要么返回 True ,要么返回 False
  • 函數直接將輸入參數當作返回值返回
  • 函數對它的輸入值求反

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

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

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

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

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

回顧

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

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

以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號