跳轉到內容

Haskell/型別基礎

來自華夏公益教科書,開放的書籍,開放的世界

在程式設計中,型別用於將類似的值歸類。在 Haskell 中,型別系統是一種強大的方式,可以減少程式碼中的錯誤數量。

程式設計處理不同型別的實體。例如,考慮將兩個數字加在一起

2 和 3 是什麼?它們是數字。中間的加號呢?那肯定不是數字,但它代表我們可以對兩個數字執行的操作,即加法。

類似地,考慮一個程式,它會詢問您的姓名,然後用“Hello”資訊向您致意。您的姓名和“Hello”這個詞都不是數字。它們是什麼呢?我們可能會將所有單詞、句子等等稱為文字。在程式設計中,通常使用一個更深奧的詞:字串,它指的是“字元的字串”。

Haskell 有一條規則,所有型別名都必須以大寫字母開頭。我們將從此遵循此約定。

資料庫清楚地說明了型別的概念。例如,假設我們有一個數據庫中的表格,用於儲存有關某人聯絡人的詳細資訊;一種個人電話簿。內容可能如下所示

地址 電話號碼
福爾摩斯 夏洛克 倫敦貝克街 221B 號 743756
瓊斯 鮑勃 維爾斯鎮長路街 99 號 655523

每個條目中的欄位包含值。福爾摩斯是一個值,和維爾斯鎮長路街 99 號一樣,以及655523也是。讓我們根據型別對本例中的值進行分類。“姓”和“名”包含文字,因此我們說這些值的型別為 String。

乍一看,我們可能會將地址歸類為 String。但是,地址背後語義非常複雜。許多人類約定規定我們如何解釋地址。例如,如果地址文字的開頭包含一個數字,它很可能就是房子的號碼。如果不是,那麼它可能就是房子的名稱,除非它以“郵政信箱”開頭,在這種情況下,它只是一個郵政信箱地址,並不表示該人在哪裡居住。地址的每個部分都有其自身的含義。

原則上,我們可以說地址是字串,但這並不能捕獲地址的許多重要特徵。當我們將某事物描述為字串時,我們所說的只是它是字元(字母、數字等)的序列。識別某事物為專門的型別更有意義。如果我們知道某事物是 Address,我們立即會了解有關該資料片段的更多資訊,例如,我們可以使用賦予地址意義的“人類約定”來解釋它。

我們也可以將此原理應用於電話號碼。我們可以指定 TelephoneNumber 型別。然後,如果我們遇到一些任意數字序列,恰好是 TelephoneNumber 型別,那麼我們就可以訪問比僅僅是一個 Number 更多的資訊,例如,我們可以開始在初始數字上查詢區號和國家程式碼等內容。

不將電話號碼視為數字的另一個原因是,對它們進行算術運算毫無意義。例如,將 TelephoneNumber 乘以 100 的意義和預期效果是什麼?它將無法透過電話呼叫任何人。此外,組成電話號碼的每個數字都很重要;我們不能接受透過舍入甚至省略前導零而丟失其中一些數字。

為什麼型別是有用的

[編輯 | 編輯原始碼]

描述和分類事物如何幫助我們編寫良好的程式?一旦我們定義了一個型別,我們就可以指定對它可以做什麼或不能做什麼。這使得管理更大的程式並避免錯誤變得更加容易。

使用互動式 :type 命令

[編輯 | 編輯原始碼]

讓我們使用 GHCi 來探索型別的工作原理。可以使用 :type(或縮寫為 :t)命令檢查任何表示式的型別。在上一模組中的布林值上嘗試一下

示例:在 GHCi 中探索布林值的型別

Prelude> :type True
True :: Bool
Prelude> :type False
False :: Bool
Prelude> :t (3 < 5)
(3 < 5) :: Bool

符號 :: 會出現在其他幾個地方,可以簡單地理解為“型別為”,它表示一個型別簽名

:type 顯示,Haskell 中的真值型別為 Bool,如上所示,對於兩個可能的值 TrueFalse,以及用於評估為其中之一的示例表示式。請注意,布林值不僅僅用於值比較。Bool 捕獲了是/否答案的語義,因此它可以表示任何這種型別的資訊,例如,在電子表格中是否找到了名稱,或者使用者是否切換了開/關選項。

字元和字串

[編輯 | 編輯原始碼]

現在讓我們在新的內容上嘗試 :t。文字字元透過用單引號括起來來輸入。例如,這是單個字母 H

示例:在 GHCi 中對文字字元使用 :type 命令

Prelude> :t 'H'
'H' :: Char

所以,文字字元值型別為 Char(“字元”的縮寫)。現在,單引號僅適用於單個字元,因此,如果我們需要輸入更長的文字,即字元的字串,我們改用引號

示例:在 GHCi 中對文字字串使用 :type 命令

Prelude> :t "Hello World"
"Hello World" :: [Char]

為什麼我們再次得到 Char?區別在於方括號。[Char] 表示多個字元連結在一起,形成一個字元列表。Haskell 認為所有字串都是字元列表。列表在 Haskell 中通常是重要的實體,我們將在稍後更詳細地介紹它們。

練習
  1. 嘗試在文字值 "H" 上使用 :type(注意雙引號)。發生了什麼?為什麼?
  2. 嘗試在文字值 'Hello World' 上使用 :type(注意單引號)。發生了什麼?為什麼?

順便說一句,Haskell 允許使用型別同義詞,它們的工作原理與人類語言中的同義詞非常相似(意思相同的詞,例如,“大”和“大”)。在 Haskell 中,型別同義詞是型別的替代名稱。例如,String 被定義為 [Char] 的同義詞,因此我們可以隨意用一個替換另一個。因此,說

"Hello World" :: String

也完全有效,在很多情況下更具可讀性。從這裡開始,我們將主要將文字值稱為 String,而不是 [Char]

函式型別

[編輯 | 編輯原始碼]

到目前為止,我們已經瞭解了值(字串、布林值、字元等)如何擁有型別,以及這些型別如何幫助我們對它們進行分類和描述。現在,讓我們來看看讓 Haskell 型別系統真正強大的關鍵所在:函式 也擁有型別。[1] 讓我們看一些例子來了解它是如何工作的。

示例:not

[編輯 | 編輯原始碼]

我們可以使用 not 來否定布林值(例如,not True 計算結果為 False,反之亦然)。為了確定函式的型別,我們需要考慮兩件事:它作為輸入接受的值的型別和它返回的值的型別。在這個例子中,事情很簡單。not 接受一個 Bool(要否定的布林值),並返回一個 Bool(否定的布林值)。寫下它的表示法是

示例: not 的型別簽名

not :: Bool -> Bool

你可以把它理解為“not 是一個從型別為 Bool 的事物到型別為 Bool 的事物的函式”。

使用:t在函式上將按預期工作

Prelude> :t not
not :: Bool -> Bool

函式型別描述的是它接受的論據型別以及它計算結果的型別。

示例:chrord

[編輯 | 編輯原始碼]

文字對計算機來說是一個難題。在最低級別上,計算機只認識二進位制的 1 和 0。為了表示文字,每個字元首先被轉換為一個數字,然後這個數字被轉換為二進位制並存儲起來。這就是一段文字(只是一系列字元)是如何被編碼成二進位制的。通常,我們只關心如何將字元編碼成它們的數字表示形式,因為計算機會在沒有我們干預的情況下完成轉換為二進位制數字的操作。

將字元轉換為數字最簡單的方法是簡單地寫下所有可能的字元,然後對它們進行編號。例如,我們可以決定 'a' 對應 1,'b' 對應 2,等等。這就是所謂的 ASCII 標準:取 128 個常用的字元並對它們進行編號(ASCII 實際上並沒有從 'a' 開始,但總體思路是一樣的)。當然,如果我們每次想要編碼一個字元時都要在一個大型查詢表中查詢,那將會是一項非常繁瑣的工作,所以我們有兩種函式可以幫我們做到這一點,chr(讀作“char”)和 ord[2]

示例: chrord 的型別簽名

chr :: Int  -> Char
ord :: Char -> Int

我們已經知道 Char 的含義。上面簽名中的新型別 Int 指的是整數,是許多不同型別的數字中的一種。[3] chr 的型別簽名告訴我們,它接受一個型別為 Int 的論據(一個整數),並計算結果為型別為 Char 的結果。ord 的情況正好相反:它接受型別為 Char 的事物,並返回型別為 Int 的事物。有了型別簽名的資訊,我們立即就能清楚地知道哪個函式將一個字元編碼成一個數字程式碼(ord),哪個函式將它解碼回一個字元(chr)。

為了使事情更加具體,這裡舉幾個例子。請注意,這兩個函式預設情況下不可用;所以在 GHCi 中嘗試使用它們之前,你需要使用 :module Data.Char(或 :m Data.Char)命令來載入定義它們的 Data.Char 模組。

示例:chrord 的函式呼叫

Prelude> :m Data.Char
Prelude Data.Char> chr 97
'a'
Prelude Data.Char> chr 98
'b'
Prelude Data.Char> ord 'c'
99

具有多個引數的函式

[編輯 | 編輯原始碼]

接受多個引數的函式的型別是什麼呢?

示例: 一個具有多個引數的函式

xor p q = (p || q) && not (p && q)

xor 是異或函式,如果兩個引數中只有一個為真,則計算結果為真,但不能同時為真;否則計算結果為假。)

形成接受多個引數的函式的型別的通用技術是簡單地按順序寫下所有引數的型別(因此在本例中,首先是 p 然後是 q),然後用 -> 將它們連線起來。最後,將結果型別的型別新增到行的末尾,並在它的前面加上一個最後的 ->[4] 在這個例子中,我們有


  1. 寫下引數的型別。在本例中,使用 (||)(&&) 表明 pq 必須是型別為 Bool 的型別
    Bool                   Bool
    ^^ p is a Bool         ^^ q is a Bool as well
    
  2. -> 填充空缺
    Bool -> Bool
  3. 新增結果型別和一個最後的 ->。在我們的例子中,我們只是在做一些基本的布林運算,所以結果仍然是一個 Bool。
    Bool -> Bool -> Bool
                     ^^ We're returning a Bool
                 ^^ This is the extra -> that got added in 

因此,最終的簽名是

示例: xor 的簽名

xor :: Bool -> Bool -> Bool

現實世界中的例子:openWindow

[編輯 | 編輯原始碼]
庫是一組由許多程式使用的通用程式碼。

正如你將在本課程的 Haskell 實踐部分中學到的那樣,一組流行的 Haskell 庫是 GUI(Graphical User Interface,圖形使用者介面)庫。它們提供了處理計算機使用者熟悉的視覺內容的函式:選單、按鈕、應用程式視窗、移動滑鼠等。其中一個庫中的一個函式叫做 openWindow,你可以使用它在你的應用程式中開啟一個新視窗。例如,假設你正在編寫一個文字處理器,使用者點選了“選項”按鈕。你需要開啟一個新的視窗,其中包含他們可以更改的所有選項。讓我們看一下這個函式的型別簽名:[5]

示例: openWindow

openWindow :: WindowTitle -> WindowSize -> Window

你可能不瞭解這些型別,但它們很簡單。那裡的三個型別,WindowTitleWindowSizeWindow,都是由提供 openWindow 的 GUI 庫定義的。正如我們之前看到的,兩個箭頭意味著前兩個型別是引數的型別,最後一個是結果的型別。WindowTitle 儲存視窗的標題(通常出現在視窗頂部的標題欄中),WindowSize 指定視窗的大小。然後,該函式返回一個型別為Window的值,它代表實際的視窗。

所以,即使你以前從未見過函式,也不知道它是如何工作的,型別簽名也能讓你對函式的功能有一個大致的瞭解。養成使用:t測試你遇到的每個新函式的習慣。如果你現在就開始這樣做,你不僅會學習關於標準庫 Haskell 函式的知識,而且還會培養對 Haskell 中函式的一種有用的直覺。

練習

以下函式的型別是什麼?對於任何涉及數字的函式,你可以假裝數字是 Int。

  1. negate 函式,它接受一個 Int,並返回該 Int 的符號反轉後的結果。例如,negate 4 = -4,而 negate (-2) = 2
  2. (||) 函式,讀作“或”,它接受兩個 Bool,並返回一個第三個 Bool,如果兩個引數中有一個為真,則返回真,否則返回假。
  3. monthLength 函式,它接受一個 Bool,如果我們正在考慮閏年,則為真,否則為假,以及一個 Int,它表示月份的數字;並返回另一個 Int,它表示該月的天數。
  4. f x y = not x && y
  5. g x = (2*x - 1)^2

程式碼中的型別簽名

[編輯 | 編輯原始碼]

我們已經探索了型別背後的基本理論以及它們是如何應用於 Haskell 的。現在,我們將瞭解型別簽名是如何用於在原始檔中對函式進行註釋的。考慮我們前面例子中的 xor 函式

示例: 一個帶有簽名的函式

xor :: Bool -> Bool -> Bool
xor p q = (p || q) && not (p && q)

這就是我們要做的所有事情。為了最大程度地清晰,型別簽名放在相應的函式定義之上。

我們以這種方式新增的簽名扮演著雙重角色:它們既向人類讀者,也向編譯器/直譯器闡明瞭函式的型別。

型別推斷

[編輯 | 編輯原始碼]

如果型別簽名告訴直譯器(或編譯器)函式的型別,那麼我們如何在沒有型別簽名的情況下編寫最早的 Haskell 程式碼呢? 嗯,當你沒有告訴 Haskell 你函式和變數的型別時,它會透過一個叫做 *型別推斷* 的過程來推斷它們。本質上,編譯器從它知道的型別開始,然後推斷出其餘值的型別。考慮一個一般的例子。

示例:簡單的型別推斷

-- We're deliberately not providing a type signature for this function
isL c = c == 'l'

isL 是一個函式,它接受一個引數 c 並返回 c == 'l' 的計算結果。在沒有型別簽名的情況下,c 的型別和結果的型別都沒有指定。然而,在表示式 c == 'l' 中,編譯器知道 'l' 是一個 Char。由於 c'l' 使用 (==) 進行相等比較,並且 (==) 的兩個引數必須具有相同的型別,[6] 因此 c 必須是一個 Char。最後,由於 isL c(==) 的結果,因此它必須是一個 Bool。因此,我們得到了函式的簽名

示例:帶有型別的 isL

isL :: Char -> Bool
isL c = c == 'l'

事實上,如果你省略型別簽名,Haskell 編譯器會透過這個過程發現它。你可以使用:tisL 上驗證,無論是否有簽名。

那麼,既然型別可以被推斷出來,為什麼要寫型別簽名呢?在某些情況下,編譯器缺乏資訊來推斷型別,因此簽名變得強制性。在其他一些情況下,我們可以使用型別簽名來在一定程度上影響函式或值的最終型別。這些情況現在不必擔心,但我們還有其他幾個理由要包含型別簽名

  • 文件:型別簽名使你的程式碼更容易閱讀。對於大多數函式來說,函式名稱加上函式型別就足以猜測函式的作用。當然,對程式碼進行註釋會有所幫助,但明確地說明型別也有幫助。
  • 除錯:當你用型別簽名註釋一個函式,然後在函式體中打錯字,從而改變了變數的型別,編譯器會在 *編譯時* 告訴你你的函式是錯誤的。省略型別簽名可能會允許你的錯誤函式編譯,並且編譯器會為它分配錯誤的型別。直到你執行程式,你才意識到自己犯了這個錯誤。

型別和可讀性

[edit | edit source]

一個稍微更現實的例子將幫助我們更好地理解簽名如何幫助文件。下面引用的程式碼片段是一個很小的 *模組*(模組是準備庫的典型方式),這種組織程式碼的方式類似於與 GHC 捆綁在一起的庫。

注意


不要因為試圖理解這些函式是如何工作的而變得瘋狂;這無關緊要,因為我們還沒有涵蓋許多正在使用的功能。繼續讀下去,一起玩吧。

示例:帶有型別簽名的模組

module StringManip where

import Data.Char

uppercase, lowercase :: String -> String
uppercase = map toUpper
lowercase = map toLower

capitalize :: String -> String
capitalize x =
  let capWord []     = []
      capWord (x:xs) = toUpper x : xs
  in unwords (map capWord (words x))

這個小庫提供了三個字串操作函式。uppercase 將字串轉換為大寫,lowercase 轉換為小寫,capitalize 將每個單詞的首字母大寫。這些函式中的每一個都接受一個 String 作為引數,並計算出另一個 String。即使我們不理解這些函式是如何工作的,檢視型別簽名也能讓我們立即知道引數和返回值的型別。結合合理的函式名稱,我們有足夠的資訊來弄清楚如何使用這些函式。

注意,當函式具有相同的型別時,我們可以選擇為它們都寫一個簽名,方法是在它們的名稱之間用逗號隔開,就像上面用 uppercaselowercase 一樣。

型別防止錯誤

[edit | edit source]

型別在防止錯誤中的作用對於型別化語言至關重要。當你傳遞表示式時,你必須確保型別匹配,就像這裡一樣。如果它們不匹配,當你嘗試編譯時,你會得到 *型別錯誤*;你的程式將無法透過 *型別檢查*。這有助於減少程式中的錯誤。舉一個非常簡單的例子

示例:一個無法型別檢查的程式

"hello" + " world"     -- type error

這一行會導致程式在編譯時失敗。你不能將兩個字串加在一起。很可能,程式設計師原本打算使用類似的連線運算子,它可以將兩個字串連線在一起形成一個字串

示例:我們錯誤的程式,已修復

"hello" ++ " world"    -- "hello world"

這是一個容易犯的錯誤,但 Haskell 在你嘗試編譯時就捕獲了這個錯誤。你不必等到執行程式才能發現這個錯誤。

更新程式通常會涉及對型別的更改。如果更改是無意的,或者產生了無法預料的後果,那麼它會在編譯時顯現出來。Haskell 程式設計師經常說,一旦他們修復了所有型別錯誤,並且他們的程式編譯透過,他們就傾向於“正常工作”。行為可能並不總是與意圖相符,但程式不會崩潰。Haskell 的 *執行時錯誤*(指你的程式在執行時出錯,而不是在編譯時出錯)比其他語言要少得多。

註釋

  1. 更深層的真相是,函式 *是* 值,就像其他所有值一樣。
  2. 這並不是 chrord 的真實行為,但這種描述符合我們的目的,而且足夠接近。
  3. 事實上,它甚至不是整數的唯一型別!我們很快就會遇到它的親屬。
  4. 這種方法現在可能看起來只是一次微不足道的技巧,但實際上它背後有很深的原因,我們將在關於 高階函式 的章節中介紹。
  5. 為了適應我們的目的,這一點已經簡化了一些。別擔心,函式的本質還在那裡。
  6. 正如在 真值 中討論的那樣。這個事實實際上是由 (==) 的型別簽名說明的——如果你好奇,你可以檢視它,儘管你將不得不稍微多等一會兒才能完全理解其中使用的符號。
華夏公益教科書