描述#
本 SPEC 建议一种针对库的延迟加载机制,该机制可以避免导入速度下降,并提供明确的子模块导出。
例如,它允许以下行为
import skimage as ski # cheap operation; does not load submodules
ski.filters # cheap operation; loads the filters submodule, but not
# any of its submodules or functions
ski.filters.gaussian(...) # loads the file in which gaussian is implemented
# and calls that function
这有几个优点
-
它公开了一个**表现为扁平命名空间的嵌套命名空间**。这避免了必须仔细导入正确的子模块组合,并允许在交互式终端中交互式地探索命名空间。
-
它**避免了必须优化导入成本**。目前,开发人员经常将导入移动到函数内部,以避免减慢模块导入速度。当在整个库中实现延迟导入时,所有导入都变得廉价。
-
它提供了**对子模块的直接访问**,避免了本地命名空间冲突。与其使用
import scipy.linalg as sla
来避免覆盖本地linalg
,现在可以导入每个库并直接访问其成员:import scipy; scipy.linalg
。
核心项目认可#
认可本 SPEC 意味着原则上同意上述延迟加载的优势。
生态系统采用#
我们不建议所有项目都使用延迟加载。例如,导入开销低的小型项目不需要它。当您担心子包导入时间,但又希望在例如 IPython 中使这些子包可用于交互式探索时,延迟加载非常有用。
采用本 SPEC 意味着使用lazy_loader
包或任何其他机制(例如模块__getattr__
)实现子包的延迟加载,以及如果需要,子包属性的延迟加载。
scikit-image、NetworkX和MNE-Python已采用延迟加载。SciPy 实现延迟加载的一个子集,该子集仅延迟公开子包。napari采用了lazy_loader
的一个原型实现。
徽章#
项目可以通过包含 SPEC 徽章来突出显示它们对本 SPEC 的采用。
[](https://scientific-python.cn/specs/spec-0001/)
|SPEC 1 — Lazy Loading of Submodules and Functions|
.. |SPEC 1 — Lazy Loading of Submodules and Functions| image:: https://img.shields.io/badge/SPEC-1-green?labelColor=%23004811&color=%235CA038
:target: https://scientific-python.cn/specs/spec-0001/
实现#
背景#
早期,大多数科学 Python 包都明确地导入了它们的子模块。例如,您可以执行以下操作
import scipy
scipy.linalg.eig(...)
这很方便:它具有扁平命名空间的简单性,但具有嵌套命名空间的组织性。但是,有一个缺点:导入子模块,尤其是大型子模块,会导致速度下降。
有一段时间,SciPy 有一种名为PackageLoader
的延迟加载机制。它最终被放弃了,因为它经常以令人困惑的方式失败,尤其是在与交互式提示一起使用时。
此后,大多数库都停止了导入子模块,而是依靠文档来告诉用户要导入哪些子模块。
现在代码通常如下所示
from scipy import linalg
linalg.eig(...)
由于linalg
子模块经常与其他库中的类似实例冲突,因此用户还会编写
# Invent an arbitrary name for each submodule
import scipy.linalg as sla
sla.eig(...)
或
# Import individual functions, making it harder to know where they are from
# later on in code.
from scipy.linalg import eig
eig(...)
Python 3.7 通过PEP 562引入了覆盖模块__getattr__
和__dir__
的功能。结合起来,这些功能使得可以再次提供对子模块的访问,但不会产生性能损失。
lazy_loader
#
为了使项目更容易实现子模块和函数的延迟加载,我们提供了一个名为lazy_loader
的实用程序库。它在https://github.com/scientific-python/lazy_loader中实现,并且可以通过pypi和conda-forge安装。
用法#
例如,我们将展示如何为skimage.filters
设置延迟导入。在库的主__init__.py
中,指定哪些子模块是延迟加载的
import lazy_loader as lazy
submodules = [
...
'filters',
...
]
__getattr__, __dir__, _ = lazy.attach(__name__, submodules)
然后,在每个子模块的__init__.py
(在本例中为filters/__init__.py
)中,指定要从哪里加载哪些函数
import lazy_loader as lazy
__getattr__, __dir__, __all__ = lazy.attach(
__name__,
submodules=['rank']
submod_attrs={
'_gaussian': ['gaussian', 'difference_of_gaussians'],
'edges': ['sobel', 'sobel_h', 'sobel_v',
'scharr', 'scharr_h', 'scharr_v',
'prewitt', 'prewitt_h', 'prewitt_v',
'roberts', 'roberts_pos_diag', 'roberts_neg_diag',
'laplace',
'farid', 'farid_h', 'farid_v']
}
)
以上等效于
from . import rank
from ._gaussian import gaussian, difference_of_gaussians
from .edges import (sobel, sobel_h, sobel_v,
scharr, scharr_h, scharr_v,
prewitt, prewitt_h, prewitt_v,
roberts, roberts_pos_diag, roberts_neg_diag,
laplace,
farid, farid_h, farid_v)
区别在于子模块仅在访问时加载一次
import skimage
dir(skimage.filters) # This works as usual
此外,子模块内的函数仅在需要时加载一次
import skimage
skimage.filters.gaussian(...) # Lazy load `gaussian` from
# `skimage.filters._gaussian`
skimage.filters.rank.mean_bilateral(...) # Loaded once `rank` is accessed
一个缺点是错误或缺失的导入不再立即失败。在开发和测试期间,可以设置EAGER_IMPORT
环境变量来禁用延迟加载,以便可以发现此类错误。
外部库#
lazy_loader.attach
函数是设置包内部导入的替代方法。我们还提供lazy_loader.load
,以便项目可以延迟导入外部库
linalg = lazy.load('scipy.linalg') # `linalg` will only be loaded when accessed
默认情况下,导入错误会被推迟到使用时才会引发。可以使用以下方法立即引发导入错误:
linalg = lazy.load('scipy.linalg', error_on_import=True)
类型检查器#
上面显示的延迟加载有一个缺点:静态类型检查器(例如mypy和pyright)将无法推断延迟加载的模块和函数的类型。因此,mypy
将无法检测潜在的错误,并且诸如VS Code之类的集成开发环境将无法提供代码补全。
为了解决此限制,我们提供了一种定义延迟导入的替代方法。与其在__init__.py
文件中使用lazy.attach
导入模块和函数,不如在__init__.pyi
文件中指定这些导入——称为“类型存根”。然后,您的__init__.py
文件使用lazy.attach_stub
从存根加载导入。
以下是如何转换此__init__.py
的示例
# mypackage/__init__.py
import lazy_loader as lazy
__getattr__, __dir__, __all__ = lazy.attach(
__name__,
submod_attrs={
'edges': ['sobel', 'sobel_h', 'sobel_v']
}
)
在与__init__.py
相同的目录中添加一个类型存根(__init__.pyi
)文件。类型存根在运行时会被忽略,但会被静态类型检查器使用。
# mypackage/__init__.pyi
from .edges import sobel as sobel, sobel_h as sobel_h, sobel_v as sobel_v
显式导入命名sobel as sobel
是由于 PEP 484 所必需的。或者,您可以手动提供__all__
# mypackage/__init__.pyi
__all__ = ['sobel', 'sobel_h', 'sobel_v']
from .edges import sobel, sobel_h, sobel_v
将mypackage/__init__.py
中的lazy.attach
替换为对attach_stub
的调用
import lazy_loader as lazy
# this assumes there is a `.pyi` file adjacent to this module
__getattr__, __dir__, __all__ = lazy.attach_stub(__name__, __file__)
请注意,如果您使用类型存根,则需要采取其他措施将.pyi
文件添加到您的 sdist 和 wheel 分发版中。有关更多信息,请参见PEP 561和mypy 文档。
注意事项#
到目前为止,我们只知道一个延迟加载不起作用的极端情况。当您在一个同名文件(my_func.py
)中定义一个延迟加载的函数(例如my_func
)**并且**运行 doctest 时,就会出现这种情况。不知何故,doctest 收集器修改了父模块的__dict__
以包含my_func
(模块,而不是函数),基本上绕过了延迟加载器及其提供my_module.my_func
(函数)的能力。幸运的是,有一种简单的方法可以解决这个问题,并且它已经与常见做法一致:改为在_my_func.py
中定义my_func
(注意下划线)。
YAML 文件#
一旦实现了延迟导入接口,其他有趣的选择就会变得可用(但在lazy_loader
中未实现)。例如,与其像上面那样指定子子模块和函数,不如在 YAML 文件中这样做
$ cat skimage/filters/init.yaml
submodules:
- rank
functions:
- _gaussian:
- gaussian
- difference_of_gaussians
- edges:
- sobel
- sobel_h
- sobel_v
- scharr
...
最终,我们希望延迟导入成为 Python 本身的一部分,但开发人员表示这不太可能1。在此期间,我们现在拥有了自行实现它的必要机制。
注释#
-
Brett Cannon 的延迟加载博客文章展示了该概念的可行性,并为我们的设计提供了信息。
-
技术改进发生在scikit-image PR附近
-
mkinit是一个自动生成
__init__.py
文件的工具,并支持这种延迟加载机制。例如,参见NetworkX PR #4496。 -
延迟加载讨论是在NetworkX PR #4401中发起的。
-
Cannon B.,个人交流,2021 年 1 月 7 日。 ↩︎