この記事は Zenn とのクロスポストです。
はじめに
この記事では、Python の typing.Protocol の使い所を思いつく限り紹介します。
Protocol は Python 3.8 で導入された型ヒントの一つで、構造的部分型付け (Structural Subtyping) をサポートしています。
設計する時にインターフェースを宣言することで実装同士が疎結合になり、変更に強いコードを書くことができます。
Python ではabc.ABCを使って抽象クラスを使う方法と、今回紹介するtyping.Protocolを使う方法がありますが、個人的には Protocol を使う方が好きなのでこちらを紹介します。
想定読者:
- 未来の自分
- Python で型ヒントを使っている人
- Python 書いててインターフェースに興味が出てきた人
- 変更に強いコードを書きたい人
今回は思いついた以下5つのパターンを紹介します。
- 基本的な使い方: Protocol を使ってインターフェースを宣言し、抽象に依存するコードを書くことで疎結合なコードを書ける。
- 返り値の型宣言: Protocol を返り値の型として使うことで、返り値の型に依存しないコードを書くことを促せる
- 複数の Protocol を組み合わせる: 複数の Protocol を組み合わせることで、単一責任原則に基づいたインターフェースを設計できる
- 関数のシグネチャの宣言: Protocol を使って名前付き引数を使った関数のシグネチャを宣言できる
- Generic な Protocol: Generic な Protocol を定義することで、型パラメータを使った柔軟なインターフェース宣言が可能になる
パターン 1: 基本的な使い方
パターン 1-1: Protocol の基本
インターフェースを定義するために Protocol を使用します。
import numpy as np
import numpy.typing as npt
from typing import Protocol
class Estimator(Protocol):
def estimate(self, image: npt.NDArray[np.float32]) -> float:
...
この Protocol を実装するクラスを作成します。
Protocol は構造的部分型付けを実現するため、宣言を満たす実装で継承する必要はありません。
自分は対応関係がわかりやすさを重視して Protocol で宣言したインターフェースを継承して実装することにしています。
class SimpleEstimator(Estimator):
def estimate(self, image: npt.NDArray[np.float32]) -> float:
return float(np.mean(image))
以上です。
これの何が嬉しいかを説明します。
わかってる人には当たり前すぎる話だと思うので次のセクションまで読み飛ばしてフーンと思ってもらえれば幸いです。
いわゆる、「抽象に依存する」というやつです。
これによって変更に対して強いコードを書くことができ、テストも書きやすくなります。
以下のような SimpleEstimator と image の numpy 配列をとり推論するだけの簡単な関数 process_image を考えます。
import numpy as np
import numpy.typing as npt
from typing import Protocol
class Estimator(Protocol):
def estimate(self, image: npt.NDArray[np.float32]) -> float:
...
class SimpleEstimator(Estimator):
def estimate(self, image: npt.NDArray[np.float32]) -> float:
return float(np.mean(image))
def process_image(image: npt.NDArray[np.float32], estimator: Estimator) -> float:
# 画像を前処理する
processed_image = image / 255.0
# 推定を行う
result = estimator.estimate(processed_image)
return result
estimator = SimpleEstimator()
image = np.random.rand(256, 256).astype(np.float32) * 255
result = process_image(image, estimator)
この関数は、 SimpleEstimator クラスと画像の配列を引数に取り推論を行い結果を返します。
Python は動的型付け言語なので、SimpleEstimator 以外の estimate メソッドを実装したオブジェクトのインスタンスを渡しても動作はします。
しかし、より本番環境に提供するコードなどで堅牢なコードを書くためには実行前に型チェックを行いたいです。
そういう場合に、引数の estimator に Protocol で宣言しておいた Estimator 型を指定しておくことで、mypy/pyright などの静的型チェッカーで型チェックが可能になります。
正確にいうと、Protocol は暗黙的な部分型付けなので、 estimate メソッドを持つオブジェクトであればなんでも受け入れられます。
以上のように、抽象に依存するように書いておくことで、実装が Estimator の具体的な実装に依存しなくなり疎結合なコードになり変更に強くなり世界が平和になります。
つまり、Hunter x Hunter でいうところの制約と誓約です。使えるメソッドの数を Protocol で宣言したインターフェースに絞ることで、実装同士の依存関係が弱くなり変更に対して強くなります。
これは class などで継承によって DRY を実現してる箇所を移譲->合成という形で書き換えていく際に有効で、これにより可読性や保守性が向上します。
パターン 1-2: 継承から移譲へ
簡単な継承で実装されたモデルクラスを考えます。
from abc import ABC, abstractmethod
import numpy as np
import numpy.typing as npt
import torch.nn as nn
class SugoiBaseModel(ABC):
def __init__(self, net: nn.Module) -> None:
self._net = net
@abstractmethod
def predict(self, image: npt.NDArray[np.float32]) -> float:
raise NotImplementedError
def train(self, data: npt.NDArray[np.float32], labels: npt.NDArray[np.float32]) -> None:
# すごい学習処理
...
class TyottoKaetaModel(SugoiBaseModel):
def __init__(self, net: nn.Module) -> None:
super().__init__(net)
def predict(self, image: npt.NDArray[np.float32]) -> float:
return self._net(image).item()
TyottoKaetaModel だけを見ると predict メソッドのみ実装していて、train メソッドは親クラスに依存していて親クラスの実装を見ないとよくわかりません。
簡単な実装のため、掴みにくいかも知れません。この程度くらいの実装なら良いのですが、これが何段にも継承してそれぞれで実装を増やしていたり、メソッドの数が多かったりすると頭の中に継承関係やメソッドがどういう実装されてるか把握してないと実装や改修が難しくなります。
さらにはそれを追うためにあっちこっちファイルを開いて読んでいく必要があったり Override してるのかどうかもメソッドを全チェックしないとわからなかったりします。
もちろん書く量を減らせるのでうまく書ければ気持ちがいいのでしょう。ただ読む方は辛いです。特に頭の中に知識が載ってない状態の時は。。
そこで以下のように移譲にしてみるコードを考えます
import numpy as np
import numpy.typing as npt
import torch.nn as nn
class TyottoKaetaTrainer:
def train(
self, model: nn.Module, data: npt.NDArray[np.float32], labels: npt.NDArray[np.float32]
) -> None:
# ちょっと変わった学習処理
...
class TyottoKaetaModel:
def __init__(self, net: nn.Module) -> None:
self._trainer = TyottoKaetaTrainer()
self._net = net
def predict(self, image: npt.NDArray[np.float32]) -> float:
return self._net(image).item()
def train(self, data: npt.NDArray[np.float32], labels: npt.NDArray[np.float32]) -> None:
self._trainer.train(self._net, data, labels)
このように、処理を別クラスに移譲することで、継承関係を追う必要がなくなり、コードの可読性や保守性が向上します。
さらに複数の Trainer 実装を使いたくなり、かつ Model クラスの初期化時に使いたい Trainer を選びたくなったとします。
そういう時は以下のように、合成を使って実装することで柔軟に切り替えることができます。そしてここで Protocol を使ってインターフェースを宣言しておくことで、実行前に CLI やエディタの補完で型チェックができるようになります。
import torch.nn as nn
import numpy.typing as npt
from typing import Protocol
class Trainer(Protocol):
def train(
self, model: nn.Module, data: npt.NDArray[np.float32], labels: npt.NDArray[np.float32]
) -> None: ...
class RTDETRV2Trainer(Trainer):
def train(
self, model: nn.Module, data: npt.NDArray[np.float32], labels: npt.NDArray[np.float32]
) -> None:
...
class UntomoSunntomoIwanaiTrainer(Trainer):
def train(
self, model: nn.Module, data: npt.NDArray[np.float32], labels: npt.NDArray[np.float32]
) -> None:
# うんともすんとも言わない学習処理
...
class LeaksityatteruTrainer(Trainer):
def train(
self, model: nn.Module, data: npt.NDArray[np.float32], labels: npt.NDArray[np.float32]
) -> None:
# メモリリークしまくる学習処理
...
class TyottoKaetaModel:
def __init__(self, net: nn.Module, trainer: Trainer) -> None:
self._trainer = trainer
self._net = net
def predict(self, image: npt.NDArray[np.float32]) -> float:
return self._net(image).item()
def train(self, data: npt.NDArray[np.float32], labels: npt.NDArray[np.float32]) -> None:
self._trainer.train(self._net, data, labels)
# data, labels, my_net の定義は省略
data = ...
labels = ...
my_net = ...
model = TyottoKaetaModel(net=my_net, trainer=RTDETRV2Trainer())
# model = TyottoKaetaModel(net=my_net, trainer=UntomoSunntomoIwanaiTrainer()) # OK
# model = TyottoKaetaModel(net=my_net, trainer=LeaksityatteruTrainer()) # OK
model.train(data, labels)
このようにすることで、Trainer の実装を簡単に切り替えることができ、かつ型チェックも効くので安心してコードを書けます。(強制できないので元から簡単に切り替えられるんですが)
ここまで Protocol を使って、インターフェースを宣言することの基本的な部分を説明しました。
Protocol を使って説明してきましたが、もちろん abc.ABC を使って抽象クラスを定義しても同様の効果が得られます。
というよりは abc.ABC を使う方は実行時にエラーを出せたり元々使われていたのでそちらを使う方がデファクトスタンダードだと思います。
一方で、abc.ABCでデフォルト実装を持たせることが継承で override で DRY に実装を誘発して可読性を失わせると個人的に思ってるので Protocol を使うことを好んでいます。
熱い風評被害の可能性否めませんが。。
エディタのチェックで赤くなってることを無視する人には効かないのが欠点です。。CI で mypy/pyright などによる型チェックを回すなどして対策しましょう。
Protocol は構造の部分型付け (Structural Subtyping) をサポートしているので、明示的に Protocol を継承しなくても、同じメソッドシグネチャを持つクラスは Protocol を実装しているとみなされます。
パターン 1-3: 移譲による テスト容易性の向上
Protocol を使った インターフェース宣言はテスト容易性の向上にも寄与します。
例えば、先ほどの Estimator Protocol を使ったコードをテストする場合を考えます。
先ほどのコードを再掲します。
import numpy as np
import numpy.typing as npt
from typing import Protocol
class Estimator(Protocol):
def estimate(self, image: npt.NDArray[np.float32]) -> float:
...
def process_image(image: npt.NDArray[np.float32], estimator: Estimator) -> float:
# 画像を前処理する
processed_image = image / 255.0
# 推定を行う
result = estimator.estimate(processed_image)
return result
このコードの estimator 以外の部分をテストしたいとします。この時に estimator をモックに差し替えることで、process_image 関数のロジックをテストすることができます。
ここでは pytest とそのプラグイン pytest-mock を使った時の例を示します。
from pytest_mock import MockerFixture
def test_process_image_should_return_correct_value(mocker: MockerFixture) -> None:
mock_estimator = mocker.Mock(spec=Estimator)
mock_estimator.estimate.return_value = 42.0
# テスト用の画像データを作成
test_image = np.array([[0, 255], [128, 64]], dtype=np.float32)
# process_image 関数を呼び出し, 内部でモックした estimator.estimate が使われる
result = process_image(test_image, mock_estimator)
# 結果を検証
# モックが返す固定値と一致することを確認
mock_estimator.estimate.assert_called_once_with(test_image / 255.0)
assert result == 42.0, f"E: 42, A: {result}. Please check process_image implementation."
このように差し替えたい処理を引数にとることでモックに差し替えやすくなることで、テスト容易性が向上します。
estimator.estimate の実装部分は別途テストすれば良いわけです。
移譲による合成によってテスト容易性が向上する点もインターフェース宣言の利点の一つです。
先ほどの合成の例では、Trainer を呼び出すだけだったが以下のような移譲による合成の例を考えます。
from typing import Protocol
class Preprocessor(Protocol):
def preprocess(self, image: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]:
...
class SimplePreprocessor:
def preprocess(self, image: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]:
# 簡単な前処理の実装
return image / 255.0
class Estimator(Protocol):
def estimate(self, image: npt.NDArray[np.float32]) -> float:
...
class Processor:
def __init__(self, preprocessor: Preprocessor, estimator: Estimator) -> None:
self._preprocessor = preprocessor
self._estimator = estimator
def _postprocess(self, result: float) -> float:
# 簡単な後処理の実装
return result + 15.0
def process(self, image: npt.NDArray[np.float32]) -> float:
processed_image = self._preprocessor.preprocess(image)
result = self._estimator.estimate(processed_image)
result = self._postprocess(result)
# S3への書き込みや加工といったなんやかんや処理
return result
この Processor クラスの process メソッドをテストしたい場合、Preprocessor や Estimator をモックに差し替えることで、process メソッドのロジックをテストすることができます。
import pytest
from pytest_mock import MockerFixture
def test_Processor_process_should_return_correct_value(mocker: MockerFixture) -> None:
mock_preprocessor = mocker.Mock(spec=Preprocessor)
mock_estimator = mocker.Mock(spec=Estimator)
mock_preprocessor.preprocess.return_value = np.array([[0.0, 1.0], [0.5, 0.25]], dtype=np.float32)
mock_estimator.estimate.return_value = 35.0
processor = Processor(
preprocessor=mock_preprocessor,
estimator=mock_estimator,
)
test_image = np.array([[0, 255], [128, 64]], dtype=np.float32)
result = processor.process(test_image)
mock_preprocessor.preprocess.assert_called_once_with(test_image)
mock_estimator.estimate.assert_called_once_with(mock_preprocessor.preprocess.return_value)
assert result == pytest.approx(50.0), f"E: 50.0, A: {result}. Please check Processor.process implementation."
このように移譲による合成を使うことで、テスト容易性が向上します。
これを可能にし、保守性を向上させ開発体験を向上させることができるのが Protocol を使ったインターフェース宣言の利点の一つです。
もちろん abc.ABC を使って抽象クラスを定義しても同様の効果が得られます。
パターン 2: Protocol を使った返り値の宣言
インターフェースの宣言で嬉しいことは抽象に依存させることで実装同士を疎結合にでき、修正が伝播しにくい状況を作ることです。
基本的な使い方でほぼ言いたいことは言ってるのですが、返り値の型宣言にも Protocol を使うことで同様の効果が得られます。
例えば以下のような Estimator を考えます。
import numpy as np
import numpy.typing as npt
from typing import Protocol
class Estimator(Protocol):
def estimate(self, image: npt.NDArray[np.float32]) -> float:
...
class SimpleEstimator(Estimator):
def estimate(self, image: npt.NDArray[np.float32]) -> float:
return float(np.mean(image))
class ConstantEstimator(Estimator):
def estimate(self, image: npt.NDArray[np.float32]) -> float:
return 0.0
ここで、Estimator を生成するファクトリ関数を考えます。
シグネチャの書き方を2通り紹介します。
パターン 2-1: 具体的なクラスで Union などを使って返す型を列挙する
from typing import Literal
def create_estimator(estimator_type: Literal["simple", "constant"]) -> SimpleEstimator | ConstantEstimator:
"""Estimator を生成するファクトリ関数
Args:
estimator_type: Estimator の種類
Returns:
Estimator のインスタンス
Raises:
ValueError: 不明な Estimator タイプが指定された場合
"""
if estimator_type == "simple":
return SimpleEstimator()
elif estimator_type == "constant":
return ConstantEstimator()
else:
raise ValueError(f"Unknown estimator type: {estimator_type}")
このパターンの場合、このファクトリ関数を呼び出す側では返り値で返ってくる型がどういう候補があるのかを知ることができます。
これにより、返り値の型に応じた処理を呼び出す側で行うことができます。
Python では呼び出し側に処理を強制することまではできませんが、型チェッカーを使うことで、呼び出し側で型に応じた処理を行うことができます。
estimator = create_estimator("simple")
if isinstance(estimator, SimpleEstimator):
# SimpleEstimator 固有の処理
...
elif isinstance(estimator, ConstantEstimator):
# ConstantEstimator 固有の処理
...
良いところは、呼び出し側で型に応じた処理を行うことができる点ですが、欠点もありファクトリ関数が返す型が増えるたびに、返り値の型宣言を修正する必要があります。
呼び出し側に処理を強制させる強い気持ちで書きましょう。
余談ですが、overload デコレータを使って関数のシグネチャを複数定義する方法もあります。
以下のように書くことで、引数に応じて、関数のシグネチャを複数定義することができます。型チェックが正確になるので開発者体験が向上します。コード量も増えます。
from typing import Literal, overload
@overload
def create_estimator(estimator_type: Literal["simple"]) -> SimpleEstimator: ...
@overload
def create_estimator(estimator_type: Literal["constant"]) -> ConstantEstimator: ...
def create_estimator(estimator_type: Literal["simple", "constant"]) -> Estimator:
if estimator_type == "simple":
return SimpleEstimator()
elif estimator_type == "constant":
return ConstantEstimator()
else:
raise ValueError(f"Unknown estimator type: {estimator_type}")
こんな感じで推論されます。より親切ですね。
パターン 2-2: Protocol を返り値の型として使う
def create_estimator(estimator_type: Literal["simple", "constant"]) -> Estimator:
"""Estimator を生成するファクトリ関数
Args:
estimator_type: Estimator の種類
Returns:
Estimator のインスタンス
Raises:
ValueError: 不明な Estimator タイプが指定された場合
"""
if estimator_type == "simple":
return SimpleEstimator()
elif estimator_type == "constant":
return ConstantEstimator()
else:
raise ValueError(f"Unknown estimator type: {estimator_type}")
次は実装は同じですが、返り値の型宣言を Protocol の Estimator にしています。
この場合、呼び出し側では返り値の型が Estimator であることしかわかりません。 (外からは)
呼び出し側は、この関数の返り値として出てくるインスタンスの具体的な型に依存しないコードを書く必要があります。(Python では強制できませんが)
つまり、この場合は Estimator インターフェースに依存したコードを書くことになるので使えるメソッドは estimate メソッドだけになります。
estimator = create_estimator("simple")
# Estimator インターフェースに依存したコードを書く
result = estimator.estimate(image)
このパターンの良いところは、ファクトリ関数が返す型が増えたとしても、返り値の型宣言を修正する必要がない点です。
さらに呼び出し側には具体的な型に依存しないコードを書くことを促すことができます。これにより疎結合なコードを書くことができます。(強制はできませんが)
ここで先ほどのように Runtime で Estimator 型のインスタンスかチェックしたい時は runtime_checkable デコレータを使うことで isinstance などで実行時チェックが可能になります。
from typing import runtime_checkable
@runtime_checkable
class Estimator(Protocol):
def estimate(self, image: npt.NDArray[np.float32]) -> float: ...
class SimpleEstimator(Estimator):
def estimate(self, image: npt.NDArray[np.float32]) -> float:
return float(np.mean(image))
class ConstantEstimator(Estimator):
def estimate(self, image: npt.NDArray[np.float32]) -> float:
return 0.0
def create_estimator(estimator_type: Literal["simple", "constant"]) -> Estimator:
"""Estimator を生成するファクトリ関数
Args:
estimator_type: Estimator の種類
Returns:
Estimator のインスタンス
Raises:
ValueError: 不明な Estimator タイプが指定された場合
"""
if estimator_type == "simple":
return SimpleEstimator()
elif estimator_type == "constant":
return ConstantEstimator()
else:
raise ValueError(f"Unknown estimator type: {estimator_type}")
# image の定義などは省略
estimator = create_estimator("simple")
if isinstance(estimator, Estimator):
# Estimator インターフェースに依存した処理
result = estimator.estimate(image)
ただし、属性のチェックと callable かのチェックしかしてないため、厳密なチェックには向いてないので注意してください。
つまり、Protocol で宣言したメソッドを持っていれば厳密なサブタイプではなくても OK になります。
class FakeEstimator:
def estimate(self, image: npt.NDArray[np.float32]) -> float:
return 42.0
if isinstance(FakeEstimator(), Estimator):
print("FakeEstimator is considered as Estimator")
この辺りは abc.ABC を使う方が厳密にチェックできるので、厳密にチェックしたい場合は abc.ABC を使うことを検討してください。
外部に強制する API 層などの型を Runtime でも厳密に縛りたい時は abc.ABC を使う方が良いのではないかと思います。
パターン 3: 複数の Protocol を組み合わせる
Protocol によるインターフェース宣言は複数の Protocol を組み合わせることもできます。
というよりは、インターフェースというのは、こういうメソッドを持つという一種の契約を宣言するものなのでインターフェースを一度決めた後に拡張することは慎重に行うべきです。
あるインターフェースの仕様が決まった後に、拡張したくなったら本当に拡張が必要かどうかを考えてから拡張を行うべきですが、複数の Protocol を組み合わせることで柔軟にインターフェースを拡張することができます。
from typing import Protocol
import numpy as np
import numpy.typing as npt
import torch.nn as nn
from torch.optim.optimizer import Optimizer
class Estimator(Protocol):
def estimate(self, image: npt.NDArray[np.float32]) -> float: ...
class Trainer(Protocol):
def train(
self, data: npt.NDArray[np.float32], labels: npt.NDArray[np.float32]
) -> None: ...
# 一番左でProtocolを継承しないように注意, 一番右に書くかこの場合は書かなくてもいい
# こう書くことで、Model -> Estimator -> Trainer -> Protocol -> object の順で解決される
# 一番左にProtocolを継承するとEstimatorもTrainerもProtocolを継承してることからMROで衝突してしまう
# Protocolのメソッド解決の順番を決めることができなくなるのでエラーになる
class Model(Estimator, Trainer, Protocol):
...
class SimpleModelImpl(Model):
_net: nn.Module
_optimizer: Optimizer
def estimate(self, image: npt.NDArray[np.float32]) -> float:
return self._net(image).item()
def train(
self, data: npt.NDArray[np.float32], labels: npt.NDArray[np.float32]
) -> None:
pred = self._net(data)
loss = ((pred - labels) ** 2).mean()
loss.backward()
self._optimizer.step()
self._optimizer.zero_grad()
このように、複数の Protocol を組み合わせることで、単一責任原則を保ちながら、複数の機能を持つクラスを設計することができます。
こうしておくと、例えば推定だけできればいい関数には Estimator Protocol を引数に取るようにし、学習もできるクラスが必要な関数には Model Protocol を引数に取るようにすることができます。
def run_estimation(image: npt.NDArray[np.float32], estimator: Estimator) -> float:
return estimator.estimate(image)
def train_and_evaluate(
train_data: npt.NDArray[np.float32],
valid_data: npt.NDArray[np.float32],
labels: npt.NDArray[np.float32],
model: Model,
) -> None:
model.train(train_data, labels)
# 評価処理
for image in valid_data:
result = model.estimate(image)
...
パターン 4: 関数のシグネチャの宣言
さて、Protocol は関数のシグネチャの宣言にも使うことができます。(正確には Callable Object のシグネチャの宣言だと思いますが)
Python には、collections.abc.Callable という型がありますのでこれを使うことで関数のシグネチャを縛り高階関数のシグネチャのチェックなどを行うことができます。
collections.abc.Callable を使って関数のシグネチャを宣言する例は以下のようになります。
import numpy as np
import numpy.typing as npt
from collections.abc import Callable
FnType = Callable[[npt.NDArray[np.float32]], float]
def apply_function(
image: npt.NDArray[np.float32], func: FnType
) -> float:
return func(image)
estimator = SimpleEstimator()
image = np.random.rand(256, 256).astype(np.float32)
result = apply_function(image, estimator.estimate)
しかし、Callable では名前付き引数が使えません。 そこで Protocol を使うことで名前付き引数を使った関数のシグネチャを宣言することができます。 (強制はできませんが)
引数が2つ以上など多くなってくると位置引数で正確に関数を呼ぶのが辛くなってくるので名前付き引数を使いたくなります。そのような場合で高階関数のシグネチャを宣言したい場合に便利です。
名前付き引数を使った関数のシグネチャを宣言するのが正しい設計につながるかはおいておいて、以下のように書くことができます。
import numpy as np
import numpy.typing as npt
from typing import Protocol
class FnType(Protocol):
def __call__(self, image: npt.NDArray[np.float32]) -> float: ...
def apply_function(
image: npt.NDArray[np.float32], func: FnType
) -> float:
return func(image=image)
estimator = SimpleEstimator()
image = np.random.rand(256, 256).astype(np.float32)
result = apply_function(image, estimator.estimate)
関数の引数が多い時に、名前付き引数を使いたい箇所を抽象化したい時に便利かも知れません。
位置引数のみでいいなら Callable で十分です。
パターン 5: Generic な Protocol
蛇足かも知れませんが、Protocol は Generic にもできます。
from typing import Protocol, TypeVar, Generic
T = TypeVar("T")
class Container(Protocol, Generic[T]):
_items: list[T]
def add(self, item: T) -> None: ...
def get_all(self) -> list[T]: ...
class IntContainer(Container[int]):
def __init__(self) -> None:
self._items: list[int] = []
def add(self, item: int) -> None:
self._items.append(item)
def get_all(self) -> list[int]:
return self._items
このように Generic な Protocol を定義することで、型パラメータを使った柔軟なインターフェース宣言が可能になります。
def process_container(container: Container[str]) -> None:
container.add("Hello")
container.add("World")
items = container.get_all()
for item in items:
print(item)
まとめ
この記事では、雑多に思いつく限りの Python の typing.Protocol の使い所を紹介しました。
- 基本的な使い方: Protocol を使ってインターフェースを宣言し、抽象に依存するコードを書くことで疎結合なコードを書ける。
- 返り値の型宣言: Protocol を返り値の型として使うことで、返り値の型に依存しないコードを書くことを促せる
- 複数の Protocol を組み合わせる: 複数の Protocol を組み合わせることで、単一責任原則に基づいたインターフェースを設計できる
- 関数のシグネチャの宣言: Protocol を使って名前付き引数を使った関数のシグネチャを宣言できる
- Generic な Protocol: Generic な Protocol を定義することで、型パラメータを使った柔軟なインターフェース宣言が可能になる
typing.Protocol や abc.ABC を使うことで、継承に頼らない疎結合な設計が可能になりコードの保守性やテスト容易性を向上させるようなコードの整理ができるようになります。
深夜テンションで書いてみましたが、修正や加筆あれば随時行っていきたいと思います。
参考
[1] PEP 544 — Protocols: Structural subtyping (static duck typing), https://peps.python.org/pep-0544/
[2] Typing — Type Hints — Python 3.12.2 documentation, https://docs.python.org/ja/3.12/library/typing.html#typing.Protocol
[3] abc — Abstract Base Classes — Python 3.12.2 documentation, https://docs.python.org/ja/3.12/library/abc.html