Python中的循环导入依赖问题

95

假设我有以下目录结构:

a\
    __init__.py
    b\
        __init__.py
        c\
            __init__.py
            c_file.py
        d\
            __init__.py
            d_file.py
a包的__init__.py中,引入了c包。但是c_file.py文件引入了a.b.d,程序报错说b不存在。实际上这个错误是因为在引入时b还未被定义。如何解决这个问题?

1
也许你可以尝试使用相对导入?https://dev59.com/y3VD5IYBdhLWcg3wJIAK - eremzeit
1
这可能会有所帮助 https://ncoghlan_devs-python-notes.readthedocs.org/en/latest/python_concepts/import_traps.html - maazza
同样作为参考,似乎在Python 3.5(及更高版本)中允许循环导入,但在3.4(及以下版本)中不允许。 - Charlie Parker
1
如果你捕获了导入错误,只要在第一个模块完成导入之前不需要使用另一个模块中的任何内容,它就可以正常工作。 - Gavin S. Yancey
@CharlieParker 根据Python 3.5的新特性,这仅适用于相对导入。相关的问题跟踪条目在此处Python 3.7也进行了更改,以支持某些绝对导入情况。但是,这并不能防止AttributeError-它使得在sys.modules中查找部分初始化的模块成为可能,但无法解决时间上的悖论。 - Karl Knechtel
7个回答

171

你可以推迟导入,例如在a/__init__.py中:

def my_function():
    from a.b.c import Blah
    return Blah()

也就是说,延迟导入直到真正需要它。然而,我还会仔细查看我的包定义/使用,因为像指出的这样的循环依赖可能表示设计问题。


5
有时候循环引用确实是不可避免的。在这种情况下,这是我唯一可行的方法。 - Jason Polites
1
这样做会不会在每次调用 foo 时增加很多开销? - Mr_and_Mrs_D
7
@Mr_and_Mrs_D - 只是适度的。Python会将所有导入的模块保存在全局缓存中 (sys.modules),因此一旦加载了模块,就不会再次加载。代码可能需要在每次调用 my_function 时进行名称查找,但引用限定名称的代码(例如 import foo; foo.frobnicate())也是如此。 - Dirk
25
有时循环引用恰好是建模问题的正确方式。认为循环依赖是设计不良的表现似乎更多地反映了 Python 作为一种语言,而不是一个合法的设计要点。 - Julie in Austin
@TomSawyer - 我不认为这本身是一种不好的做法(特别是有时候可能是必须的)。然而,在放弃并使用这种解决方案之前,我会尝试通过重新构建我的模块依赖关系来解决问题,因为我自己发现它不太可读。实际上被认为是一种不好的做法是在局部作用域中执行from xxx import *。但是,即使在模块级别上,import *也不受欢迎,并且在“现代”Python中在局部作用域中仍然是语法错误,这使得问题变得复杂。 - Dirk
显示剩余2条评论

67

如果a依赖于c,而c又依赖于a,那么它们实际上不是同一个单元吗?

你应该仔细考虑为什么要将a和c拆分成两个包,因为要么你有一些代码应该拆分成另一个包(使它们都依赖于该新包,但彼此之间不依赖),要么就应该将它们合并为一个包。


131
是的,它们可以被视为同一套软件包。但是,如果这会导致一个非常巨大的文件,那么这是不切实际的。我同意,经常出现循环依赖意味着设计应该再次考虑。但是有一些设计模式是适合这种情况的(并且将文件合并在一起会导致一个巨大的文件),因此我认为说这些软件包应该合并或重新评估设计是教条主义的。 - Matthew Lund

34

我有时会想到这个问题(通常是处理需要相互了解的模型时)。简单的解决方案就是导入整个模块,然后引用你所需的东西。

因此,不要这样做:

from models import Student

在一个里面,并且

from models import Classroom

在另一个中,只需执行

import models

在它们中的一个中,当你需要时调用models.Classroom


你可以展示一下models.py的样子吗?我不想把所有的类定义都放在一个文件里。我想创建一个models.py,从每个文件中导入每个类。我需要看到一个示例文件结构。 - R OMS
1
它不需要成为一个文件@ROMS模型可以是一个目录,其中包含一个__init__.py文件,该文件从models.classroom进行导入。 - zachaysan
2
请注意,这仅在没有立即运行并尝试访问“models”属性的顶级代码的情况下解决问题。 - Karl Knechtel
非常好的观点@KarlKnechtel,尽管最好的做法是避免这样做。 - zachaysan

18

由于类型提示而导致的循环依赖

使用类型提示,会有更多机会创建循环导入。幸运的是,可以使用特殊常量typing.TYPE_CHECKING来解决。

以下示例定义了一个Vertex类和一个Edge类。边由两个顶点定义,而顶点维护一个属于它的相邻边的列表。

没有类型提示,没有错误

文件:vertex.py

class Vertex:
    def __init__(self, label):
        self.label = label
        self.adjacency_list = []

文件:edge.py

class Edge:
    def __init__(self, v1, v2):
        self.v1 = v1
        self.v2 = v2

类型提示导致ImportError

ImportError: 无法从部分初始化的模块 'edge' 导入 'Edge'(很可能是由于循环导入)

文件:vertex.py

from typing import List
from edge import Edge


class Vertex:
    def __init__(self, label: str):
        self.label = label
        self.adjacency_list: List[Edge] = []

文件:edge.py

from vertex import Vertex


class Edge:
    def __init__(self, v1: Vertex, v2: Vertex):
        self.v1 = v1
        self.v2 = v2

使用TYPE_CHECKING的解决方案

文件:vertex.py

from typing import List, TYPE_CHECKING

if TYPE_CHECKING:
    from edge import Edge


class Vertex:
    def __init__(self, label: str):
        self.label = label
        self.adjacency_list: List[Edge] = []

文件:edge.py

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from vertex import Vertex


class Edge:
    def __init__(self, v1: Vertex, v2: Vertex):
        self.v1 = v1
        self.v2 = v2

请注意类型提示周围的引号的缺失。由于Python 3.10中注解的延迟评估, 这些类型提示被视为在引号中。

带引号与不带引号的类型提示

在Python 3.10之前的版本中,有条件地导入类型必须用引号括起来,使它们成为“前向引用”,这将隐藏它们免受解释器运行时的影响。

在Python 3.7、3.8和3.9中,可以使用以下特殊导入作为解决方法。

from __future__ import annotations

0
我建议使用以下模式。这样做将允许自动完成和类型提示正常工作。

cyclic_import_a.py

import playground.cyclic_import_b

class A(object):
    def __init__(self):
        pass

    def print_a(self):
        print('a')

if __name__ == '__main__':
    a = A()
    a.print_a()

    b = playground.cyclic_import_b.B(a)
    b.print_b()

cyclic_import_b.py

import playground.cyclic_import_a

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

    def print_b(self):
        print('b1-----------------')
        self.a.print_a()
        print('b2-----------------')

您不能使用此语法导入A类和B类

from playgroud.cyclic_import_a import A
from playground.cyclic_import_b import B

在类B的__init__方法中,您无法声明参数a的类型,但可以通过以下方式进行“转换”:

def __init__(self, a):
    self.a: playground.cyclic_import_a.A = a

0
问题在于,当从目录运行时,默认情况下只有子目录中的包可见作为候选导入,因此您无法导入a.b.d。但是,您可以导入b.d.,因为b是a的子包。
如果您真的想在c/__init__.py中导入a.b.d,您可以通过将系统路径更改为a上面的一个目录并更改a/__init__.py中的导入来实现这一点,即import a.b.c。
您的a/__init__.py应该像这样:
import sys
import os
# set sytem path to be directory above so that a can be a 
# package namespace
DIRECTORY_SCRIPT = os.path.dirname(os.path.realpath(__file__)) 
sys.path.insert(0,DIRECTORY_SCRIPT+"/..")
import a.b.c

当您想将C模块作为脚本运行时,会出现另一个困难。这里不存在a和b包。您可以在C目录中修改__int__.py以将sys.path指向顶级目录,然后在C内的任何模块中导入__init__,以便使用完整路径导入a.b.d。我怀疑导入__init__.py不是一个好的做法,但它对我的用例起作用。


-4

另一种解决方案是使用代理来处理d_file。

例如,假设您想与c_file共享blah类。因此,d_file包含:

class blah:
    def __init__(self):
        print("blah")

这是你在c_file.py中输入的内容:

# do not import the d_file ! 
# instead, use a place holder for the proxy of d_file
# it will be set by a's __init__.py after imports are done
d_file = None 

def c_blah(): # a function that calls d_file's blah
    d_file.blah()

在a的__init__.py文件中:
from b.c import c_file
from b.d import d_file

class Proxy(object): # module proxy
    pass
d_file_proxy = Proxy()
# now you need to explicitly list the class(es) exposed by d_file
d_file_proxy.blah = d_file.blah 
# finally, share the proxy with c_file
c_file.d_file = d_file_proxy

# c_file is now able to call d_file.blah
c_file.c_blah() 

11
在另一个文件中修改全局模块属性,这样做很快就会导致噩梦。 - Antimony

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接