すごいH本を初めて読んだ方なら「newtypeが何の役に立つのか全く分からない」と思ったことがあると思います。実はnewtypeは堅牢なアプリケーションを構築する上で非常に重要な機能です。
## 概要
newtypeとは
すごいH本にはnewtypeに関して以下の記述があります。
newtypeは「1つの型を取り、それを何かに包んで別の型に見せかけたい」という場合のために作られたもの
この「別の型に見せかける」ことが安全かつ利用しやすいhaskellコードを書く上で重要になってきます。
図書館プログラムにnewtypeを使用する
今回は簡単な図書館アプリケーションを構築し、その上でnewtypeがいかに有用なのかを紹介したいと思います。
図書館ではユーザーが本を借りることができます。
今回はユーザー、本、貸出履歴を以下のように表現します。
-- | ユーザー(図書館利用者)
data User = User
{ uId :: !Integer
, uName :: !Text
} deriving (Eq, Show)
-- | 本
data Book = Book
{ bId :: !Integer
, bAuthor :: !Text
, bTitle :: !Text
} deriving (Eq, Show)
-- | 貸出履歴
data Record = Record
{ rId :: !Integer
, rBookId :: !Integer
, rUserId :: !Integer
} deriving (Eq, Show)
type LibraryDB = [Record]
注目してほしいのはそれぞれのId
です。初期バージョンではそれぞれを単なるInteger
として表現します。
また本を貸し出す関数borrowBook
は以下の流れで処理を行います。
1.LibraryDB
にアクセスし、貸出履歴があるか確認する。
2. ある場合にはその履歴を返す。
3. ない場合には新たなレコードを定義し、LibraryDB
に追加する。
加えてBookId
, RecordId
から貸出履歴を参照する関数も実装してみましょう。
初期版
import Data.Text (Text)
data User = User
{ uId :: !Integer
, uName :: !Text
} deriving (Eq, Show)
data Book = Book
{ bId :: !Integer
, bAuthor :: !Text
, bTitle :: !Text
} deriving (Eq, Show)
data Record = Record
{ rId :: !Integer
, rBookId :: !Integer
, rUserId :: !Integer
} deriving (Eq, Show)
type LibraryDB = [Record]
borrowBook :: Integer -> Integer -> LibraryDB -> Either Record LibraryDB
borrowBook bookId userId db =
case lookupRecordByBookId bookId db of
Just record -> Left record
Nothing -> do
let newRecordId = getNewRecordId db
newRecord = Record newRecordId bookId userId
return $ newRecord:db
where
getNewRecordId :: [Record] -> Integer
getNewRecordId = fromIntegral . length
lookupRecordByBookId :: Integer -> LibraryDB -> Maybe Record
lookupRecordByBookId bookId = lookupRecord bookId rBookId
lookupRecordById :: Integer -> LibraryDB -> Maybe Record
lookupRecordById recordId = lookupRecord recordId rId
lookupRecord :: (Eq a) => a -> (Record -> a) -> LibraryDB -> Maybe Record
lookupRecord _ _ [] = Nothing
lookupRecord someId getter (r:rs)
| someId == getter r = Just r
| otherwise = lookupRecord someId getter rs
問題点
Integer
が何を表現しているのか不明です。とくにborrowBook
関数に関しては引数としてInteger
を2つ受け取っており、どちらがBookId
、UserId
なのかはその実装内容を確認しない限り全くわかりません。
参照関数に関しても、現状では任意のInteger
を引数として受け取る関数となっているので、誤ってUserId
を渡して予期しない結果を出力する可能性が高いでしょう。
型シノニム(type)
それでは型シノニムを利用し、Integer
が何を表現しているのかを明示的に表記しましょう。
import Data.Text (Text)
type UserId = Integer
data User = User
{ uId :: !UserId
, uName :: !Text
} deriving (Eq, Show)
type BookId = Integer
data Book = Book
{ bId :: !BookId
, bAuthor :: !Text
, bTitle :: !Text
} deriving (Eq, Show)
type RecordId = Integer
data Record = Record
{ rId :: !RecordId
, rBookId :: !BookId
, rUserId :: !UserId
} deriving (Eq, Show)
type LibraryDB = [Record]
borrowBook :: BookId -> UserId -> LibraryDB -> Either Record LibraryDB
borrowBook bookId userId db =
case lookupRecordByBookId bookId db of
Just record -> Left record
Nothing -> do
let newRecordId = getNewRecordId db
newRecord = Record newRecordId bookId userId
return $ newRecord:db
where
getNewRecordId :: [Record] -> RecordId
getNewRecordId = fromIntegral . length
lookupRecordByBookId :: BookId -> LibraryDB -> Maybe Record
lookupRecordByBookId bookId = lookupRecord bookId rBookId
lookupRecordById :: RecordId -> LibraryDB -> Maybe Record
lookupRecordById recordId = lookupRecord recordId rId
lookupRecord :: (Eq a) => a -> (Record -> a) -> LibraryDB -> Maybe Record
lookupRecord _ _ [] = Nothing
lookupRecord someId getter (r:rs)
| someId == getter r = Just r
| otherwise = lookupRecord someId getter rs
一見これで問題は解決されたようにみえます。
問題点
BookId
, UserId
, RecordId
は単なる型シノニムなので、暗黙的にはInteger
と同じ型です。よってBooKId == UserId == RecordId == Integer
となるため、以下のような誤った使い方をしたとしてもエラーとなりません。
*> let bookId = 11
*> let userId = 10
-- 渡すIdの順番が間違ってる
*> JustType.borrowBook userId bookId []
Right [Record {rId = 0, rBookId = 10, rUserId = 11}]
開発者からすれば「型シグネチャにBookId
とあるやん!なんでUserId
を渡したの?」と思うかもしれませんが、他の人からすれば「なんで渡すパラメーターを間違えたのにエラーが出ないの?」と言うでしょう。
これを解決するのがnewtype
です。
newtype
それでは今度はBookId
,UserId
,RecordId
をそれぞれnewtype
でくるんでみましょう。(開発チームではこれを"Wrap it with newtype"と表現しています。)
import Data.Text (Text)
newtype UserId = UserId
{ getUserId :: Integer
} deriving (Eq, Show)
data User = User
{ uId :: !UserId
, uName :: !Text
} deriving (Eq, Show)
newtype BookId = BookId
{ getBookId :: Integer
} deriving (Eq, Show)
data Book = Book
{ bId :: !BookId
, bAuthor :: !Text
, bTitle :: !Text
} deriving (Eq, Show)
newtype RecordId = RecordId
{ getRecordId :: Integer
} deriving (Eq, Show)
data Record = Record
{ rId :: !RecordId
, rBookId :: !BookId
, rUserId :: !UserId
} deriving (Eq, Show)
type LibraryDB = [Record]
borrowBook :: BookId -> UserId -> LibraryDB -> Either Record LibraryDB
borrowBook bookId userId db =
case lookupRecordByBookId bookId db of
Just record -> Left record
Nothing -> do
let newRecordId = getNewRecordId db
newRecord = Record newRecordId bookId userId
return $ newRecord:db
where
getNewRecordId :: [Record] -> RecordId
getNewRecordId = RecordId . fromIntegral . length
lookupRecordByBookId :: BookId -> LibraryDB -> Maybe Record
lookupRecordByBookId bookId = lookupRecord bookId rBookId
lookupRecordById :: RecordId -> LibraryDB -> Maybe Record
lookupRecordById recordId = lookupRecord recordId rId
lookupRecord :: (Eq a) => a -> (Record -> a) -> LibraryDB -> Maybe Record
lookupRecord _ _ [] = Nothing
lookupRecord someId getter (r:rs)
| someId == getter r = Just r
| otherwise = lookupRecord someId getter rs
型シノニムと違い、newtypeを使用するとBookId
型とUserId
型は別の型をみなされます。
(UserId /= BookId /= RecordId
)。
よって先ほどのように誤って最初の引数にuserId
を渡そうとすると、型が一致しないためエラーとなります。
実際に使ってみると:
*> let bookId = BookId 11
*> let userId = UserId 10
*> Newtype.borrowBook bookId userId []
Right [Record {rId = RecordId {getRecordId = 0}, rBookId = BookId {getBookId = 11}, rUserId = UserId {getUserId = 10}}]
となります。
まとめ
newtypeにくるむことによってそれぞれのId
に、型レベルで明確な意味をもたせることができました。
もし堅牢なアプリケーションを構築するのであれば全てのフィールドをnewtypeでくるむのが望ましいでしょう。
newtype BookId = BookId
{ getBookId :: Integer
} deriving (Eq, Show)
newtype Title = Title
{ getTitle :: Text
} deriving (Eq, Show)
newtype Name = Name
{ getName :: Text
} deriving (Eq, Show)
data Book = Book
{ bId :: !BookId
, bAuthor :: !Name
, bTitle :: !Title
} deriving (Eq, Show)
一見、冗長的で面倒に見えますが、これがエラー耐性のあるアプリケーションの構築につながります。
本当に堅牢なのか
newtype
を利用したとしても、間違った関数を実装することは十分可能です。
evilFun :: BookId -> UserId -> Integer
evilFun bookId userId =
let bookNum = getBookId bookId -- Integer
userNum = getUserId userId -- Integer
in bookNum + userNum -- What...?
なのでそのようなことはしないよう気をつけましょう。