Python 模块和包

以下来自《Python3 学习笔记》第六章的学习整理。

定义

模块(module)是顶层代码组织单元,其提供大粒度封装和复用。

通常一个模块对应一个源码文件。从某些角度来看, 模块就像更大规模度的类。其中定义的变量、函数、类型等,都属于私有成员。

模块在首次导入(import)时,被编译成字节码。随后解释器开始创建模块实例,执行初始化语句,构建内部成员。模块不仅是代码组织形式,还是一个运行期对象,其为成员提供全局名字空间。

无论被导入多少次,每个模块在整个解释器进程内都仅有一个实例存在。随后不会监测源文件是会改变。重复导入只是引用已经存在的实例,不会再次执行初始化过程。

demo.py

1
2
3
4
x = 1234
def hello(): pass
class User: pass
1
2
3
4
>>> import demo
>>> type(demo)
<class 'module'>

可以看到是module类的。

初始化

初始化过程就是将模块里的代码按序执行一遍。

普通语句直接执行,类似于defclass等则创建函数和类型对象。最终这些成员都被保存在模块的全局空间名字内。

注意:重复导入只是引用,不会再次执行初始化。

名字空间

模块的全局名字空间对应__dict__属性,其在内部不能被直接访问,且不会被dir输出。

dir返回目标,或当前名字空间可访问的名字列表

def创建函数对象时,会将所在模块的名字空间作为构造参数。无论后续将该函数传递到何处,或者绑定给其它模块,均不能改变函数内部globals宗师返回出生地的名字空间。

demo.py

1
2
def test():
return golobals()
1
2
3
4
5
6
7
8
9
10
>>> import demo
>>> demo.test() is vars(demo)
True
>>> m = types.ModuleType("abc")
>>> m.test = demo.test # 绑定给其它模块
>>> m.test() is vars(demo)
True
>>> m.test.__module__
'demo'

名字

可通过__name____file__获知所在模块的信息。或者,利用__module__返回类型、函数等对象的定义模块名称。

1
2
3
4
5
6
>>> x = demo.test
>>> x.__module__
'demo'
>>> demo.__file__
'/Users/XXX/Workspaces/Python/books/python notes/ch06-module/demo.py'

正常情况下,模块的名字对应源文件的名字(不含扩展名)。但是,当模块作为程序入口时,会被赋予__main__名字:

1
2
3
4
5
6
7
>>> demo.__name__
'demo'
# 修改 demo.py 内容为 print(__name__)
$ python3 demo.py
__main__

导入

在 Python 中每个模块文件都有自己独立的全局名字空间。除了内置函数的__builtins__模块外,所有的名字搜索不能超出当前模块。所以在外部导入时,需要先导入其当前的名字空间。

完整的导入步骤:

  1. 搜索目标模块文件
  2. 按需编译目标模块
  3. 创建模块实例,执行初始化
  4. 将模块实例保存到全局列表
  5. 在当前名字空间建立引用

一次简单的导入(import)就完成了这么多动作。

搜索

首先来看搜索。

导入的模块名没有路径信息的话,就需要按照系统提供的搜索方式和匹配规则。其中搜索路径,在解释器启动时,按优先级整理到sys.path列表中。

搜索路径列表:

  1. 程序根目录
  2. 环境变量(PYTHONPATH)设定的路径列表
  3. 标准库目录
  4. 第三方扩展库等附加路径

编译

确定模块文件路径后,解释器优先选择已编译过的字节码文件,为了提神性能嘛。在执行之前,需读取头信息,确定源码文件是否有更新,以便重新编译。

__pycache__这个目录就是存放字节码缓存文件的目录。

.pyc就是编译后的二进制文件,可以对其直接使用解释器执行,不需要源码(.py)

引用

这里说几点注意的:

  • 为了编码规范,通常是按照「标准库」、「扩展库」、「其它程序模块」的顺序引入
  • 不要使用*导入目标模块的所有成员
  • 使用成员导入比模块导入性能更好

针对星号导入这种不好的操作,可以通过成员私有化(添加下划线),这样私有成员就不会被星号导入;更好的做法是在模块内添加__all__声明,指定那些可以被星号导入的成员名字列表,为空是表示不会导入任何成员。

当然无论是私有成员还是__all__声明,都不会影响到显式导入。

动态导入

有时候我们需要动态导入,这时候可以通过两种方法进行导入:

1)使用exec动态执行,随后从sys.modules中获取模块实例:

1
2
3
4
5
6
7
8
import sys
def test(name):
exec(f"import {name}")
m = sys.modules[name]
print(m)
test("inspect)

2)使用importlib

1
2
3
4
5
import importlib
def test(name):
m = importlib.importlib_module(name)
print(m)

这两种方法都会将模块实例保存到sys.modules中。

重新载入

有时候我们需要在不重启进程的情况下,对某些模块实现「热更新」。

但是如何检测源文件改变、如何启动热更新,不在这里讨论。这里讨论的是尝试模块的重载方案。

首先我们知道模块实例保存在sys.modules列表中,通过移除实例引用,使其被回收后再重新冷导入呢?

demo.py

1
x = 1234

在 Ipython 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
In [1]: import demo
In [2]: demo.x
Out[2]: 1234
In [3]: import sys
In [4]: del sys.modules["demo"]
In [5]: !echo "x = 999" > demo.py
In [6]: import demo
In [7]: demo.x
Out[7]: 999

这样看来没有什么问题,再看其他引用呢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
In [8]: m = demo
In [9]: m.x
Out[9]: 999
In [14]: del sys.modules["demo"]
In [15]: !echo "x = 888" > demo.py
In [16]: import demo
In [17]: demo.x
Out[17]: 888
In [18]: m.x
Out[18]: 999

失败了,是因为虽然我们删除了旧模块,但是因为m的存在,导致旧demo模块实例没有被回收。如此一来,更新后它们各自引用不同的模块实例(即使此时旧模块已经不在sys.modules中)。

1
2
In [19]: m is demo
Out[19]: False

标准库为我们提供了另一种方案importlib.reload。它直接在原址(内存地址)更换模块内容,这样所有的引用都是指向的新的模块实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
In [20]: import demo
In [21]: m = demo
In [22]: m.x
Out[22]: 888
In [23]: !echo "x = 666" > demo.py
In [24]: import importlib
In [25]: importlib.reload(demo)
Out[25]: <module 'demo' from '/Users/binja/Workspaces/Python/books/python notes/ch06-module/demo.py'>
In [26]: demo.x
Out[26]: 666
In [27]: m.x
Out[27]: 666
In [28]: demo is m
Out[28]: True

模块组织代码,包组织模块。

将多个源文件放置于同一个目录,就构成了包。包可以隐藏内部文件的组织结构,仅暴露必要的用户接口。包在形式上是一个目录,但是它也是运行期对象,它也有自己的名字空间。

1
2
3
4
5
6
7
8
>>> import lib
>>> lib
<module 'lib' (namespace)>
>>> lib.demo.__package__
'lib'
>>> lib.demo.__name__
'lib.demo'

仅导入包不能直接访问其内部模块,须显式导入。

初始化

在包内加入__init__文件,可以用来执行一些初始化操作,比如提供对外借口,解除用户对内部模块的直接依赖。

初始化文件在包或者内部模块首次导入时自动执行, 且仅执行一次。

重载(reload)包内模块,不会再次执行初始化文件,但重载会。

在包内创建__main__.py文件,作为直接执行包时的入口。

1
2
python -m lib # 以包方式运行,会自动执行初始化文件
python lib # 以普通程序方式运行,__main__.py 为入口模块

隐藏结构

既然初始化文件构成了包名字空间,那么只要将要公开给用户的模块或者成员导入进来就可以解除用户对具体模块的依赖。

demo.py

1
2
def hello():
print("hello world")

__init__.py

1
from .demo import hello

解释器

1
2
3
4
>>> import lib
>>> lib.hello()
hello world

如此一来,用户只依赖包和初始化文件中导入的成员。无论我们以后重构代码还是如何,都不会影响用户调用接口,灵活使用。

导出列表

同样可在初始化文件中添加__all__星号导出成员列表。

__init__.py

1
2
3
4
__all__ = ["x", "demo"]
x = 100
y = 200

解释器

1
2
3
4
>>> from lib import *
>>> dir()
["demo", "x", ...]

相对导入

搜索路径列表并不包括包目录本身,导致在包内访问同级模块时,发生找不到文件的状况。

解决方法是:

  • 使用全名的绝对路径
  • 更好的是:使用点前缀表达当前或者上级包
  • 相对导入只能使用from子句

拆分

当包内的模块文件过多时,可建立子包分组维护,但这需要修改内部的相对导入路径。有一种解决方法就是,将文件分散到多个子目录下,但他们依然属于同一级别的包成员。

  1. 先将文件按照规定移动到子目录
  2. 为了让它们继续以包成员的形式存在,须在包__init__中修改__path__属性
  3. __path__的作用类似于sys.path,实现包内搜索路径列表。并使用相同匹配规则。在该搜索列表中默认为包的全路径,只需将子目录的全部路径添加进去即可。

lib 目录结构

1
2
3
4
5
|______init__.py
|____a
| |____demo.py
|____b
| |____hello.py

__init__.py

1
2
3
4
5
6
7
8
9
10
import os.path
__path__.extend(os.path.join(__path__[0], d) for d in ("a", "b"))
from . import demo
from . import hello
print(__path__)
print(demo.__file__)
print(hello.__file__)
1
2
3
4
5
>>> import lib
['/Users/binja/Workspaces/Python/lib', '/Users/binja/Workspaces/Python/lib/a', '/Users/binja/Workspaces/Python/lib/b']
/Users/binja/Workspaces/Python/lib/a/demo.py
/Users/binja/Workspaces/Python/lib/b/hello.py

因为__path__添加的是全路径,所以可以将子目录放到任意位置,甚至可以将其它包内的内容引入进来。但是可能导致混乱,不推荐使用。