Круговой импорт в Python и как его избежать

Вы когда-нибудь сталкивались с круговым импортом в Python? Это очень распространенный запах кода, который указывает на то, что что-то не так с дизайном или структурой.

Пример кругового импорта

Как появляется круговой импорт? Эта ошибка импорта обычно возникает, когда два или более взаимозависимых модуля пытаются совершить импорт до того, как будут полностью инициализированы.

Допустим, у нас есть два модуля: module_1.py и module_2.py.

# module_1.py
from module_2 import ModY
class ModX:
    mody_obj = ModY()
# module_2.py
from module_1 import ModX
class ModY:
    modx_obj = ModX()

Здесь module_1 и module_2 взаимно зависят друг от друга.

Инициализация mody_obj в module_1 зависит от module_2, а инициализация modx_obj в module_2 зависит от module_1.

Это то, что мы называем круговой зависимостью. При попытке загрузить друг друга оба модуля застрянут в циклах импорта.

Если мы запустим module_1.py, то получим следующий трейсбек:

Traceback (most recent call last):
  File "module_1.py", line 1, in <module>
    from module_2 import ModY
  File "module_2.py", line 1, in <module>
    from module_1 import ModX
  File "module_1.py", line 1, in <module>
    from module_2 import ModY
ImportError: cannot import name 'ModY' from partially initialized module 'module_2' (most likely due to a circular import)

Эта ошибка объясняет ситуацию с круговым импортом. Когда программа попыталась импортировать ModY из module_2, в это время module_2 не был полностью инициализирован (из-за другого оператора импорта, который пытался импортировать ModX из module_1).

Как исправить круговой импорт в Python? Есть несколько способов.

Исправление кругового импорта в Python

Перенос кода в общий файл

Чтобы избежать ошибок импорта, можно перенести код в общий файл, а затем попытаться импортировать модули из этого файла.

# main.py ----> common file
class ModX:
    pass

class ModY:
    pass

В этом фрагменте кода мы перенесли классы ModX и ModY в общий файл (main.py).

# module_1.py
from main import ModY

class Mod_X:
    mody_obj = ModY()
# module_2.py
from main import ModX

class Mod_Y:
    modx_obj = ModX()

Теперь module_1 и module_2 импортируют классы из main, что исправляет ситуацию с круговым импортом.

У этого подхода есть проблема: иногда кодовая база настолько велика, что переносить код в другой файл становится рискованно.

Перенос импорта в конец модуля

Мы можем перенести оператор import в конец модуля. Это даст время на полную инициализацию модуля перед импортом другого модуля.

# module_1.py
class ModX:
   pass

from module_2 import ModY

class Mod_X:
   mody_obj = ModY()
# module_2.py
class ModY:
   pass

from module_1 import ModX

Импорт модуля в области видимости класса или функции

Кругового импорта можно избежать, импортируя модули в области видимости класса или функции. Это позволяет импортировать модуль только при вызове этого класса (функции). Это актуально, когда мы хотим минимизировать использование памяти.

# module_1.py
class ModX:
  pass

class Mod_X:
   from module_2 import ModY
   mody_obj = ModY()
# module_2.py
class ModY:
   pass

class Mod_Y:
   from module_1 import ModX
   modx_obj = ModX()

Мы перенесли операторы импорта в область видимости классов Mod_X и Mod_Y в module_1 и module_2 соответственно.

Если мы запустим module_1 или module_2, то не получим ошибку кругового импорта. Однако при таком подходе класс становится доступным только в пределах области видимости класса, поэтому мы не можем использовать импорт глобально.

Использование имени модуля или алиаса

Использование имени модуля или просто псевдонима тоже решает проблему. Это позволяет обоим модулям загружаться полностью, откладывая круговую зависимость до времени выполнения.

# module_1.py
import module_2 as m2

class ModX:
    def __init__(self):
        self.mody_obj = m2.ModY()
# module_2.py
import module_1 as m1

class ModY:
    def __init__(self):
        self.modx_obj = m1.ModX()

Использование библиотеки importlib

Мы также можем использовать библиотеку importlib, чтобы импортировать модули динамически.

# module_1.py
import importlib

class ModX:
    def __init__(self):
        m2 = importlib.import_module('module_2')
        self.mody_obj = m2.ModY()
# module_2.py
import importlib

class ModY:
    def __init__(self):
        m1 = importlib.import_module('module_1')
        self.mody_obj = m1.ModX()

Круговой импорт в пакетах Python

Обычно круговой импорт происходит в модулях одного пакета. В сложных проектах структура каталогов также сложная, с пакетами внутри пакетов.

Эти пакеты и подпакеты содержат файлы __init__.py, чтобы обеспечить более легкий доступ к модулям. Именно здесь иногда непреднамеренно возникают круговые зависимости между модулями.

Допустим, у нас есть следующая структура каталогов:

root_dir/
|- mainpkg/
|---- modpkg_x/
|-------- __init__.py
|-------- module_1.py
|-------- module_1_1.py
|---- modpkg_y/
|-------- __init__.py
|-------- module_2.py
|---- __init__.py
|- main.py

У нас есть пакет mainpkg и файл main.py. Внутри mainpkg есть два подпакета: modpkg_x и modpkg_y.

Вот как выглядит каждый Python-файл в modpkg_x и modpkg_y.

mainpkg/modpkg_x/__init__.py:

from .module_1 import ModX
from .module_1_1 import ModA

Этот файл импортирует оба класса (ModX и ModA) из module_1 и module_1_1.

mainpkg/modpkg_x/module_1.py:

from ..modpkg_y.module_2 import ModY
class ModX:
    mody_obj = ModY()

module_1 импортирует класс ModY из module_2.

mainpkg/modpkg_x/module_1_1.py:

class ModA:
    pass

module_1_1 ничего не импортирует. Он не зависит ни от одного модуля.

mainpkg/modpkg_y/__init__.py:

from .module_2 import ModY

Этот файл импортирует класс ModY из module_2.

mainpkg/modpkg_y/module_2.py:

from ..modpkg_x.module_1_1 import ModA
class ModY:
    moda_obj = ModA()

module_2 импортирует класс ModA из module_1_1.

В файле main.py мы имеем следующий код.

root_dir/main.py:

from mainpkg.modpkg_y.module_2 import ModY

def mody():
    y_obj = ModY()

mody()

Файл main импортирует класс ModY из module_2. Этот файл является зависимым от module_2.

Если не учитывать файлы __init__.py внутри modpkg_x и modpkg_y, цикл импорта будет выглядеть следующим образом:

Цикл импорта

Мы видим, что файл main зависит от module_2, module_1 также зависит от module_2, а module_2 зависит от module_1_1. Кругового импорта нет.

Но вы знаете, что модули зависят от своего файла __init__.py, поэтому сначала инициализируется файл __init__.py, а затем модули повторно импортируются.

Цикл импорта, измененный из-за __init__.py

Вот как теперь выглядит цикл импорта.

Итоговый цикл импорта

В результате module_1_1 стал зависеть от module_1, что является фальшивой зависимостью.

Если дело обстоит именно так, то удаление файлов __init__.py из подпакетов и использование отдельного файла __init__.py может помочь централизовать импорт на уровне пакета.

root_dir/
|- mainpkg/
|---- modpkg_x/
|-------- __init__.py  # empty file
|-------- module_1.py
|-------- module_1_1.py
|---- modpkg_y/
|-------- __init__.py  # empty file
|-------- module_2.py
|---- subpkg/
|-------- __init__.py
|---- __init__.py
|- main.py

В этой структуре мы добавили еще один подпакет subpkg внутри mainpkg.

mainpkg/subpkg/__init__.py:

from ..modpkg_x.module_1 import ModX
from ..modpkg_x.module_1_1 import ModA
from ..modpkg_y.module_2 import ModY

Это позволит внутренним модулям импортировать из одного источника, что уменьшит необходимость в перекрестном импорте.

Теперь можно обновить оператор import в файле main.py.

root_dir/main.py:

from mainpkg.subpkg import ModY
def mody():
    y_obj = ModY()

mody()

Это решит проблему круговой зависимости между модулями внутри одного пакета.

Заключение

Круговая зависимость или круговой импорт в Python — это запах кода, который указывает на необходимость серьезной реструктуризации и рефакторинга кода.

Чтобы избежать круговой зависимости в Python, вы можете попробовать любой из вышеупомянутых способов.

Перевод статьи “Stuck with circular imports? Use these methods to avoid them…”.

Прокрутить вверх