🐍

Pythonの型ヒントと共に進化するコード(#8: Protocol)

に公開
これまでの連載記事


前回の 7 日目では ABC(抽象基底クラス)を使って HTTP クライアントを抽象化しました。

また、記事の最後で ABC には以下の辛みがあることを確認しました。

  • 辛み 1: 継承が必須なので外部ライブラリをそのまま使えない
  • 辛み 2: 不要な機能まで継承される(Fat Interface 問題)

今回は ABC の辛みを緩和する Protocol を導入します。

Protocol とは何か

Protocol は ABC と同じく「このメソッドを持つべき」という契約を定義できるものです。ただし、継承を必要としないという決定的な違いがあります。まずは Protocol が生まれた背景から見ていきましょう。

なぜ Protocol が生まれたのか

Python は長らくダックタイピングを基本としてきました。公式ドキュメントでも duck-typing が Python らしい書き方として紹介されています。

ただ、ダックタイピングには弱点があります。メソッドや属性の存在を静的に保証できないため、「このメソッドがない」といったエラーが本番で発覚することも珍しくありませんでした。

その後、PEP 3119(Python 2.6/3.0)で導入された ABC を使えば「このメソッドを実装すべき」という契約を明示できるようになりました。さらに PEP 484(Python 3.5)で型ヒントが導入され、mypy などの静的解析ツールと組み合わせることで ABC を型アノテーションとしても活用できるようになりました。

しかし前回の記事で見たとおり、ABC には継承が必須という制約があります。これは「名前的部分型(nominal subtyping)」の仕組みであり、明示的に継承したクラスだけがインターフェースを満たすと扱われます。外部ライブラリのクラスに継承を後付けできない以上、ラッパーを書く手間は避けられません。

Protocol がもたらした緩和

この継承必須という制約を根本的に完全解決したわけではありませんが、静的型チェックの世界では事実上緩和できる手段を提供したのが PEP 544(Python 3.8)で導入された Protocol です。

PEP 544 は Protocol構造的部分型(Structural Subtyping) として定義しています。

Subtyping is determined by the presence of methods and attributes, regardless of inheritance.
(必要なメソッド・属性を持っていれば、継承に関係なくその型とみなせる)

Python のダックタイピング文化と静的解析を統合しつつ、ABC が抱えていた「継承しないと型として扱えない」、「外部ライブラリが扱いづらい」という問題を静的解析の領域では回避可能にしました。

Protocol は継承を必要としない

Protocol は ABC には決定的な違いがあります。

from typing import Protocol

class HttpResponse(Protocol):
    @property
    def status_code(self) -> int: ...
    def json(self) -> object: ...

class HttpClient(Protocol):
    def post(self, url: str, json: JsonObject) -> HttpResponse: ...

この HttpClient を使う関数に、post() メソッドを持つ任意のクラスを渡せます。そのクラスが HttpClient を継承しているかどうかは関係ありません。

def fetch_address(client: HttpClient) -> None:
    response = client.post("https://example.com", {})
    print(response.status_code)

# requests.Session は HttpClient を継承していないが、
# post() メソッドを持っているので型チェックを通る。
# (説明のために、ここでは post(url, json) というシグネチャだと仮定しています)
session = requests.Session()
fetch_address(session)  # OK

これが 構造的部分型(Structural Subtyping) と呼ばれる考え方です。

一言でいえばメソッドの形が合っていれば合格という仕組みです。

血筋(どのクラスを継承したか)ではなく、能力(どのメソッドを持っているか)を見ます。

ABC の辛みが緩和される

Protocol を使うと、前回挙げた ABC の辛みはどちらもかなり軽減されます。

辛み 1 の緩和: 「継承していないから使えない」をなくす

ABC では requests.Session をそのまま HttpClient として使えませんでした。外部ライブラリは継承を追加できないからです。

Protocol なら post() メソッドのシグネチャが一致していれば継承なしで HttpClient として扱えます。

import requests

def fetch_address(client: HttpClient) -> None:  # Protocol を期待
    response = client.post("https://example.com", {})
    ...

session = requests.Session()
fetch_address(session)  # 継承なしで OK

「継承していないから型として使えない」という制約がなくなったので、シグネチャさえ合っていれば Adapter 的なラッパーを書く必要がなくなります。

ただし、今回のように自前で用意した HttpClient.post() と外部の requests.Session.post() の引数構成が異なる場合はシグネチャを合わせるためのラッパーが必要です。[1]

Protocol が緩和するのは、あくまで「継承していないから型として扱えない」という ABC 由来の制約です。外部ライブラリの API シグネチャそのものが違う場合は依然としてラッパーによる調整が必要になります。[2]

辛み 2 の緩和: Fat Interface に「後付けで細い窓」を開けられる

「小さなインターフェースを定義できるか」だけを見れば、ABC でも PostOnlyHttpClient のような細い抽象基底クラスを作れます。

class PostOnlyHttpClient(ABC):
    @abstractmethod
    def post(self, url: str, json: JsonObject) -> HttpResponse: ...

では Protocol の何が違うのか。それは 「いつ・どこに」そのインターフェースを定義するか という点です。言い換えれば、インターフェースの主導権が実装側にあるか、利用側にあるかの違いです。

  • ABC
    • 実装クラス側で class X(PostOnlyHttpClient, ...) のように「どの ABC を継承するか」をあらかじめ埋め込む必要がある(実装側が主導権を持つ)
  • Protocol
    • 実装クラス側は Protocol の存在を一切知らなくてよい。利用する関数を書く人が「この機能さえあればいい」と判断して、その場限りの狭いインターフェースを後付けで定義できる(利用側が主導権を持つ)

具体例を見てみましょう。requests ライブラリが提供する requests.Sessionget(), post(), put() などを持つ全部入りのクラスです。Protocol なら、このクラスを変更することなく利用側で必要な機能だけを切り出せます。

from typing import Protocol

# 利用側が「自分に必要な機能だけ」を Protocol として定義
class PostOnlyClient(Protocol):
    def post(self, url: str, json: JsonObject) -> HttpResponse: ...

class GetOnlyClient(Protocol):
    def get(self, url: str) -> HttpResponse: ...

# 各関数は自分が必要とする最小限のインターフェースだけを要求
def fetch_address(client: PostOnlyClient) -> None:
    # post() しか使わないので PostOnlyClient で十分
    ...

def fetch_health(client: GetOnlyClient) -> None:
    # get() しか使わないので GetOnlyClient で十分
    ...

# requests.Session は両方の Protocol を満たすので、どちらにも渡せる
session = requests.Session()
fetch_address(session)  # OK: post() を持っている
fetch_health(session)   # OK: get() を持っている

このように、同じ requests.Session に対して用途別の細いインターフェースを後付けできるのが Protocol の強みです。

インターフェース分離の原則(ISP)は「クライアントは自分が使わないメソッドへの依存を強制されるべきではない」と言っています。Protocol であれば実装クラスを汚さず、既存コードや外部ライブラリに対してもこの状態をあとから実現できます。

ここでのポイントは「Fat Interface を理論的に防げるか」ではなく、「現実の運用で防ぎやすいか」です。ABC は「共通実装+大きいインターフェース」を 1 つの基底クラスに載せがちなので太りやすい。一方で Protocol は原則として実装を持たないぶん、用途ごとに薄いインターフェースを定義する設計を自然に促してくれます。

コードの進化: ABC から Protocol へ

前回 ABC で書いたコードを Protocol で書き直してみましょう。

ABC 版(前回)

from abc import ABC, abstractmethod

class HttpResponse(ABC):
    @property
    @abstractmethod
    def status_code(self) -> int: ...

    @abstractmethod
    def json(self) -> object: ...

class HttpClient(ABC):
    @abstractmethod
    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> HttpResponse: ...

# 実装クラスは ABC を継承する必要がある
class RequestsResponse(HttpResponse):  # 継承必須
    def __init__(self, response: requests_lib.Response):
        self._response = response

    @property
    def status_code(self) -> int:
        return self._response.status_code

    def json(self) -> object:
        return self._response.json()

Protocol 版(今回)

from typing import Protocol

class HttpResponse(Protocol):
    @property
    def status_code(self) -> int: ...
    def json(self) -> object: ...

class HttpClient(Protocol):
    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> HttpResponse: ...

# 実装クラスは Protocol を継承しなくてよい
class RequestsResponse:  # 継承不要!
    def __init__(self, response: requests_lib.Response):
        self._response = response

    @property
    def status_code(self) -> int:
        return self._response.status_code

    def json(self) -> object:
        return self._response.json()

定義側の違いは ABCProtocol かだけですが、実装クラス側に大きな違いがあります。

  • ABC 版
    • RequestsResponse(HttpResponse) のように継承が必須
  • Protocol 版
    • RequestsResponse だけでよく、同じメソッドを持っていれば型として認められる

ABC と Protocol の比較

両者の違いを整理します。

観点 ABC Protocol
継承 必須 不要
外部ライブラリの利用 継承がないため型不一致 シグネチャが合えば可
ランタイム検出 インスタンス化時にエラー なし(静的解析のみ)
実装の共有 可能(ミックスイン) 原則として行わない
型システムの分類 名前的部分型 構造的部分型

どちらを選ぶのか

以下の観点で考えてみるといいかもしれないです。

Protocol を選ぶべきケース

  • 外部ライブラリのクラスをシグネチャさえ合えばそのまま使いたいか
  • 実装側に継承を強制したくないか
  • ランタイムではなく静的解析(mypy)で守れば十分か

ABC を選ぶべきケース

  • 未実装をランタイムで必ず検出したいか
  • 共通ロジックを基底クラスに持たせたいか(※ FAT 注意)

今回の題材(HTTP クライアント)ではどちらがよいか

今回の住所検索 API のケースでは Protocol の方が適しています。

  • 外部ライブラリのクラスをシグネチャさえ合えばそのまま使いたいか → YES
    • ただし今回はシグネチャが異なるためラッパーを使用
  • 実装側に継承を強制したくない → YES
  • 静的解析で守れば十分 → YES

テスト用のモッククライアント

Protocol を使ったテスト用モックも見ておきましょう。

class MockResponse:
    def __init__(self, status_code: int, data: object):
        self._status_code = status_code
        self._data = data

    @property
    def status_code(self) -> int:
        return self._status_code

    def json(self) -> object:
        return self._data


class MockHttpClient:
    def __init__(self, response: MockResponse):
        self._response = response

    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> MockResponse:
        return self._response


def test_fetch_address_success():
    mock_response = MockResponse(200, {
        "zipcode": "1000001",
        "prefecture": "東京都",
        "city": "千代田区",
        "town": "千代田",
        # ...
    })
    mock_client = MockHttpClient(mock_response)

    result = fetch_and_format_address(
        ZipCode("1000001"),
        include_kana=True,
        http_client=mock_client,
    )

    assert result is not None
    assert "東京都" in result

MockHttpClientMockResponse は Protocol を継承していません。同じメソッドを持っているだけで HttpClient / HttpResponse として扱えます。

まとめ

Protocol は継承していないから型として扱えないという ABC 特有の制約を取り除き、さらに利用側から用途ごとに細いインターフェースを後付けできるようにすることで、現実のコードベースで発生しがちな Fat Interface 問題もかなり緩和してくれることを紹介しました。
一方で、メソッドのシグネチャ差分や実行時検査まで自動で吸収してくれるわけではないので、ABC と Protocol をどちらか一方ではなく、用途ごとに使い分けるのが現実的な落としどころになるかと思います。

得られたものと、次の課題

今回は Protocol を導入して ABC との違いを確認しました。

Protocol で依存の抽象化がより柔軟になり、このコードはテスト容易性と柔軟性を獲得しました。

しかし、address_fetcher.py を見返すと 1 つのファイルに様々な役割を持つコードが混在しています。

  • データ構造の定義(AddressDict, FormattedAddressDict
  • HTTP 通信の責務(HttpClient, RequestsHttpClient
  • ビジネスロジック(fetch_and_format_address
  • 実行コード(if __name__ == "__main__"

ファイルが少しずつ窮屈になってきました。

次回予告

次回は一旦立ち止まり、責務ごとにファイルを分割してコードを構造化していきます。


処方後のコードはこちら
address_fetcher.py
from typing import Protocol, TypedDict, NewType, cast
import json as json_lib
import requests as requests_lib

type Headers = dict[str, str]
type JsonObject = dict[str, object]

ZipCode = NewType("ZipCode", str)


class HttpResponse(Protocol):
    @property
    def status_code(self) -> int: ...
    def json(self) -> object: ...


class HttpClient(Protocol):
    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> HttpResponse: ...


class RequestsResponse:
    def __init__(self, response: requests_lib.Response):
        self._response = response

    @property
    def status_code(self) -> int:
        return self._response.status_code

    def json(self) -> object:
        return self._response.json()


class RequestsHttpClient:
    def __init__(self):
        self._session = requests_lib.Session()

    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> RequestsResponse:
        response = self._session.post(url, json=json, headers=headers)
        return RequestsResponse(response)


class AddressDict(TypedDict):
    zipcode: str
    prefecture: str
    prefecture_kana: str
    city: str
    city_kana: str
    town: str
    town_kana: str


class FormattedAddressDict(TypedDict):
    zipcode: str
    full_address: str
    prefecture: str
    city: str
    town: str


class FormattedAddressWithKanaDict(FormattedAddressDict):
    full_address_kana: str


def _build_full_address(address_data: AddressDict) -> str:
    return address_data["prefecture"] + address_data["city"] + address_data["town"]


def fetch_and_format_address(
    zipcode: ZipCode,
    include_kana: bool,
    http_client: HttpClient,
    headers: Headers | None = None,
) -> str | None:
    api_url = "https://api.zipcode-jp.example/v1/address"

    try:
        response = http_client.post(api_url, json={"zipcode": zipcode}, headers=headers)
        if response.status_code != 200:
            print(f"Error: Failed to fetch address. Status: {response.status_code}")
            return None

        # HTTPクライアントは「任意のJSON」を返すまでが責務。
        # 「このAPIはAddressDictを返す」という知識はドメイン側の責務。
        address_data = cast(AddressDict, response.json())

        full_address = _build_full_address(address_data)

        result: FormattedAddressDict = {
            "zipcode": zipcode,
            "full_address": full_address,
            "prefecture": address_data["prefecture"],
            "city": address_data["city"],
            "town": address_data["town"],
        }

        if include_kana:
            result_with_kana: FormattedAddressWithKanaDict = {
                **result,
                "full_address_kana": (
                    address_data["prefecture_kana"]
                    + address_data["city_kana"]
                    + address_data["town_kana"]
                ),
            }
            return json_lib.dumps(result_with_kana, indent=2, ensure_ascii=False)

        return json_lib.dumps(result, indent=2, ensure_ascii=False)

    except Exception as e:
        print(f"An error occurred: {e}")
        return None


if __name__ == "__main__":
    http_client = RequestsHttpClient()
    zipcode = ZipCode("1000001")
    result = fetch_and_format_address(zipcode, include_kana=True, http_client=http_client)
    if result is not None:
        print(result)
おまけ:Protocol でも ABC のようにランタイムで検査できないのか?
脚注
  1. 「Protocol なら requests.Session をそのまま使えるのでは」と疑問に思うかもしれません。実は requests.Session.post() のシグネチャは (url, data=None, json=None, ...) です。今回定義した HttpClient Protocol の post(url, json, headers) とは引数の順序や構成が異なります。そのため、上記の「処方後のコード」では RequestsHttpClient というラッパーを定義してシグネチャを合わせています。Protocol によって継承不要になるだけで、すべての外部クラスがそのまま使えるわけではありません。 ↩︎

  2. 今回あえて HttpClient.post() のシグネチャを requests.Session.post() と異なる形にしたのは、Protocol が「継承していないから使えない問題」を緩和する一方で、メソッドシグネチャの違いまでは吸収しないという限界を明確に示したかったからです。 ↩︎

Discussion