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
建立的新型態,就是為了能多得到一層編譯時期檢查,執行時期不會值建構式的呼叫或模式比對的負擔,執行時期,新型態與欄位的型態仍視為相同型態。