type 與 newtype
February 7, 2022在〈從 tuple 到 product 型態〉談到,如果我寫了 (1, 2),這是點座標還是向量呢?(10, 20) == (10, 20) 的結果會是 True,如果我說 == 左邊是點座標右邊是向量,那這個 True 是對的嗎?
緊接著我就使用 data 定義了 Point 等 product 型態,後續的文件也逐步進入 sum 型態等代數型態的組合,然而,有時候你的需求並不需要使用 data 來定義新型態呢?
type 取個別名
例如,你只是覺得以下的函式並是很清楚:
move :: (Float, Float) -> Float -> Float -> (Float, Float)
move p x y = (fst p + x, snd p + y)
若這邊的 (Float, Float) 意義上代表點座標,你不想建立新型態,畢竟 move (1, 2) 1 2 的呼叫方式比較方便,這時可以考慮使用 type 為它取個別名:
type Point = (Float, Float)
move :: Point -> Float -> Float -> Point
move p x y = (fst p + x, snd p + y)
這麼一來,閱讀上就清楚多了,type 沒有建立新型態,(Float, Float) 只是多了個名稱,move (1, 2) 1 2 的呼叫方式還是可行。
記得之前的文件說過 String 是 [Char] 的別名嗎?這是因為 Haskell 如下定義了:
type String = [Char]
如果你寫了個 allToUpper 函式,可以將指定的小寫字串清單,全部轉為大寫的字串清單:
import Data.Char
allToUpper :: [[Char]] -> [[Char]]
allToUpper xs = [map toUpper x | x <- xs]
稍後就會談到一些模組的觀念,這邊用到了 Data.Char 模組的 toUpper 函式,可以用來將小寫字串轉大寫字串,在上例中,[[Char]] -> [[Char]] 並不好閱讀,如果用 [String] -> [String] 會好一些:
import Data.Char
allToUpper :: [String] -> [String]
allToUpper xs = [map toUpper x | x <- xs]
除了為具體型態取別名,也可以基於型態參數取別名,例如,你想定義一個簡單的 dict 函式,可以接受鍵、值的 list,然後傳回成對鍵值組成的 list:
dict :: [a] -> [b] -> [(a, b)]
dict keys values = zip keys values
若想為 (a, b) 取別名的話,可以如下:
type KV a b = (a, b)
dict :: [a] -> [b] -> [KV a b]
dict keys values = zip keys values
或許你的鍵限定為字串,這麼寫也是可以的:
type Idx a = (String, a)
lookupTable :: [String] -> [a] -> [Idx a]
lookupTable names values = zip names values
如果想知道型態的別名等資訊,可以使用 :info:
ghci> :info String
type String :: *
type String = [Char]
-- Defined in ‘GHC.Base’
ghci>
newtype 建立編譯時期新型態
type 只是為既有的型態取別名,沒有建立新型態,方才的這個例子:
type Point = (Float, Float)
move :: Point -> Float -> Float -> Point
move p x y = (fst p + x, snd p + y)
編譯器檢查型態時,還是基於 (Float, Float) 來檢查,只是你撰寫程式碼及閱讀上可以使用 Point 罷了,這也就是 move (1, 2) 1 2 的呼叫方式仍然可行的原因。
如果有個程式運算流程中有個 let vt = (1, 2),vt 實際上代表向量,為了避免 move 被濫用,你希望 move vt 1 2 這類呼叫必須編譯失敗呢?
這時可以使用 newtype,基於 (Float, Float) 建立新型態,例如:
newtype Point = Point (Float, Float) deriving Show
move :: Point -> Float -> Float -> Point
move (Point (px, py)) x y = Point (px + x, py + y)
newtype 的右邊指定了型態名稱,= 的右邊是值建構式,接著是作為新型態基礎的型態,這麼一來,就要使用 Point (1, 2) 這種方式來建立 Point 實例。
因為 move 現在接受的是 Point 型態,而不是接受 tuple,也就不能用 fst、snd 來 x、y 座標,然而 newtype 本身也是基於結構來定義新型態,也就可以搭配模式比對來拆解欄位。
來看個簡單的執行結果,顯然地,方才的談到的 move vt 1 2 是行不通的:
ghci> let p = Point (1, 2)
ghci> move p 1 2
Point (2.0,4.0)
ghci> let vt = (1, 2)
ghci> move vt 1 2
<interactive>:29:6: error:
‧ Couldn't match expected type ‘Point’ with actual type ‘(a0, b0)’
‧ In the first argument of ‘move’, namely ‘vt’
In the expression: move vt 1 2
In an equation for ‘it’: it = move vt 1 2
ghci>
newtype 乍看與 data 非常類似,實際上也能搭配 record 語法:
newtype Point = Point {xy :: (Float, Float)} deriving Show
許多文件會談到 newtype 與 data 最大的差別限制是,newtype 只能有一個欄位,不過應該進一步思考的是,為什麼限定為只能有一個欄位?
記得嗎?方才談到的需求是,你希望的是編譯時期,對於 move vt 1 2 這類呼叫必須編譯失敗,因為 newtype 定義時,只能有一個欄位,因此本質上 newtype 建立的型態可以直接對應至該欄位的型態,也就是說,只要 newtype 建立的型態,只能滿足編譯時期需求就夠了,像方才 newtype 定義的 Point 型態,在執行時期是不需要的,執行時期還是 tuple。
簡單來說,newtype 建立的新型態,就是為了能多得到一層編譯時期檢查,執行時期不會值建構式的呼叫或模式比對的負擔,執行時期,新型態與欄位的型態仍視為相同型態。


