Python | テスト・設計・品質:Protocol

Python Python
スポンサーリンク

Protocolって何?一言でいうと「“こう振る舞うもの”を型で表す」

Protocol は、型ヒントの世界で
「このオブジェクトは、こういうメソッドや属性を“持っているもの”として扱える」
という“振る舞いのインターフェース”を表すための仕組みです。

クラス名や継承関係ではなく、
「このメソッドがあるか」「この属性があるか」という“形”で型を決めます。

Python らしい「ダックタイピング(ガーガー鳴けばアヒル)」を、
型ヒントの世界に持ち込むための道具――それが Protocol です。


まずは超シンプルな例:Logger Protocolでイメージをつかむ

「logメソッドを持つものなら何でも受け取る」関数

例えば、こんな関数を考えます。

def do_something(logger):
    logger.log("start")
    # 何か処理
    logger.log("end")
Python

ここで欲しいのは、

log(message: str) -> None というメソッドを持っているものなら何でもいい」

という条件です。
クラス名は何でもいいし、継承していなくてもいい。

これを Protocol で表すと、こうなります。

from typing import Protocol

class Logger(Protocol):
    def log(self, message: str) -> None:
        ...

def do_something(logger: Logger) -> None:
    logger.log("start")
    # 何か処理
    logger.log("end")
Python

ここで重要なのは、Logger は「実装を持たないインターフェース」だということです。
log メソッドの“形”だけを宣言しています。

実装側は「継承しなくても」OK(ここが超重要)

例えば、こんなクラスがあったとします。

class PrintLogger:
    def log(self, message: str) -> None:
        print(message)
Python

このクラスは Logger を継承していませんが、
log(self, message: str) -> None を持っているので、
型チェッカー的には「Logger として扱ってよい」とみなされます。

pl = PrintLogger()
do_something(pl)  # Logger としてOK
Python

これが Protocol の一番おいしいところです。

「特定の基底クラスを継承しているか」ではなく、
「必要なメソッド・属性を持っているか」で判断する。

Python の“実態”にかなり近い型付けができます。


Protocolが設計・テスト・品質に効いてくるポイント

「依存するのはクラス名ではなく“振る舞い”」という設計にできる

Protocol を使うと、
関数やクラスが「何に依存しているか」を、
「具体的なクラス名」ではなく「必要な振る舞い」で表現できます。

さっきの例で言えば、

do_somethingPrintLogger に依存している」のではなく
log(str) -> None を持つものに依存している」

と表現できるわけです。

これは、設計的にかなり強いです。

テストのときに別の実装を差し替えやすい
本番用・テスト用・ダミー実装を簡単に切り替えられる
新しいロガーを追加しても、Protocol を満たしていれば既存コードを変えなくていい

「インターフェースに依存し、実装には依存しない」という
オブジェクト指向の鉄板原則を、Python でも型レベルで表現できます。

テストダブル(モック・スタブ)を作るのが楽になる

例えば、テスト用に「ログを記録するだけのロガー」を作りたいとします。

class DummyLogger:
    def __init__(self) -> None:
        self.messages: list[str] = []

    def log(self, message: str) -> None:
        self.messages.append(message)
Python

このクラスも Logger を継承していませんが、
log(str) -> None を持っているので Logger として扱えます。

def test_do_something_logs_start_and_end():
    logger = DummyLogger()
    do_something(logger)

    assert logger.messages == ["start", "end"]
Python

Protocol があることで、

「テスト用のダミーは、Logger を継承していなくてもいい」
「必要なメソッドさえあれば、型的にもOK」

という状態を作れます。

これは、テストのしやすさ=品質に直結します。


もう少し複雑な例:リポジトリProtocolでドメインをきれいに保つ

ユーザーを保存・取得する「リポジトリ」のProtocol

ドメイン層のコードから、
DB の具体的な実装を切り離したいとします。

そこで、こんな Protocol を定義します。

from typing import Protocol, Optional
from dataclasses import dataclass

@dataclass
class User:
    id: int
    name: str

class UserRepository(Protocol):
    def get(self, user_id: int) -> Optional[User]:
        ...

    def save(self, user: User) -> None:
        ...
Python

ここで UserRepository は、
getsave を持つもの」という“契約”だけを表しています。

実装は好きに作れる(DBでもメモリでも)

本番用の実装:

class SqlUserRepository:
    def __init__(self, conn) -> None:
        self.conn = conn

    def get(self, user_id: int) -> Optional[User]:
        # 実際はSQLを叩く
        ...

    def save(self, user: User) -> None:
        # 実際はINSERT/UPDATEする
        ...
Python

テスト用のインメモリ実装:

class InMemoryUserRepository:
    def __init__(self) -> None:
        self._store: dict[int, User] = {}

    def get(self, user_id: int) -> Optional[User]:
        return self._store.get(user_id)

    def save(self, user: User) -> None:
        self._store[user.id] = user
Python

どちらも UserRepository を継承していませんが、
getsave のシグネチャが合っているので、
型チェッカー的には「UserRepository として扱ってよい」となります。

ドメインサービス側は「Protocolだけ」を見る

def register_user(repo: UserRepository, name: str) -> User:
    # id の採番は適当な例
    new_user = User(id=1, name=name)
    repo.save(new_user)
    return new_user
Python

この関数は、
SqlUserRepository にも InMemoryUserRepository にも依存していません。

依存しているのは「UserRepository という振る舞い」だけです。

テストでは InMemoryUserRepository を渡し、
本番では SqlUserRepository を渡す。

この切り替えが、型レベルでも自然に表現できます。


Protocolと抽象基底クラス(ABC)の違いを整理する

ABCは「継承前提」、Protocolは「構造で判断」

抽象基底クラス(abc.ABC)を使うと、
「このクラスを継承して、抽象メソッドを実装しなさい」という形で
インターフェースを表現できます。

from abc import ABC, abstractmethod

class LoggerABC(ABC):
    @abstractmethod
    def log(self, message: str) -> None:
        ...
Python

これを使うには、こう継承します。

class PrintLogger(LoggerABC):
    def log(self, message: str) -> None:
        print(message)
Python

一方 Protocol は、
継承しなくても「形が合っていればOK」です。

class Logger(Protocol):
    def log(self, message: str) -> None:
        ...

class PrintLogger:
    def log(self, message: str) -> None:
        print(message)
Python

PrintLoggerLogger を継承していませんが、
log のシグネチャが合っているので Logger として扱えます。

Python らしい「構造的型付け(structural typing)」を
型ヒントで表現したいときは、Protocol が向いています。


Protocolを使うときにハマりやすいポイント

実行時には何も強制されない(基本は静的解析用)

Protocol は、基本的に「静的型チェッカーのための情報」です。

実行時に「このオブジェクトは Logger Protocol を満たしていないからエラー」
みたいなことは起きません(runtime_checkable を使う特殊なケースを除く)。

つまり、

型ヒントを書いた
→ mypy や pyright などでチェックする
→ 設計のズレやミスを“開発中に”見つける

という流れを前提にしています。

型チェッカーを使わずに Protocol だけ書いても、
実行時の挙動は変わりません。

「なんでもProtocolにする」はやりすぎ

便利だからといって、
何でもかんでも Protocol にすると、
設計が逆に分かりにくくなります。

向いているのは、

外部に公開するインターフェース
依存を逆転させたい境界(リポジトリ、ロガー、通知、ストレージなど)
テストで差し替えたいポイント

など、「ここは実装を差し替えたい」「ここは振る舞いで依存したい」という場所です。

全部を Protocol にするのではなく、
「変わりうるところ」「差し替えたいところ」に絞るのが現実的です。


初心者がProtocolとどう付き合うといいか

まずは「Logger Protocol」から始めるのが一番おすすめ

最初の一歩としては、
さっきのような「Logger Protocol」を自分で書いてみるのが一番分かりやすいです。

Logger Protocol を定義する
PrintLogger(print するだけ)を作る
DummyLogger(リストに溜めるだけ)を作る
Logger を受け取る関数を書いて、両方を差し替えてテストしてみる

これだけで、

「クラス名ではなく振る舞いに依存する」
「テスト用の実装を簡単に差し替えられる」

という Protocol の本質が体感できます。

「依存を弱くするための道具」として意識する

Protocol を使う目的は、

「特定の実装にベタッと依存しないようにする」

ことです。

その結果として、

テストしやすくなる
設計が柔らかくなる
変更に強くなる

という品質面のメリットがついてきます。

「型をカッコよく書くため」ではなく、
「依存を弱くして、テストと変更を楽にするための道具」として
Protocol を捉えると、使いどころが見えてきます。


まとめ(Protocolは「Python流インターフェース」を型で表す)

Protocol を初心者目線で整理すると、こうなります。

Protocol は、「このメソッド・属性を持つものなら何でもOK」という“振る舞いのインターフェース”を型で表す仕組みで、クラス名や継承ではなく“形”で判断する。
Logger や Repository のような「差し替えたい依存」を Protocol で表すと、実装を自由に入れ替えられ、テスト用のダミーも簡単に作れるので、設計とテストのしやすさが一気に上がる。
抽象基底クラス(ABC)が「継承前提」なのに対して、Protocol は「継承しなくても、シグネチャが合っていればOK」という構造的型付けで、Python のダックタイピングと相性が良い。
「全部をProtocolにする」のではなく、「依存を弱くしたい境界」に絞って使うと、コードの柔軟性と品質のバランスがとても良くなる。

タイトルとURLをコピーしました