Skip to the content.

← 目次← 前: 22次: 24 →

23. オブジェクト指向と例外処理

テーマ: 銀行口座クラス(継承・カプセル化・カスタム例外・単体テスト)

学習点: class, 継承, @property, カスタム例外, repr, 特殊メソッド, unittest による自動テスト

依存: 標準ライブラリのみ / 難易度: 上級

実行方法

uv run 23_oop_bank.py

スクリプト冒頭の PEP 723 メタデータ(# /// script)により、必要なライブラリは uv が自動的に仮想環境へ導入します。事前の pip install は不要です。

解説

何をするプログラムか

銀行口座という業務システムの定番題材で、Python のオブジェクト指向設計を一通り学ぶスクリプトです。基底クラス Account(普通預金)から、当座貸越枠を持つ CheckingAccount と利息が付く SavingsAccount を継承で派生させます。金額は 02 章で学んだ Decimal で扱い、誤差のない会計処理を徹底しています。

業務コードで重要なのは「正しく動く」ことだけでなく「誤った使い方を確実に拒否する」ことです。残高不足や負の金額といった異常はカスタム例外で通知し、残高そのものは外部から書き換えられないようカプセル化します。最後に unittest による自動テストを実行し、これらの約束事が守られていることを機械的に検証します。

コードの読みどころ

実行結果の見方

デモ部分ではまず __repr__ による 2 口座の表示、送金と利息計算、当座貸越で残高が −50,000 円になる様子を確認してください。[例外処理] の 3 行は、それぞれ残高不足・負の金額・貸越枠超過が別々の例外として捕捉され、InsufficientFunds のメッセージに不足額まで含まれていることを示します。

「=== 単体テスト ===」以降は unittest の出力(標準エラー出力由来)です。test_deposit ... ok のように 6 つのテストすべてに ok が付き、最後に Ran 6 testsOK が表示されています。1 件でも失敗すれば okFAIL に変わり 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

← 目次← 前: 22次: 24 →