🐍

Pythonの型ヒントと共に進化するコード(#7: ABC — Abstract Base Classes)

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


前回の 6 日目ではNewTypeTypeAliasを使い、コードの「意図」と「意味」を型で表現しました。

今回からはコードの構造そのもの、特に依存のあり方に焦点を移します。

依存を抽象化するアプローチは Python には大きく分けて 2 つ存在します。
本記事ではまず、従来から使われてきた ABC(抽象基底クラス) の具体的な適用例を試し、次回の記事で Protocol と比較検討します。

今回の課題:テストできないコード

現在のaddress_fetcher.pyには HTTP リクエストを行うrequests.post(...)がコードの奥深くに直接埋め込まれています。

address_fetcher.py の一部
def fetch_and_format_address(zipcode: ZipCode, include_kana: bool, headers: Headers | None = None):
    # ...
    try:
        # 'requests'という具体的なライブラリに強く依存している
        response = requests.post(api_url, json={"zipcode": zipcode}, headers=headers)
        # ...

この構造には、依存が剥き出しになっていることによる看過できない問題が潜んでいます。

1. ネットワーク環境に縛られるテスト

この関数をテストするには毎回本物の API サーバーにリクエストを送るしかありません。
ネットワーク環境に依存するテストは実行が遅いだけでなく不安定になりがちです。

2. 異常系の再現が困難

「API がエラーを返したケース」や「タイムアウトが発生した状況」といった異常系を実際の外部 API で意図的に再現するのは極めて困難です。結果として、エラーハンドリングのコードが十分に検証できない状態に陥ります。

3. 外部ライブラリへの高い結合度

requestsのような具体的なライブラリに強く依存しているため、例えばhttpxなど別の HTTP クライアントに乗り換えたい場合、コードのあちこちを修正しなければなりません。

これらの問題を解決するには、requestsへの直接的な依存を断ち切って抽象化するという手段が有効です。

処方箋:ABC で「契約」を定義する

Python の標準ライブラリには abc モジュールがあり、抽象基底クラス(Abstract Base Class) を定義できます。

ABC を使うと「このメソッドは必ず実装しないとダメよ」という契約を定義できます。

ここでは、ABC を用いて以下 2 つの抽象基底クラスを定義してみます。

from abc import ABC, abstractmethod

# 6日目で導入した型エイリアスを使う
type Headers = dict[str, str]
type JsonObject = dict[str, object]

class HttpResponse(ABC):
    """HTTP レスポンスの契約を定義する抽象基底クラス"""

    @property
    @abstractmethod
    def status_code(self) -> int:
        """ステータスコードを返す(サブクラスで実装必須)"""
        ...

    @abstractmethod
    def json(self) -> object:
        """レスポンスボディを JSON としてパースする(サブクラスで実装必須)"""
        ...


class HttpClient(ABC):
    """HTTP クライアントの契約を定義する抽象基底クラス"""

    @abstractmethod
    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> HttpResponse:
        """POST リクエストを送信する(サブクラスで実装必須)"""
        ...

@abstractmethodデコレータが付いたメソッドは、サブクラスで必ず実装しなければなりません。実装しないままインスタンス化しようとすると実行時エラーが発生します。

class BrokenClient(HttpClient):
    pass  # post を実装していない

client = BrokenClient()
# TypeError: Can't instantiate abstract class BrokenClient with abstract method post

また、mypy もこの問題を検出してくれます。

mypy 実行結果
error: Cannot instantiate abstract class "BrokenClient" with abstract attribute "post"  [abstract]

これで「HTTP クライアントはこのメソッドを持つべき」という契約が実行時と静的解析の両方で強制されるようになりました。

HttpResponse 抽象基底クラスの json() の戻り値に関する補足

HttpResponse.json() の戻り値はあえて dict[str, object] ではなく object にしています。
HTTP クライアント層はどんな形の JSON が返ってくるかを知りません。「この API は AddressDict を返す」というドメイン知識は呼び出し側の責務なので、そちらで TypedDict へのキャストを行う方針にします。

コードの進化:ABC を使った抽象化

それでは、この ABC を使ってaddress_fetcher.pyをリファクタリングしてみましょう。

変更前のコード

変更前
def fetch_and_format_address(zipcode: ZipCode, include_kana: bool, headers: Headers | None = None):
    # ...
    response = requests.post(api_url, json={"zipcode": zipcode}, headers=headers)
    # ...

ABC を使った抽象化

ABC を定義し、requestsをラップする実装クラスを作り、関数が HttpClient を受け取るように変更します。

変更後
from abc import ABC, abstractmethod
import requests as requests_lib

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


# 👉 レスポンスの契約を定義した抽象基底クラス
class HttpResponse(ABC):
    @property
    @abstractmethod
    def status_code(self) -> int: ...

    @abstractmethod
    def json(self) -> object: ...  # 「任意の JSON」を返す


# 👉 HTTP クライアントの契約を定義した抽象基底クラス
class HttpClient(ABC):
    @abstractmethod
    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> HttpResponse: ...


# 👉 requests ライブラリをラップした実装クラス
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()


class RequestsHttpClient(HttpClient):
    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> HttpResponse:
        response = requests_lib.post(url, json=json, headers=headers)
        return RequestsResponse(response)


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:
        # 👉 requests を直接呼ばず、注入された http_client を使う
        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
        # ...

これでrequestsへの直接依存がなくなりました。

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

ABC を継承したモッククライアントを作れば、ネットワーク通信なしでテストできます。

test_address_fetcher.py
from address_fetcher import HttpClient, HttpResponse, Headers, fetch_and_format_address, ZipCode

class MockResponse(HttpResponse):
    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(HttpClient):
    def __init__(self, response: MockResponse):
        self._response = response

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


def test_fetch_address_success():
    # 👉 好きなレスポンスを返すモックを用意
    mock_response = MockResponse(200, {
        "zipcode": "1000001",
        "prefecture": "東京都",
        "prefecture_kana": "トウキョウト",
        "city": "千代田区",
        "city_kana": "チヨダク",
        "town": "千代田",
        "town_kana": "チヨダ",
    })
    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

抽象化によってテストが可能になりました。

得られたものと、残った課題

ABC を使った抽象化で以下のメリットを得られました。

  • requestsへの直接依存がなくなった
  • テスト用のモックを注入できるようになった
  • @abstractmethodによって実装漏れが実行時に検出される

また、ABC には以下のような固有の強みもあります。

ランタイムでの確実な検出
ABC はインスタンス化の時点で未実装メソッドを検出します。これは実行時のガードレールとして機能し、不完全な実装が誤って本番環境で動いてしまう事態を防ぎます。

class BrokenClient(HttpClient):
    pass  # post を実装し忘れた

client = BrokenClient()
# TypeError: Can't instantiate abstract class BrokenClient
#            without an implementation for abstract method 'post'

実装の共有(ミックスイン)
ABC は抽象メソッドだけでなく、具体的な実装メソッドも持てます。共通のロジックを親クラスに持たせ、詳細な実装のみをサブクラスに委譲する「テンプレートメソッドパターン」のような設計が可能です。

class HttpClient(ABC):
    @abstractmethod
    def _do_post(self, url: str, json: JsonObject) -> HttpResponse:
        """実際の POST 処理(サブクラスで実装)"""
        ...

    def post(self, url: str, json: JsonObject) -> HttpResponse:
        """リトライ付き POST(基底抽象クラスが持つ共通のロジック)"""
        for attempt in range(3):
            response = self._do_post(url, json)
            if response.status_code != 503:
                return response
            time.sleep(2 ** attempt)
        return response


しかし、ABC にはいくつかの辛みもあります。

辛み 1: 継承が必須なので外部ライブラリを型として扱えない

ABC を使うには継承が必要です。これは名前的部分型(Nominal Subtyping) と呼ばれる設計思想に基づいています。

「このクラスは HttpClient です」と、クラス定義側で明示的に継承したものだけHttpClient の仲間として取り扱う

つまり、同じメソッドを持っていても、継承をしていなければ HttpClient とは認められません。構造が合っているかどうかは関係ないのです。

この制約が具体的にどう困るのか。外部ライブラリを使う場面で問題が顕在化します。

例えば requests.Session(Python で広く使われている HTTP クライアントで.get().post() を持つクラスです)を使いたいとします。

import requests

def fetch_address(client: HttpClient):  # 抽象基底クラスの HttpClient を期待
    ...

session = requests.Session()
fetch_address(session)  # requests.Session を渡すと型エラー!

requests.Sessionpost() を持っており、構造的には HttpClient と同じです。
しかし HttpClient を継承していないため、型チェッカーは「これは HttpClient ではない」と判断します。

「それなら requests.SessionHttpClient を継承させればいいのでは?」と思うかもしれません。

# こう書ければ解決するが...
class requests.Session(HttpClient):  # SyntaxError: invalid syntax
    ...

そもそもこの構文は Python として不正です。仮に構文が許されたとしても、外部ライブラリのクラス定義を書き換えることはできません。requests のソースコードに手を入れるわけにはいかないのです。

結果として、ABC ベースの設計では、外部ライブラリのクラスを自分のインターフェースとして安全に扱うには、その ABC(ここでは HttpClient)を継承したアダプタ(ラッパークラス)を書くことが前提になります。本記事の前半で作った RequestsHttpClient(HttpClient) がまさにそれです。

辛み 2: 不要な機能まで継承される(Fat Interface 問題)

先ほど ABC のメリットとして挙げた「実装の共有」は諸刃の剣でもあります。
抽象基底クラスに共通のメソッドをどんどん追加していくと、それにぶら下がる全てのサブクラスがそのインターフェースを実装し続けなければならなくなります。

例えば、HttpClientgetpostputdelete の全 HTTP メソッドを定義したとします。

抽象基底クラス
class HttpClient(ABC):
    @abstractmethod
    def get(self, url: str) -> HttpResponse: ...

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

    @abstractmethod
    def put(self, url: str, json: JsonObject) -> HttpResponse: ...

    @abstractmethod
    def delete(self, url: str) -> HttpResponse: ...

住所検索 API は post しか使いませんが、HttpClient を継承する以上、getputdelete もすべて実装しなければなりません。テスト用のモッククライアントでも、使わないメソッドにダミー実装を書く羽目になります。

実装クラス
class MockHttpClient(HttpClient):
    def get(self, url: str) -> HttpResponse:
        raise NotImplementedError()  # 使わないのに書かないといけない

    def post(self, url: str, json: JsonObject) -> HttpResponse:
        return self._response  # これだけ使う

    def put(self, url: str, json: JsonObject) -> HttpResponse:
        raise NotImplementedError()  # 使わないのに書かないといけない

    def delete(self, url: str) -> HttpResponse:
        raise NotImplementedError()  # 使わないのに書かないといけない

これは インターフェース分離の原則(ISP) に違反しています。クライアントは自分が使わないメソッドへの依存を強制されるべきではないという原則です。

ABC で抽象化しようとすると、利用者が必要としない機能まで含んだ「肥大化したインターフェース(Fat Interface)」が生まれやすくなります。

上記の Fat Interface コード例についての補足

もちろん、「post しか使わないなら PostOnlyHttpClient のようなインターフェースを別に切ればよいのでは?」という意見もあると思いますが、

まさにそれがインターフェース分離の原則(ISP)が言っていることです。

問題は、ABC に共通メソッドや共通実装をどんどん足していく設計を取りやすい結果、とりあえず全部入りの HttpClient を継承しておこうという形になりやすい点です。その瞬間から使わないメソッドまで実装・テストし続けるコストを将来に向かって背負うことになります。

さらに厄介なのは、ABC の「実装の共有」機能と組み合わさった場合です。

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

    # 「便利だから」と追加された共通メソッド
    def post_with_retry(self, url: str, json: JsonObject, max_retries: int = 3) -> HttpResponse:
        """リトライ付き POST(RequestsHttpClient 用に追加された)"""
        for attempt in range(max_retries):
            response = self.post(url, json)
            if response.status_code != 503:
                return response
            time.sleep(2 ** attempt)
        return response

RequestsHttpClient のために追加された post_with_retry() は、テスト用の MockHttpClient では不要です。しかし継承している以上、このメソッドも一緒に付いてきます。

共通メソッドを ABC へ追加するたびに、それを必要としないサブクラスまで影響が波及してしまう。これが継承ベースの設計が抱える構造的な問題です。
そしてこれが膨れ上がると、いわゆる「神クラス」のような何でも入りの基底クラスが爆誕します。

ABC が継承ベースの仕組みである以上、これらの辛みは必然的に生じてしまいます。

実は Python には、上記に挙げた辛みを緩和する別のアプローチがあります。継承を強要せず、構造さえ合えば OK という仕組み。

それが Protocol です。

次回予告

次回は Protocol を導入して同じ依存の抽象化を実現します。ABC との違いを体感し、どちらを選ぶべきかの指針をまとめようと思います。

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

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

ZipCode = NewType("ZipCode", str)


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: ...


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()


class RequestsHttpClient(HttpClient):
    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> HttpResponse:
        response = requests_lib.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:
        # http_client の実装によって例外の種類が変わりうるため、
        # ここでは広くキャッチして None を返す
        print(f"An error occurred during API request: {e}")
        return None

Discussion