Python类型提示如何避免循环导入

341
我试图将我的庞大类拆分成两个,基本上是拆分成"主"类和一个带有附加函数的mixin,如下所示:

main.py文件:

import mymixin.py

class Main(object, MyMixin):
    def func1(self, xxx):
        ...

mymixin.py文件:

class MyMixin(object):
    def func2(self: Main, xxx):  # <--- note the type hint
        ...

现在,虽然这样做完全没有问题,但是MyMixin.func2中的类型提示当然无法工作。我不能导入main.py,因为会出现循环导入,并且没有提示,我的编辑器(PyCharm)无法知道self是什么。

我正在使用Python 3.4,但如果有解决方案,我愿意转到3.5。

有没有办法将我的类拆分成两个文件并保持所有“连接”,以便我的IDE仍然提供自动完成和从中了解类型等所有其他好处?


5
通常情况下,不需要注释self的类型,因为它总是当前类的子类(任何类型检查系统都应该能够自行解决这个问题)。func2是否尝试调用在MyMixin中未定义的func1?也许应该将其定义为抽象方法 (abstractmethod)? - Blckknght
1
通常情况下,更具体的类(例如您的mixin)应该在类定义中放在基类的左侧,即class Main(MyMixin, SomeBaseClass),以便来自更具体类的方法可以覆盖来自基类的方法。 - Anentropic
29
我不确定这些评论有什么用,因为它们与所提出的问题毫无关系。velis并没有要求代码审查。 - Jacob Lee
7个回答

481

一般来说,处理导入循环并没有什么非常优雅的方法。你的选择是要么重新设计你的代码以消除循环依赖,要么如果不可行,就像这样处理:

# some_file.py

from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from main import Main

class MyObject(object):
    def func2(self, some_param: 'Main'):
        ...

TYPE_CHECKING常量在运行时始终为False,因此导入不会被评估,但是mypy(和其他类型检查工具)将评估该块的内容。

我们还需要将Main类型注释转换为字符串,有效地预先声明它,因为Main符号在运行时不可用。

如果您使用的是Python 3.7+,我们可以利用PEP 563来跳过提供显式字符串注释的步骤:

# some_file.py

from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from main import Main

class MyObject(object):
    # Hooray, cleaner annotations!
    def func2(self, some_param: Main):
        ...

from __future__ import annotations 导入将使得所有类型提示成为字符串并跳过对它们的评估。这可以帮助我们的代码在某种程度上更加人性化。

尽管如此,使用mypy和mixins可能需要比您目前拥有的更多的结构。Mypy 建议一种方法,基本上是描述了 deceze 所说的 - 创建一个ABC,让你的MainMyMixin类都继承它。我不会惊讶如果你最终需要做类似的事情来让Pycharm的检查器满意。


9
谢谢。我的当前Python 3.4版本没有typing,但PyCharm对于使用if False:也很满意。 - velis
3
这是与 typing. TYPE_CHECKING 相应的 PEP:https://www.python.org/dev/peps/pep-0484/#runtime-or-type-checking - Conchylicultor
1
这个很棒!你可以在混合时进行代码检查和类型检查,而且不会在运行时出现循环导入问题。谢谢! - Wyrmwood
1
当我尝试指定一个方法的返回类型时,例如 def func()->Main: pass 并使用示例中的 Main,我仍然遇到了问题。如果我按照您所描述的方式进行导入,那么返回类型 Main 将无法识别,它必须正常导入。 - KZiovas
感谢您七年后来到这个问题,试图理解如何为自定义类做Python类型提示,而不创建Java接口的等效物。 - chiffa
显示剩余2条评论

94

对于那些在仅为了类型检查而导入类时遇到循环导入问题的人们,你可能会想要使用向前引用(PEP 484 - 类型提示):

当类型提示包含尚未定义的名称时,可以将该定义表示为字符串文字,以便稍后解析。

所以,不是:

class Tree:
    def __init__(self, left: Tree, right: Tree):
        self.left = left
        self.right = right

你做:

class Tree:
    def __init__(self, left: 'Tree', right: 'Tree'):
        self.left = left
        self.right = right

可能是PyCharm。你使用的是最新版本吗?你尝试过 File -> Invalidate Caches 吗? - Tomasz Bartkowiak
谢谢。抱歉,我已经删除了我的评论。它提到这个代码可以工作,但是PyCharm会报错。我使用了Velis建议的if False hack解决了这个问题。清除缓存并不能解决它。这可能是一个PyCharm的问题。 - Jacob Lee
2
@JacobLee 你可以使用 from typing import TYPE_CHECKINGif TYPE_CHECKING: 来代替 if False: - luckydonald
13
如果该类型位于另一个模块中,这种方法将不起作用(至少 PyCharm 无法理解)。如果字符串可以是一个完全限定的路径,那就太好了。 - olejorgenb
这个解决方案在VSCode中运行良好!谢谢!! - B Bau

27

更为重要的问题是,你的类型本身就不合理。 MyMixin 做出了一个硬编码的假设,即它将混入 Main 中,而它实际上可以混入任意数量的其他类中,这样很可能会导致其破裂。如果你的 mixin 被硬编码为只能混入一个特定的类中,那么你可能还不如直接将方法编写到该类中,而不是将它们分开。

为了以合理的类型方式来正确实现这一点,MyMixin 应该针对一个接口或抽象类进行编码:

import abc


class MixinDependencyInterface(abc.ABC):
    @abc.abstractmethod
    def foo(self):
        pass


class MyMixin:
    def func2(self: MixinDependencyInterface, xxx):
        self.foo()  # ← mixin only depends on the interface


class Main(MixinDependencyInterface, MyMixin):
    def foo(self):
        print('bar')

2
好的,我并不是说我的解决方案很棒。这只是我尝试做的事情,以使代码更易于管理。你的建议可能可行,但在我的__特定__情况下,这实际上意味着将整个Main类移动到接口中。 - velis
我认为这是唯一正确的解决方案。既然 OP 想要 MainMyMixin 分别在文件 main.py 和 mymixin.py 中,那么我猜这必然意味着需要创建第三个文件 api.py 来保存 MixinDependencyInterface,对吗? - Géry Ogam
1
@velis 在编程中,typing.Protocol 可以代替 abc.ABC,因为你不需要实际子类化它来注册。这是提供接口的正确方式,而 abc.ABC 更适用于提供部分完成的实现,即你实际上想要子类化它。 - Simply Beautiful Art

21

与其强制自己参与typing.TYPE_CHECKING的麻烦,有一种简单方法可以避免循环类型提示:不要使用from导入,并使用from __future__ import annotations或字符串注释。

# foo.py
from __future__ import annotations
import bar


class Foo:
    bar: bar.Bar

# bar.py
import foo


class Bar:
    foo: "foo.Foo"

这种导入方式是“惰性求值”的,而使用 from foo import Foo 则会强制 Python 运行整个 foo 模块,以立即在导入行获取 Foo 的最终值。如果需要在运行时使用它,那么它非常有用,例如如果在函数/方法中需要使用 foo.Foobar.Bar,因为只有在可以使用 foo.Foobar.Bar 时才应调用您的函数/方法。


如果不实际导入用户定义的类(因为这会导致循环导入错误),那么最佳方法是什么?例如,如果用户定义的类作为参数传递给另一个模块中的类。我本以为第二个例子是正确的,但是我的VS Code中的代码检查器将其标记为问题。 - dduffy

21
自Python 3.5以来,将类拆分为单独的文件变得容易。
实际上,您可以在class ClassName:块中使用import语句将方法导入到类中。例如,

class_def.py:

class C:
    from _methods1 import a
    from _methods2 import b

    def x(self):
        return self.a() + " " + self.b()

在我的例子中,

  • C.a() 是一个返回字符串 hello 的方法。
  • C.b() 是一个返回 hello goodbye 的方法。
  • C.x() 因此将返回 hello hello goodbye

要实现ab,请执行以下操作:

_methods1.py:

from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from class_def import C

def a(self: C):
    return "hello"
说明:当类型检查器读取代码时,TYPE_CHECKINGTrue。由于类型检查器不需要执行代码,因此循环导入在出现在if TYPE_CHECKING:块内时是可以的。__future__导入启用了postponed annotations。这是可选的;如果没有它,您必须引用类型注释(即def a(self: "C"):)。

我们同样地定义_methods2.py

from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from class_def import C

def b(self: C):
    return self.a() + " goodbye"

在 VS Code 中,当我悬停在 self.a() 上时,可以看到检测到的类型: enter image description here。 一切都按预期运行:
>>> from class_def import C
>>> c = C()
>>> c.x()
'hello hello goodbye'

关于旧版Python的注释

对于Python版本≤3.4,未定义TYPE_CHECKING,因此此解决方案将无法使用。

对于Python版本≤3.6,延迟注释未定义。作为解决方法,省略from __future__ import annotations并引用上述提到的类型声明。


15

结果表明,我的原始尝试也非常接近解决方案。这是我目前正在使用的代码:

# main.py
import mymixin.py

class Main(object, MyMixin):
    def func1(self, xxx):
        ...
# mymixin.py
if False:
    from main import Main

class MyMixin(object):
    def func2(self: 'Main', xxx):  # <--- note the type hint
        ...

请注意在if False语句中的导入,该导入永远不会被导入(但IDE仍然知道它),以及将Main类用作字符串,因为在运行时未知。


我希望这会引起有关死代码的警告。 - Phil
3
@Phil: 是的,当时我使用的是Python 3.4。现在有了typing.TYPE_CHECKING。 - velis
2
看起来很傻,但在PyCharm中可以工作。我投了赞成票! :) - shredEngineer

-2

我建议您重构代码,正如其他人建议的那样。

我可以向您展示我最近遇到的一个循环错误:

之前:

# person.py
from spell import Heal, Lightning

class Person:
    def __init__(self):
        self.life = 100

class Jedi(Person):
    def heal(self, other: Person):
        Heal(self, other)

class Sith(Person):
    def lightning(self, other: Person):
        Lightning(self, other)

# spell.py
from person import Person, Jedi, Sith

class Spell:
    def __init__(self, caster: Person, target: Person):
        self.caster: Person = caster
        self.target: Person = target

class Heal(Spell):
    def __init__(self, caster: Jedi, target: Person):
        super().__init__(caster, target)
        target.life += 10

class Lightning(Spell):
    def __init__(self, caster: Sith, target: Person):
        super().__init__(caster, target)
        target.life -= 10

# main.py
from person import Jedi, Sith

步骤如下:

# main starts to import person
from person import Jedi, Sith

# main did not reach end of person but ...
# person starts to import spell
from spell import Heal, Lightning

# Remember: main is still importing person
# spell starts to import person
from person import Person, Jedi, Sith

控制台:

ImportError: cannot import name 'Person' from partially initialized module
'person' (most likely due to a circular import)

一个脚本/模块只能被一个脚本导入。

之后:

# person.py
class Person:
    def __init__(self):
        self.life = 100

# spell.py
from person import Person

class Spell:
    def __init__(self, caster: Person, target: Person):
        self.caster: Person = caster
        self.target: Person = target

# jedi.py
from person import Person
from spell import Spell

class Jedi(Person):
    def heal(self, other: Person):
        Heal(self, other)

class Heal(Spell):
    def __init__(self, caster: Jedi, target: Person):
        super().__init__(caster, target)
        target.life += 10

# sith.py
from person import Person
from spell import Spell

class Sith(Person):
    def lightning(self, other: Person):
        Lightning(self, other)

class Lightning(Spell):
    def __init__(self, caster: Sith, target: Person):
        super().__init__(caster, target)
        target.life -= 10

# main.py
from jedi import Jedi
from sith import Sith

jedi = Jedi()
print(jedi.life)
Sith().lightning(jedi)
print(jedi.life)

执行代码行的顺序:

from jedi import Jedi  # start read of jedi.py
from person import Person  # start AND finish read of person.py
from spell import Spell  # start read of spell.py
from person import Person  # start AND finish read of person.py
# finish read of spell.py

# idem for sith.py

控制台:

100
90

文件组成是关键,希望能对你有所帮助 :D


4
我想提醒一下,这个问题不是关于将多个类拆分到多个文件中。而是关于将单个类拆分成多个文件。也许我可以把这个类重构为多个类,但在这种情况下我不想这样做。实际上,每件东西都应该在那里。但是维护一个超过1000行的源码很困难,所以我按照一些任意标准进行了拆分。 - velis

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