第17章: レンタルビデオシステム¶
はじめに¶
本章では、Martin Fowler の「リファクタリング」で有名なレンタルビデオシステムを題材に、関数型プログラミングによる料金計算ロジックの設計を学びます。
この問題を通じて以下の概念を学びます:
- パターンマッチによる料金計算
- 明細書フォーマッターの実装
- データと処理の分離
- DSL による流暢な API
1. ドメインモデル¶
映画カテゴリ¶
レンタルビデオシステムでは、以下の3種類の映画カテゴリをサポートします:
- 通常(Regular): 2日まで2.0、以降1日ごとに1.5追加
- 新作(New Release): 1日ごとに3.0
- 子供向け(Children's): 3日まで1.5、以降1日ごとに1.5追加
enum MovieCategory:
case Regular // 通常
case NewRelease // 新作
case Childrens // 子供向け
映画¶
case class Movie(title: String, category: MovieCategory)
object Movie:
def regular(title: String): Movie = Movie(title, MovieCategory.Regular)
def newRelease(title: String): Movie = Movie(title, MovieCategory.NewRelease)
def childrens(title: String): Movie = Movie(title, MovieCategory.Childrens)
レンタルと顧客¶
case class Rental(movie: Movie, days: Days)
case class Customer(name: String, rentals: List[Rental] = Nil):
def addRental(rental: Rental): Customer = copy(rentals = rentals :+ rental)
2. 料金計算¶
料金計算器¶
trait PriceCalculator:
def calculateAmount(days: Days): Money
def calculatePoints(days: Days): Points
object RegularPricing extends PriceCalculator:
def calculateAmount(days: Days): Money =
if days > 2 then BigDecimal(2.0) + BigDecimal((days - 2) * 1.5)
else BigDecimal(2.0)
def calculatePoints(days: Days): Points = 1
object NewReleasePricing extends PriceCalculator:
def calculateAmount(days: Days): Money = BigDecimal(days * 3.0)
def calculatePoints(days: Days): Points = if days > 1 then 2 else 1
object ChildrensPricing extends PriceCalculator:
def calculateAmount(days: Days): Money =
if days > 3 then BigDecimal(1.5) + BigDecimal((days - 3) * 1.5)
else BigDecimal(1.5)
def calculatePoints(days: Days): Points = 1
計算例¶
| カテゴリ | 日数 | 料金計算 | 結果 |
|---|---|---|---|
| 通常 | 2日 | 2.0 | 2.0 |
| 通常 | 5日 | 2.0 + 3 * 1.5 | 6.5 |
| 新作 | 3日 | 3 * 3.0 | 9.0 |
| 子供向け | 3日 | 1.5 | 1.5 |
| 子供向け | 6日 | 1.5 + 3 * 1.5 | 6.0 |
3. 明細データ¶
case class RentalLine(
title: String,
days: Days,
amount: Money,
points: Points
)
case class StatementData(
customerName: String,
rentalLines: List[RentalLine],
totalAmount: Money,
totalPoints: Points
)
def generateStatementData(customer: Customer): StatementData =
val lines = customer.rentals.map { rental =>
RentalLine(
title = rental.movie.title,
days = rental.days,
amount = calculateRentalAmount(rental),
points = calculateRentalPoints(rental)
)
}
StatementData(
customerName = customer.name,
rentalLines = lines,
totalAmount = totalAmount(customer.rentals),
totalPoints = totalPoints(customer.rentals)
)
4. 明細書フォーマッター¶
フォーマット形式¶
enum StatementFormat:
case Text
case Html
case Json
テキスト形式¶
object TextFormatter extends StatementFormatter:
def format(data: StatementData): String =
val header = s"Rental Record for ${data.customerName}\n"
val lines = data.rentalLines.map { line =>
s"\t${line.title}\t${line.amount}\n"
}.mkString
val footer = s"Amount owed is ${data.totalAmount}\n" +
s"You earned ${data.totalPoints} frequent renter points"
header + lines + footer
HTML形式¶
object HtmlFormatter extends StatementFormatter:
def format(data: StatementData): String =
val header = s"<h1>Rental Record for <em>${data.customerName}</em></h1>\n<ul>\n"
val lines = data.rentalLines.map { line =>
s" <li>${line.title} - ${line.amount}</li>\n"
}.mkString
val footer = s"</ul>\n" +
s"<p>Amount owed is <strong>${data.totalAmount}</strong></p>\n" +
s"<p>You earned <strong>${data.totalPoints}</strong> frequent renter points</p>"
header + lines + footer
出力例¶
テキスト形式:
Rental Record for John
Inception 3.5
Frozen 3.0
New Movie 6.0
Amount owed is 12.5
You earned 4 frequent renter points
HTML形式:
<h1>Rental Record for <em>John</em></h1>
<ul>
<li>Inception - 3.5</li>
<li>Frozen - 3.0</li>
<li>New Movie - 6.0</li>
</ul>
<p>Amount owed is <strong>12.5</strong></p>
<p>You earned <strong>4</strong> frequent renter points</p>
5. ポリシーベースの設計¶
より柔軟な設計として、料金ポリシーを関数として表現できます。
case class PricingPolicy(
name: String,
calculateAmount: Days => Money,
calculatePoints: Days => Points
)
object PricingPolicies:
val regular: PricingPolicy = PricingPolicy(
"Regular",
days => if days > 2 then BigDecimal(2.0 + (days - 2) * 1.5) else BigDecimal(2.0),
_ => 1
)
val premium: PricingPolicy = PricingPolicy(
"Premium",
days => BigDecimal(days * 5.0),
days => days // 日数分のポイント
)
6. レンタルショップ¶
class RentalShop:
private var movies: Map[String, Movie] = Map.empty
private var customers: Map[String, Customer] = Map.empty
def addMovie(movie: Movie): Unit = ...
def registerCustomer(name: String): Customer = ...
def rentMovie(customerName: String, movieTitle: String, days: Days): Option[Rental] = ...
def generateCustomerStatement(customerName: String, format: StatementFormat): Option[String] = ...
7. 割引計算¶
sealed trait Discount:
def apply(amount: Money): Money
case class PercentageDiscount(percent: Double) extends Discount:
def apply(amount: Money): Money = amount * BigDecimal(1 - percent)
case class FixedDiscount(value: Money) extends Discount:
def apply(amount: Money): Money = (amount - value) max BigDecimal(0)
def applyDiscounts(amount: Money, discounts: Seq[Discount]): Money =
discounts.foldLeft(amount)((acc, d) => d.apply(acc))
8. 売上レポート¶
case class SalesReport(
period: String,
totalRentals: Int,
totalRevenue: Money,
rentalsByCategory: Map[MovieCategory, Int],
revenueByCategory: Map[MovieCategory, Money]
)
def generateSalesReport(rentals: Seq[Rental], period: String): SalesReport =
val rentalsByCategory = rentals.groupBy(_.movie.category).view.mapValues(_.size).toMap
val revenueByCategory = rentals.groupBy(_.movie.category).view.mapValues(rs => totalAmount(rs)).toMap
SalesReport(period, rentals.size, totalAmount(rentals), rentalsByCategory, revenueByCategory)
9. DSL¶
object RentalDSL:
def customer(name: String): CustomerBuilder = CustomerBuilder(name)
case class CustomerBuilder(name: String, rentals: List[Rental] = Nil):
def rents(movie: Movie, days: Days): CustomerBuilder =
copy(rentals = rentals :+ Rental(movie, days))
def build: Customer = Customer(name, rentals)
def statement: String = generateStatement(build)
def statementAs(format: StatementFormat): String = generateStatement(build, format)
def regular(title: String): Movie = Movie.regular(title)
def newRelease(title: String): Movie = Movie.newRelease(title)
def childrens(title: String): Movie = Movie.childrens(title)
// 使用例
import RentalDSL.*
val statement = customer("John")
.rents(regular("Inception"), 3)
.rents(newRelease("New Movie"), 2)
.statement
10. Clojure との比較¶
| 概念 | Clojure | Scala |
|---|---|---|
| 映画カテゴリ | キーワード (:regular) |
enum |
| 料金計算 | マルチメソッド | パターンマッチ / object |
| 明細データ | マップ | case class |
| フォーマッター | マルチメソッド | trait + object |
11. リファクタリングのポイント¶
OOP vs FP¶
OOP アプローチ:
- Movie クラスに getCharge() メソッド
- サブクラスでオーバーライド
FP アプローチ: - 映画はデータのみ(case class) - 料金計算は別の関数/object - カテゴリに応じたディスパッチ
利点¶
- データと処理の分離: テストが容易
- 拡張容易性: 新しいカテゴリ追加が容易
- オープン・クローズド原則: 既存コード変更不要
まとめ¶
本章では、レンタルビデオシステムを通じて以下を学びました:
- パターンマッチ: カテゴリによる料金計算のディスパッチ
- データと処理の分離: 映画データと計算ロジックの分離
- フォーマッターパターン: 複数の出力形式への対応
- 拡張性: 新しいカテゴリやフォーマットの追加が容易
- DSL: 流暢な API による読みやすいコード
参考コード¶
本章のコード例は以下のファイルで確認できます:
- ソースコード:
apps/scala/part6/src/main/scala/VideoRentalSystem.scala - テストコード:
apps/scala/part6/src/test/scala/VideoRentalSystemSpec.scala