Haskell Tutorial(2)一絲不苟的型態系統


我在〈靜態定型與單元測試之爭〉談 過「任何數值都是記憶體中的一組位元,型態賦予這組位元意義,這樣開發者就能得知如何對待這組位元,因此型態也部份解釋了開發者想要程式做哪些事 情。」

也因此,任何程式語言的學習,都得認識型態系統,就結論而言,Haskell 是靜態定型(Static type)強 型別(Strong type)並具有強大型態推斷(Type inference)能 力的語言。

靜態定型

靜態定型表示編譯器在編譯時期,就可以得知各個值與運算式(expression)的型態,舉例來說,你可以設計一個簡單的函式 (Function):

doubleMe :: Float -> Float
doubleMe x = x + x

雖然還沒有正式要介紹函式,不過,這個函式很簡單,第一行是函式的型態宣告,函式名稱是 doubleMeFloat -> Float 表示接受 Float 引數並傳回 Float 結果,x 是接受引數的參數名稱,函式的傳回值是 x + x 運算式的結果。

你可以將之儲存為 myfuncs.hs,然後在 GHCI 中使用 :l 來載入:

靜態定型

可以看到,doubleMe 3.14 表示使用 3.14 來呼叫函式 doubleMe, 這沒問題,因為 3.14 被推斷為一個 Float,然而 doubleMe "3.14" 就不行,因為 "3.14" 不會是一個 Float, 靜態定型的 Haskell 會在執行之前,就檢查出這類型態不匹配的錯誤,避免許多執行時期因型態不正確而可能引發的錯誤。

強型別

注意!上面我說 doubleMe 3.14 時,3.14 被推斷為一個 Float, 因為上面 3.14 這 literal 本身沒有指定型態,因而編譯器試圖為它推斷一個適合的型態。你可以自行指定型態。例如:

指定型態

當你指定 3.14::Float 時,表示 3.14 的型態就是 Float, 可以看到,當你指定 1::Int1::Double並試圖呼叫 doubleMe 時,就會發生編譯錯誤,因為函式只接受 Float 的引數。

就多數主流的靜態語言來說,不能將 double 之類的值指定給 float 比較容易理解,因為可以解釋為記憶體長度不同(必要時可以使用 CAST 語法來關閉編譯器檢查),然而 1::Int 不能指定給 Float 的參數,就比較覺得令人詫異了,在多數主流的靜態語言中,將 int 指定給 float 之類的變數是允許的。

在 Haskell 中,1::Int 不能指定給 Float 的參數說明了,Haskell 是座落於強型別這側的語言,強型別意謂著,型態轉換不會自動發生,如果函式預期接受 Float, 而你給他一個 Int 引數,引數並不會自動轉換為 Float

Haskell 的型態系統有多嚴格?來看看 Int 與浮點數相加會如何?

Int 與浮點數相加

在 Haskell 中,會不意外地發生編譯錯誤,這類錯誤當然不會像這邊範例這麼直接發生,而會像是以下這種情況:

Int 與浮點數相加

[1, 2, 3] 在 Haskell 中會建立一個清單, length 函式可以取得這個清單的長度,以 Int 傳回,Int 與 3.14 相加就會引發錯誤。類似地,以下也會發生錯誤:

整數與浮點數相加

型態推斷

看到以上的範例,你可能會有疑問,那麼 10 + 3.14 為什麼可以?

10 + 3.14

如前所述,這是因為編譯器推斷出這兩個 literal(嚴格來說,是推斷出 (10 + 3.14) 這個運算式)最適合的型態 Fractional,在這邊,:t 是可以在 GHCI 中用來檢驗型態的指令,你可以隨時用它來檢驗 Haskell 中任何值的型態。

然後,當你令某代數 x 與 y 為某值時,因為前後程式文脈(Context)的沒有型態可供參考(像是 + 函式這類),編譯器會分別給予的型態會像是:

令某代數 x 與 y 為某值

這也就是為何,x + y 不能通過編譯的原因,如果你想要令其通過編譯,可以使用 fromInteger 函式,例如:

fromInteger

可以看到,fromInteger 的型態宣告是 Num a => Integer -> aNum 是個 Typeclass,某些程度上,你可以將 Typeclass 類比為 Java 中的 interfaceNum a 表示 a 必須是個具有 Num 行為的型態,Integer -> a 表示參數型態為 Integer,而傳回型態為 a

所有的整數與浮點數都有 Num 的行為,當你執行 fromInteger x + y 時,首先 fromInteger x 傳回值型態為 Num,現在 + 兩邊可以進行相加了,接著編譯器會根據 y 決定,最後的傳回值型態會是 Double

不過,如果你是這麼寫,那麼 fromInteger 就吐了:

fromIntegral

記得嗎?fromInteger 的型態宣告是 Num a => Integer -> a,而在上面,你的 10 是個 Int,型態轉換在 Haskell 中不會自動發生,就算是 Int 自動轉為 Integer 也不行。

fromIntegral(注意!字尾是 ral 不是 er)的型態是 (Integral a, Num b) => a -> b,表示 a 必須有 Integral 的行為,而 b 必須有 Num 的行為,Integral 也是個 Typeclass,所有整數都有 Integral 的行為,因此就涵蓋了 Int。 就整個 fromIntegral (10::Int) + 3.14 程式文脈,編譯器最後推斷出的型態會是 Fractional, 實際上,Fractional 也是個 Typeclass。

實際上,Haskell 的編譯器很強大,如果你沒有指定型態,總是會努力為你推斷出適合的型態,例如,先前的 doubleMe 可以只寫為:

doubleMe x = x + x

重新在 GHCI 中用 :l 載入,使用 :t 檢驗看看,編譯器為你推斷為何種型態?

型態推斷

在 Haskell 中,絕大多數的情況下,不聲明型態是可以的,這使得 Haskell 程式碼看起來,會像是動態語言中的變數無需宣告型態(實際上,Haskell 沒有變數,之後會詳述),不過,在 Haskell 中,宣告函式時明確聲明型態,反而是鼓勵的,如果你真的不知道你的函式型態要如何宣告型態,可以像這邊,在 GHCI 中檢驗完之後,再將型態宣告添加至原始碼之中。

基本型態與 Typeclass

暈頭了嗎?也許在 Haskell 中,使用函數式風格並不是最難的,使用正確型態通過編譯才是最難的,因 為嚴格的強型別、靜態型態,使得在 Haskell 中要通過編譯本身就是件難事,因此有「It Compiles! Let's ship it!」的笑話!

然而換取而來的代價是,不少因型態不正確的錯誤,在通過編譯之前都被抓出來了,很多時候確實是如此,我以為自己已經謹慎思考過型態了,編譯器卻 總會抓出我沒想到的部份。

最後,來看看這篇文章中提到的幾個基本型態:

  • Int

    有界整數,如果是 32 位元機器,上下界會分別是 2147483647-2147483648

  • Integer

    無界整數,效率比較慢,不過可以儲存大整數,例如 2147483648::Int 結果會是 -21474836482147483648::Integer 才會是 2147483648。

  • FloatDouble

    分別代表單精度浮點數與倍精度浮點數。

  • Bool

    TrueFalse 兩個布林值的型態。

  • Char

    字元型態,之後還會看到,像 “Justin” 這個字串,其實是由字元組成的清單。

來看看這篇文章中提到的幾個 Typeclass:

  • Integral

    代表所有整數的 Typeclass,IntInteger 具有 Integral 的行為。

  • Floating

    代表所有浮點數的 Typeclass,FloatDouble 具有 Floating 的行為。

  • Fractional

    代表分數的 Typeclass,涵蓋了 FloatDouble

  • Num

    代表所有數字的 Typeclass。

當然,還有其他的,不過,認識它們全部並不是重點,真正的重點在於,如果你是從其他弱型別、動態定型,或者是型態系統上要求較寬鬆的語言,進入 到 Haskell,必須得重新思考一下,型態對你而言到底是什麼意義?我曾經在 Ruby Conference Taiwan 2014 的〈Understanding Typing. Understanding Ruby.〉做過一些探討。

當你進入到 Haskell 之中,你會發現一件事「開發者對型態的思考總是不足的」,在這篇中認真地重新思考一下型態,之後繼續在 Haskell 中繼續前進時,才不至於處處碰壁。