我在〈靜態定型與單元測試之爭〉談 過「任何數值都是記憶體中的一組位元,型態賦予這組位元意義,這樣開發者就能得知如何對待這組位元,因此型態也部份解釋了開發者想要程式做哪些事 情。」
也因此,任何程式語言的學習,都得認識型態系統,就結論而言,Haskell 是靜態定型(Static type)、強 型別(Strong type)並具有強大型態推斷(Type inference)能 力的語言。
靜態定型
靜態定型表示編譯器在編譯時期,就可以得知各個值與運算式(expression)的型態,舉例來說,你可以設計一個簡單的函式 (Function):
doubleMe :: Float -> Float
doubleMe x = x + x
雖然還沒有正式要介紹函式,不過,這個函式很簡單,第一行是函式的型態宣告,函式名稱是 doubleMe
,Float
-> 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::Int
或 1::Double
並試圖呼叫 doubleMe
時,就會發生編譯錯誤,因為函式只接受 Float
的引數。
就多數主流的靜態語言來說,不能將 double
之類的值指定給 float
比較容易理解,因為可以解釋為記憶體長度不同(必要時可以使用 CAST 語法來關閉編譯器檢查),然而 1::Int
不能指定給 Float
的參數,就比較覺得令人詫異了,在多數主流的靜態語言中,將 int
指定給 float
之類的變數是允許的。
在 Haskell 中,1::Int
不能指定給 Float
的參數說明了,Haskell 是座落於強型別這側的語言,強型別意謂著,型態轉換不會自動發生,如果函式預期接受 Float
,
而你給他一個 Int
引數,引數並不會自動轉換為 Float
。
Haskell 的型態系統有多嚴格?來看看 Int
與浮點數相加會如何?
在 Haskell 中,會不意外地發生編譯錯誤,這類錯誤當然不會像這邊範例這麼直接發生,而會像是以下這種情況:
[1, 2, 3]
在 Haskell 中會建立一個清單, length
函式可以取得這個清單的長度,以 Int
傳回,Int
與 3.14
相加就會引發錯誤。類似地,以下也會發生錯誤:
型態推斷
看到以上的範例,你可能會有疑問,那麼 10 + 3.14
為什麼可以?
如前所述,這是因為編譯器推斷出這兩個 literal(嚴格來說,是推斷出 (10 + 3.14)
這個運算式)最適合的型態 Fractional
,在這邊,:t
是可以在 GHCI
中用來檢驗型態的指令,你可以隨時用它來檢驗 Haskell 中任何值的型態。
然後,當你令某代數 x 與 y 為某值時,因為前後程式文脈(Context)的沒有型態可供參考(像是 +
函式這類),編譯器會分別給予的型態會像是:
這也就是為何,x + y
不能通過編譯的原因,如果你想要令其通過編譯,可以使用 fromInteger
函式,例如:
可以看到,fromInteger
的型態宣告是 Num a => Integer
-> a
,Num
是個 Typeclass,某些程度上,你可以將 Typeclass
類比為 Java 中的 interface
,Num a
表示 a
必須是個具有 Num
行為的型態,Integer -> a
表示參數型態為
Integer
,而傳回型態為 a
。
所有的整數與浮點數都有 Num
的行為,當你執行 fromInteger x + y
時,首先 fromInteger x
傳回值型態為 Num
,現在 +
兩邊可以進行相加了,接著編譯器會根據 y
決定,最後的傳回值型態會是 Double
。
不過,如果你是這麼寫,那麼 fromInteger
就吐了:
記得嗎?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
結果會是-2147483648
,2147483648::Integer
才會是 2147483648。 -
Float
與Double
分別代表單精度浮點數與倍精度浮點數。
-
Bool
True
與False
兩個布林值的型態。 -
Char
字元型態,之後還會看到,像 “Justin” 這個字串,其實是由字元組成的清單。
來看看這篇文章中提到的幾個 Typeclass:
-
Integral
代表所有整數的 Typeclass,
Int
與Integer
具有Integral
的行為。 -
Floating
代表所有浮點數的 Typeclass,
Float
與Double
具有Floating
的行為。 -
Fractional
代表分數的 Typeclass,涵蓋了
Float
與Double
。 -
Num
代表所有數字的 Typeclass。
當然,還有其他的,不過,認識它們全部並不是重點,真正的重點在於,如果你是從其他弱型別、動態定型,或者是型態系統上要求較寬鬆的語言,進入 到 Haskell,必須得重新思考一下,型態對你而言到底是什麼意義?我曾經在 Ruby Conference Taiwan 2014 的〈Understanding Typing. Understanding Ruby.〉做過一些探討。
當你進入到 Haskell 之中,你會發現一件事「開發者對型態的思考總是不足的」,在這篇中認真地重新思考一下型態,之後繼續在 Haskell 中繼續前進時,才不至於處處碰壁。