Haskell

すごい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...?

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