11
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[Python]循環importになるケースでも型アノテーションができるケースがある、という話

Last updated at Posted at 2021-12-30

TL;DR

Pythonで2つのモジュール間でお互いにimportしており、且つその中でのクラスなどの型アノテーションをエラーにならずに対応できるケースがあるよ、という話です。

使う環境

  • OS: Debian GNU/Linux 10
  • Python 3.6

※個人で趣味で作っているPythonライブラリの最低バージョンの3.6に合わせているので新しいPythonバージョンではもしかしたら話が変わってくるかもしれません(そろそろPython 3.6のサポートを切っても良いかもですしね・・・)。

悩ましいPythonの循環importのエラーの話

他の静的型付け言語だと特に気になったりしないのですが、Pythonだと2つのモジュールでお互いにimportしあう形になっているとImportErrorになったりすることがあります。例えば以下のようにsample_A.pyというモジュールとsample_B.pyというモジュールがあるとして、お互いに各モジュール内のクラスを型アノテーションなどでimportしている・・・といったケースです。

sample_A.py
from sample_B import SampleB


class SampleA:

    def sample_method(self) -> SampleB:
        ...
sample_B.py
from sample_A import SampleA


class SampleB:

    def sample_method(self) -> SampleA:
        ...

この状態でsample_Aモジュールを読み込んだりすると以下のようなエラーになったりします。

...
    from sample_B import SampleB
ImportError: cannot import name 'SampleB'

こうなるケースは稀ではあり基本的には意識することはあまり無いのですが、コード量が多いプロジェクトなどだとたまに型アノテーション目的などでこのような循環importをしたくなるケースが発生しています。

今まではどうしても循環importにするのが自然な場合には片側の型アノテーションでAnyを使ったりもしくは型エイリアスを使って「なんの型なのか?」ということを分かりやすいように調整したりしていました。

sample_B.pyでのAnyを使った例
from typing import Any


class SampleB:

    def sample_method(self) -> Any:
        ...
sample_B.pyでのAnyを使った例
from typing import Any


class SampleB:

    def sample_method(self) -> Any:
        ...
sample_B.pyでの型エイリアスを使って型が分かりやすい名前を設定した例
from typing import Any

_SampleA = Any


class SampleB:

    def sample_method(self) -> _SampleA:
        ...

しかしこれらの対応の場合AnyなどではmypyやPylanceなどでの型チェックはスルーされてしまいます。出来たら正確な型アノテーションをしたいところです。

解決策1: ローカルスコープであればそのスコープ内でimportを行う

1つ目の解決策として世の中のネット記事でも良く触れられているものとして、もし片側がローカルスコープ(関数やメソッド内など)であればその中でimportするという方法があります。例えばメソッド内の変数で対象の型のアノテーションが必要なのであればメソッド内でimportすることで循環importとならずに対応が効きます。例えば以下のような感じになります。

sample_B.py
class SampleB:

    def sample_method(self) -> None:
        from sample_A import SampleA
        sample_variable: SampleA = SampleA()

これであればsample_A側のモジュールが読み込まれた際にトップレベルのスコープでsample_Bモジュールがimportされますが、sample_B側ではこのメソッドが呼び出されるまでsample_A側のimportはされないので循環importにはなりません。

ただしこの書き方ではトップレベルの要素での型アノテーションなどができません。例えばグローバル変数であったりクラスのメソッドの引数や返却値などへの型アノテーションができません(そもそもローカルスコープでしかimportしていないのでそのローカルスコープのもので型アノテーションをすることができません)。

解決策2: モジュールだけimportして且つ文字列を使って型アノテーションをする

2つ目の解決策として、モジュールのみのimportと文字列による型アノテーションの方法があります。

まずモジュールのみのimportの場合ですが、これは循環importでエラーになったりはしません。各モジュールが以下のようなコードになっている状態で実行してみてもImportError無く通ります。

sample_A.py
import sample_B


class SampleA:

    def sample_method(self) -> None:
        ...
sample_B.py
import sample_A


class SampleB:

    def sample_method(self) -> None:
        ...

ただしこの状態で対象のモジュールを参照する形でクラスを使った型アノテーションなどをしてしまうとこの時点でエラーになります(ImportErrorではないのが少々紛らわしい・・・)。

sample_A.py
import sample_B


class SampleA:

    def sample_method(self) -> sample_B.SampleB:
        ...
    def sample_method(self) -> sample_B.SampleB:
AttributeError: module 'sample_B' has no attribute 'SampleB'

このエラーを回避する方法として、型アノテーション部分を文字列でやっておく・・・とエラーを回避できるという対策法があります。もともとはPython 3.7~3.10などでは自身のクラスをそのクラスのメソッドに対して型アノテーションをする場合from __future__ import annotationsなどの指定が必要(Python 3.11以降からはデフォルトの挙動となるので不要)、且つ3.6などの場合にはfutureのものも使えないため文字列で型アノテーションすると対応ができる・・・という言語仕様のものを利用した形となります。

例えば以下のように'sample_A.SampleA'といったようにクォーテーションで囲って書きます。

sample_A.py
import sample_B


class SampleA:

    def sample_method(self) -> 'sample_B.SampleB':
        ...
sample_B.py
import sample_A


class SampleB:

    def sample_method(self) -> 'sample_A.SampleA':
        ...

このように書くことでImportErrorなどにもならずに且つ両方のモジュールでお互いのクラスの型アノテーションの指定ができるようになります。また、この書き方でもVS Codeでのシンタックスハイライトはクラスなどの色としてちゃんと認識してくれますし、Pylanceなどの型チェックや入力補完なども正しく動作します。

image.png

解決策3: typing.TYPE_CHECKING を使う

前述までの対策でも大体解決できるのですが、まだこれだと対応が効かないケースがあります。たとえば継承用のクラスとして使い、且つ循環importになっているケースです。

継承用のクラス指定ではメソッドの引数や返却値などへの型アノテーションと異なり文字列での型の指定が効きません(エラーになります)。例えば以下のように片方のモジュールでは継承として使い、もう片方では型アノテーションとしてお互いのクラスを使用しているケースを考えます。

sample_A.py
from sample_B import SampleB


class SampleA(SampleB):

    def sample_method(self) -> None:
        ...
sample_B.py
import sample_A


class SampleB:

    def sample_method(self) -> 'sample_A.SampleA':
        ...

上記だとエラーになります。

...
    from sample_B import SampleB
ImportError: cannot import name 'SampleB'

メソッドの引数や返却値への型アノテーションと異なり、クラスの継承に文字列を指定してみてもこれはサポートされていないため動きません。

sample_A.py
import sample_B


class SampleA('sample_B.SampleB'):

    def sample_method(self) -> None:
        ...
...
    class SampleA('sample_B.SampleB'):
TypeError: str() argument 2 must be str, not tuple

ではどうするのか・・・という感じですが、この記事を書いていて調べていたら知ったのですが(いまだに知らない型アノテーションの言語仕様が出てきます・・・)型チェック時のみTrueになるtypingパッケージのTYPE_CHECKINGという値も存在します。

このTYPE_CHECKINGの値がTrueの時のみ対象の型アノテーション用のものをimportする・・・と設定すると、

  • 実際の実行時 -> importはされないので循環importのエラーにはならない
  • mypyなどによる型チェック時 -> importはされるものの、mypyやPylance上では循環importによるエラーになったりしない

といった挙動になるためエラーを回避しつつmypyやPylanceの恩恵を得る・・・ということができます。例えば先ほどの例でいうと継承が必要なモジュール側では普通にimportを行って、もう片方の型アノテーションでのみ必要なmodule側ではTYPE_CHECKINGを使った分岐を加えておく(且つ、こちらでは文字列で型アノテーションをしておく)・・・という対応が効きます。

コードは以下のような形になります。

from sample_B import SampleB


class SampleA(SampleB):

    def sample_method(self) -> None:
        ...
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from sample_A import SampleA


class SampleB:

    def sample_method(self) -> 'SampleA':
        ...

使うケースは稀ではありますが、とりあえずこれらを把握しておくことでAnyを使わなければいけない・・・といったケースを軽減できそうです。

参考文献・参考サイト

11
8
1

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
11
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?