一、Module and Package?

在Python中,Module和Package十分相似,但又有所不同。

1. 相同点

import os              # module
import concurrent      # package

无论是module还是package, 直接使用import语句导入它们都是没有问题的。

此外,Python解释器导入module或package的第一步, 都是先查找缓存。如果缓存里没有这个变量,它才会真正去做"load"这个动作。

并且,当我们打印:

print(type(os))       # <class 'module'>
print(type(concurrent))      # <class 'module'>

我们会发现,module和package实际上都是module类。实际上,package就是特殊的module。

2. 不同点

Module通常指一个.py文件,而Package则是一个文件夹。

  • 当Python解释器load一个Module时,它首先会在一个单独的命名空间中执行一遍这个module对应的.py文件,然后将运行时得到的Python object作为属性赋值给module object。module此时就是一个普普通通的对象,你可以用module.XX来访问这个对象中的属性。

  • 当Python解释器load一个package时,它首先会在package对应的文件夹中寻找是否存在__init__.py这个文件。如果找不到这个文件,那么什么文件都不会运行。我们除了得到一个全局变量package module对象外,不会得到任何东西。反之,如果有__init__.py文件,python解释器会主动运行它,并将运行时得到的Python object作为属性赋值给package, 就像上面的module一样。

二、Python解释器运行时如何查找Package和module所在位置?

1. 区分内置模块、标准库模块和 第三方模块

作为过来人,我们很容易误以为Python的内置模块和标准库模块是同一个东西,因为它们在诸多方面有种完全相同的特征。但事实上,它们是不同的。

  • Built-in Module 内置模块是 Python解释器直接提供的模块,它们是编译到 Python 解释器中的,不需要从文件系统加载。千万务必注意最后这句话:“不需要从文件系统加载”。 (问题1)

  • Standard Library Modules标准库模块是在Python安装时就直接存在于安装目录的Lib目录下的模块。这也就意味着,它们的导入依赖文件系统,你真把这个文件删了,Python也就没法导入这个标准库了。

  • Third-party Modules,通常是pip安装的模块,也有可能是你自己在当前目录写的package或module。总之,既不是内置模块,也不是标准库模块的,通通称之为第三方模块。

2. 最高优先级的Built-in模块

正是由于Built-in模块已经深深嵌入Python解释器当中,当我们使用语句,如:

import test

Python解释器的第一步,就是“自查”,看看test是不是嵌入模块。像os就是一个嵌入式模块。

此时,哪怕你是否在当前目录下存在一个os.py文件,Python解释器都不会错误的把当前目录的os.py作为module导入。

3. 同等地位的标准库和第三方模块

比Built-in模块查找地位略低的,是标准库模块和第三方模块。

还是上面的例子,当我们import test时,当Python解释器确认这不是一个Built-in模块后,它就会跑到sys.path中进行寻找。

import sys

print(sys.path)
# ['D:\\Learning\\Python.learning\\XXX', 'D:\\python3.11\\python311.zip', 'D:\\python3.11\\DLLs', 'D:\\python3.11\\Lib', 'D:\\python3.11', 'C:\\Users\\pbl\\AppData\\Roaming\\Python\\Python311\\site-packages', 'D:\\python3.11\\Lib\\site-packages', 'D:\\python3.11\\Lib\\site-packages\\win32', 'D:\\python3.11\\Lib\\site-packages\\win32\\lib', 'D:\\python3.11\\Lib\\site-packages\\Pythonwin']

需要注意的是,Python解释器在查找模块时,是严格按照sys.path中的顺序进行的。而sys.path中的第一个路径就是你当前的工作目录,优先级最高。

这也就意味着: 如果你在当前目录下创建了一个与标准模块或第三方同名的.py文件或者文件夹,那么就会产生糟糕的“覆盖”现象!

同名模块覆盖导入

运行结果:

ModuleNotFoundError: No module named 'concurrent.futures'; 'concurrent' is not a package。

三、绝对导入与相对导入

1. 直观的写法区别

from package import moduleA        # 绝对导入
from . import moduleB              # 相对导入

从写法上直观的看,相对导入的模块以"."开头,而绝对导入则直接以模块名开头。

2. 绝对导入的不同形式

绝对导入有多种不同的形式:

import packageA                     # ①
import packageA.moduleA             # ②             
from packageA.moduleA import A      # ③

尽管形式多样,但它们的第一步都是先按照(二)中的顺序进行模块查找;

例如导入语句:
import packageA.moduleA

Python解释器首先会看package是不是嵌入式模块,如果不是,就按照sys.path里的顺序查找package;

找到package后,就在package里找有没有moduleA。如果没有,就退出去,继续在sys.path里看还有没有其他package,直到最后找到package, 并且在package中找到moduleA;

回顾前面提到的知识点,由于packageA是一个包而非模块,Python解释器会看packageA中是否有init.py文件,并尝试运行。如果没有,由于导入语句最终指向模块的是moduleA,Python解释器会将moduleA作为属性添加到packageA中。

特别需要注意,哪怕packageA中本来有moduleA.py这个文件,但如果你只是使用导入语句:
import packageA

packageA这个不会有moduleA这个属性。这一点你可以通过代码print(dir(packageA))进行查看。这便是导入语句①②的区别。

而对于导入语句③,它们和①②唯一的区别就是: 在Python解释器通过moduleA或packageA找到对应模块,将模块对应的__init__.py脚本运行一遍(如果存在)。在把模块中对应的A变量赋值给当前运行脚本的A变量后就完成了任务。packageA.moduleA这个变量也是无法访问的。

3. 让人疑惑的相对导入

如果你尝试过直接运行过使用相对导入的.py文件,例如:
from .moduleA import A
你会遭遇错误:ImportError: attempted relative import with no known parent package。 我们首先得明白,一切相对导入,都只是形式上的。真正在Python运行时,相对导入都会变成绝对导入。 那么Python是如果把相对导入变成绝对导入的呢?事实上,它是依靠__package__这个变量。
当我们在一个.py中打印__package__时:

print(__package__)      # None

我们会得到None。这意思是,Python不认为当前文件所在目录是模块,而是一个脚本文件。 但是,如果我们导入一个package, 如 from package import moduleA, 然后打印moduleA.__package__时,我们就会得到moduleA当前所在package文件夹的真实名字: package。
假设在moduleA中存在导入语句:

from .utils import Utils 

那么在Python运行时,它就会变成:

from package.utils import Utils;

所以,本质上,你无法运行一个使用相对导入的文件,是因为它无法根据__package__(=None)把"相对导入"变成"绝对导入"。

但如果你是从别处导入这个文件,那么这个使用相对导入文件的package变量就是正确的。

那么有没有办法直接以模块的形式运行使用相对导入的模块呢?很简单,你可以使用:

python -m moduleA.py。

其中, “-m”参数就是以模块形式运行".py"文件的意思。 通过这种方式, 这个相对导入文件中的package就是当前文件夹的名字,而非None了。

4. 相对导入有何用?

如果你尝试过自己写一个package, 并且package中有多个python文件,文件与文件之间存在互相导入的关系。那么,你就会发现,如果此时文件与文件之间的导入全部是绝对导入,当你在直接运行这些module时,不会出现任何问题。

那么什么时候会出现问题呢?答案是当一个外部文件导入这个package的时候!

为了更好地模拟,我们假设存在这么一个文件目录结构:

Root/
│
├── mypackage/
│   │
│   ├── packageA/
│   │   └── moduleA.py
│   │
│   ├── packageB/
│   │   └── moduleB.py
│   │
│   └── __init__.py
│
└── main.py

假设我在mypackage包的__init__.py使用导入语句:

from packageA import moduleA

紧接着,在main.py中使用导入语句:

import mypackage

那么将会报错:ModuleNotFoundError: No module named 'packageA'
那么,为什么会Python解释器会找不到packageA呢?理由很简单,因为使用了绝对导入,Python解释器会按照(二)中提到的顺序:内置模块->sys.path查找这个模块的位置,然而这些路径中根本找不到packageA这个文件夹啊!
如果你一定要在__init__.py使用绝对导入, 应该写成:

from mypackage.packageA import moduleA

但是,这种写法意味着,你要保证mypackage这个包的名字不会发生变化,这并不优雅!
相反,使用相对导入:

from .packageA import moduleA

在Python运行时,自然可以根据__package__这个变量将这个语句变成上述的绝对导入语句。而且这么写更加简洁明了,何乐而不为呢?

四、其他问题解答

问题一:

内置模块是 Python 解释器直接提供的模块,它们是编译到 Python 解释器中的,不需要从文件系统加载。可是我在Python安装目录中看到了os.py文件。如果说os模块已经嵌入到了Python解释器中,是不是意味着哪怕我删掉这个os.py文件,Python解释器依然可以正常导入os模块呢?

解答:

  1. 内置模块的特性 内置模块(如 os、sys、math 等)是直接编译到 Python 解释器中的。这意味着它们的实现是用 C 语言(或其他底层语言)编写的,并且在 Python 解释器启动时就已经加载到内存中。因此,这些模块的加载不依赖于文件系统。
  2. 为什么 Python 安装目录中有 os.py 文件? 尽管 os 是一个内置模块,但 Python 安装目录中确实存在一个 os.py 文件。这是因为: os.py 是一个包装器:os 模块的核心功能是内置的,但 os.py 文件是一个 Python 编写的包装器,它提供了额外的功能和接口,使得 os 模块更加易于使用。 跨平台兼容性:os 模块的行为在不同操作系统(如 Windows、Linux 和 macOS)上可能有所不同。os.py 文件中包含了针对不同操作系统的特定实现和接口,使得 os 模块在不同平台上都能正常工作。
  3. 即使删除 os.py 文件,os 模块是否仍然可用? 答案是:是的,os 模块仍然可以正常导入和使用,但可能会丢失一些高级功能。 核心功能:os 模块的核心功能是内置的,即使删除了 os.py 文件,这些核心功能仍然可用。 高级功能:os.py 文件中包含了一些额外的实现和接口,这些功能可能会丢失。例如,某些跨平台的特性或高级功能可能会失效。

问题二:

是不是所有相对导入语句都可以写成绝对导入语句?

解答:

  • 只要你的脚本文件是正常运行到package之外的,哪怕你把package中所有module的相对导入语句写死成绝对导入语句,也是不会报错的。因此,只要你正确的运行脚本,所有相对导入语句都可以写成绝对导入语句而不影响实际运行。