Python 程序由 modules 和 packages 组成,使用 import 语句导入。

Modules and the import Statement

任何 Python 源文件都可以作为一个模块导入,例如下面 module.py 代码:

a = 37

def func():
    print(f'func says that a is {a}')

class SomeClass:
    def method(self):
        print('method says hi')

print('loaded module')

改文件包含一些常见的编程元素,包括一个全局变量、一个函数、一个类定义 和 最后的语句。 通过下面方法导入:

import module

module.a
module.func()
s = module.SomeClass()
s.method()

执行 import 会发送下面这几件事:

  1. 加载模块源码,如果找不到抛出 ImportError
  2. 创建新模块对象。该对象作为模块内所有全局定义 global defintions 的容器,被称为 “命名空间” namespace
  3. 该模块源码在新创建的模块命名空间内执行
  4. 如果没有错误发生,调用者会创建一个名称,指向新的模块对象。该名称与模块名称一致,但不包含任何文件后缀。

这些步骤中,第一步是最复杂的。新手容易犯的错误就是使用错误的名称或将代码放到了未知的位置。 且模块文件必须放在 sys.path 所包含的文件路径中,且文件名称要遵循和 python 变量一样的规则。

剩下的步骤都隔离在一个模块中,因此不用担心不同模块间命名冲突的问题。 Python import 会执行所有导入的源码,因此导入上面模块会输出 loaded module

如果要在单个 import 中导入多个模块,使用逗号将其分隔,例如:

import socket, os, re

有时候一个模块会使用 as 改变名称:

import module as mo
mo.func()

模块是一等公民,这意味着他们可以被复制给变量、填充数据结构和作为程序数据传递。

Module Caching

模块的源码只会导入一次,无论使用多少次 import 语句。 一个新手常犯的错误就是,在交互式环境中,先导入了某个模块。 然后该模块更新了,然后重新 import 该模块,但该模块并不会更新。

可以在 sys.modules 里面找到缓存的模块,为一个字典,键为模块名称,值为模块对象。 如果将对应模块删除,则下一次 import 可以重新导入该模块。

有时导入语句会在一个模块内,例如:

def f(x):
    import math
    return math.sin(x) + math.cos(x)

看上去好像这样会导致速度很慢,即每次函数调用都会重新导入一次。 但实际上成本是很低的,只承担一次查询字典的开销。 这样写法主要是风格上的问题,一般倾向于将所有导入放到开头。 另一方面,如果你有一个特殊的很少使用的函数,将导入写在函数内部,可以加快程序导入速度。

Importing Selected Names from a Module

from module import name 该语法将模块特定定义导入当前命名空间。 这和直接 import 不同的地方在于,不是创建一个新的命名空间,而是将该对象引用放入当前命名空间。

from module import func  # Imports module and puts func in current namespace

func()  # Calls module.func()

module.func()  # Fails. NameError: module

from 语句接受逗号分隔的名称列表,来导入多个定义。

from module import func, SomeClass

从语义上来说,from module import name 语句将名称从模块复制到本地命名空间。 Python 会在背后首先执行 import module,然后使用缓存进行赋值 name = sys.modules['module'].name

一个常见的误解是人为使用 from 形式的导入更加高效,即只加载模块的一部分。 实际上并非如此,每当一个模块加载时,整个模块都会被加载并存储在缓存中。

使用 from 形式的 import 不会改变作用域。 当函数寻找变量的时候,它仍然会在定义中的文件中寻找,而不是导入或调用的函数中寻找。

from module import func

a = 42
func()  # func says that a is 37
func.__module__  # 'module'
func.__globals__['a']  # 37

使用星号 asterisk (*) 可以导入所有非下划线开始的定义:

# Load all definitions into the current namespace
from module import *

这种语法不能写在函数内部,否则会报错 SyntaxError。 因为这样会破坏 Python 编译器对局部变量 Local Variables 的优化机制。

模块可以通过定义 __all__ 列表来精确控制通过 from module import * 导入的集和。

# module.py

__all__ = [ 'func', 'SomeClass' ]

a = 37  # Not exported

def func():  # Exported
    ...

class SomeClass:  # Exported
    ...

在实践中,使用 from module import * 是不被推荐的。 过度使用会导致混淆,并污染局部命名空间。

from math import *
from random import *
from statistics import *

a = gauss(1.0, 0.25)  # From which module ???

显示导入要好得多

from math import sin, cos, sqrt
from random import gauss
from statistics import mean

a = gauss(1.0, 0.25)

Circular Imports

如果两个模块相互导入对方会产生特别的问题。 例如假设你有两个文件:

# moda.py
import modb

def func_a():
    modb.func_b()

class Base:
    pass
# modb.py
import moda

def func_b():
    print('B')

class Child(moda.Base):
    pass

这段对面存在一种奇怪的导入顺序问题。 具体是,使用 import modb 能正常工作,但如果使用 import moda 就会崩溃,并说 moda.Base 没有定义。

根据控制流,错误原因如下:

  • 执行 modb.py 文件,Python 首先创建一个 __name__ = __main__ 的模块
  • 第一行为 import moda,会加载模块 moda.py
  • 由于 moda.py 第一行为 import modb,这会导致又去加载 modb
  • 由于模块缓存 sys.module 中没有 modb (而是 __main__),因此又会回到 modb.py 作为导入模块执行
  • 这时候,modb.py 的第一行的导入存在模块缓存内,因此不会循环导入
  • 当执行到 Child 的时候,由于缓存中创建的 moda 并没有加载完全,moda.Base 还未定义,从而导致报错

要修复这个问题,可以将 import modb 语句放到其他地方。 例如将其移动到 func_a() 函数内部:

# moda.py
def func_a():
    import modb
    modb.func_b()

class Base:
    pass

或者也可以将导入写在文件末尾

# moda.py
def func_a():
    modb.func_b()

class Base:
    pass

import modb  # Must after Base is defined

但这两者写法在代码审查中都可能被质疑。 大多数情况下,不会看到导入语句出现在文件末尾。 更好的处理方式一般是将 Child 类定义搬到单独的 base.py 文件中去。

# modb.py
import base

def func_b():
    print('B')

class Child(base.Base):
    pass

Module Roloading and Unloading

目前没有可靠的机制来支持对先前导入的 modules(模块)进行 reloading(重新加载)或 unloading(卸载)。 即使可以通过删除 sys.modules 导入的模块,但这些内容仍然会保存在内存中。 这样因为其他导入的模块会缓存模块变量。 此外,如果模块中定义了类的实例,这些实例会包含指向其类对象的引用,而类对象又持有定义模块的引用。

模块引用存在于多个位置,这使得修改其实现后重新加载模块通常不切实际。 例如,如果从 sys.modules 里面删除模块,并通过 import 重新导入它, 但这并不会追溯性地改变程序中之前对该模块的所有引用。 相反,将会拥有最近 import 语句导入的一个新模块,和之前旧 import 代码保留的旧模块。

importlib 库中有一个 reload() 函数来重新导入模块。 将之前的模块作为参数导入该模块:

import module
import importlib
importlib.reload(module)  # loaded module <module 'module' from 'module.py'>

reload() 通过导入新的模块源码,并在已存在的模块命名空间顶部执行实现。

如果其他模块之前通过 import module 这种标准导入语句,则该函数可以当代码自动更新。

但这里存在很多危险:

  1. 首先 reloading 不是递归的,它只会导入传递给 reload() 的单个模块
  2. 如果导入模块使用 from module import name 的方式导入,则无法重新导入
  3. 最后,如果创建了类的实例,则这些实例仍使用的是旧的类定义

此外,C/C++ 扩展无法通过任何方式取消或重新导入。

Module Compliation

当模块首次导入时,他们会被编译为字节码。 该代码写在目录 __pycache__.pyc 文件中,该目录通常和运行的 .py 代码目录下。 当程序的不同地方再次导入时,会直接加载编译后的代码,加快 import 速度。

如果在操作系统中 Python 没有权限创建文件,则也能正常工作,但 import 速度会慢很多。

另一个需要知道模块缓存的原因是要警惕可能影响它的编程技巧。 高级源编程涉及动态代码生成,和 exec() 函数会削弱字节码缓存的优势。

一个显著的例子是使用 dataclasses:

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

Dataclass 通过生成方法函数作为文本块并使用 exec() 执行。 所有这些生成的代码都没有通过 import 系统进行缓存。 对于单个 dataclass 可能注意不到,但如果有 100 个 dataclasses,就会发现他们比正常的类慢 20 倍。

The Module Search Path

当导入模块的时候,解释器搜索 sys.path 里的路径。 第一个值通常是空字符串 '',指代当前工作目录。 如果运行一个 py 脚本,则第一个值是当前脚本所在的目录。 其他的值通常包括一些列目录名,和 .zip 文件。 sys.path 的顺序即模块搜索顺序。 如果要添加新的搜索路径,将其添加到该列表中。 可以通过设置 PYTHONPATH 环境变量来实现。

env PYTHONPATH=/some/path

ZIP 文件提供了一种将多个模块放到一个文件中的方法。 例如,假如创建了两个模块 foo.pybar.py,并将他们放到 mymodules.zip 中。 则该文件可以添加到 Python 路径中:

import sys
sys.path.append('mymodules.zip')
import foo, bar

压缩文件内部的路径也可以加入 path:

sys.path.append('/tmp/modules.zip/lib/python')

并且压缩文件不一定要有 .zip 后缀,历史上曾经使用 .egg,该后缀源自于一个早期 python 包管理工具 setuptools。 但 .egg 文件只不过是在 .zip 之上添加了一些元数据罢了(版本号、依赖等)。

Execution as the Main Program

Python 文件经常作为脚本运行,例如:

python3 module.py

每个模块定义一个变量 __name__,其中包括了模块名。 代码可以检查该变量来确定执行的模块。 在命令行中指定的程序会在 __main__ 模块中运行。 有时候程序会根据是否是 __main__ 模块来改变其行为。 例如,一个模块可能包含一些测试代码,如果模块在 __main__ 模块中就运行,在其他模块中则不运行。

# Check if running as a program
if __name__ == '__main__':
    # Yes. Running as the main script
    statements
else:
    # No, be imported as a module
    statements

库源码文件通常使用这种技巧,来测试或运行示例代码。 假如你在开发一个模块,可以将调试代码放到上面展示的 if 语句中,并在主程序运行模块。

假如你在创建一个 Python 代码目录,可以使用特殊的 __main__.py 文件。 例如:

myapp/
    foo.py
    bar.py
    __main__.py

然后可以通过命令 python3 myapp 来运行该模块。 执行会从 __main__.py 开始,将 myapp/ 做成压缩文件也可以。

Packages

Python 代码可以被组织成一个 “包”。包是一组模块的集和,这些模块被归在一个共同的高级名称下。 这种分组有助于解决不同应用程序中使用的模块名称之间的冲突,并将你的代码与他人的分开。 包通过创建一个独特的目录名称和一个空的 __init__.py 文件定义。 然后将额外的 Python 文件和子包放到目录中,例如:

graphics/
    __init__.py
    primitive/
        __init__.py
        lines.py
        fill.py
        text.py
        ...
    graph2d/
        __init__.py
        plot2d.py
        ...
    graph3d/
        __init__.py
        plot3d.py
        ...
    formats/
        __init__.py
        gif.py
        png.py
        tiff.py
        jpeg.py

import 语句用于从包中加载模块,其使用方式与加载简单模块相同,只是现在需要使用更长的名称。

# Full path
import graphics.primititve.fill
...
graphics.primitive.fill.floodfill(img, x, y, color)

# Load s specific submodule
from graphics.primitive import fill
...
fill.floodfill(img, x, y, color)

# Load a specific function from a submodule
from graphics.primitive.fill import floodfill
...
floodfill(img, x, y, color)

当包导入的时候,__init__.py 中的代码会先被执行。 该文件可以是空的,也可以包含一些初始化代码。 如果导入深度嵌入的子模块,在遍历目录结构时遇到的所有 __init__.py 文件都会执行。 import graphics.primitive.fill 首先会执行 graphics/ 下的 __init__.py 文件,然后是 primitive/ 下的 __init__.py 文件。

一个 import 语句的重要特性是,所有模块的导入都要求绝对的或明确的包路径。 这包括包内部使用的导入语句。 例如,假设模块 graphics.primitives.fill 模块想要导入 graphics.primitives.lines 模块。 则简单的 import lines 无法正常工作,会得到一个 ImportError 错误。 相反,需要完全确定导入路径,像这样:

# graphics/primitives/fill.py

# Fully qualified submodules import
from graphics.primitives import lines

遗憾的是,像这样写出完整包名即麻烦又容易出错。 例如,重命名一个包会导致硬编码的导入失效。 因此,更好的选择是相对包导入:


# graphics/primitives/fill.py

# Paskage-relative import
from . import lines

相对导入只能使用 from module import symbol 这样的语法。 因此 import ..primitives.linesimport .lines 这样写是语法错误。 或 from .. import primitives.lines 这样也是不合法的。 总之,相对导入只能在一个 package 包内使用,可以是同一个包内的不同子包,但不能是不同包之间引用。

Running a Package submodule as a script

组织成 package 的代码的 runtime 运行时环境和简单的脚本并不相同。 存在一个外层包名、子模块以及相对导入的使用。 其中一个不再支持的特性是无法直接在包的源文件上运行 Python 程序。 例如,graphics/graph2d/plot2d.py 文件有一些测试代码:

# graphics/graph2d/plot2d.py
from ..primitives import lines, text

class Plot2D:
    ...

if __name__ == '__main__':
    print('Testing Plot2D')
    p = Plot2D()
    ...

如果直接运行该脚本会得到相对导入导致的崩溃:

$ python3 graphics/graph2d/plot2d.py

Traceback (most recent call last):
  File "graphics/graph2d/plot2d.py", line 1, in <module>
    from ..primitive import line, text
ValueError: attempted relative import beyond top-level package

也不能进入到模块中去执行:

$ cd graphics/graph2d/
$ python3 plot2d.py

Traceback (most recent call last):
  File "plot2d.py", line 1, in <module>
    from ..primitive import line, text
ValueError: attempted relative import beyond top-level package

如果要将子模块作为 main script 运行,需要使用 -m 参数:

$ python3 -m graphics.graph2d.plot2d

Testing Plot2D

-m 参数将模块或包作为主程序,python 会使用正确的环境运行该模块,从而确保 import 能正常工作。 许多 python 的内置模块拥有的 “秘密” 特性可以通过 -m 运行。 最常见的是通过 python3 -m http.server 在当前目录运行一个 web 服务器。 你可以通过自己的包提供类似的功能。 如果 python -m name 对应一个包目录,则 python 会查找 __main__.py 文件,并将其作为脚本运行。

Controlling the Package Namespace

包的主要目的的作为代码的顶层容器。 有时候用户会只导入顶层名称。

import graphics

这样导入不指定任何子模块,这会导致这样的代码失效:

import graphics

graphics.primitive.fill.floodfill(img, x, y, color)  # Fails!

当仅给出定义包导入时,唯一导入的文件是关联的 __init__.py 文件。 在这个例子中,就是 graphics/__init__.py 文件。

__init__.py 文件的主要目的就是创建与管理顶级包命名空间的内容。 通常这涉及到从更低层级的子目录中导入函数、类和其他对象。 例如,假设 graphics 包含有数百个低级函数,但大部分细节被封装在少数几个高级类中。 __init__.py 文件可能选择值暴露这些类:

# graphics/__init__.py
from .graph2d.plot2d import Plot2D
from .graph3d.plot3d import Plot3D

通过 __init__.py 文件,只有 Plot2DPlot3D 会出现在包顶层。 用户可以这样使用该类:

from graphics import Plot2D

plt = Plot2D(100, 100)
plt.clear()

这样用户使用起来就十分方便,无需知道你的代码组织结构。

Controlling Package Exports

一个组织上的问题涉及 __init__.py 文件与低处子模块之间的交互。 例如,一个包的不同子模块通常知道那些符号需要导出到顶层。 然而,实际的工作是在 __init__.py 中完成的。 这使得阅读包的代码并理解其组织结构变得困难。

为了更好地管理子模块,通常会定义一个 __all__ 列表。 该列表是命名空间中需要提升一级的包。

# graphics/graph2d/plot2d.py

__all__ = ['Plot2D']

class Plot2D:
    ...

相关的 __init__.py 文件通过 * 号导入:

# graphics/graph2d/__init__.py
# Only loads names explicitly listed in __all__ variables
from .plot2d import *

# Propagate the __all__ up to next level
__all__ = plot2d.__all__

这种提升过程会一直持续到顶层的 __init__.py,例如:

# graphics/__init__.py
from .graph2d import *
from .graph3d import *

# Consolidate exports
__all__ = [
    *graph2d.__all__,
    *graph3d.__all__,
]

值的注意的是,尽管在用户代码中使用 * 导入并不被推荐,但在包的 __init__.py 中这种做法非常普遍。

Package data

有时候包会包含一些数据文件(非代码)。 在包中,__file__ 变量会给予关于源文件位置的信息。 但是包会被编译,他们可能被打包成 .zip 文件或其他不常见的格式。 文件中的 __file__ 变量可能并不可靠,甚至根本没有定义。 结果导致读取数据文件并不容易,通过文件名和内置函数 open() 也不方便。

为了读取包数据,使用 pkgutil.get_data(package, resource)。 例如包结构如下:

mycode/
    resources/
        data.json
    __init__.py
    spam.py
    yow.py

为了导入 data.json 文件,可以这样:

# mycode/spam.py
import pkgutil
import json

def func():
    # rawdata: bytes 字节流 | None
    rawdata = pkgutil.get_data(__package__, 'resources/data.json')
    textdata = rawdata.decode('utf-8')
    data = json.loads(textdata)
    print(data)

实际上,现代 Python 版本更加推荐使用 importlib.resources,例如:

from importlib import resources

# 1. 获取资源句柄(并未读取文件内容)
source = resources.file('my_package').joinpath('data/config.json')

# 2. 读取文件内容
content = source.read_text(encoding='utf-8')

# 3. 第三方库如果需要无物理路径
with resources.as_file(source) as physical_path:
    # 即使在压缩包中,Python 也会创建一个临时物理文件
    print(physical_path)

Module Objects

模块是一等公民,下表列出了常用的模块属性:

AttributeDescription
__name__完整的模块名称
__doc__文档字符串
__dict__模块字典
__file__模块定义所在的文件名
__package__所在包的名称(如果有)
__path__搜索包子模块的子目录列表
__annotations__模块级别的类型提示

这里的 __dict__ 属性是一个字典,代表模块的命名空间。 所有再模块内定义的都在这里。

__name__ 属性是通常用于脚本编写,通常会进行类似 if __name__ == '__main__' 的检查。 以判断文件十分作为主程序运行。

__package__ 属性包含所在的包 (enclosing package) 名称。 如果设置了该属性,__path__ 属性是目录列表,用于定位包的子模块。 通常,它包含一个单一条目,即包的位置。 有时,大型框架会操作 __path__ 以包含额外的目录,目的是支持插件和其他高级功能。

并非所有的模块都具备所有属性。 例如,内置模块可能没有设置 __file__ 属性。 同样地,对于顶级模块,与包相关的属性不会被设置。

__doc__ 属性是模块的 docstring,这是文件中第一行出现的语句。 __annotations__ 属性是一个模块级别的类型提升字典,类似这样:

# mymodule.py

'''
The doc string
'''

# Type hints (placed into __annotations__)
x: int
y: float
...

和其他类型提升相比,模块级别的类型提升不会改变 Python 的行为。 他们实际上也不定义变量,纯粹是元数据,其他工具可以根据需要选择查看。

Deploying Python Packages

最后关于模块和包的问题是将代码发给他人。 这一个广泛的话题,多年来一直是持续活跃开发的重点。 具体流程参考:Python 官方文档

尝试给包起一个独特的名称,以免和其他可能的依赖项冲突。 可以参考 Python 包索引网站来帮助选择名称。 在组织代码结构时,尽量保持简洁。

考虑到绝对简洁性,分发纯 Python 代码最极简的方式是使用 setuptools 模块。 假设编写来一些代码,项目结构如下:

spam-project/
    README.txt
    Documentation.txt
    spam/  # A package of code
    __init__.py
    foo.py
    bar.py
    runspam.py  # A script to run as: python runspam.py

为了创建分发,在顶层目录创建一个 setup.py 文件,并编写如下代码:

# setup.py
from setuptools import setup

setup(
    name='spam',
    version='0.0',
    packages=['spam'],
    scripts=['runspam.py'],
)

在这个 setup() 调用中,packages 是所有包目录的列表,scripts 是脚本文件列表。 如果包没有相关内容则省略,name 是包名称,version 是字符串版本号。

使用 setup.py 文件创建软件分发包足够了,使用下面命令来创建一个源码分发包:

python3 setup.py sdist

这会创建一个存档文件,例如 spam-1.0.tar.gzspam-1.0.zip 到目录 spam/dist 下面。 这就是你给予他人按照的文件。 如果要安装该软件包,使用命令:

python3 -m pip install spam-1.0.tar.gz

这会将软件安装到本地 Python 环境。 一般会放在 Python 库的 site-packages 目录下面,可以通过 sys.path 查看。

Scripts 脚本通常会安装在相同的目录下。 如果脚本第一行以 #! 开头,且包含 python 文本,这安装器会重写该行指向本地 Python 地址。 因此,即使硬编码了 Python 地址,也应该仍然能工作。

这里描述的 setuptools 使用方式是最简化的。 大型项目可能涉及 C/C++ 扩展、复杂的包结构、示例等。 涵盖所有工具以及部署此类代码的方式可以查阅 https://python.orghttps://pypi.org 以获取最新建议。