こんにちは。Yuinaです。
本日は、TDD駆動開発(著・Kent Beck)の第1部第17章時点での実装コードを元のJavaからPythonに書き換えて実装しました。
解説させていただきます。よろしくお願いいたします。
概要
コードは異なる通貨を扱うための「多通貨」システムを実装しています。
金額や通貨の概念をオブジェクトとしてモデル化し、加算や乗算といった操作を柔軟に行えるように設計されています。
主なクラスと役割
Expression (抽象クラス):
金額を扱う操作(加算、乗算、換算)の共通インターフェースを定義する抽象基底クラスです。
Money
や Sum
といった具体的なクラスがこのインターフェースを実装することで、異なるオブジェクトを同じように扱えるようになります。
Money:
特定の金額と通貨(例:5ドル、10フラン)を表現するクラスです。__eq__
や__hash__
などの特殊メソッドをオーバーロードすることで、金額の比較や辞書のキーとしての使用を可能にしています。
Sum:
複数の Expression
オブジェクトの加算を表現するクラスです。例えば、「5ドル + 10フラン」という式を、すぐに計算せず「式」として保持します。これにより、後からまとめて換算・計算することが可能になります。
Bank:
異なる通貨間の換算レートを管理するクラスです。通貨のペア(例:USDとCHF)とそれに対応するレートを辞書に保存し、reduce
メソッドを通じて、Expression
オブジェクトの換算処理を実行します。
Pair:
「どの通貨からどの通貨へ」という通貨のペアを表現する補助的なクラスです。__eq__
と__hash__
をオーバーロードすることで、Bank
クラスの辞書のキーとして使用できるようにしています。

作成したコード
money.py
from abc import ABC, abstractmethod
# 通貨を扱う「式」を表す抽象基底クラス
class Expression(ABC):
#具体的な金額を単純に掛け算する
@abstractmethod
def times(self,multiplier):
pass
# 既存のSumオブジェクトに、さらに別のExpressionオブジェクトを足す
@abstractmethod
def plus(self,addend:'Expression'):
pass
@abstractmethod
# 式に含まれるすべての要素を換算し、その合計を算出する
# (例)
# 5ドルをドルに換算(そのまま5ドル)
# 10フランをドルに換算(例えば、10フランが2ドルだった場合)
# 換算した両方の金額を足し合わせる(5ドル + 2ドル = 7ドル)
# 最終的な金額である7ドルのMoneyオブジェクトを返す。
def reduce(self, bank: 'Bank', to: str) -> 'Money':
"""指定された通貨に金額を換算する抽象メソッド。"""
pass
#「オブジェクト同士の加算」という複雑な処理を、
# Pythonの組み込み機能である+演算子に結びつける
@abstractmethod
def __add__(self, other: 'Expression') -> 'Expression':
"""'+' 演算子をオーバーロードするための抽象メソッド。"""
pass
# 具体的な金額と通貨(例:5ドル、10フラン)を扱うクラス
class Money(Expression):
def __init__(self, amount: int, currency: str):
self.amount = amount
self._currency = currency
# 2つのMoneyオブジェクトが等しいか比較する
def __eq__(self, other: object) -> bool:
if isinstance(other, Money):
# 金額と通貨が両方等しいか比較
return self.amount == other.amount and self.currency() == other.currency()
return False
# オブジェクトを開発者向けの文字列表現で返す
def __repr__(self) -> str:
return f"Money({self.amount}, '{self.currency()}')"
# 辞書のキーとして使えるようハッシュ値を返す
def __hash__(self) -> int:
return hash((self.amount, self.currency()))#金額,通貨
# '+' 演算子でSumオブジェクトを返す
def __add__(self, other: 'Expression') -> 'Expression':
return Sum(self, other)
# 金額を指定された倍率で掛ける
# 元の金額(self.amount)に倍率(multiplier)を掛け、新しい金額を算出
def times(self, multiplier: int) -> 'Expression':
return Money(self.amount * multiplier, self.currency())
# 通貨の種類を返す
def currency(self) -> str:
return self._currency
# 金額を指定された通貨に換算する
# 次の行で実際の金額(self.amount)を換算するために使用する
def reduce(self, bank: 'Bank', to: str) -> 'Money':
rate = bank.rate(self.currency(), to)
# 金額を指定されたレートで換算し、新しいMoneyオブジェクトとして返す
return Money(int(self.amount / rate), to)
# ドル(USD)のインスタンスを生成する
@classmethod
def dollar(cls, amount: int) -> 'Money':
return cls(amount, "USD")
# フラン(CHF)のインスタンスを生成する
@classmethod
def franc(cls, amount: int) -> 'Money':
return cls(amount, "CHF")
# 既存の計算式に新しい要素を追加する
def plus(self,addend:'Expression'):
return Sum(self,addend)
# 2つの「お金の式」を一つの「計算式」としてまとめる
# すぐに計算せずに、後からまとめて換算できるよう、式を覚えておく
class Sum(Expression):
def __init__(self, augend: Expression, addend: Expression):
self.augend = augend #足される数
self.addend = addend #足す数
# 要素ごとの乗算: self.augend.times(multiplier)とself.addend.times(multiplier)を呼び出して、
# 式に含まれるそれぞれの要素(augendとaddend)に倍率を適用する
def times(self,multiplier:int):
# 新しいSumの生成
return Sum(self.augend.times(multiplier),self.addend.times(multiplier))
# 和を換算し、合計金額を返す
def reduce(self, bank: 'Bank', to: str) -> 'Money':
# augendとaddendをそれぞれ換金して、amountを足す
amount = self.augend.reduce(bank, to).amount + self.addend.reduce(bank, to).amount
return Money(amount, to)
# '+' 演算子で新しいSumオブジェクトを返す
def __add__(self, other: 'Expression') -> 'Expression':
return Sum(self, other)
# Sumという未解決の計算式に、新しい要素(addend)を加えて、
# 新しいSumオブジェクトを生成して返す
def plus(self,addend:'Expression'):
return Sum(self,addend)
# 異なる通貨間の「換算レート」を管理するクラス
class Bank:
# 通貨ペアと換算レートを保存する辞書を初期化する
def __init__(self):
self.rates: dict['Pair', int] = {}
# 換金実行メソッド
def reduce(self,source:Expression,to:str):
return source.reduce(self,to)
# 換算レートを登録する
def add_rate(self, from_currency: str, to: str, rate: int) -> None:
self.rates[Pair(from_currency, to)] = rate
# 指定された2つの通貨間の換算レートを、
# Bankに登録された情報から探し出して返す
def rate(self, from_currency: str, to: str) -> int:
# 通貨が同じ場合:レートは1
if from_currency == to:
return 1
# 通貨が異なる場合:通貨の組み合わせを表すPairオブジェクトを作成
pair = Pair(from_currency, to)
# レートが存在しない場合
if pair not in self.rates:
#エラーメッセージ
raise ValueError(f"Rate not found for {from_currency} to {to}")
# レートが存在する場合
#対応する換算レート(整数値)を取得し、返す
return self.rates[pair]
# 「どの通貨からどの通貨へ」というペア(組み合わせ)を表現する
class Pair:
def __init__(self, from_currency: str, to: str):
self.from_currency = from_currency
self.to = to
# 2つのPairオブジェクトが論理的に等しいか判定する
def __eq__(self, other: object) -> bool:
if isinstance(other, Pair):
return self.from_currency == other.from_currency and self.to == other.to
return False
# Pairオブジェクトを一意に識別するためのハッシュ値を生成
def __hash__(self) -> int:
return hash((self.from_currency, self.to))
作成したテストコード
MoneyTest.py
import unittest
from money import Bank, Expression, Money, Sum
class MoneyTest(unittest.TestCase):
# フランを掛け算するテスト
def testMultiplication(self):
five = Money.franc(5)
self.assertEqual(Money.franc(10), five.times(2))
# フランに3を掛けた結果が15フランと等しいか(このテストは成功する)
self.assertEqual(Money.franc(15), five.times(3))
# 通貨が同じか、違うかを比較するテスト
def testEquality(self):
# 5ドルと6ドルは等しくない
self.assertEqual(Money.dollar(5), Money.dollar(5))
# 5フランと6フランは等しくない
self.assertNotEqual(Money.dollar(5), Money.dollar(6))
# 5フランと6ドルは等しくない
self.assertNotEqual(Money.franc(5), Money.dollar(5))
# 通貨の種類テスト
def testCurrency(self):
self.assertEqual("USD",Money.dollar(1).currency())
self.assertEqual("CHF",Money.franc(1).currency())
def testSimpleAddition(self):
#5ドルをfiveに格納
five = Money.dollar(5)
# 5ドル+5ドル=10ドル
#sum = Money.dollar(5).plus(five)
sum = five + five
#5ドルにplusメソッドで5ドル加算しsumに格納
bank = Bank()
#Expressionに為替レートを適用することによって得られる換算結果
reduced = bank.reduce(sum,"USD")
#Moneyクラスの10ドルとbankクラスのsum(five.plus(five))が同じ値かどうかチェック
self.assertEqual(Money.dollar(10),reduced)
# 加算演算子が正しくSumオブジェクトを返しているかをテスト
# Moneyオブジェクト同士を+記号で足し合わせたときに、
# すぐに計算結果の金額が返されるのではなく、
# 計算式を表すSumオブジェクトが返されるかどうか確認
def testPlusReturnsSum(self):
five = Money.dollar(5)
result: Expression = five + five
#five.plus(five)とSumクラスで手動で計算された結果が同じかどうかテスト
self.assertIsInstance(result,Sum)
self.assertEqual(five, result.augend)
self.assertEqual(five, result.addend)
# 加算された結果が同じかどうかテスト
def testReduceSum(self):
self.sum = Sum(Money.dollar(3),Money.dollar(4))
bank = Bank()
#7ドル,USD
result = bank.reduce(self.sum,"USD")
#Moneyクラスのインスタンスの値とresult(7ドル,USD)が同じか確認する
self.assertEqual(Money.dollar(7),result)
# 金額と貨幣の種類(通貨)の両方が一致しているかどうかを検証
def testReduceMoney(self):
bank = Bank()
result = bank.reduce(Money.dollar(1),"USD")
# Moneyクラスのインスタンスとreduceの結果が一致しているかどうか
self.assertEqual(Money.dollar(1),result)
# 異なる通貨を換算できるかをテスト
def testReduceMoneyDifferentCurrency(self):
bank = Bank()
bank.addRate("CHF","USD",2)
result = bank.reduce(Money.franc(2),"USD")
self.assertEqual(Money.dollar(1),result)
# 同じ通貨間の換算レートが1であることを確認
def testIdentityRate(self):
self.assertEqual(1,Bank().rate("USD","USD"))
# 異なる通貨の金額を正しく足し算できるかをテスト
def testMixedAddition(self):
self.fiveBucks = Money.dollar(5)
self.tenFrancs = Money.franc(10)
self.bank = Bank()
self.bank.addRate("CHF","USD",2)
result = self.bank.reduce(self.fiveBucks + self.tenFrancs,"USD")
self.assertEqual(Money.dollar(10),result)
# 金額の合計(Sum)に別の金額(Money)を正しく足し、
# その合計を正確に換算できるか
def testSumplusMoney(self):
self.fiveBucks = Money.dollar(5)
self.tenFrancs = Money.franc(10)
self.bank = Bank()
self.bank.addRate("CHF","USD",2)
self.sum = Sum(self.fiveBucks,self.tenFrancs).plus(self.fiveBucks)
result = self.bank.reduce(self.sum,"USD")
self.assertEqual(Money.dollar(15),result)
#計算式全体を乗算できるか
# 単に数値に倍率をかけるのではなく、
# "金額の和"という式そのものに倍率をかけて、
# その結果が正しいかを確認
def testSumTimes(self):
self.fiveBucks = Money.dollar(5)
self.tenFrancs = Money.franc(10)
self.bank = Bank()
self.bank.addRate("CHF","USD",2)
self.sum = Sum(self.fiveBucks,self.tenFrancs).times(2)
result = self.bank.reduce(self.sum,"USD")
self.assertEqual(Money.dollar(20),result)
if __name__ == '__main__':
unittest.main()
まとめ
今回は、OOPの基本的な考え方である「柔軟性」と「拡張性」を意識して作成しました。
このブログが、読者の皆さんがオブジェクト指向の設計について考える際の、ささやかなきっかけとなれば幸いです。
ありがとうございました✨
