【Python】循環importでハマった話と解決法

Python

こんにちは、かつコーチです。

「さっきまで動いていたのに、ファイルを1つ分割しただけでImportErrorが出るようになった」

そんな経験はないでしょうか。

原因を探ると、AがBをimportし、BもAをimportしている循環importだった、というのはよくある話です。

この記事では、循環importがなぜ起きるのか、実際のエラーメッセージを見ながら再現し、遅延importと設計変更による解決策を解説します。

読み終える頃には、循環importに遭遇しても慌てず対処できるようになります。

循環importが起きる状況を再現する

モデルとサービスが互いを参照するケース

ECサイトのような構成を想定します。

models.pyUserクラス、services.pyに注文処理の関数を置きます。

# models.py
from services import calculate_discount


class User:
    def __init__(self, name, points):
        self.name = name
        self.points = points

    def get_discount(self, price):
        return calculate_discount(self, price)
# services.py
from models import User


def calculate_discount(user: User, price: int) -> int:
    if user.points > 100:
        return int(price * 0.9)
    return price

Usercalculate_discountを使いたくてservicesをimportし、services側も型ヒントのためにUserをimportしています。

一見自然な設計ですが、この時点で循環importが仕込まれています。

実際に発生するImportErrorのメッセージ

models.pyを直接実行してみます。

python models.py
Traceback (most recent call last):
  File "models.py", line 1, in <module>
    from services import calculate_discount
  File "/path/to/services.py", line 1, in <module>
    from models import User
ImportError: cannot import name 'User' from partially initialized module 'models' (most likely due to a circular import) (/path/to/models.py)

筆者も初めてこのエラーを見たとき、「models.pyUserは確かに定義してあるのに、なぜ見つからないんだ」と数十分悩みました。

原因は名前の消し忘れではなく、import処理の途中で自分自身に戻ってきてしまうことにあります。

なぜ「途中で戻ってくる」と失敗するのか

Pythonはモジュールを読み込むとき、ファイルの中身を上から順に実行します。

models.pyを実行すると、1行目のfrom services import calculate_discountservices.pyの読み込みが始まります。

services.pyの1行目はfrom models import Userですが、この時点のmodelsモジュールはまだUserクラスの定義に到達する前の状態です。

そのため「modelsモジュールは存在するが、Userという名前はまだない」というエラーになります。

partially initialized module(初期化途中のモジュール)という表現が、まさにこの状態を指しています。

よくあるつまずきポイント・エラー対処

相互import自体をやめられないときの遅延import

すぐに設計を見直す時間がない場合、遅延import(関数の中でimportする方法)で応急処置ができます。

❌ Before(ファイル先頭でimportして循環が起きる)

# services.py
from models import User


def calculate_discount(user: User, price: int) -> int:
    if user.points > 100:
        return int(price * 0.9)
    return price

✅ After(関数の中でimportする)

# services.py
def calculate_discount(user, price: int) -> int:
    from models import User  # 関数内でimportすることで実行タイミングを遅らせる

    if not isinstance(user, User):
        raise TypeError("userはUserインスタンスである必要があります")
    if user.points > 100:
        return int(price * 0.9)
    return price

関数の中に書いたimportは、その関数が呼び出された時点で初めて実行されます。

モジュール読み込み時にはまだ実行されないため、循環にぶつかりません。

ただし型ヒントとして使えなくなる副作用があるので、あくまで応急処置と考えてください。

型ヒントを維持したい場合は、if TYPE_CHECKING:ブロックを使う方法もあります。

# services.py
from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from models import User


def calculate_discount(user: "User", price: int) -> int:
    if user.points > 100:
        return int(price * 0.9)
    return price

TYPE_CHECKINGは実行時にはFalseなので、実際のimportは発生しません。

型チェッカー(mypyなど)を使うときだけUserが解決される仕組みです。

根本解決は「共通部分を第3のモジュールに切り出す」設計変更

遅延importは対症療法にすぎません。

根本的には、AとBが互いを直接参照する構造そのものを見直す必要があります。

有効な手段は、両者が依存する共通部分を第3のモジュールに切り出すことです。

# discount.py(新設:割引ロジックだけを独立させる)
def calculate_discount_amount(points: int, price: int) -> int:
    if points > 100:
        return int(price * 0.9)
    return price
# models.py
from discount import calculate_discount_amount


class User:
    def __init__(self, name, points):
        self.name = name
        self.points = points

    def get_discount(self, price):
        return calculate_discount_amount(self.points, price)
# services.py
from models import User


def notify_discount(user: User, price: int) -> None:
    discount_price = user.get_discount(price)
    print(f"{user.name}様の割引後価格は{discount_price}円です")

discount.pyはどちらのモジュールにも依存していないため、循環の起点になりません。

依存の方向が「models.pydiscount.py」「services.pymodels.py」と一方向に整理され、循環そのものが構造的に発生しなくなります。

筆者の経験では、循環importが起きるコードはたいてい「1つのファイルに責務を詰め込みすぎている」サインでもあります。

エラーの応急処置だけで終わらせず、設計の見直しにつなげることをおすすめします。

まとめ

この記事のポイント

  • 循環importは、AがBを、BがAをimportし合う構造で発生する
  • エラーメッセージのpartially initialized moduleは、読み込み途中で自分自身に戻ってきたことを示す
  • 遅延import(関数内import)やTYPE_CHECKINGは応急処置として有効
  • 根本解決には、共通ロジックを第3のモジュールに切り出す設計変更が必要
  • 循環importの多発は、責務が分離できていない設計上のサインでもある

次に読むべき記事

  • dataclassでシンプルにクラスを定義する
  • Pythonで簡単なCLIツールを作ってみる(argparse活用)
  • モジュールとインポートの基本

タグ: Python, 上級者向け, エラー解決

タイトルとURLをコピーしました