15
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-09-06

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

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

15
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?