「インターフェース」と聞くと、難しそう…と感じるかもしれません。
Pythonにはプロトコル(Protocol)という仕組みがあり、Java の interface のような堅苦しさはなく、ふだんの Python コードに自然に扱えます。しかも、型ヒントと一緒に使うことで、実行前にミスを発見できる静的型チェックにも対応できます。
今回は、インターフェースとしてのプロトコルについて解説します。
Pythonにはダックタイピングという考え方があります。 「アヒルみたいに鳴いて歩けば、それはアヒルとして扱っていい」という柔軟な考え方です。
プロトコルは、この柔軟さを保ちながらも、「このオブジェクトは少なくともこのメソッド(や属性)を持っていますよ」という約束を、型ヒントとして明確に表現する仕組みです。
特定のクラスを継承しているかどうかではなく、必要なメソッドや属性が揃っているかで判断します。だから、既存のクラスを作り直さなくても、振る舞いが合っていればプロトコルを満たします。
説明だけではよく分からないと思うので、実際のコードで確認していきましょう。
Python 3.8 以降では、標準の typing モジュールから Protocol
を読み込んで使えます。以下のように「こういうメソッドがあるはず」という“型の約束”を記述します。
from typing import Protocol
class MyProtocol(Protocol):
def method_a(self) -> str:
...
def method_b(self, value: int) -> None:
...
...
は「ここでは実装しません」という意味です。プロトコル自体は実装を持たず、あくまで「こう振る舞うもの」という型の説明書になります。
実際にこのプロトコルを満たすクラスを作るときは、method_a と method_b を定義しなければなりません。
プロトコルのポイントは「明示的に継承しなくても、型が合っていれば自動的に適合する」という点です。 つまり、MyProtocol を引数として受け取る関数に、method_a と method_b を実装している別のクラスを渡せば、それも正しく扱えるようになります。
次の例では、draw()
できるものを「Drawable」というプロトコルで表し、丸や四角がその“インターフェース”に従う様子を見てみます。
from typing import Protocol
# プロトコルの定義
class Drawable(Protocol):
def draw(self) -> None:
...
# プロトコルに従うクラス(継承は不要)
class Circle:
def draw(self) -> None:
print("Drawing a Circle")
class Square:
def draw(self) -> None:
print("Drawing a Square")
# プロトコルを受け取る関数
def render(shape: Drawable) -> None:
shape.draw()
# 実行
circle = Circle()
square = Square()
render(circle) # 出力: Drawing a Circle
render(square) # 出力: Drawing a Square
ここでは Circle
も Square
も Drawable
を継承していません。それでも draw()
メソッドを持っていれば、render()
関数は問題なく動きます。これがプロトコルの良さで、既存コードにも後から型の意味を与えられます。「インターフェースを満たすかどうかは、継承ではなく“構造”(持っているメソッドや属性)で決まる」という点が最大の特徴です。
静的型チェッカー(たとえば mypy)を使うと、実行前に「ちゃんと draw()
を持っているか」を検査できます。では、実行時に isinstance(obj, Drawable)
のように確認したいときは? そんな場合は @runtime_checkable
を使います。
from typing import Protocol, runtime_checkable
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> None:
...
class Circle:
def draw(self) -> None:
print("Drawing a Circle")
class Triangle:
# draw を実装していない
pass
c = Circle()
t = Triangle()
print(isinstance(c, Drawable)) # True
print(isinstance(t, Drawable)) # False
@runtime_checkable
を付けると、isinstance
で「必要な属性が存在するか」を実行時にざっくり確認できます(関数の引数の型や戻り値までは厳密にチェックしません。厳密さは静的型チェッカーの役目です)。
プロトコルはメソッドの有無だけでなく、属性やプロパティの存在も表せます。インスタンスが name
という文字列属性を持つことを約束させたいときは、こんなふうに書けます。
from typing import Protocol
class Named(Protocol):
name: str
def greet(x: Named) -> None:
print(f"Hello, {x.name}!")
class User:
def __init__(self, name: str) -> None:
self.name = name
class Bot:
name = "HelperBot"
greet(User("Alice")) # Hello, Alice!
greet(Bot()) # Hello, HelperBot!
User
も Bot
も Named
を継承していませんが、どちらも name: str
を持っているため、greet()
に渡せます。まさに「必要なものが揃っていれば OK」という考え方です。
「抽象クラス(ABC)と何が違うの?」と感じた方もいるでしょう。抽象クラスは“名目上の型”で、クラスを継承して実装を埋める前提です。対してプロトコルは“構造的な型”で、継承しなくても必要なメソッドや属性が揃っていれば合格です。
既存コードに型の意味を後付けしたい、ライブラリ間のゆるい結合を保ちたい、といった場面ではプロトコルが特に便利です。一方で、実装のテンプレートを配布したい、大枠の共通処理を持たせたい場合は抽象クラスが向いています。
プロトコルは以下のような状況で役に立ちます。
「ルールで縛る」のではなく、「必要十分な約束をはっきり書く」ことで、読みやすさと保守性がぐっと上がります。
ここまで学習した内容をまとめます。
「抽象クラスほど重くはしたくないけれど、インターフェースは明示したい」。そんなときにプロトコルは最適です。次は「継承によるコードの再利用」に進み、オブジェクト指向らしい設計をさらに深めていきましょう。