Python 的模块系统允许将代码拆分到独立 .py 文件;包系统支持以目录形式批量组织多个模块。熟练掌握模块、包、相对导入与动态导入机制,是搭建大型项目、实现插件化架构的重要基础。
一个 .py 文件就是一个模块。模块名即文件名(去掉 .py 后缀)。例如:
(1)创建一个 math_utils.py 文件,内容如下:
"""数学工具模块"""
PI = 3.14159
def add(a: float, b: float) -> float:
return a + b
def circle_area(r: float) -> float:
return PI * r ** 2
# 模块自测代码,仅在直接运行时执行
if __name__ == "__main__":
print(f"add(1,2) = {add(1, 2)}")
print(f"circle_area(3) = {circle_area(3)}")(2)再次创建一个 demo.py 文件,使用 import 导入 math_utils 模块,例如:
import math_utils
print(math_utils.PI) # 3.14159
print(math_utils.add(2, 3)) # 5
# 给模块起别名
import math_utils as mu
print(mu.circle_area(2)) # 12.56636
# 只导入指定符号
from math_utils import add, PI
print(add(10, 20)) # 30
print(PI) # 3.14159
# 使用通配符导入(不推荐,见 __all__ 部分)
from math_utils import *
print(circle_area(1)) # 3.14159注意,math_utils.py 文件中,if __name__ == "__main__":模块被 import 时 __name__ 为模块名,直接运行文件时 __name__ 为 "__main__",因此只有在直接运行时,if __name__ == "__main__":中的内容才被执行。这是编写可测试模块的标准做法。
执行 import xxx 时,Python 按有序路径列表依次检索,找到模块文件后加载。这个搜索路径列表保存在 sys.path 中。例如:
import sys
import os
print("Python 模块搜索路径:")
for idx, p in enumerate(sys.path, 1):
exists = "存在" if os.path.isdir(p) else "不存在"
print(f" {idx}. {p} [{exists}]")运行代码,输出如下:
Python 模块搜索路径:
1. d:\share_dir\workspace\5.demo\python_demo [存在]
2. D:\Program Files\Python313\python313.zip [不存在]
3. D:\Program Files\Python313\DLLs [存在]
4. D:\Program Files\Python313\Lib [存在]
5. D:\Program Files\Python313 [存在]
6. C:\Users\Administrator\AppData\Roaming\Python\Python313\site-packages [存在]
7. C:\Users\Administrator\AppData\Roaming\Python\Python313\site-packages\win32 [存在]
8. C:\Users\Administrator\AppData\Roaming\Python\Python313\site-packages\win32\lib [存在]
9. C:\Users\Administrator\AppData\Roaming\Python\Python313\site-packages\Pythonwin [存在]
10. D:\Program Files\Python313\Lib\site-packages [存在]sys.path 的构建顺序:
当前脚本所在目录(或空字符串表示当前目录)
PYTHONPATH 环境变量中的目录
标准库目录
.pth 文件指定的目录
临时添加路径:
import sys
sys.path.insert(0, r"D:\my_libs") # 插入到最前面,优先查找注意:脚本运行时当前目录会被加入 sys.path,但直接运行模块(python -m mypkg.mod)时当前目录可能不同,这是相对导入失败的最常见原因。
包是一个包含 __init__.py 文件的目录,目录名就是包名。包结构如下:
myproject/
├── main.py
└── utils/
├── __init__.py # 包初始化,可为空
├── string_utils.py
└── file_utils.py按照上面的包结构创建文件,如下图:

其中:
utils/__init__.py 内容如下
# utils/__init__.py
"""工具包"""
# 暴露子模块的常用符号,简化导入路径
from .string_utils import camel_to_snake
from .file_utils import read_json, write_json
__version__ = "1.0.0"utils/string_utils.py 内容如下
# utils/string_utils.py
def camel_to_snake(name: str) -> str:
import re
s = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s).lower()utils/file_utils.py 内容如下
# utils/file_utils.py
import json
from typing import Any
from pathlib import Path
def read_json(path: str | Path) -> Any:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def write_json(path: str | Path, data: Any) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)main.py 内容如下
# main.py(与 utils 同级)
import utils
print(utils.__version__) # 1.0.0
print(utils.camel_to_snake("HelloWorld")) # hello_world
data = utils.read_json("config.json")
utils.write_json("output.json", data)最后不要忘记 config.json 配置文件内容,如下:
{
"host": "127.0.0.1",
"port": 8808
}当我们运行 main.py 时,发生了如下事情:
import utils 语句 —— Python 在模块搜索路径里找到文件夹 utils,里面存在 __init__.py,识别为普通包。立刻执行 utils/__init__.py 里面所有代码。
from .string_utils import camel_to_snake
from .file_utils import read_json, write_json
__version__ = "1.0.0".string_utils、.file_utils 是相对导入,. 代表当前 utils 包目录。这两行作用:把子模块里的函数提升到包的顶层命名空间。
此时,在 main.py 中,可以使用 utils 直接调用暴露出来的函数。
从 Python 3.3 开始,__init__.py 不再强制要求。没有 __init__.py 的目录被称为命名空间包(Namespace Package),允许多个物理目录共同组成一个逻辑包(常用于大型项目的插件拆分)。
例如:磁盘上两个完全独立的文件夹
/path1/myplugin/
└── foo.py
/path2/myplugin/
└── bar.py两个目录都叫 myplugin,都没有 __init__.py 因此就不是常规包。
然后把两个父目录加入 sys.path:
import sys
# 手动将父路径添加到搜索路径中
sys.path.insert(0, "/path1")
sys.path.insert(0, "/path2")
import myplugin.foo
import myplugin.bar # 同一个逻辑包 myplugin 来自两个不同目录在你代码视角,看起来它们都属于 myplugin 这一个包;但是磁盘上文件不在同一个文件夹,是拼接出来的!注意:
常规包:一本书所有章节全部放在同一个文件夹。
命名空间包:同一套丛书,一部分章节放在 A 柜子,一部分放在 B 柜子,对外仍然统称一套丛书。
命名空间包多用于插件系统、大型项目组件拆分。主程序自带一部分插件 myplugin,用户后续安装扩展插件,放在另外一个目录,同样归入 myplugin。不需要修改原有代码,自动合并成同一个包。
建议:常规项目仍然保留 __init__.py,语义清晰、兼容旧工具,并可用于控制导出。
下面给出一个实际项目中的 __init__.py 常见写法示例,可作为参考:
"""mylib 包 — 数据操作工具"""
# 1. 统一版本号
__version__ = "2.1.0"
# 2. 统一暴露公开 API,方便用户只 import mylib 就能使用核心功能
from .core import Database, ConnectionPool
from .models import User, Order
from .exceptions import DbError, ConnectionTimeout
# 3. 延迟导入(大型包,避免 import 时加载所有子模块)
def _get_client():
from .client import HttpClient
return HttpClient()
# 4. 包级别初始化(如日志配置、环境检查)
import logging
logging.getLogger("mylib").addHandler(logging.NullHandler())
# 5. 控制 __all__(见下节)
__all__ = ["Database", "ConnectionPool", "User", "Order", "DbError"]上面代码中,import mylib 的时候,不会导入 client 模块。只有外部调用 mylib._get_client() 这一瞬间,才会执行 from .client import HttpClient。这样做可以解决如下问题:
痛点1:加快包导入速度(大型包最重要价值)
如果 client.py 依赖很多第三方库、复杂初始化逻辑、网络相关代码:
很多使用者只用 Database、User,根本不需要 HttpClient;
立即导入方案:不管用不用,全部加载,拖慢 import mylib;
延迟导入方案:不用就永远不加载,节省启动耗时与内存。
痛点 2:规避循环导入(非常高频场景)
假设依赖关系:
client.py 内部会导入 mylib.Database
core.py 有可能需要用到 HttpClient
如果在 __init__.py 头部直接 from .client import HttpClient,会触发互相导入、抛出 ImportError。
把导入放进函数体内,推迟到运行时加载,避开导入阶段的循环依赖。
痛点 3:按需初始化,避免无用实例
有些对象创建代价很高(HTTP 连接池、SSL 上下文)。
不需要导入包就直接构造,等到业务真正使用时再创建。
默认情况下,from module import * 会导入模块中所有不以 _ 开头的全局名称。__all__ 列表可以精确控制哪些名称被导出。例如:
(1)mylib.py 模块的内容如下
"""演示 __all__ 的作用"""
__all__ = ["public_func", "PUBLIC_CONST"] # 只有这两个会被 * 导入
PUBLIC_CONST = 100
HIDDEN_CONST = 999 # 以下划线开头,或不在 __all__ 中
def public_func():
return "公开函数"
def _private_func():
return "私有函数"
def hidden_func():
return "隐藏函数,不在 __all__ 中"(2)demo.py 导入上面的 mylib 模块
# 交互式测试
from mylib import *
print(public_func()) # OK
print(PUBLIC_CONST) # OK
# 以下都会报错(NameError),因为不在 __all__ 中
try:
print(hidden_func())
except NameError as e:
print(f"[预期错误] {e}")
try:
print(HIDDEN_CONST)
except NameError as e:
print(f"[预期错误] {e}")
# 但显式指定名称仍然可以导入
from mylib import hidden_func, HIDDEN_CONST
print(hidden_func()) # OK
print(HIDDEN_CONST) # OK运行代码,输出如下:
公开函数
100
[预期错误] name 'hidden_func' is not defined
[预期错误] name 'HIDDEN_CONST' is not defined
隐藏函数,不在 __all__ 中
999# utils/__init__.py
__all__ = ["string_utils", "file_utils"] # 控制 from utils import * 导出哪些子模块建议:
每个公共模块都应定义 __all__,明确公开 API 边界
__all__ 中的字符串名称必须与模块中实际定义的名称一致,否则 import * 会报错
当模块名在运行时才能确定,或需要从配置文件中加载时,使用 importlib.import_module()。例如:
import importlib
from typing import Any
# 动态导入标准库模块
# 假设已安装有 sqlite3 标准库
sqlite3 = importlib.import_module("sqlite3")
print(sqlite3.sqlite_version)
# 动态导入并调用函数
json_mod = importlib.import_module("json")
data = json_mod.loads('{"name": "Alice"}')
print(data)
# 动态获取属性
os = importlib.import_module("os")
getcwd = getattr(os, "getcwd")
print(getcwd())运行代码,输出:
3.49.1
{'name': 'Alice'}
d:\share_dir\workspace\5.demo\python_demo开发阶段修改模块后,不需要重启解释器,可以热重载。例如:
import importlib
import sys
import tempfile, os
import shutil
import time
# 先创建一个临时模块文件
tmp = tempfile.mkdtemp()
mod_path = os.path.join(tmp, "config.py")
with open(mod_path, "w", encoding="utf-8") as f:
f.write('VERSION = "1.0"\n')
sys_path_orig = list(sys.path) # 将搜索路径拷贝一份,后续还原
sys.path.insert(0, tmp) # 将 TMP 目录放入到搜索路径第一个位置
# =================================================
import config # 首次导入
print(f"第一次: {config.VERSION}") # 1.0
# =================================================
time.sleep(2) # 间隔2秒采取修改,不然会出现重新载入后还是 1.0
# =================================================
# 修改文件内容(假如模块被动态修改了)
with open(mod_path, "w", encoding="utf-8") as f:
f.write('VERSION = "2.0"\n')
# 重新载入,接收 reload 返回值
config = importlib.reload(config)
print(f"reload后: {config.VERSION}") # 2.0
# =================================================
# 清理
sys.path[:] = sys_path_orig # 还原搜索路径
shutil.rmtree(tmp, ignore_errors=True) # 递归删除整个目录 tmp运行代码,输出如下:
第一次: 1.0
reload后: 2.0注意,两次文件写入发生在同一秒内可能会导致重载后还是 1.0。Linux 文件系统默认 mtime 精度常常只有 1 秒。两次写入时间戳一模一样!Python 判断:文件没有改动 → 拒绝重载源码。
Windows 很多文件系统同样 1s 时间粒度,这是跨平台共性问题,不是系统 bug。
reload 的注意事项:
只重新加载模块本身,已经通过 from config import VERSION 获取的变量不会自动更新
使用旧引用(如 v = config.VERSION)的对象仍指向旧值
类实例如果已创建,实例的类不会自动更新
相对导入使用点号表示当前包和上级包,只能在包内部使用(模块被 python -m pkg.mod 或 import pkg.mod 时)。
例如,包结构如下:
myapp/
├── __init__.py
├── main.py
├── core/
│ ├── __init__.py
│ ├── config.py
│ └── db.py
└── api/
├── __init__.py
├── routes.py
└── handlers.pycore/db.py 文件内容如下:
# core/db.py
from .config import DB_HOST, DB_PORT # 同包内导入
from ..api.routes import router # 上级包再进入另一子包api/routes.py 文件内容如下:
# api/routes.py
from .handlers import UserHandler # 同级模块
from ..core.db import Database # 上级包的 core 子包| 语法 | 含义 | 示例 |
| from .module import X | 当前包的同级模块 | from .config import DB_HOST |
| from ..module import X | 上级包的模块 | from ..main import app |
| from ...pkg import X | 更上级的包 | 三层嵌套时使用 |
| from . import module | 当前包中的子模块 | from . import handlers |
假设这是 myapp/core/db.py 的内容:
# 正确的相对导入(包内使用时)
from .config import DB_HOST
from ..api.routes import router
# 错误:直接在命令行运行含相对导入的模块会报错
try:
# 如果直接 python myapp/core/db.py,会触发:
# ImportError: attempted relative import with no known parent package
pass
except ImportError:
print("[说明] 相对导入只能在包内使用,不能直接运行文件")运行含相对导入的模块的正确方式:
# 错误:直接运行含相对导入的文件
python myapp/core/db.py
# ImportError: attempted relative import with no known parent package
# 正确 1:使用 -m 参数,从包顶层运行
python -m myapp.core.db
# 正确 2:在外层脚本中 import myapp.core.db
# myapp 的父目录需在 sys.path 中相对导入的适用场景:
包内部模块互相引用(重构时修改包名,相对路径不受影响)
发布到 PyPI 的库,子模块间用相对导入避免硬编码绝对包名
绝对导入 vs 相对导入:
| 方式 | 优点 | 缺点 | 推荐场景 |
| 绝对导入 | 清晰,从 sys.path 根查找 | 包重命名后需批量修改 | 顶层入口、项目根脚本 |
| 相对导入 | 包可重命名,不依赖包名 | 不能直接运行文件 | 包内子模块互相引用 |
模块首次 import 时会执行整个文件,后续导入直接从 sys.modules 缓存获取。如果需要强制重新执行,使用 importlib.reload()。
例如:
import sys
import math
# 所有已导入的模块都缓存在 sys.modules 中
print(f"math 模块是否已缓存: {'math' in sys.modules}") # True
print(f"math 模块 id: {id(sys.modules['math'])}")
print(f"import math 的 id: {id(math)}")
# 二次导入不会重新执行模块代码,直接从缓存获取
import math as math2
print(f"两次导入是同一对象: {math is math2}") # True运行代码,输出:
math 模块是否已缓存: True
math 模块 id: 2724436341312
import math 的 id: 2724436341312
两次导入是同一对象: True