导入一个包时发生了什么?

19 投票
2 回答
5312 浏览
提问于 2025-04-17 18:19

为了提高效率,我正在尝试了解 Python 是如何处理它的对象堆和命名空间的(命名空间的部分我大致明白)。简单来说,我想弄清楚对象是何时加载到堆中的,它们有多少,生命周期有多长等等。

我的问题是当我使用一个包并从中导入某些东西时

from pypackage import pymodule

哪些对象会被加载到内存中(也就是 Python 解释器的对象堆)?更一般来说:会发生什么? :)

我猜上面的例子大概是这样的:在内存中创建了一个包 pypackage 的对象(这个对象包含了一些关于包的信息,但不多),模块 pymodule 被加载到内存中,并在本地命名空间中创建了它的引用。这里重要的是:没有其他的 pypackage 模块(或其他对象)被加载到内存中,除非在模块本身或包的初始化过程中明确说明(这些我不太熟悉)。最后,内存中唯一的大对象就是 pymodule(也就是在导入模块时创建的所有对象)。是这样吗?如果有人能稍微澄清一下这个问题,我会很感激。也许你能推荐一些有用的文章?(文档通常涵盖更具体的内容)

我找到了一些关于模块导入的相同问题的回答:

当 Python 导入一个模块时,它首先检查模块注册表(sys.modules)以查看该模块是否已经被导入。如果是这样,Python 就会使用现有的模块对象。

否则,Python 会做以下事情:

  • 创建一个新的空模块对象(这本质上是一个字典)
  • 将该模块对象插入到 sys.modules 字典中
  • 加载模块代码对象(如果需要,先编译模块)
  • 在新模块的命名空间中执行模块代码对象。所有由代码分配的变量都可以通过模块对象访问。

我也希望能得到关于包的类似解释。

顺便提一下,包的模块名称在 sys.modules 中的添加方式有点奇怪:

>>> import sys
>>> from pypacket import pymodule
>>> "pymodule" in sys.modules.keys()
False
>>> "pypacket" in sys.modules.keys()
True

还有一个实际问题与此相关。

当我构建一组工具,可能会在不同的进程和程序中使用时,我把它们放在模块里。即使我只想使用其中声明的一个函数,我也不得不加载整个模块。看来可以通过制作小模块并将它们放入一个包中来减轻这个问题(如果一个包在你只导入其中一个模块时不会加载所有模块的话)。

有没有更好的方法来在 Python 中制作这样的库?(这些函数本身没有模块内的依赖关系。)使用 C 扩展可以做到吗?

PS 抱歉提了这么长的问题。

2 个回答

3

当我们导入一个模块时,大致会经历以下几个步骤:

  1. Python会先在sys.modules中查找这个模块,如果找到了,就不再做其他事情。模块是通过它的全名来索引的,所以如果pymodule不在sys.modules中,但pypacket.pymodule在的话,我们可以通过sys.modules["pypacket.pymodule"]来获取它。

  2. Python会找到实现这个模块的文件。如果这个模块是某个包的一部分(通过x.y的语法来判断),它会查找名为x的目录,这个目录里需要有一个__init__.py文件和一个y.py文件(或者更深层的子包)。最终找到的文件可能是.py文件、.pyc文件,或者.so/.pyd文件。如果没有找到合适的文件,就会抛出一个ImportError错误。

  3. 接着,Python会创建一个空的模块对象,并用这个模块的__dict__作为执行的命名空间来执行模块中的代码。1

  4. 最后,这个模块对象会被放入sys.modules中,并注入到导入者的命名空间里。

第3步是“对象被加载到内存”的时刻:这里的对象指的是模块对象,以及它的__dict__中包含的命名空间内容。这个字典通常包含在执行模块中所有的defclass和其他顶层语句时产生的顶层函数和类。

需要注意的是,上述内容只是描述了import的默认实现。实际上,有很多方法可以自定义导入行为,比如重写内置的__import__函数,或者实现导入钩子。


1 如果模块文件是.py源文件,它会先被编译到内存中,然后执行编译后生成的代码对象。如果是.pyc文件,代码对象会通过反序列化文件内容来获取。如果模块是.so.pyd共享库,它会通过操作系统的共享库加载机制来加载,并调用init<module>这个C函数来初始化模块。

13

你这里有几个不同的问题……

关于导入包

当你导入一个包时,步骤和导入一个模块是一样的。唯一的区别是,包的代码(也就是创建“模块代码对象”的代码)是在包的__init__.py文件里。

所以,包的子模块不会被加载,除非__init__.py里明确地加载它们。如果你写from package import module,那么只会加载module,当然,如果这个模块又导入了包里的其他模块,那就另当别论了。

sys.modules中加载的模块名称

当你从一个包中导入一个模块时,添加到sys.modules中的名称是“合格名称”,它包含了模块名称以及你导入它的包的名称,用点号分隔。所以如果你写from package.subpackage import mod,那么添加到sys.modules的就是"package.subpackage.mod"

只导入模块的一部分

通常,导入整个模块而不是只导入一个函数并不是个大问题。你说这很“痛苦”,但实际上几乎不会有这种情况。

如果你说这些函数没有外部依赖,那它们就是纯Python代码,加载它们不会花太多时间。通常,如果导入一个模块需要很长时间,那是因为它还加载了其他模块,这意味着它确实有外部依赖,你必须加载整个模块。

如果你的模块在导入时有一些耗时的操作(也就是说,它们是全局模块级别的代码,而不是在函数内部),但并不是所有函数都需要这些操作,那么你可以考虑重新设计你的模块,把这些加载推迟到后面。也就是说,如果你的模块做了类似这样的事情:

def simpleFunction():
    pass

# open files, read huge amounts of data, do slow stuff here

你可以把它改成

def simpleFunction():
    pass

def loadData():
    # open files, read huge amounts of data, do slow stuff here

然后告诉别人“当你想加载数据时,调用someModule.loadData()”。或者,正如你建议的那样,你可以把模块中耗时的部分放到包里的一个单独模块中。

我从来没有发现导入一个模块会对性能产生显著影响,除非这个模块已经大到可以合理地拆分成更小的模块。创建很多只包含一个函数的小模块,除了让维护变得麻烦之外,几乎不会有什么好处。你真的有具体的情况让你觉得这样会有区别吗?

另外,关于你最后提到的,依我所知,C扩展模块和纯Python模块一样,都是采用全或无的加载策略。显然,就像Python模块一样,你可以把东西拆分成更小的扩展模块,但你不能写from someExtensionModule import someFunction而不同时运行作为这个扩展模块一部分的其他代码。

撰写回答