Python typing 遇到循环导入
Python typing 遇到循环导入
Python 有着和 C++ 极为相似的 import 系统,都是 include once。这样就很容易产生循环包含/导入的问题。
从依赖关系角度,确实可以通过将依赖关系构建成树的方式来避免循环导入。但是当使用 typing 的时候,情况就不一样了……
测试环境配置
Ubuntu 22.04,配置 conda。
wget https://mirrors.tuna.tsinghua.edu.cn/anaconda/miniconda/Miniconda3-latest-Linux-x86_64.sh
sudo sh ./Miniconda3-latest-Linux-x86_64.sh
然后会进入一个命令行环境,一路回车/空格/yes 即可。安装完成后,重启终端即可使用 conda
。
创建环境:
conda create --name py310 python=3.10
conda activate py310
但是我使用的是 fish,它会提示(bash 则没有该问题):
CommandNotFoundError: Your shell has not been properly configured to use 'conda activate'.
To initialize your shell, run
$ conda init <SHELL_NAME>
Currently supported shells are:
- bash
- fish
- tcsh
- xonsh
- zsh
- powershell
See 'conda init --help' for more information and options.
IMPORTANT: You may need to close and restart your shell after running 'conda init'.
根据提示,得到解决方法:
conda init fish
然后遵循提示重启终端。这样 activate
就没问题了。查看当前环境:
> conda activate py310 (base)
> conda env list (py310)
# conda environments:
#
base /home/oslab/miniconda3
py310 * /home/oslab/miniconda3/envs/py310
其实不用特地查看,fish 会自动显示当前环境……
简单的测试用例与问题
下面来测试一个简单的 EC 系统。每个 Entity 会挂 Component,同时 Component 保存持有它的 Entity。
# component.py
class Component:
def __init__(self, entity: Entity):
self.entity = entity
# entity.py
from component import Component
class Entity:
def __init__(self):
self.component = Component(self)
测试代码如下:
from entity import Entity
e = Entity()
print(e.component.entity)
尝试运行,没有问题,因为没有循环导入。
> python main.py (py310)
<entity.Entity object at 0x7fd0b3957d60>
modern python 是要使用 typing 的,于是我们给 Component 加上 typing:
# component.py
from entity import Entity
class Component:
def __init__(self, entity: Entity):
self.entity = entity
结果不出所料,循环导入导致导入了部分初始化的类。
> python main.py (py310)
Traceback (most recent call last):
File "***/main.py", line 1, in <module>
from entity import Entity
File "***/entity.py", line 1, in <module>
from component import Component
File "***/component.py", line 1, in <module>
from entity import Entity
ImportError: cannot import name 'Entity' from partially initialized module 'entity' (most likely due to a circular import) (***/entity.py)
如果把 import 放到最后也是没有用的。它不提示循环引用了,而是 Entity 未定义:
# component.py
class Component:
def __init__(self, entity: Entity):
self.entity = entity
from entity import Entity
> python main.py (py310)
Traceback (most recent call last):
File "***/main.py", line 1, in <module>
from entity import Entity
File "***/entity.py", line 1, in <module>
from component import Component
File "***/component.py", line 1, in <module>
class Component:
File "***/component.py", line 2, in Component
def __init__(self, entity: Entity):
NameError: name 'Entity' is not defined
如果使用 Pylance 它还会亲切地提示 "Entity" is unbound
。解决方案:给类型注解的 Entity
加引号:entity: "Entity"
。不过新的问题就来了:
> python main.py (py310)
Traceback (most recent call last):
File "***/main.py", line 1, in <module>
from entity import Entity
File "***/entity.py", line 1, in <module>
from component import Component
File "***/component.py", line 11, in <module>
from entity import Entity
ImportError: cannot import name 'Entity' from partially initialized module 'entity' (most likely due to a circular import) (***/entity.py)
刚才只是语法检查时发生错误,现在仍然没有避免循环导入。
解决这个问题就需要消除两个文件互相 import。但实质上是不存在循环导入问题的,因为我仅仅是增加了方便静态分析的类型注解,对程序运行毫无影响。
解决方法
解决方法参考了 https://adamj.eu/tech/2021/05/13/python-type-hints-how-to-fix-circular-imports/。
源于一个思想:我如何让 import 只用于静态分析,让分析器找到注解的类型?运行期并不需要这个导入。
typing
提供了一个 TYPE_CHECKING
全局常量,它是这么描述的,仅当类型检测时为 True
:
# Constant that's True when type checking, but False here.
TYPE_CHECKING = False
然后就可以写成:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from entity import Entity
class Component:
def __init__(self, entity: "Entity"):
self.entity = entity
注意,如果不给注解的 Entity
加引号,就会再次出 NameError: name 'Entity' is not defined
……这样很蠢,不是吗?
但是我们有 __future__
用于实现未来版本增加的特性。
# __future__.py
annotations = _Feature((3, 7, 0, "beta", 1),
(3, 11, 0, "alpha", 0),
CO_FUTURE_ANNOTATIONS)
这里可以看到,这个特性在 3.7 出现,到了 3.11 正式包含为默认。修改后代码如下:
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from entity import Entity
class Component:
def __init__(self, entity: Entity):
self.entity = entity
然后问题就解决了。
这个特性还可以解决另一个问题:Self 类型。给 Component
加一个 clone
方法:
class Component:
def __init__(self, entity: "Entity"):
self.entity = entity
def clone(self) -> "Component":
return Component(self.entity)
返回类型需要加引号,否则同样会出现 NameError
。导入 annotations
则可以免去引号。
当然,返回类型也可以使用 Self
,不过在 3.10 会报错 ImportError: cannot import name 'Self' from 'typing'
,因为这是 3.11 才加的。
...
from typing import Self
class Component:
def __init__(self, entity: "Entity"):
self.entity = entity
def clone(self) -> Self:
return Component(self.entity)
但是 typing_extensions
提供了未来版本增加的类型注解(miniconda 默认未安装,需要手动下载),可在 Python 3.11 前代替 typing
里的 Self
。
from typing_extensions import Self
可以看一下这个 Self
是怎么实现的。如果版本大于等于 3.11,那么直接从官方 typing 里导入相关类型,否则声明一个 _SpecialForm
类型的变量(不知道有无编译器开洞)。
# unfortunately we have to duplicate this class definition from typing.pyi or we break pytype
class _SpecialForm:
def __getitem__(self, parameters: Any) -> object: ...
if sys.version_info >= (3, 10):
def __or__(self, other: Any) -> _SpecialForm: ...
def __ror__(self, other: Any) -> _SpecialForm: ...
# New things in 3.11
# NamedTuples are not new, but the ability to create generic NamedTuples is new in 3.11
if sys.version_info >= (3, 11):
from typing import (
LiteralString as LiteralString,
NamedTuple as NamedTuple,
Never as Never,
NotRequired as NotRequired,
Required as Required,
Self as Self,
Unpack as Unpack,
assert_never as assert_never,
assert_type as assert_type,
clear_overloads as clear_overloads,
dataclass_transform as dataclass_transform,
get_overloads as get_overloads,
reveal_type as reveal_type,
)
else:
Self: _SpecialForm
Never: _SpecialForm
...
换到 3.11 就没这么多问题了,涉及 TYPE_CHECKING
的类型,理论上不加引号是可以的,但是如果提示 NameError
,那就把 import annotations
加上吧……