第17章: レンタルビデオシステム¶
はじめに¶
本章では、Martin Fowler の「リファクタリング」で有名なレンタルビデオシステムを題材に、関数型プログラミングによる料金計算ロジックの設計を学びます。
この問題を通じて以下の概念を学びます:
- ADT によるカテゴリ別ポリシーパターン
- 明細書フォーマッターの実装
- データと処理の分離
1. ドメインモデル¶
映画のカテゴリ¶
レンタルビデオシステムでは、以下の3種類の映画カテゴリをサポートします:
- 通常(Regular): 2日まで$2.0、以降1日ごとに$1.5追加
- 新作(New Release): 1日ごとに$3.0
- 子供向け(Children's): 3日まで$1.5、以降1日ごとに$1.5追加
2. 型定義¶
映画とカテゴリ¶
-- | Movie category determines pricing rules
data Category
= Regular -- ^ 2 days for $2, then $1.5/day
| NewRelease -- ^ $3/day
| Childrens -- ^ 3 days for $1.5, then $1.5/day
deriving (Show, Eq)
-- | A movie with title and category
data Movie = Movie
{ movieTitle :: String
, movieCategory :: Category
} deriving (Show, Eq)
映画作成のヘルパー¶
-- | Create a movie
makeMovie :: String -> Category -> Movie
makeMovie = Movie
-- | Create a regular movie
makeRegular :: String -> Movie
makeRegular title = makeMovie title Regular
-- | Create a new release movie
makeNewRelease :: String -> Movie
makeNewRelease title = makeMovie title NewRelease
-- | Create a children's movie
makeChildrens :: String -> Movie
makeChildrens title = makeMovie title Childrens
レンタル¶
-- | A rental of a movie for a number of days
data Rental = Rental
{ rentalMovie :: Movie
, rentalDays :: Int
} deriving (Show, Eq)
-- | Create a rental
makeRental :: Movie -> Int -> Rental
makeRental = Rental
3. 料金計算¶
パターンマッチングによる料金計算¶
-- | Calculate the rental amount based on movie category and days
calculateAmount :: Rental -> Double
calculateAmount rental = case movieCategory (rentalMovie rental) of
Regular ->
if days > 2
then 2.0 + fromIntegral (days - 2) * 1.5
else 2.0
NewRelease ->
fromIntegral days * 3.0
Childrens ->
if days > 3
then 1.5 + fromIntegral (days - 3) * 1.5
else 1.5
where
days = rentalDays rental
ポイント計算¶
-- | Calculate frequent renter points
calculatePoints :: Rental -> Int
calculatePoints rental = case movieCategory (rentalMovie rental) of
NewRelease -> if rentalDays rental > 1 then 2 else 1
_ -> 1
4. 顧客モデル¶
-- | A customer with name and rentals
data Customer = Customer
{ customerName :: String
, customerRentals :: [Rental]
} deriving (Show, Eq)
-- | Create a customer with no rentals
makeCustomer :: String -> Customer
makeCustomer name = Customer name []
-- | Add a rental to a customer
addRental :: Rental -> Customer -> Customer
addRental rental customer = customer
{ customerRentals = rental : customerRentals customer
}
集計関数¶
-- | Calculate total amount for all rentals
totalAmount :: Customer -> Double
totalAmount = sum . map calculateAmount . customerRentals
-- | Calculate total points for all rentals
totalPoints :: Customer -> Int
totalPoints = sum . map calculatePoints . customerRentals
5. 明細書フォーマッター¶
フォーマット種別¶
-- | Statement format
data StatementFormat = TextFormat | HtmlFormat
deriving (Show, Eq)
-- | Generate a rental statement in the specified format
generateStatement :: StatementFormat -> Customer -> String
generateStatement format = case format of
TextFormat -> generateTextStatement
HtmlFormat -> generateHtmlStatement
テキストフォーマット¶
-- | Generate a plain text statement
generateTextStatement :: Customer -> String
generateTextStatement customer = unlines
[ "Rental Record for " ++ customerName customer
, ""
] ++ rentalLines ++ unlines
[ ""
, "Amount owed is " ++ show (totalAmount customer)
, "You earned " ++ show (totalPoints customer) ++ " frequent renter points"
]
where
rentalLines = unlines $ map formatRentalLine (customerRentals customer)
formatRentalLine rental =
"\t" ++ movieTitle (rentalMovie rental) ++ "\t" ++ show (calculateAmount rental)
HTML フォーマット¶
-- | Generate an HTML statement
generateHtmlStatement :: Customer -> String
generateHtmlStatement customer = unlines
[ "<html>"
, "<head><title>Rental Statement</title></head>"
, "<body>"
, "<h1>Rental Record for " ++ customerName customer ++ "</h1>"
, "<table>"
, "<tr><th>Movie</th><th>Amount</th></tr>"
] ++ rentalRows ++ unlines
[ "</table>"
, "<p>Amount owed is <strong>" ++ show (totalAmount customer) ++ "</strong></p>"
, "<p>You earned <strong>" ++ show (totalPoints customer)
++ "</strong> frequent renter points</p>"
, "</body>"
, "</html>"
]
where
rentalRows = unlines $ map formatRentalRow (customerRentals customer)
formatRentalRow rental =
"<tr><td>" ++ movieTitle (rentalMovie rental)
++ "</td><td>" ++ show (calculateAmount rental) ++ "</td></tr>"
6. テスト¶
describe "calculateAmount" $ do
it "charges $2 for 1-2 day regular rental" $ do
let rental = makeRental (makeRegular "Test") 2
calculateAmount rental `shouldBe` 2.0
it "charges extra for regular rental over 2 days" $ do
let rental = makeRental (makeRegular "Test") 5
-- 2 + 3 * 1.5 = 6.5
calculateAmount rental `shouldBe` 6.5
it "charges $3/day for new release" $ do
let rental = makeRental (makeNewRelease "Test") 5
calculateAmount rental `shouldBe` 15.0
describe "calculatePoints" $ do
it "earns bonus points for new release over 1 day" $ do
let rental = makeRental (makeNewRelease "Test") 2
calculatePoints rental `shouldBe` 2
describe "generateStatement" $ do
it "generates correct text statement" $ do
let customer = addRental (makeRental (makeRegular "The Matrix") 3)
$ makeCustomer "John"
let statement = generateTextStatement customer
statement `shouldContain` "Rental Record for John"
statement `shouldContain` "The Matrix"
まとめ¶
レンタルビデオシステムの Haskell 実装のポイント:
- ADT でカテゴリを表現:
Category型でビジネスルールを型に埋め込む - パターンマッチングで料金計算: 各カテゴリの計算ルールを明示的に記述
- 不変データ構造:
addRentalは新しいCustomerを返す - 関数合成で集計:
totalAmount = sum . map calculateAmount . customerRentals - フォーマッター分離: ビジネスロジックと表示ロジックを分離
- 拡張性: 新しいカテゴリやフォーマットを追加しやすい設計