0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python 3.15のdisjoint_baseで多重継承を実行時に禁止できるか

0
Posted at

Python 3.15のdisjoint_baseで多重継承を実行時に禁止できるか

@typing.disjoint_baseを付けたクラス同士は、多重継承できなくなるように見えます。
しかし、Python 3.15のdisjoint_baseは型チェッカーに情報を渡すためのもので、実行時の継承を禁止する仕組みではありません。
class文が成立するかどうかは別の問題です。
プラグインやドメインモデルの継承関係を制限するなら、静的検査と実行時検査を分けて考える必要があります。

disjoint_baseを付けた二つのクラス

Python 3.15では、typing.disjoint_baseが導入されました。

このデコレーターは、対象クラスのサブクラスが、ほかの独立した基底クラスのサブクラスと重なれないことを型チェッカーに伝えます。

まず、二つの独立したクラスを用意します。

動作対象はPython 3.15.0rc3です。標準ライブラリだけを使用します。

# disjoint_demo.py
from typing import disjoint_base


@disjoint_base
class CustomerRecord:
    def customer_id(self) -> str:
        return "C001"


@disjoint_base
class PaymentRecord:
    def payment_id(self) -> str:
        return "P001"


class CombinedRecord(CustomerRecord, PaymentRecord):
    pass


record = CombinedRecord()

print(record.customer_id())
print(record.payment_id())
print(CombinedRecord.__mro__)

ここでは、顧客情報と決済情報を、それぞれ独立した継承系統として表現したいとします。

CustomerRecordとPaymentRecordの両方に@disjoint_baseを付けています。

それにもかかわらず、CombinedRecordでは両方を継承しています。

注目したいのは、メソッド名の衝突やMRO(Method Resolution Order)の複雑さではありません。

独立していると宣言した二つの継承系統を、同じクラスに持ち込めるのかという点です。

通常のPythonの多重継承では、メソッド解決順序や基底クラスのレイアウトに問題がなければ、クラスを作成できます。

では、disjoint_baseを追加すると何が変わるのでしょうか。

型チェッカーが拒否してもclass文は成立する

Python 3.15のdisjoint_baseで多重継承を実行時に禁止できるか

先ほどのコードには、静的な型の制約に反する箇所があります。

class CombinedRecord(CustomerRecord, PaymentRecord):
    pass

二つの基底クラスは、どちらも独立した継承系統として宣言されています。

対応する型チェッカーは、この組み合わせを不正なクラス階層として検出できます。

一方、Pythonインタープリターは、disjoint_baseだけを理由にクラス定義を拒否しません。

先ほどのファイルを実行すると、次の結果になります。

C001
P001
(<class '__main__.CombinedRecord'>, <class '__main__.CustomerRecord'>, <class '__main__.PaymentRecord'>, <class 'object'>)

CombinedRecordのインスタンスが作成され、両方のメソッドを呼び出せています。

MROにも二つの基底クラスが含まれています。

つまり、disjoint_baseを宣言しても、それだけでは実行時の多重継承を止めません。

型チェッカーとインタープリターの担当が違う

型チェッカーは、ソースコードの型情報を解析します。

disjoint_baseは、その解析で利用する宣言です。

一方、Pythonがclass文を実行するときには、メタクラスによるクラス生成、MROの計算、基底クラスのレイアウト制約などが関係します。

ここにdisjoint_baseの静的な制約が、そのまま実行時検査として組み込まれるわけではありません。

Pythonの型ヒントは、基本的に実行時の値やクラス階層を自動検証する仕組みではないためです。

ただし、実行時の制約が存在しないという意味でもありません。

組み合わせる基底クラスのインスタンスレイアウトが互換でなければ、PythonはTypeErrorを発生させることがあります。

これはdisjoint_baseとは別の制約です。

Pythonの型システムでは、このような実行時の性質に関連してsolid baseという概念が扱われます。

disjoint_baseは静的な独立性を表す宣言であり、solid baseは実行時の型やレイアウトに関係する概念です。同じ機構ではありません。

型チェック結果も確認する

静的検査は、Pythonを実行するだけでは確認できません。

Python 3.15対応の型チェッカーでdisjoint_demo.pyを検査します。

確認するポイントは三つです。

  1. typing.disjoint_baseを認識しているか
  2. CombinedRecordの継承関係を問題として検出するか
  3. 検出結果がPython実行時の例外とは独立しているか

型チェッカーの実装やバージョンによって、対応状況や診断メッセージは異なります。

特定のエラーメッセージに依存するより、該当するクラス定義が診断対象になることを確認する方が適切です。

また、型チェッカーが問題を検出できても、静的解析の対象外にあるコードまで保証されるわけではありません。

動的に生成されたクラスや、検査していない外部プラグインは別途考える必要があります。

実行時にも禁止するならどう書くか

プラグインやドメインモデルでは、静的な型チェックだけでは足りない場合があります。

たとえば、外部から読み込んだプラグインが、互いに排他的な二つの基底クラスを継承していたとします。

クラスを読み込んだ時点で拒否したいなら、実行時にも検査が必要です。

ここでは__init_subclass__を使います。

このメソッドは、基底クラスから新しいサブクラスが作成される際に呼び出されます。

# runtime_guard.py
from typing import disjoint_base


class ExclusiveBase:
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)

        roots = [
            base
            for base in cls.__mro__
            if base.__dict__.get("__exclusive_root__", False)
        ]

        if len(roots) > 1:
            raise TypeError(
                f"{cls.__name__}: incompatible bases "
                + ", ".join(base.__name__ for base in roots)
            )


@disjoint_base
class CustomerRecord(ExclusiveBase):
    __exclusive_root__ = True


@disjoint_base
class PaymentRecord(ExclusiveBase):
    __exclusive_root__ = True


try:
    class CombinedRecord(CustomerRecord, PaymentRecord):
        pass
except TypeError as exc:
    print(exc)

実行結果は次のとおりです。

CombinedRecord: incompatible bases CustomerRecord, PaymentRecord

今度はCombinedRecordのクラス定義そのものが失敗します。

なぜMROを調べるのか

この実装では、cls.__mro__に含まれるクラスを確認しています。

直接の基底クラスだけを調べると、間接的な継承を見落とす可能性があるためです。

class CustomerExtension(CustomerRecord):
    pass


class PaymentExtension(PaymentRecord):
    pass


class InvalidRecord(CustomerExtension, PaymentExtension):
    pass

InvalidRecordが直接継承しているのは、二つの拡張クラスです。

しかし、MROをたどれば、元のCustomerRecordとPaymentRecordも含まれています。

__exclusive_root__という独自の属性を付け、MRO上に複数のルートがあれば例外を発生させています。

ここでbase.__dict__.get(...)を使用しているのにも理由があります。

getattr(base, "__exclusive_root__", False)では、継承された属性まで取得します。

その結果、CustomerExtensionまで排他的なルートとして数えてしまう可能性があります。

今回必要なのは、属性を明示的に定義したクラスだけを数えることです。

継承関係を検査するコードでは、この違いに注意が必要です。

静的制約と実行時制約を混ぜない

ここまでの実装では、二つの仕組みを併用しています。

@disjoint_baseは、型チェッカーにクラス階層の制約を伝えます。

__init_subclass__は、Python実行時にクラス階層を調べ、条件に違反した場合に例外を発生させます。

同じルールを表現していても、検査する場所と保証できる範囲が違います。

型チェッカーによる検査は、開発時に問題を発見するのに向いています。CIに組み込めば、不適切な継承関係を早い段階で検出できます。

一方、実行時の検査は、外部から読み込むコードや動的なクラス生成に対して、実際にクラスが成立するかどうかを制御したい場合に使います。

ただし、今回のExclusiveBaseにも制限があります。

まず、この実装はExclusiveBaseを継承するクラスだけが対象です。

共通の基底クラスを使わない別のクラス階層には適用されません。

また、MROに複数の排他的ルートが存在することを禁止する独自ルールであり、disjoint_baseの型システム上の意味を完全に再実装したものではありません。

今回のコードでは、排他的ルートが二つ以上あれば、組み合わせに関係なく拒否します。

「顧客系統と決済系統の組み合わせだけを禁止したい」という要件なら、禁止する組み合わせを明示した検査に変更する必要があります。

さらに、__init_subclass__は通常のクラス生成時に呼び出される仕組みです。クラス作成後に__bases__を書き換えるような操作まで、継続的に監視するものではありません。

特殊なメタクラスを使う設計との組み合わせも、別途検証が必要です。

型ヒントを業務ルールの唯一の防壁にしない

たとえば、プラグインの種類によって実装できる機能を制限したいとします。

型チェッカーが検出する仕組みを用意すれば、開発時のミスを減らせます。

しかし、読み込むプラグインがすべて型チェック済みとは限りません。

この場合、プラグイン登録時に明示的な検証処理を置く方が、どこで拒否するかが分かりやすくなります。

そもそも排他的な役割を多重継承で表現する必要があるのか、という設計上の問いも残ります。

組み合わせて使うことが自然な機能なら、継承ではなくコンポジションで表現する方が適切な場合もあります。

逆に、ライブラリの公開APIとして、利用者が作るサブクラスの継承関係を静的に制限したいなら、disjoint_baseはその意図を型チェッカーに伝える手段になります。

型チェッカーで拒否されることと、実行時に拒否されることは別の保証です。

disjoint_baseを採用する際には、まずどちらの保証が必要なのかを決めるべきだと考えています。

今回の実行時検査は、通常の継承を使う小規模なクラス階層を想定した例です。動的なプラグイン基盤や複数のメタクラスが関係する設計に適用する場合は、間接継承、クラス生成、読み込み失敗時の処理まで確認する必要があります。

参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?