構造的部分型を実現する、PythonのProtocolについて調べました
はじめに
どうも、こんにちは。Belong で Software Engineer として働いている suzu です。
業務の一環で Python を使う機会があったので「ロバスト Python」1を読んでいたところ、Protocol という機能が紹介されていたので詳しく調べてみました。
※ Belong には業務に関連する技術書を、会社書籍として購入できる制度があります!
Protocol とは
Protocol は Python 3.8 から導入された機能で、Structural Subtyping を実現するためのものです。
根底の仕様は、PEP 544 – Protocols: Structural subtyping (static duck typing)
2に基づいています。
from typing import Protocol
class Animal(Protocol):
def talk(self) -> None:
print("Animal talks")
class Dog:
def talk(self) -> None:
print("Bow wow")
def make_animal_talk(animal: Animal) -> None:
animal.talk()
make_animal_talk(Dog()) # Bow wow
Structural Subtyping とは
Structural Subtyping (構造的サブタイピング)は、クラスの継承関係に依存せず、同じメソッドやプロパティを持っているかどうかでサブクラスかどうかを判定する仕組みです。 TypeScript や Go などの言語で見られる Duck Typing に似た概念です。 それ以外の言語で見られる、型の「名前」や「明示的な宣言」によってサブクラス関係を定めるものを、Nominal Subtyping と言います。
また、関連する概念として、型の互換性や等価性の判断を行う文脈で Structural Typing / Nominal Typing という概念があります。
Go のインターフェースとの違い
Go のインターフェースと似ていますが、Go はコンパイル時・実行時の両方でインターフェースをチェックする一方で、Python の Protocol は「静的な型チェックに限定される点・ メソッドだけでなく、プロパティも Protocol として定義可能という点」が大きく異なりそうです。
ABC との違い
Python には、抽象基底クラス (ABC) という機能もありますが、 Protocol は ABC と異なり、Structural Subtyping であるため、明示的な継承を必要としません。 以下のような違いがあります。
typing.Protocol | abc.ABC | |
---|---|---|
方式 | 構造的サブタイピング | 公称的サブタイピング |
関係性 | 構造が一致すれば OK | 明示的に継承する必要がある |
適合方法 | 必要なメソッドを実装するだけ | @abstractmethod を実装し、クラスで継承 |
主な用途 | 静的型チェック | 実行時の型チェック、API の強制 |
Protocol の使い方
定義
Protocol を定義するには、typing
モジュールから Protocol
クラスをインポートし、継承してクラスを定義します。
Protocol として表現したいメソッドやプロパティを定義することで、Protocol を作成できます。
class Animal(Protocol):
name: str # プロパティの定義
def talk(self) -> None: # メソッドの定義
pass
class Dog:
name: str = "Dog"
def talk(self) -> None:
print("Bow wow")
Protocol は、定義したメソッド、プロパティのことを Protocol members と呼びます。 Protocol members が欠けると、そのクラスは Protocol を実装していないとみなされます。
実装
Protocol を実装するには、同じメソッド名と引数を持つクラスを定義するだけで OK です。
from typing import Protocol
class Animal(Protocol):
name: str
def talk(self) -> None:
pass
class Dog:
name: str = "Dog"
def talk(self) -> None:
print("Bow wow")
make_animal_talk(Dog()) # Bow wow
逆に要素が足りないと、そのクラスは Protocol を実装していないとみなされます。
以下に例を示します。
QuietDog
は、メソッド talk
が定義されていないため、Animal
Protocol を実装していないとみなされます。
NamelessDog
は、Protocol members の name
プロパティが欠けているため、Animal
Protocol を実装していないとみなされます。
# NamelessDog doesn't implement Animal.
class NamelessDog:
def talk(self) -> None:
print("Bow wow")
# QuietDog doesn't implement Animal.
class QuietDog:
name: str = "Dog"
def make_animal_talk(animal: Animal) -> None:
animal.talk()
make_animal_talk(NamelessDog()) # Bow wow (But linter make warning.)
make_animal_talk(QuietDog()) # AttributeError: 'QuietDog' object has no attribute 'talk'
この場合、静的解析がどうなるかみてみましょう。
NamelessDog
、QuietDog
は、Animal
Protocol を実装していないため、make_animal_talk
関数に渡そうとすると Linter で警告が出ます。
しかし、実際の実行では少し違います。
NamelessDog
は、talk
メソッドを持っているため動作でき、実行時には問題なく動作します。
一方で、QuietDog
は talk
メソッドを持っていないため、実行時に AttributeError
が発生します。
なので、静的解析上では厳密にチェックされますが、実際の実行では Duck Typing に基づいた動作をとります。
Protocol の継承
Protocol は継承でき、その場合は継承元からデフォルト値を継承します。
class Animal(Protocol):
name: str = "Default Name"
def talk(self) -> None:
pass
class SampleAnimal(Animal):
pass
print(SampleAnimal().name) # Default Name
複数の Protocol の実装
また、実装を満たせば 1 つのクラスに対し、複数の Protocol を実装することも可能です。 下記の例を見てみましょう。
from typing import Protocol
class Talker(Protocol):
name: str
def talk(self) -> None:
pass
class Walker(Protocol):
def walk(self) -> None:
pass
class Dog:
name: str = "Dog"
def talk(self) -> None:
print("Bow wow")
def walk(self) -> None:
print("Dog walks")
def make_talker(talker: Talker) -> None:
talker.talk()
def make_walker(walker: Walker) -> None:
walker.walk()
make_talker(Dog()) # Bow wow
make_walker(Dog()) # Dog walks
Dog
クラスは Talker
のもつ talk
メソッド (及び name
プロパティ) と Walker
のもつ walk
メソッドを実装しています。
したがって、Dog
クラスは Talker
と Walker
の両方の Protocol を実装しており、どちらの Protocol としても利用できます。
複合プロトコル
複数の Protocol を満たす制約を作ることができる、複合プロトコルという機能もあります。 Protocol そのものと、満たしたい Protocol を継承して、1 つの Protocol として定義できます。
from typing import Protocol
class Talker(Protocol):
name: str
def talk(self) -> None:
pass
class Walker(Protocol):
name: str
def walk(self) -> None:
pass
class TalkerWalker(Talker, Walker, Protocol):
pass
@abstractmethod について
Protocol では、@abstractmethod
の記述がなくても実装がないものは抽象メソッドとして扱われますが、可読性を高めるために、@abstractmethod
を使用することが推奨されます。
class Animal(Protocol):
name: str
@abstractmethod
def talk(self) -> None:
raise NotImplementedError
@runtime_checkable
@runtime_checkable
3を定義に付けることで、Protocol を実行時にチェックできます。
注釈 runtime_checkable() will check only the presence of the required methods or attributes, not their type signatures or types. For example, ssl.SSLObject is a class, therefore it passes an issubclass() check against Callable. However, the ssl.SSLObject.init method exists only to raise a TypeError with a more informative message, therefore making it impossible to call (instantiate) ssl.SSLObject.
ただし、メソッド名の一致しか確認しないとのことで、シグネチャのチェックには使えないようですね、残念.....
Generic protocols
Protocol は、ジェネリックに定義する4ことも可能です。
from typing import Protocol, TypeVar
T = TypeVar('T')
class Container(Protocol[T]):
def add(self, item: T) -> None:
pass
ただし、Python 3.12 で導入された、Protocol, Generic[T, S, ...]
などは使用できません。
まとめ
最後に Protocol についてのまとめになります。
メリットとしては、
- 明示的な継承を行わずに、インターフェースを実現できる
- Duck Typing の機構を維持しながら、mypy 等で静的な型チェックが可能
デメリットとしては、
- 明示的にどの Protocol を実装しているかがわからないため、コードの理解が難しくなることがある
- 実装させる対象を人為的に規制できない
- 静的解析のサポートが主であり、実行時エラーは基本起きないので、実装が間違っていても気づきにくい
といった点が挙げられるので、Linter を利用する前提で、Duck Typing を行いたい場合・柔軟に複数のサブタイプとして扱いたい場合などで使用するのが良いように思いました。
また、利用シーンとして以下が考えられるのではないかと思いました。
- クラスごとの書き換えが必要な、大きなリファクタを行う際に、ABC から Protocol に置き換え、変更がしやすい状態を作る。
- Interface の変更が頻繁に起こるケースや多重継承が必要なケースでは、あらかじめ ABC で定義せず、柔軟に対応させやすい Protocol を使う。
Protocol の仕様については、紹介しきれなかったものもあるので、興味のある方は公式ドキュメント5を読んでいただければと思います。
弊社 Belong では一緒に働く仲間を募集しています。
Python を使った課題解決や、Go でのサーバーサイド開発に興味のある方など、
エンジニアリングチーム紹介ページをご覧いただけると幸いです。