PythonにおいてannotationsとTYPE_CHECKINGで循環参照を防ぐ

3 min read読了の目安(約2700字

はじめに

Python で annotation(型付け)を付けていると、循環参照に陥ることがあります。

循環参照とは以下のようにモジュールの参照がループしてしまうことを指します。

  • モジュール A がモジュール B を参照
  • モジュール B がモジュール A を参照

a.py

from b import B


class A:
    def __init__(self):
        pass


if __name__ == '__main__':
    a: A = A()
    b: B = B(a)

b.py

from a import A


class B:
    def __init__(self, a: A):
        self.a: A = a

最初から型を付けながら設計・プログラミングする場合は、循環参照を起こさないように留意できます。
しかし、すでに何年も前に作られたライブラリに対して型を新しく付けようとすると、問題が発生します。
これは、型定義をしているモジュールをインポートしようとすると、相互参照になることがあるためです。
Python は型がなくても 実行時に実体が入っていればなんとかなってしまう ため、実体さえあればいいという設計だと、型定義を新しく付けた場合相互参照になります。

またプログラムが複雑になってくると、注意をしていたにも関わらず、型定義の問題で相互参照になってしまうことがあります。

TYPE_CHECKING

typingモジュールにはTYPE_CHECKINGという定数が設定されています。

これは mypy のような静的検査が使用されている場合に限り True となる変数です。
そのため、普段の通常使用時には False が入っています。

そのため、以下のようにすると静的検査の場合のみモジュールが読み込まれます。
はじめに、a.py では B が必要であるため、通常のように読み込みます。
しかし、b.py は annotation のためだけに必要であるため、TYPE_CHECKING==Trueのときのみ読み込むようにします。
このようにすると、静的検査と通常利用それぞれで以下のように動作が異なります。

  • 静的検査時
    • TYPE_CHECKING = False
    • 静的検査のときにはモジュール A を読み込む
  • 通常利用時
    • TYPE_CHECKING = True
    • モジュール A を読み込まないため循環参照にならない

このようにすることで、解析時のみ型のためにインポートをできます。
これによって、型+IDE の恩恵を受けることができます。

a.py

from b import B


class A:
    def __init__(self):
        pass


if __name__ == '__main__':
    a: A = A()
    b: B = B(a)

b.py

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from a import A


class B:
    def __init__(self, a: A):
        self.a: A = a

しかし、実は上 2 つのファイルは動作しません。
b.py において、「Aというクラスが型ヒントに利用されていますが、インポートされておらず名前がよくわからないよ」と出ます。
このままでは型の支援を受けることができますが実行できません。

NameError: name 'A' is not defined

annotations

__future__annotations モジュールを利用します。
こちらで分かりやすく書かれています。
PEPはこのページです。

クラス内において __lt__ メソッドをオーバーライドする際の型ヒントのためにも使われます。

アノテーションの評価が、コンパイル時ではなく 実行時 のタイミングで行われるようになります。
そのため、実行時点においてその名前が解決されていれば良くなります。

b.py を以下のように変更しました。

from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from a import A


class B:
    def __init__(self, a: A):
        self.a: A = a

これによって、実際にクラス B の中の引数 A が実行されたタイミングで、Aという名前を解決します。
このとき、すでに A の身元が分かっているため、問題ありません。
(おそらく、身元がわかっていなくても python は、型ヒントをたかだか ヒント までにしか標準では利用しないため、問題ありません。)

TYPE_CHECKING + annotations

TYPE_CHECKING は、静的検査のときのみモジュールをインポートさせることができます。
そのため、IDE の恩恵を受けながら、実行時はモジュールをインポートさせず、循環参照を防ぐことができます。

annotations は、実行時に注釈を解決するようになります。
そのため、IDE の恩恵を受けながら、実行時にはモジュールをインポートしなくても良くなります。

ただ、極力は相互参照しないようにコードをうまく設計したいですね。
もっと勉強していこうと思います。