まえがき
今回はポリモーフィズム(多態性)について扱う。
ポリモーフィズム(多態性)の基本
ポリモーフィズム:同じ名前のメソッドで異なる挙動を実現する仕組み
以下に具体的な例を示す。Triangle, RectangleクラスはいずれもFigureクラスを継承しており、それぞれ同名のget_areaメソッドを定義している。
form typing import override
class Figure():
# width(幅)、height(高さ)を準備
def __init__(self, width: float, heigth: float) -> None:
self.width = width
self.height = height
# 面積を取得(中身はダミー)
def get_area(self) -> float:
return 0.0
class Triangle(Figure):
# 三角形の面積を求めるためのget_areaメソッドを定義
@override
def get_area(self) -> float:
return self.width * self.height / 2
class Rectangle(Figure):
# 四角形の面積を求めるためのget_areaメソッドを定義
@override
def get_area(self) -> float:
return self.width * self.height
if __name__ == '__main__'
t = Triangle(10, 15)
r = Rectangle(10, 15)
print(t.get_area()) # 75.0
print(r.get_area()) # 150
get_areaメソッドは、図形の面積を求めるためのメソッド。上の例では、Figureクラスを継承する2個の派生クラスTriangle/Rectangleで同名のget_areaメソッドをオーバーライドし、それぞれ三角形と四角形の面積を求めるようにしている。
このコードのポイントは、複数のクラスで同じ名前のメソッドを定義しているという点(これがポリモーフィズム)。
→ 同じ目的の機能を呼び出すために異なる名前/命令を覚えなくてもよいという点(メリット)。
ただし、上記程度の内容であれば、ポリモーフィズムは不要ともいえる。なぜなら、オーバーライドの機能のみで、最低限のポリモーフィズムの実現は可能。極論、同名メソッドさえ定義されていれば、Triangle/Rectangleクラスを独立したクラスとして定義してもかまわない(継承すら不要)。
しかし、この状態ではTriangle/Rectangleクラスがget_areaメソッドを実装することを保証できない。基底クラスFigureは、派生クラスがget_areaメソッドをオーバーライドすることを期待している(コメントなどでもその意図を表明することは可能だが、オーバーライドを強制するものではない)。
ここで活用するべきなのが抽象メソッド。
抽象メソッド
抽象メソッド:それ自体は機能を持たない空のメソッド
→ 派生クラスによって外から機能を与える。
抽象クラス:抽象メソッドを含んだクラス
→ 抽象クラスを継承したクラスは、すべての抽象メソッドをオーバーライドしなければならない(オーバーライドしない場合は、自身も抽象クラスとして、更に派生クラスとしてオーバーライドしてもらわなければならない)
全ての抽象メソッドをオーバーライドしていなければ、派生クラスはそもそもインスタンス化することすら出来ない(もちろん、抽象クラスそのものをインスタンス化することも禁止)。抽象メソッドによって、特定のメソッドが派生クラスでオーバーライドされることを保証される。
from typing import override
from abc import ABC, abstractmethod
class Figure(ABC):
# 幅と高さを準備
def __init__(self, width: float, height: float) -> None:
self.width = width
self.height = height
# 面積を取得する抽象メソッド
@abstractmethod
def get_area(self) -> float:
"""サブクラスで必ず実装すべき抽象メソッド"""
pass
class Triangle(Figure):
# 三角形の面積を求めるためのget_areaメソッドを定義
@override
def get_area(self) -> float:
return self.width * self.height / 2
class Rectangle(Figure):
# 四角形の面積を求めるためのget_areaメソッドを定義
@override
def get_area(self) -> float:
return self.width * self.height
if __name__ == '__main__':
t = Triangle(10, 15)
r = Rectangle(10, 15)
print(t.get_area()) # 75.0
print(r.get_area()) # 150.0
抽象基底クラス/抽象メソッドを定義するには、
・abcモジュールのABCクラスを継承する
・対象のメソッドを@abstractmethodデコレ―タで修飾する
抽象メソッドは派生クラスでのオーバーライドを想定しているので、基底クラスでは空のブロックとしている(pass)
また、この状態で、派生クラスTriangle/Rectangleからget_areaメソッドを取り除くと、TypeErrorを吐く。
isinstance関数
ポリモーフィズムの性質を利用するうえで、オブジェクトが実装している機能(抽象クラス)を確認することは重要。
isinstance関数:引数objの型が、引数clazzのインスタンス、またはclazz派生クラスのインスタンスである場合にTrueを返す。
from typing import override
from abc import ABC, abstractmethod
class Figure(ABC):
# 幅と高さを準備
def __init__(self, width: float, height: float) -> None:
self.width = width
self.height = height
# 面積を取得する抽象メソッド
@abstractmethod
def get_area(self) -> float:
"""サブクラスで必ず実装すべき抽象メソッド"""
pass
class Triangle(Figure):
# 三角形の面積を求めるためのget_areaメソッドを定義
@override
def get_area(self) -> float:
return self.width * self.height / 2
class Rectangle(Figure):
# 四角形の面積を求めるためのget_areaメソッドを定義
@override
def get_area(self) -> float:
return self.width * self.height
if __name__ == '__main__':
# 複数の図形インスタンスをリストにまとめる
figs = [Triangle(10, 15), Rectangle(10, 15), Triangle(5, 1)]
# for文で順に取り出して判定・処理
for f in figs:
if isinstance(f, Triangle):
print(f"Triangle: width={f.width}, height={f.height}, area={f.get_area()}")
elif isinstance(f, Rectangle):
print(f"Rectangle: width={f.width}, height={f.height}, area={f.get_area()}")
else:
print(f"Unknown Figure: {type(f)}")
Triangle: width=10, height=15, area=75.0
Rectangle: width=10, height=15, area=150.0
Triangle: width=5, height=1, area=2.5
for文以下の内容で、リストfigsから取り出した内容が、Figureクラスを継承しているかを確認している。Figure派生クラスであれば、get_areaメソッドを必ず実装しているはずなので、これを安全に取り出せる。
このように異なる種類のインスタンスが混在している場合でも、まとめて処理できるのがポリモーフィズムのメリットである。例えば、将来新しいDiamondクラスが追加されたとしても、呼び出しコードが影響を受けることはない。
ダックタイピング
ダックタイピング:「もしそれがアヒルのように歩き、アヒルのように鳴くなら、それはアヒルに違いない」
(If it walks like a duck and quacks like a duck, it must be a duck.)
→ オブジェクトの「型そのもの」ではなく、「持っているメソッドや属性」に基づいて扱うという考え方。
JavaやC++:オブジェクトがどのクラスを継承しているかを重要視し、型宣言を明示的に行い、型を確認してから処理する。
Python:クラス階層や警鐘を気にせずに、必要なメソッドが実装されていれば、そのオブジェクトを利用できる。
→ Pythonの方が柔軟である(が、同時に弱点でもある)
まずは簡単な例。
class Duck:
def quack(self):
print("Quack!")
class Person:
def quack(self):
print("I can also quack!")
def make_it_quack(obj):
# 型は問わず「quackメソッドを持っていればOK」
obj.quack()
d = Duck()
p = Person()
make_it_quack(d) # Quack!
make_it_quack(p) # I can also quack!
Figureクラスについて考えてみる。
from typing import override
from abc import ABC, abstractmethod
class Figure(ABC):
# 幅と高さを準備
def __init__(self, width: float, height: float) -> None:
self.width = width
self.height = height
# 面積を取得する抽象メソッド
@abstractmethod
def get_area(self) -> float:
"""サブクラスで必ず実装すべき抽象メソッド"""
pass
class Triangle(Figure):
# 三角形の面積を求めるためのget_areaメソッドを定義
@override
def get_area(self) -> float:
return self.width * self.height / 2
class Rectangle(Figure):
# 四角形の面積を求めるためのget_areaメソッドを定義
@override
def get_area(self) -> float:
return self.width * self.height
class Japan:
def get_area(self) -> float:
return 378000
# Figure型の値を受け取り、面積を算出/表示するための関数
def show_area(figure: Figure) -> None:
print(f'{figure.__class__.__name__}の面積は{figure.get_area()}です。')
if __name__ == '__main__':
show_area(Triangle(10, 15)) # Triangleの面積は75.0です。
show_area(Rectangle(10, 15)) # Rectangleの面積は150です。
show_area(Japan()) # Japanの面積は378000です。
実行はされるものの、show_area(Japan())では、型違反のアラートが発生する。
→ この例であれば、Japan型がFigure型を継承するように改めることで警告を抑制できるが、望ましくはない。JapanとFigureはis-aの関係にないからである。
これをダックタイピングの原理に従って書き直すと以下。
from typing import Protocol, runtime_checkable, override
# 「面積を計算できる」ものを表すインターフェイス的役割
@runtime_checkable
class Areable(Protocol):
def get_area(self) -> float: ...
class Triangle:
def __init__(self, width: float, height: float) -> None:
self.width = width
self.height = height
@override
def get_area(self) -> float:
return self.width * self.height / 2
class Rectangle:
def __init__(self, width: float, height: float) -> None:
self.width = width
self.height = height
@override
def get_area(self) -> float:
return self.width * self.height
class Japan:
def get_area(self) -> float:
return 378000
# 「Areableなオブジェクト」なら受け入れる
def show_area(figure: Areable) -> None:
print(f'{figure.__class__.__name__}の面積は{figure.get_area()}です。')
if __name__ == '__main__':
show_area(Triangle(10, 15)) # Triangleの面積は75.0です。
show_area(Rectangle(10, 15)) # Rectangleの面積は150です。
show_area(Japan()) # Japanの面積は378000です。
アヒルのように歩く(鳴く)ことを表すのが、Protocolの役割。Protocol型を継承して、空のメソッドを定義すればよい。これで、get_areaメソッドを持つAreableプロトコルを定義したことになる。
あとは、show_area関数の引数型をAreableに変更すればよい。Japan/Triangle/Rectangleに特別な関連付けがない点も注目。
このように、ダックタイピングを用いれば、Pyhtonの動的型付けの性質にもなじみやすく、シンプルにポリモーフィズムを実装できる。
厳密な型体系を表現したいなら、継承を用いるべきであるし、メソッドの有無のミニ関心がある場合は、シンプルなダックタイピングが好まれる。
参考文献
[1] 独習Python (2020, 山田祥寛, 翔泳社)
[2] Pythonクイックリファレンス 第4版(2024, Alex, O’Reilly Japan)