型態系統入門
January 27, 2022任何數值都是記憶體中的一組位元,型態賦予這組位元意義,這樣開發者才能知道如何對待這組位元,因此學習任何一門語言,首先要認識該語言的型態系統。
有些語言對型態定義的比較鬆散,這類語言讓開發者入門時,可以用較抽象的概念、較接近人類的觀點看待型態,例如數字、布林、字元、字串,因而往往較易入門,然而在面對需要認真看待型態的場合時,初期對型態系統的輕忽性,就會造成維護上的負擔。
有些語言型態定義的較為嚴謹,這類語言讓開發者入門時,往往就得面對 int
、long
、float
、double
等(以 Java 為例)語言定義的、較接近機器觀點的型態,這類語言入門會比較麻煩,然而面對需要認真看待型態的場合時,初期對型態系統的麻煩性就會成為一道防線,減少因型態錯誤而可能產生的臭蟲。
Haskell 屬於後者,而且是對型態方面處理極為嚴謹!
data 型態、型態類別
你在 Haskell 中寫下一個值,會是什麼型態呢?在 ghci
中想檢驗型態,可以使用 :t
,例如:
hci> :t 10
10 :: Num a => a
ghci> :t 3.14
3.14 :: Fractional a => a
ghci> :t True
True :: Bool
ghci> :t 'h'
'h' :: Char
ghci>
:t
顯示的結果中,::
前是你指定的值,::
後表示值的型態,'h'
是個 Char
,True
是 Bool
,這比較好理解,那麼 Fractional a => a
、Num a => a
是指?
Char
、Bool
是 Haskell 的 data
型態,一個 data
型態單純哪一類(category)的值具有特定「結構」,例如 Int
是整數,Float
是具有小數的浮點數等,來看幾個常見的 data
型態:
Int
:有界整數,如果是 64 位元系統,上下界會分別是-9223372036854775808
、9223372036854775807
。Integer
:無界整數,效率比較慢,不過可以儲存大整數。Float
與Double
:分別代表單精度浮點數與倍精度浮點數。Bool
:True
與False
兩個布林值的型態。Char
:字元型態。
你會說,方才 10 的型態檢驗結果不是 Int
,3.14 的型態檢驗結果也不是 Float
或 Double
啊?是的!你可以在值的後方接上 ::
標註值的型態:
ghci> :t 10::Int
10::Int :: Int
ghci> :t 3.14::Double
3.14::Double :: Double
大多數的情況下,不建議標註值的型態,建議讓 Haskell 編譯器根據程式的前後文,推斷出值(或是運算式)的合適型態,在可行的情況下,編譯器會試著從前後文推斷出,值需要有什麼行為,找出對應的型態類別(Typeclass),用來定義值的型態。
例如方才看到的 Num
或 Fractional
,它們就是型態類別,這邊的「類別」並不是指物件導向中的類別,而是指「這一類的型態(class of types)」,這就是為何被稱型態類別的原因,型態類別是用來定義一組行為,因此可以將型態類別理解為「行為」的分類,之後的文件會看到,在 Haskell 中,可以使用型態類別規範行為。
也就是說,Haskell 看待型態的方式有兩種,哪一類(category)的值具有特定「結構」,哪一類(class)的型態具有特定「行為」的型態,data
用來定義結構,型態類別用來定義行為,而 data
型態可以定義為型態類別的實例(instance),實作型態類別規範的行為。
例如,可以定義 Comp
型態類別,必須具有比較兩個運算元的行為,然後定義 Int
、Float
等為 Comp
的實例,實作兩個運算元的比較。
:t 10
的結果顯示型態為 Num a => a
,這表示 a
可以是實現 Num
行為的任何 data
型態,也就是說 a
的型態不一定,或許是 Int
,也可以是 Float
,然而受到 Num
的約束,在 Haskell 中,稱 a
為型態變數(Type variable);類似地,:t 3.14
的顯示型態為 Fractional a => a
,這表示 a
具有 Fractional
的行為。
來看看幾個跟數字有關的型態類別:
Num
:規範數字應有的行為,實例為Float
、Double
、Int
、Integer
。Fractional
:規範分數應有的行為,實例為Float
、Double
。Floating
:規範浮點數應有的行為,實例為Float
、Double
。Integral
:規範整數應有的行為,實例為Int
、Integer
。
顯然地,Num
最抽象,基本上就是說「這是一個數字」的概念,Num
的行為衍生出 Fractional
的行為,Fractional
衍生出 Floating
,至於 Integral
,基本上也具有 Num
的行為(也有實現了其他型態類別的行為),然而不會有小數,至於實際上這些型態類別,真正規範了哪些行為,要等後續談如何自訂型態類別時,再來細究會比較好。
靜態定型
方才說到,大多數的情況下,不建議直接標註值的型態,因為 Haskell 編譯器能根據程式的前後文,推斷出值(或是運算式)的合適型態,可以的情況下,會儘量推斷出行為上的型態,這在你處理數字的 +
、-
、*
、/
等運算時會比較方便。例如:
ghci> 10 + 3.14
13.14
ghci> (10::Int) + 3.14
<interactive>:51:13: error:
‧ No instance for (Fractional Int) arising from the literal ‘3.14’
‧ In the second argument of ‘(+)’, namely ‘3.14’
In the expression: (10 :: Int) + 3.14
In an equation for ‘it’: it = (10 :: Int) + 3.14
ghci>
目前可以先知道的是,+
在 Haskell 是個函式,它接受的兩個引數必須有相同型態,10 + 3.14
可以運算,是因為編譯器可以為 10 與 3.14 推斷出相同的型態 Fractional
;然而 (10::Int) + 3.14
無法運算,因為你直接標註 10 為 Int
型態,3.14 再怎樣,都無法是個 Int
型態,兩者也就無法運算而發生編譯錯誤了。
就程式語言的分類來說,Haskell 是屬於靜態定型(Statically typed),編譯時期就可以確定值(運算式的型態),若發現不正確的型態資訊,就會發生編譯錯誤。
方才談過,+
的兩個引數必須具有相同的型態,因此以下可以執行:
ghci> (10::Double) + 3.14
13.14
ghci> :t (10::Double) + 3.14
(10::Double) + 3.14 :: Double
ghci>
編譯器根據前後文,可以將 3.14 推斷為 Double
,然後與 Double
的 10 運算,不過,結果型態也會是 Double
,值的型態不是根據行為推斷出來的話,在後續能運算的情境會被限縮,也容易導致需要自行標註型態的機會變多,甚至需要進行型態轉換。
順便一提的是,你也可以直接以行為標註型態,只是一般不會這麼做就是了,例如標註 10 要有 Fractional
的行為:
ghci> (10::(Fractional a => a)) + 3.14
13.14
ghci>
型態轉換
來看看型態轉換的一個例子:
ghci> let x = 10::Integer
ghci> let y = 3.14::Double
ghci> x + y
<interactive>:62:5: error:
‧ Couldn't match expected type ‘Integer’ with actual type ‘Double’
‧ In the second argument of ‘(+)’, namely ‘y’
In the expression: x + y
In an equation for ‘it’: it = x + y
ghci> fromInteger x + y
13.14
ghci> :t fromInteger
fromInteger :: Num a => Integer -> a
ghci>:t fromInteger x + y
fromInteger x + y :: Double
ghci>
let
用來建立變數,let x = 10::Integer
就是「令 x 為 10(Integer)」的意思,在上面的範例中,x
與 y
型態不同,也就直接無法透過 +
來運算。
fromInteger
的型態是 Num a => Integer -> a
,這表示接受 Integer
傳回 a
,而 a
會有 Num
的行為,編譯器從 fromInteger x
得到 Num
,接著推斷為 Double
,兩個都是 Double
了,就可以做運算,結果也會是 Double
。
fromInteger
的型態是 Num a => Integer -> a
,因此想處理 Int
的話是不行的,這時要使用 fromIntegral
:
ghci> let x = 10::Int
ghci> let y = 3.14::Double
ghci> fromInteger x + y
<interactive>:69:13: error:
‧ Couldn't match expected type ‘Integer’ with actual type ‘Int’
‧ In the first argument of ‘fromInteger’, namely ‘x’
In the first argument of ‘(+)’, namely ‘fromInteger x’
In the expression: fromInteger x + y
ghci> fromIntegral x + y
13.14
ghci> :t fromIntegral
fromIntegral :: (Integral a, Num b) => a -> b
ghci>
fromIntegral
的型態是 (Integral a, Num b) => a -> b
,這表示它接受 a
傳回 b
,a
必須有 Integral
的行為,而 b
會有 Num
的行為。
如果需要自行標註型態,或者使用到 fromInteger
、fromIntegral
之類的函式,可能就代表著不好的訊號,你可能對型態考量的不夠周詳,才會導致需要轉換型態,以符合編譯器的要求。
在 Haskell 中,使用函數式風格或許不是最難的,使用正確型態通過編譯才是最難的,因為開發者對型態的考量,往往不夠周詳,使得 Haskell 要通過編譯本身就是件難事,因此有「It Compiles! Let’s ship it!」的笑話。
然而換取而來的代價是,不少因型態方面的錯誤,都會被編譯器抓出來,很多時候確實是如此,我以為已經謹慎思考過型態了,編譯器卻總會抓出我沒想到的部份!