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,也就不能用 fstsnd 來 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

許多文件會談到 newtypedata 最大的差別限制是,newtype 只能有一個欄位,不過應該進一步思考的是,為什麼限定為只能有一個欄位?

記得嗎?方才談到的需求是,你希望的是編譯時期,對於 move vt 1 2 這類呼叫必須編譯失敗,因為 newtype 定義時,只能有一個欄位,因此本質上 newtype 建立的型態可以直接對應至該欄位的型態,也就是說,只要 newtype 建立的型態,只能滿足編譯時期需求就夠了,像方才 newtype 定義的 Point 型態,在執行時期是不需要的,執行時期還是 tuple。

簡單來說,newtype 建立的新型態,就是為了能多得到一層編譯時期檢查,執行時期不會值建構式的呼叫或模式比對的負擔,執行時期,新型態與欄位的型態仍視為相同型態。

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