型態系統入門

January 27, 2022

任何數值都是記憶體中的一組位元,型態賦予這組位元意義,這樣開發者才能知道如何對待這組位元,因此學習任何一門語言,首先要認識該語言的型態系統。

有些語言對型態定義的比較鬆散,這類語言讓開發者入門時,可以用較抽象的概念、較接近人類的觀點看待型態,例如數字、布林、字元、字串,因而往往較易入門,然而在面對需要認真看待型態的場合時,初期對型態系統的輕忽性,就會造成維護上的負擔。

有些語言型態定義的較為嚴謹,這類語言讓開發者入門時,往往就得面對 intlongfloatdouble 等(以 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' 是個 CharTrueBool,這比較好理解,那麼 Fractional a => aNum a => a 是指?

CharBool 是 Haskell 的 data 型態,一個 data 型態單純哪一類(category)的值具有特定「結構」,例如 Int 是整數,Float 是具有小數的浮點數等,來看幾個常見的 data 型態:

  • Int:有界整數,如果是 64 位元系統,上下界會分別是 -92233720368547758089223372036854775807
  • Integer:無界整數,效率比較慢,不過可以儲存大整數。
  • FloatDouble:分別代表單精度浮點數與倍精度浮點數。
  • BoolTrueFalse 兩個布林值的型態。
  • Char:字元型態。

你會說,方才 10 的型態檢驗結果不是 Int,3.14 的型態檢驗結果也不是 FloatDouble 啊?是的!你可以在值的後方接上 :: 標註值的型態:

ghci> :t 10::Int     
10::Int :: Int
ghci> :t 3.14::Double
3.14::Double :: Double

大多數的情況下,不建議標註值的型態,建議讓 Haskell 編譯器根據程式的前後文,推斷出值(或是運算式)的合適型態,在可行的情況下,編譯器會試著從前後文推斷出,值需要有什麼行為,找出對應的型態類別(Typeclass),用來定義值的型態。

例如方才看到的 NumFractional,它們就是型態類別,這邊的「類別」並不是指物件導向中的類別,而是指「這一類的型態(class of types)」,這就是為何被稱型態類別的原因,型態類別是用來定義一組行為,因此可以將型態類別理解為「行為」的分類,之後的文件會看到,在 Haskell 中,可以使用型態類別規範行為。

也就是說,Haskell 看待型態的方式有兩種,哪一類(category)的值具有特定「結構」,哪一類(class)的型態具有特定「行為」的型態,data 用來定義結構,型態類別用來定義行為,而 data 型態可以定義為型態類別的實例(instance),實作型態類別規範的行為。

例如,可以定義 Comp 型態類別,必須具有比較兩個運算元的行為,然後定義 IntFloat 等為 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:規範數字應有的行為,實例為 FloatDoubleIntInteger
  • Fractional:規範分數應有的行為,實例為 FloatDouble
  • Floating:規範浮點數應有的行為,實例為 FloatDouble
  • Integral:規範整數應有的行為,實例為 IntInteger

顯然地,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)」的意思,在上面的範例中,xy 型態不同,也就直接無法透過 + 來運算。

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 傳回 ba 必須有 Integral 的行為,而 b 會有 Num 的行為。

如果需要自行標註型態,或者使用到 fromIntegerfromIntegral 之類的函式,可能就代表著不好的訊號,你可能對型態考量的不夠周詳,才會導致需要轉換型態,以符合編譯器的要求。

在 Haskell 中,使用函數式風格或許不是最難的,使用正確型態通過編譯才是最難的,因為開發者對型態的考量,往往不夠周詳,使得 Haskell 要通過編譯本身就是件難事,因此有「It Compiles! Let’s ship it!」的笑話。

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

分享到 LinkedIn 分享到 Facebook 分享到 Twitter