Edited at

すごいH本で見落としがちだが実は重要な機能:newtype

すごい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つ受け取っており、どちらがBookIdUserIdなのかはその実装内容を確認しない限り全くわかりません。

参照関数に関しても、現状では任意の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...?

なのでそのようなことはしないよう気をつけましょう。