Haskell Tutorial(29)一個型態的 newtype


在〈Haskell Tutorial(27)可直接函式套用的 Applicative〉中看過,List 在實現 Applicative 的行為時,實際上隱藏了 List Comprehension 的細節,因此,對於 (+) <$> [1, 2, 3] <*> [4, 5, 6],你得到的結果與 [x + y | x <- [1, 2, 3], y <- [4, 5, 6]] 相同。

然而,對於 List 還有另一個常見的操作,那就是將兩個 List 中的元素,兩兩配對進行某個運算。例如 zipWith (+) [1, 2, 3] [4, 5, 6],你會得到 [5, 7, 9] 的結果,因為 + 是以 [1 + 4, 2 + 5, 3 + 6] 的方式套用。

如果你希望 List 有另一個 Applicative 的實現,對於 (+) <$> [1, 2, 3] <*> [4, 5, 6] 可以得到 [5, 7, 9],其實是沒辦法直接做到的,因為一個型態在實現一個 Typeclass,只能有一個實作,你需要有一個「新型態」來代表 List,然後用新型態來實現 Applicative 的行為。

為型態建立 newtype

Haskell 中可以使用 newtype 為某型態建立新型態,就方才我們的需求來說,我們需要有個新型態 ZipLt a 來代表 [a],這時可以如下定義:

newtype ZipLt a = ZipLt [a] deriving Show

這麼一來,對於 [Integer],就可以使用 ZipLt Integer 來代表它:

newtype

因此,你可以使用 ZipLt 來實現 FunctorApplicative 的行為:

import Control.Applicative

newtype ZipLt a = ZipLt [a] deriving Show

instance Functor ZipLt where
    fmap f (ZipLt xs) = ZipLt $ map f xs

instance Applicative ZipLt where  
    pure x = ZipLt (repeat x)      
    ZipLt fs <*> ZipLt xs = ZipLt (zipWith (\f x -> f x) fs xs)

對於 Functor 的實作,其實沒什麼意思,就只是使用 ZipLt 來代表 map 的結果,實現它的目的,只是因為一個 Applicative 得是一個 Functor,如果你不想親自動手做這件事,那可以在編譯時加上 -XDeriveFunctor,然後令 ZipLt 自動衍生 Functor

newtype ZipLt a = ZipLt [a] deriving (Show, Functor)

ZipLt 在實現 Applicativepure 時,就只是產生無限長的 List,當中是指定的函式罷了,由於惰性的關係,repeat 只會在真正需要時才會產出下一個元素,因此,實際上最後總共產生的 fs 長度,會是與 xs 的長度相同的。

現在,對於 (+) <$> ZipLt [1, 2, 3] <*> ZipLt [4, 5, 6],會產生 ZipLt [5,7,9] 的結果,實際上,Control.Applicative 模組中,就定義有 ZipList,並實現了 Applicative 的行為:

newtype

ZipList 的定義大致上是:

newtype ZipList a = ZipList { getZipList :: [a] }

data 與 newtype

看起來,newtype 似乎與 data 相似,它可以定義值建構式,可以衍生,也可以使用 Record 語法,實際上,上面的例子中,將 newtype 改成 data 也行得通,兩者都可以用來為既有的型態建立一個新型態,那麼,datanewtype 有什麼樣的不同呢?

就語法上來說,newtype 的限制是只能有一個值建構式,而當中只能有一個欄位,當你發現使用 data 定義只用到一個值建構式且當中為單一欄位時,其實表示新型態與欄位中的型態是直接對應的,也就是說,兩者是等價的,使用 data 定義只用到一個值建構式且當中為單一欄位時,可以考慮將 data 改為 newtype

這樣的好處是,newtype 對編譯器來說,仍是建立一個新型態,這個新型態與值建構式型態(記得嗎?值建構式是個函式,它有型態)只提供編譯器檢查之用,實際上不會真正有值建構式的包裹與拆解負擔,在執行時期,新型態與欄位的型態仍視為相同型態。

這會產生有趣的結果,舉例來說:

data MyInt = CMyInt Int

showMyInt :: MyInt -> Int
showMyInt (CMyInt _) = 100

main = print $ showMyInt undefined

其中 undefined 的定義是:

undefined :: a
undefined = error "Prelude.undefined"

程式可以通過編譯,然而執行時期會嘗試使用值建構式拆解 undefined 傳回值,因而發生例外。

如果將 data 改為 newtype,程式也可以通過編譯,也可以順利執行而顯示 100 的結果,這是因為實際上不會有任何 MyInt 型態的值是經由 CMyInt 建構而成,也不會有執行時期嘗試使用值建構式拆解 undefined 傳回值的問題,undefined 根本不會被執行,因而不會有例外產生。

type 與 newtype

你可能會想到另一個關鍵字 typetype 並不會建立新型態,單純只是為型態建立一個別名,就像 type String = [Char],無論你在型態宣告上使用 String[Char],編譯器都視為相同型態。

因此可以這麼區分,data 定義的新型態與欄位型態,編譯時期與執行時期都是不同型態,newtype 定義的新型態,只在編譯時期視為不同型態,而 type 只是別名,單純用來改善型態的可讀性,無法提供編譯器進行型態檢查。

最後來出個題目,以下這個程式可以通過編譯:

data MyInt = CMyInt Int

getInt :: MyInt -> IO Int
getInt (CMyInt i) = do
    print "getInt"
    return i

main = do
    i <- getInt undefined
    print i

不過執行時會發生例外:

newtype

data 改成 newtype,程式也可以通過編譯,執行時一樣也是會發生例外:

newtype

不過仔細一看有點不同,這次是顯示了 "getInt" 才發生例外,思考一下,這是為什麼呢?