はじめに
Pythonでポリモーフィズムを実現する方法として、ABC(Abstract Base Classes) または Protocolがあります。個人的には、最近はProtocolを選ぶことの方が多いのですが、ちゃんと比較してみたことがなかったので、調べて比較してみました。
ズバリ、ABCとProtocolは何が違うか?
ざっくり、このような目的・使いどころです。
- ABC (Abstract Base Classes)
- その名の通り、抽象基底クラスを定義するためのもの (メタクラスとして実装されている)
- 抽象メソッドを示すデコレータをメソッドに付け加えることで、継承先にメソッド実装を強制させることができる
- Javaのabstractクラスと似たような概念
- Protocol (Python 3.8以降)
- 静的タイプチェッカーがダックタイピングを認識するためのクラスを定義できる
- typingに実装されている型ヒント機能の一部
- Golangのinterfaceと似たような概念 (異なるところもあります)
それぞれの使い勝手をおさらい
ABC
abc.ABC
を継承することで抽象クラスを作ることができます。(ちなみに、ABCはメタクラスとして
ABCMeta
を指定してくれるヘルパークラスです。)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | from abc import ABC, abstractmethod from typing import Any import uuid class UserRepository(ABC): """リポジトリの抽象クラス""" @abstractmethod def find(self, user_id: uuid.UUID) -> dict[str, Any]: pass @abstractmethod def add(self, user: dict[str, Any]) -> None: pass @abstractmethod def replace(self, user_id: uuid.UUID, user: dict[str, Any]) -> None: pass def add_or_replace(self, user_id: uuid.UUID, user: dict[str, Any]) -> None: if self.find(user_id): self.replace(user_id, user) else: self.add(user) |
@abstractmethod
がついたメソッドは抽象メソッドとなり、継承先クラスでの実装が強制されます。そうでないメソッドは非抽象メソッドとなり、継承先クラスでの実装は強制されませんし、実装しない場合はこの抽象メソッドの実装が実行されます。
ここからは、実装クラスと抽象型を引数のとる関数、そしてその関数の呼び出し時に実装クラスのインスタンスを渡す部分をおさらいします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | class DBUserRepository(UserRepository): """リポジトリの実装クラス""" def find(self, user_id: uuid.UUID) -> dict[str, Any]: return {"id": user_id, "name": "John Doe"} def add(self, user: dict[str, Any]) -> None: # サンプルなので実装は省略 print("add user") def replace(self, user_id: uuid.UUID, user: dict[str, Any]) -> None: # サンプルなので実装は省略 print("replace user") # 抽象型を引数に取る関数 def find_user(user_id: uuid.UUID, repo: UserRepository) -> dict[str, Any]: return repo.find(user_id) if __name__ == "__main__": # 実装クラスのインスタンスを渡す u = find_user(uuid.uuid4(), DBUserRepository()) print(u) |
ちなみに、実装クラスが抽象メソッドの実装を満たしていない場合は、インスタンス生成時にランタイムエラーが発生します。試しに、findメソッドの実装を削除してみます。
1 2 3 4 5 | Traceback (most recent call last): File "study/rye/sample-project/src/sample_project/repository_abc.py", line 51, in <module> u = find_user(uuid.uuid4(), DBUserRepository()) ^^^^^^^^^^^^^^^^^^ TypeError: Can't instantiate abstract class DBUserRepository without an implementation for abstract method 'find' |
Protocol
typing.Protocol
を継承することで継承することでプロトコルを作ることができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | from typing import Any, Protocol import uuid class UserRepository(Protocol): """リポジトリのプロトコル""" def find(self, user_id: uuid.UUID) -> dict[str, Any]: ... def add(self, user: dict[str, Any]) -> None: ... def replace(self, user_id: uuid.UUID, user: dict[str, Any]) -> None: ... def add_or_replace(self, user_id: uuid.UUID, user: dict[str, Any]) -> None: # 継承した場合のみに有効となるデフォルト実装を定義することができる (ABC的な使い方) if self.find(user_id): self.replace(user_id, user) else: self.add(user) |
プロトコルを使った場合でも、ABCの非抽象メソッドのようにデフォルト実装を持たせることができます。ただし、このデフォルト実装が実行されるのはこのプロトコルを継承した場合のみであり、そうでなければ実装クラス自身で実装する必要があります。
以下は、継承せずにプロトコルを実装した例です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | class DBUserRepository: def find(self, user_id: uuid.UUID) -> dict[str, Any]: return {"id": user_id, "name": "John Doe"} def add(self, user: dict[str, Any]) -> None: # サンプルなので実装は省略 print("add user") def replace(self, user_id: uuid.UUID, user: dict[str, Any]) -> None: # サンプルなので実装は省略 print("replace user") def add_or_replace(self, user_id: uuid.UUID, user: dict[str, Any]) -> None: # 継承していないのでUserRepositoryを満たすには実装が必要 print("add or replace user") # プロトコルの型を引数に取る関数 def find_user(user_id: uuid.UUID, repo: UserRepository) -> dict[str, Any]: return repo.find(user_id) if __name__ == "__main__": # 実装クラスのインスタンスを渡す u = find_user(uuid.uuid4(), DBUserRepository()) print(u) |
先ほどと同じように、findメソッドの実装をやめてみると、mypyやpylanceといった静的タイプチェッカーがエラーを出力します。
もっとも、Protocolはタイプヒント機能の一種であるため、実際に実行してみるとインスタンス生成自体はできますが、その後のfindメソッドの呼び出し時に
AttributeError: 'DBUserRepository' object has no attribute 'find'
というランタイムエラーが発生します。
このように、継承をすることなくプロトコルを実装することができ、タックタイピングの嬉しい点である、継承による複雑化を避けることができます。
実装クラスからの継承
プロトコルといっても、結局はクラスなので継承することもできます。以下が継承してみた実装クラスです。
1 2 3 4 5 6 7 8 9 10 11 12 13 | class DBUserRepository(UserRepository): def find(self, user_id: uuid.UUID) -> dict[str, Any]: return {"id": user_id, "name": "John Doe"} def add(self, user: dict[str, Any]) -> None: # サンプルなので実装は省略 print("add user") def replace(self, user_id: uuid.UUID, user: dict[str, Any]) -> None: # サンプルなので実装は省略 print("replace user") # 継承しているのでadd_or_replaceメソッドの実装はなくても良い |
継承すると、以下のようなできるようになります。
- プロトコルのデフォルト実装を使用することができるようになる (ABCのような使い方)
- IDEのジャンプ機能が使えるようになる (プロトコルの各メソッド↔︎実装クラスの各メソッド)
個人的には2つ目が嬉しい点で、継承しなければ、IDEのジャンプ機能でプロトコルの実装クラスを追うことは不可能です。そのため、プロトコルを使う場合でも、継承した方が実際のところは作業しやすいのではないかと思います。
どっちを使うか?
基本的には、冒頭に書いたように以下のような使い分けになると思います。
- クラスの抽象化をしたいのであればABC
- ダックタイピングをしたいのであればProtocol
その上で、基本的にはProtocolを使った方が良いと思いました。その理由は、以下のとおりです。
- 継承が不要であるため、クラスの階層構造が生まれない
- その上で、必要であれば、継承することでABCの非抽象メソッドのようなことをすることもできる
こういった実装がPythonになされている背景には、クラスを持たないプログラミング言語の台頭があると思います。継承を好まない人にもPythonを気持ちよく書けるように、ダックタイピングが取り入れられたのではないかと考えます。
実際、ProtocolのPEPでは、TypeScriptやGolangが引き合いに出されています。
さいごに
Pythonは歴史が長いため、目的を達成するための手段が複数用意されていることがあります。その中でどれが最適なのかを調べると奥が深くて面白いため、今後も記事にしていきたいと思います。