23. オブジェクト指向と例外処理
テーマ: 銀行口座クラス(継承・カプセル化・カスタム例外・単体テスト)
学習点: class, 継承, @property, カスタム例外, repr, 特殊メソッド, unittest による自動テスト
依存: 標準ライブラリのみ / 難易度: 上級
実行方法
uv run 23_oop_bank.py
スクリプト冒頭の PEP 723 メタデータ(# /// script)により、必要なライブラリは
uv が自動的に仮想環境へ導入します。事前の pip install は不要です。
解説
何をするプログラムか
銀行口座という業務システムの定番題材で、Python のオブジェクト指向設計を一通り学ぶスクリプトです。基底クラス Account(普通預金)から、当座貸越枠を持つ CheckingAccount と利息が付く SavingsAccount を継承で派生させます。金額は 02 章で学んだ Decimal で扱い、誤差のない会計処理を徹底しています。
業務コードで重要なのは「正しく動く」ことだけでなく「誤った使い方を確実に拒否する」ことです。残高不足や負の金額といった異常はカスタム例外で通知し、残高そのものは外部から書き換えられないようカプセル化します。最後に unittest による自動テストを実行し、これらの約束事が守られていることを機械的に検証します。
コードの読みどころ
- 残高は
self.__balanceと二重下線で始めることで名前修飾(name mangling)が働き、外部から直接アクセスできません。読み取りは@propertyのbalance経由のみで、デモのa.balance = ...はAttributeErrorになります。 - 例外は
BankErrorを基底とする階層になっています。InsufficientFundsはメッセージだけでなくself.shortfall(不足額)を属性として持つため、呼び出し側やテストが数値で検証できます。 available(引出可能額)プロパティをサブクラスがオーバーライドするのが継承の要所です。CheckingAccountはbalance + overdraftを返すため、基底クラスのwithdraw()を一切変更せずに「残高マイナスでも貸越枠内なら出金可」が実現されています(ポリモーフィズム)。transfer()は先にself.withdraw(a)を呼ぶため、残高不足なら例外で処理が中断し、相手口座には入金されません。この原子性はtest_transfer_atomicityで「送金元は減らない・相手も増えない」として検証されます。- 特殊メソッド
__repr__・__len__・__lt__により、print でのデバッグ表示、len(a)での取引件数取得、sorted([a, b])での残高順ソートが自然に書けます。 TestAccount(unittest.TestCase)では、setUpによる前処理、assertRaisesのコンテキストマネージャ形式、複数入力を回すsubTestという unittest の基本 3 パターンが登場します。末尾のunittest.main(argv=["x"], exit=False, verbosity=2)はスクリプト内からテストを起動する書き方です。
実行結果の見方
デモ部分ではまず __repr__ による 2 口座の表示、送金と利息計算、当座貸越で残高が −50,000 円になる様子を確認してください。[例外処理] の 3 行は、それぞれ残高不足・負の金額・貸越枠超過が別々の例外として捕捉され、InsufficientFunds のメッセージに不足額まで含まれていることを示します。
「=== 単体テスト ===」以降は unittest の出力(標準エラー出力由来)です。test_deposit ... ok のように 6 つのテストすべてに ok が付き、最後に Ran 6 tests と OK が表示されています。1 件でも失敗すれば ok が FAIL に変わり OK は表示されません。人がデモ出力を目視するのではなく、仕様をテストコードとして固定し機械に検証させる――これが単体テストの価値です。
ソースコード
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""23: オブジェクト指向と例外処理 -----------------------------------------
テーマ: 銀行口座クラス(継承・カプセル化・カスタム例外・単体テスト)
学習点: class, 継承, @property, カスタム例外, __repr__, 特殊メソッド,
unittest による自動テスト
"""
from __future__ import annotations
import unittest
from datetime import datetime
from decimal import Decimal
class BankError(Exception):
"""この銀行モジュールの基底例外。"""
class InsufficientFunds(BankError):
def __init__(self, balance: Decimal, requested: Decimal):
self.shortfall = requested - balance
super().__init__(f"残高不足: 残高{balance}円 に対し {requested}円 の出金"
f"(不足額 {self.shortfall}円)")
class InvalidAmount(BankError):
pass
class Account:
"""普通預金口座。残高は外部から直接書き換えられないようにする。"""
_next_id = 1000
def __init__(self, owner: str, initial: Decimal | int = 0):
Account._next_id += 1
self.id = f"ACC{Account._next_id}"
self.owner = owner
self.__balance = Decimal(initial) # 名前修飾(name mangling)
self.history: list[tuple[datetime, str, Decimal, Decimal]] = []
self._log("開設", Decimal(initial))
# --- 読み取り専用プロパティ ---
@property
def balance(self) -> Decimal:
return self.__balance
def _log(self, kind: str, amount: Decimal) -> None:
self.history.append((datetime(2026, 1, 1), kind, amount, self.__balance))
@staticmethod
def _validate(amount) -> Decimal:
a = Decimal(amount)
if a <= 0:
raise InvalidAmount(f"金額は正の数でなければなりません: {a}")
return a
def deposit(self, amount) -> Decimal:
a = self._validate(amount)
self.__balance += a
self._log("入金", a)
return self.__balance
def withdraw(self, amount) -> Decimal:
a = self._validate(amount)
if a > self.available:
raise InsufficientFunds(self.available, a)
self.__balance -= a
self._log("出金", a)
return self.__balance
@property
def available(self) -> Decimal:
"""引き出せる上限。サブクラスで上書きする。"""
return self.__balance
def transfer(self, other: "Account", amount) -> None:
a = self._validate(amount)
self.withdraw(a) # 失敗すれば例外が飛び、相手に入金されない
other.deposit(a)
def __repr__(self) -> str:
return (f"{type(self).__name__}(id={self.id!r}, owner={self.owner!r}, "
f"balance={self.balance})")
def __len__(self) -> int:
return len(self.history)
def __lt__(self, other: "Account") -> bool:
return self.balance < other.balance
class CheckingAccount(Account):
"""当座貸越(オーバードラフト)枠を持つ口座。"""
def __init__(self, owner: str, initial=0, overdraft: Decimal | int = 0):
super().__init__(owner, initial)
self.overdraft = Decimal(overdraft)
@property
def available(self) -> Decimal:
return self.balance + self.overdraft
class SavingsAccount(Account):
"""利息が付く口座。"""
def __init__(self, owner: str, initial=0, rate: str = "0.001"):
super().__init__(owner, initial)
self.rate = Decimal(rate)
def add_interest(self) -> Decimal:
interest = (self.balance * self.rate).quantize(Decimal("1"))
if interest > 0:
self.deposit(interest)
return interest
def demo() -> None:
print("=== デモ ===")
a = SavingsAccount("佐藤", 500_000, rate="0.002")
b = CheckingAccount("鈴木", 30_000, overdraft=100_000)
print(a, "\n", b, sep="")
a.transfer(b, 120_000)
print(f"\n送金後: 佐藤 {a.balance:,}円 / 鈴木 {b.balance:,}円")
print(f"利息 {a.add_interest():,}円 -> 残高 {a.balance:,}円")
print("\n[当座貸越の範囲内なら残高がマイナスでも出金可能]")
b.withdraw(200_000)
print(f" 鈴木 残高 {b.balance:,}円 / 引出可能額 {b.available:,}円")
print("\n[例外処理]")
for acc, amt in [(a, 10_000_000), (a, -5), (b, 999_999)]:
try:
acc.withdraw(amt)
except InsufficientFunds as e:
print(f" InsufficientFunds: {e}")
except InvalidAmount as e:
print(f" InvalidAmount: {e}")
else:
print(f" 出金成功 {amt}")
finally:
pass
print("\n[カプセル化の確認]")
try:
a.balance = Decimal("999999999")
except AttributeError as e:
print(f" balance への直接代入は不可: {type(e).__name__}")
print(f"\n[特殊メソッド] 取引件数 len(a) = {len(a)} / "
f"残高比較 a < b = {a < b}")
print(f"[ソート] {sorted([a, b])}")
print("\n[取引履歴]")
for _, kind, amt, bal in a.history:
print(f" {kind:<4}{amt:>12,} 残高 {bal:>12,}")
class TestAccount(unittest.TestCase):
def setUp(self):
self.a = Account("テスト", 1000)
def test_deposit(self):
self.assertEqual(self.a.deposit(500), Decimal(1500))
def test_withdraw_ok(self):
self.assertEqual(self.a.withdraw(400), Decimal(600))
def test_withdraw_insufficient(self):
with self.assertRaises(InsufficientFunds) as cm:
self.a.withdraw(2000)
self.assertEqual(cm.exception.shortfall, Decimal(1000))
def test_invalid_amount(self):
for bad in (0, -1):
with self.subTest(amount=bad):
self.assertRaises(InvalidAmount, self.a.deposit, bad)
def test_transfer_atomicity(self):
b = Account("相手", 0)
with self.assertRaises(InsufficientFunds):
self.a.transfer(b, 5000)
self.assertEqual(self.a.balance, Decimal(1000)) # 送金元は減らない
self.assertEqual(b.balance, Decimal(0)) # 相手も増えない
def test_overdraft(self):
c = CheckingAccount("当座", 100, overdraft=900)
c.withdraw(1000)
self.assertEqual(c.balance, Decimal(-900))
if __name__ == "__main__":
demo()
print("\n=== 単体テスト ===")
unittest.main(argv=["x"], exit=False, verbosity=2)
実行結果
=== デモ ===
SavingsAccount(id='ACC1001', owner='佐藤', balance=500000)
CheckingAccount(id='ACC1002', owner='鈴木', balance=30000)
送金後: 佐藤 380,000円 / 鈴木 150,000円
利息 760円 -> 残高 380,760円
[当座貸越の範囲内なら残高がマイナスでも出金可能]
鈴木 残高 -50,000円 / 引出可能額 50,000円
[例外処理]
InsufficientFunds: 残高不足: 残高380760円 に対し 10000000円 の出金(不足額 9619240円)
InvalidAmount: 金額は正の数でなければなりません: -5
InsufficientFunds: 残高不足: 残高50000円 に対し 999999円 の出金(不足額 949999円)
[カプセル化の確認]
balance への直接代入は不可: AttributeError
[特殊メソッド] 取引件数 len(a) = 3 / 残高比較 a < b = False
[ソート] [CheckingAccount(id='ACC1002', owner='鈴木', balance=-50000), SavingsAccount(id='ACC1001', owner='佐藤', balance=380760)]
[取引履歴]
開設 500,000 残高 500,000
出金 120,000 残高 380,000
入金 760 残高 380,760
=== 単体テスト ===
test_deposit (__main__.TestAccount.test_deposit) ... ok
test_invalid_amount (__main__.TestAccount.test_invalid_amount) ... ok
test_overdraft (__main__.TestAccount.test_overdraft) ... ok
test_transfer_atomicity (__main__.TestAccount.test_transfer_atomicity) ... ok
test_withdraw_insufficient (__main__.TestAccount.test_withdraw_insufficient) ... ok
test_withdraw_ok (__main__.TestAccount.test_withdraw_ok) ... ok
----------------------------------------------------------------------
Ran 6 tests in 0.000s
OK