はじめに
こんにちは。アドベントカレンダーの3記事目の記事です。(2025/12/04)
今回はオブジェクト指向の中でも"理解したつもりでも実は危険"になりがちな動的ディスパッチについて解説します。
オブジェクト指向で代表的な機能の1つである**継承*をなんとなく使っていると、この機能によって意図しない動作を生む可能性があります。
動的ディスパッチとは何か?
まずは例として、以下のコードを見てください
class PhysicalAttack:
def single_attack(self) -> int:
return 30
def double_attack(self) -> int:
return self.single_attack() * 2
class Fighter(PhysicalAttack):
def single_attack(self) -> int
return super().single_attack() + 20
def double_attack(self) -> int:
return super().double_attack() + 10
fighter = Fighter()
print("格闘家の攻撃力:", fighter.double_attack())
さて、出力結果は何になると思いますか?
間違えやすい思考
一見すると、
Fighter.double_attack()が呼ばれる
そこから super().double_attack() → 親クラスの double_attack() が呼ばれる
親クラスの double_attack() 内では self.single_attack() を呼んでいる
だから PhysicalAttack.single_attack() が呼ばれるはず…
と思って、
30 * 2 + 10 = 70
になりそうですよね。
実際の出力結果
出力結果
dynamic_dispatch git:(main) ✗ python main.py
格闘家の攻撃力: 110
なぜ110になるのか?ログを仕込んで解説します。
ログ付きのコード
class PhysicalAttack:
def single_attack(self) -> int:
print("PhysicalAttack#single_attack")
return 30
def double_attack(self) -> int:
print("PhysicalAttack#double_attack")
return self.single_attack() * 2
class Fighter(PhysicalAttack):
def single_attack(self) -> int:
print("Fighter#single_attack")
return super().single_attack() + 20
def double_attack(self) -> int:
print("Fighter#double_attack")
return super().double_attack() + 10
fighter = Fighter()
print("格闘家の攻撃力:", fighter.double_attack()) # 70ではなく110
出力結果
➜ dynamic_dispatch git:(main) ✗ python main.py
Fighter#double_attack
PhysicalAttack#double_attack
Fighter#single_attack
PhysicalAttack#single_attack
格闘家の攻撃力: 110
なにが起きているのか?
注目すべきはここ:
親クラスの double_attack() が呼んでいるのはself.single_attack() であり、PhysicalAttack.single_attack() ではない
つまり、親クラスの中からメソッドを呼んでも、
子クラスがオーバーライドしていればそれが優先される
これが 動的ディスパッチ です。
子クラスの single_attack を消した場合
では、子クラスの single_attack を削除してみると…?
出力は 70 に戻ります。
理由は、
- self.single_attack() が子クラスに存在しない
- よって親クラスのメソッドが使われる
というだけです。
ここでの問題点
本質的な問題は子クラスの実装を追加・削除しただけで親クラスのメソッドの出力結果が変わってしまうことです
。これは極めて密結合で、拡張性が低く、バグが生まれやすい構造です。
例えば新しい職業クラスを追加する度に、
- 親クラスのメソッドの挙動が変わる可能性がある
- 親のコードを理解していないと子クラスを書けない
という地獄が待っています。
どう直すべきか? →移譲(Composition)を使う
継承の代わりに、内部でクラスを持つ移譲を使う方法を見ていきます。
class PhysicalAttack:
def single_attack(self) -> int:
return 30
def double_attack(self) -> int:
return self.single_attack() * 2
class Fighter:
def __init__(self):
self.physical_attack = PhysicalAttack()
# def single_attack(self) -> int:
# print("Fighter#single_attack")
# return self.physical_attack.single_attack() + 20
def double_attack(self) -> int:
print("Fighter#double_attack")
return self.physical_attack.double_attack() + 10
fighter = Fighter()
print("格闘家の攻撃力:", fighter.double_attack()) # 70
この構成のメリット
- Fighter のロジックが PhysicalAttack に依存しない
- PhysicalAttack の仕様変更があっても Fighter 側のコードは安全
- 子クラスが親の「内部挙動」を壊すことがなくなる
- 拡張しやすく、テストしやすい
さいごに
今回はオブジェクト指向の動的ディスパッチについて解説しました。オブジェクト指向の代表的な機能である継承がよく書籍で紹介されますが、初心者や一般人が使うにはあまりのも難易度が高すぎます。
移譲は依存関係をきれいに実装することができるため、使いやすくてよいですね。
過去にDjangoを眺めていたときに内部が継承まみれで書かれていたため、自分のそのクラスをラップして実装しましたが、途中でカオスになり頓挫しました。
少なくとも学生の自分には継承を使いこなすには難しすぎました。
移譲よりも継承で実装するべき所があれば知りたいです。(今の理解では無理に親クラス定義するくらいならインターフェースを定義するだけの方が良い気がしてる。備忘録としてこの無知を晒します。)
今度勉強します。
アドベントカレンダー、引き続き執筆活動頑張ります。
また明日よろしくお願いします。