03-包的结构
Python 3.11+
概念铺垫
3.1 什么是包
实际场景
你的项目越来越大:
user_utils.py- 用户相关(验证邮箱、格式化手机号)order_utils.py- 订单相关(计算价格、生成订单号)payment_utils.py- 支付相关(处理支付、退款)file_utils.py- 文件相关(读写文件、压缩解压)
20 多个模块文件堆在一起,乱成一团。你想把它们按功能分类整理。
问题:如何用"文件夹"的方式组织多个模块?
概念说明
如果说模块是文件,那么包就是文件夹(目录)。
包是一种通过使用"带点号的模块名"来组织 Python 模块命名空间的方法。
┌─────────────────────────────────────────────────────────────┐
│ 模块 vs 包 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 模块 = 文件(.py 文件) │
│ 包 = 文件夹(目录) │
│ │
│ 示例: │
│ my_package/ ← 这是一个包 │
│ ├── __init__.py ← 标识这是一个包 │
│ ├── module_a.py ← 这是一个模块 │
│ └── module_b.py ← 这是一个模块 │
│ │
│ 导入方式: │
│ import my_package # 导入包 │
│ import my_package.module_a # 导入包中的模块 │
│ from my_package import module_a # 另一种导入方式 │
│ │
└─────────────────────────────────────────────────────────────┘包的作用
┌─────────────────────────────────────────────────────────────┐
│ 包的作用 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 模块组织 │
│ 将相关模块放在同一目录下,便于管理 │
│ │
│ 2. 命名空间 │
│ 包名作为前缀,避免模块名冲突 │
│ │
│ 3. 代码分层 │
│ 按功能划分目录,结构清晰 │
│ │
│ 4. 可复用 │
│ 包可以独立发布和安装 │
│ │
└─────────────────────────────────────────────────────────────┘包的目录结构
3.2 基本结构
简单包结构
my_package/
├── __init__.py # 包初始化文件(必需)
├── module_a.py # 模块 A
├── module_b.py # 模块 B
└── subpackage/ # 子包
├── __init__.py
└── module_c.py实际项目结构
myproject/
├── pyproject.toml # 项目配置
├── README.md
├── src/
│ └── myproject/
│ ├── __init__.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ └── exceptions.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── product.py
│ ├── views/
│ │ ├── __init__.py
│ │ └── home.py
│ └── utils/
│ ├── __init__.py
│ └── helpers.py
└── tests/
├── __init__.py
├── test_models.py
└── test_utils.py__init__.py 文件
3.3 __init__.py 的作用
功能说明
__init__.py 文件标识一个目录为 Python 包。
┌─────────────────────────────────────────────────────────────┐
│ __init__.py 的作用 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 标识包 │
│ 告诉 Python 这个目录是一个包 │
│ │
│ 2. 初始化代码 │
│ 包被导入时自动执行 │
│ │
│ 3. 简化导入 │
│ 在 __init__.py 中导入子模块,简化外部使用 │
│ │
│ 4. 控制导出 │
│ 通过 __all__ 控制 from package import * 的行为 │
│ │
└─────────────────────────────────────────────────────────────┘Python 3.3+ 的变化
在 Python 3.3 之前,一个目录必须包含 __init__.py 文件,Python 才会把它当成一个包。
从 Python 3.3 开始,引入了"命名空间包"(Namespace Packages),__init__.py 不再是强制要求的。
但是,在实际开发中,强烈建议保留 __init__.py:
┌─────────────────────────────────────────────────────────────┐
│ 为什么建议保留 __init__.py │
├─────────────────────────────────────────────────────────────┤
│ │
│ ✓ 兼容性:支持旧版本 Python │
│ │
│ ✓ 明确意图:清楚表明这是一个包 │
│ │
│ ✓ 初始化:可以放置包级初始化代码 │
│ │
│ ✓ IDE 支持:更好的代码补全和导航 │
│ │
│ ✓ 导入控制:可以使用 __all__ 控制导出 │
│ │
└─────────────────────────────────────────────────────────────┘__init__.py 常见用法
用法 1:简化导入路径
python
# my_package/__init__.py
from .module_a import func_a
from .module_b import func_b
# 使用者可以直接导入
from my_package import func_a, func_b
# 而不需要
# from my_package.module_a import func_a用法 2:初始化代码
python
# my_package/__init__.py
import logging
# 配置日志
logging.getLogger(__name__).addHandler(
logging.NullHandler()
)
# 初始化资源
_cache: dict[str, Any] = {}
def get_cache() -> dict[str, Any]:
return _cache用法 3:控制导出
python
# my_package/__init__.py
from .module_a import func_a
from .module_b import func_b
__all__ = ['func_a', 'func_b']用法 4:完整包初始化(推荐)
python
# myproject/__init__.py
"""MyProject 工具包"""
from __future__ import annotations
__version__ = "1.0.0"
__all__ = ["user", "order", "payment"]
# 导入子模块供外部使用
from myproject import user
from myproject import order
from myproject import payment用法 5:延迟导入(避免循环依赖)
python
# myproject/__init__.py
"""MyProject - 一个示例项目
提供核心功能和工具类。
"""
from __future__ import annotations
__version__ = "1.0.0"
__author__ = "张三"
# 延迟导入,避免循环依赖
def __getattr__(name: str) -> Any:
if name == "User":
from .models.user import User
return User
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")子包
3.5 创建子包
目录结构
my_package/
├── __init__.py
├── core/
│ ├── __init__.py
│ ├── config.py
│ └── exceptions.py
├── models/
│ ├── __init__.py
│ ├── user.py
│ └── product.py
└── utils/
├── __init__.py
└── helpers.py命名空间包
3.6 命名空间包的概念
概念说明
命名空间包(Namespace Package)允许多个目录贡献到同一个包名。
传统包 vs 命名空间包:
┌─────────────────────────────────────────────────────────────┐
│ 传统包 vs 命名空间包 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 传统包: │
│ • 必须有 __init__.py │
│ • 包目录必须在一个位置 │
│ │
│ 命名空间包: │
│ • 不需要 __init__.py │
│ • 可以分散在多个目录 │
│ • 多个项目可以扩展同一个包 │
│ │
└─────────────────────────────────────────────────────────────┘创建命名空间包
目录结构:
# 项目 1
/path/to/project1/
└── mynamespace/
└── package_a/
├── __init__.py
└── module_a.py
# 项目 2
/path/to/project2/
└── mynamespace/
└── package_b/
├── __init__.py
└── module_b.py隐式命名空间包(Python 3.3+)
不需要 __init__.py,Python 自动识别:
myproject/
└── mynamespace/ # 无 __init__.py
├── package_a/
│ └── __init__.py
└── package_b/
└── __init__.pyL1 理解层:会用
导入子包
python
# 导入子包中的模块
from my_package.core import config
from my_package.models.user import User
# 导入子包中的函数
from my_package.utils.helpers import format_date
# 导入整个子包
from my_package import models
user = models.User()使用命名空间包
python
import sys
sys.path.extend(['/path/to/project1', '/path/to/project2'])
# 两个包都在 mynamespace 下
from mynamespace.package_a import module_a
from mynamespace.package_b import module_b包的组织建议
目录结构建议
myproject/
├── src/ # 源代码目录
│ └── myproject/ # 包目录
│ ├── __init__.py
│ ├── core/
│ ├── models/
│ └── utils/
├── tests/ # 测试目录
├── docs/ # 文档目录
├── pyproject.toml # 项目配置
└── README.md__init__.py 编写建议
python
# myproject/__init__.py
"""MyProject 工具包
提供用户管理、订单处理和支付功能。
"""
from __future__ import annotations
__version__ = "1.0.0"
__author__ = "Your Name"
__all__ = ["UserService", "OrderService", "PaymentService"]
# 延迟导入,避免循环依赖
def __getattr__(name: str) -> Any:
"""延迟导入服务类"""
services = {
"UserService": ".services.user.UserService",
"OrderService": ".services.order.OrderService",
"PaymentService": ".services.payment.PaymentService",
}
if name in services:
module_path = services[name]
module = __import__(module_path, fromlist=[name], level=1)
return getattr(module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")L2 实践层:用好
推荐做法
| 做法 | 原因 | 示例 |
|---|---|---|
保留 __init__.py 文件 | 明确包意图,兼容旧版本,IDE 友好 | 每个包目录都保留(即使是空文件) |
__init__.py 中用相对导入聚合 | 改包名时无需修改内部导入 | from .module_a import func_a |
设置 __all__ 控制包级导出 | 明确公开 API,保护内部实现 | __all__ = ["UserService", "OrderService"] |
使用 __getattr__ 延迟导入 | 避免循环依赖,加快导入速度 | 见上方完整包初始化示例 |
使用 src/ 布局 | 避免导入混淆,结构清晰 | src/mypackage/ |
| 子包按功能领域划分 | 高内聚低耦合 | core/, models/, services/, utils/ |
在 __init__.py 中定义版本号 | 单一版本来源,便于程序获取 | __version__ = "1.0.0" |
反模式:不要这样做
python
# ❌ 删除 __init__.py 创建命名空间包(非必要场景)
# 没有特殊理由不要删除,IDE 和工具链可能出问题
# my_package/ ← 缺少 __init__.py
# ✅ 保留 __init__.py,即使是空文件
# my_package/
# └── __init__.py ← 至少保留空文件
# ---
# ❌ 在 __init__.py 中使用绝对导入
# my_package/__init__.py
from my_package.module_a import func_a # 包改名后需要修改
# ✅ 使用相对导入
# my_package/__init__.py
from .module_a import func_a # 包改名无需修改
# ---
# ❌ 在 __init__.py 中执行副作用代码
# __init__.py
import logging
logging.basicConfig(level=logging.DEBUG) # 影响所有导入此包的程序!
_cache = load_expensive_data() # 导入时阻塞
# ✅ 延迟初始化
# __init__.py
_logger = None
def get_logger():
global _logger
if _logger is None:
_logger = logging.getLogger(__name__)
return _logger
# ---
# ❌ 包结构扁平化,所有模块堆在一起
myproject/
├── __init__.py
├── user.py
├── product.py
├── order.py
├── payment.py
└── utils.py
# 模块过多,难以定位
# ✅ 按功能分层
myproject/
├── __init__.py
├── models/
│ ├── __init__.py
│ ├── user.py
│ └── product.py
├── services/
│ ├── __init__.py
│ ├── order.py
│ └── payment.py
└── utils/
├── __init__.py
└── helpers.py适用场景
| 场景 | 推荐做法 | 原因 |
|---|---|---|
| 小型项目(<5个模块) | 可以不使用包,直接用模块 | 包增加复杂度,简单场景不需要 |
| 中型项目(5-20个模块) | 使用简单包结构,保留 __init__.py | 结构清晰,维护成本低 |
| 大型项目(>20个模块) | src/ 布局 + 多层子包 | 分层清晰,模块化管理 |
| 需要跨项目共享代码 | 命名空间包(PEP 420) | 多项目扩展同一个命名空间 |
| 发布到 PyPI 的库 | 标准 src/ 布局 + pyproject.toml | 符合现代打包规范 |
| 循环导入问题 | __init__.py 中 __getattr__ 延迟导入 | 解决包级别的循环依赖 |
L3 专家层:深入
Python 如何实现:PEP 420 命名空间包
命名空间包在 Python 3.3+ 通过 PEP 420 实现,不依赖 __init__.py:
┌──────────────────────────────────────────────────────────────┐
│ 命名空间包(PEP 420)发现机制 │
├──────────────────────────────────────────────────────────────┤
│ │
│ import mynamespace │
│ │ │
│ ▼ │
│ PathFinder.find_module("mynamespace") │
│ │ │
│ ├── 遍历 sys.path 中每个目录 │
│ │ │
│ ├── 目录 /path/A/mynamespace/ 存在? │
│ │ ├─ 有 __init__.py → 传统包 → 停止搜索 │
│ │ └─ 无 __init__.py → 标记为命名空间包部分 → 继续搜索 │
│ │ │
│ ├── 目录 /path/B/mynamespace/ 存在? │
│ │ └─ 无 __init__.py → 合并到此命名空间 │
│ │ │
│ └── 结果: │
│ mynamespace.__path__ = [ │
│ "/path/A/mynamespace/", │
│ "/path/B/mynamespace/", │
│ ] │
│ │
│ 关键差异: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 传统包 (有 __init__.py): │ │
│ │ - __path__ = ["/path/to/package/"] │ │
│ │ - 找到一个后停止搜索 │ │
│ │ - 执行 __init__.py 代码 │ │
│ │ │ │
│ │ 命名空间包 (无 __init__.py): │ │
│ │ - __path__ = ["/path1/", "/path2/", ...] │ │
│ │ - 继续搜索所有匹配目录 │ │
│ │ - 不执行任何初始化代码 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘__path__ 属性详解
__path__ 是包对象上的一个列表,定义了包中子模块的搜索路径:
┌──────────────────────────────────────────────────────────────┐
│ __path__ 属性机制 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 当执行 from mypackage import module_x 时: │
│ │
│ 1. Python 找到 mypackage 包对象 │
│ 2. 读取 mypackage.__path__ │
│ 3. 在 __path__ 的每个目录中查找 module_x │
│ 4. 找到第一个匹配项即停止 │
│ │
│ 代码等价: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ # 传统包 │ │
│ │ import mypackage │ │
│ │ print(mypackage.__path__) │ │
│ │ # ['/path/to/mypackage/'] │ │
│ │ │ │
│ │ # 命名空间包 │ │
│ │ import mynamespace │ │
│ │ print(mynamespace.__path__) │ │
│ │ # ['/proj1/mynamespace/', '/proj2/mynamespace/'] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ __path__ 可被修改(高级用法): │
│ # __init__.py 中动态扩展子包搜索路径 │
│ __path__.append("/extra/path/") │
│ # 现在 from mypackage import x 也会在 /extra/path/ 搜索 │
│ │
└──────────────────────────────────────────────────────────────┘验证代码:
python
import sys
import importlib.util
import pathlib
import tempfile
# --- 演示传统包的 __path__ ---
import json # 标准库中的包
print("=== 传统包 (json) ===")
print(f"json.__path__: {json.__path__}")
print(f"json 有 __init__.py: {(pathlib.Path(json.__path__[0]) / '__init__.py').exists()}")
# --- 查看包加载的 Spec ---
spec = importlib.util.find_spec("json")
print(f"\njson spec:")
print(f" name: {spec.name}")
print(f" submodule_search_locations: {spec.submodule_search_locations}")
# submodule_search_locations 就是 __path__ 的来源
# --- 创建命名空间包示例 ---
# Python 3.3+ 自动识别无 __init__.py 的目录为命名空间包
with tempfile.TemporaryDirectory() as tmpdir:
# 创建两个分散的命名空间包目录
ns_dir1 = pathlib.Path(tmpdir) / "ns_pkg" / "sub_a"
ns_dir2 = pathlib.Path(tmpdir) / "ns_pkg" / "sub_b"
ns_dir1.mkdir(parents=True)
ns_dir2.mkdir(parents=True)
# 加入搜索路径
sys.path.insert(0, tmpdir)
import importlib
# 注意:需要在每个子目录放置至少一个 Python 文件,否则命名空间包不会创建
(ns_dir1 / "module_a.py").write_text("VALUE_A = 1")
(ns_dir2 / "module_b.py").write_text("VALUE_B = 2")
# 导入子包(这会触发命名空间包的发现)
from ns_pkg.sub_a import module_a
from ns_pkg.sub_b import module_b
print(f"\n=== 命名空间包 ===")
print(f"ns_pkg.__path__: {ns_pkg.__path__}")
print(f"ns_pkg 有 __init__.py: {hasattr(ns_pkg, '__init__')}") # 命名空间包没有
print(f"module_a.VALUE_A: {module_a.VALUE_A}")
print(f"module_b.VALUE_B: {module_b.VALUE_B}")包的加载流程(传统包 vs 命名空间包)
python
# 模拟包加载过程的简化代码
def find_package(name: str, paths: list[str]) -> tuple:
"""模拟 PathFinder 查找包的过程"""
results = []
for path in paths:
package_dir = pathlib.Path(path) / name
if package_dir.is_dir():
has_init = (package_dir / "__init__.py").exists()
results.append((str(package_dir), has_init))
return tuple(results)
# 传统包:第一个有 __init__.py 的目录
print(find_package("json", sys.path[:3]))
# 输出类似: (('/usr/lib/python3.12/json/', True),)
# 命名空间包:多个无 __init__.py 的目录
print(find_package("mynamespace", ["/proj1", "/proj2"]))
# 输出类似: (('/proj1/mynamespace/', False), ('/proj2/mynamespace/', False))性能考量
| 操作 | 复杂度 | 说明 |
|---|---|---|
| 导入传统包 | O(1) + O(n) 执行 __init__.py | 找到第一个匹配目录即停止 |
| 导入命名空间包 | O(m) 遍历全部 sys.path | m = sys.path 条目数,无 __init__.py 执行 |
package.__path__ 读取 | O(1) | 直接返回列表引用 |
| 从包导入子模块 | O(p) 遍历 __path__ | p = __path__ 中的目录数 |
__getattr__ 延迟导入 | O(1) 首次,O(导入) 按需 | 仅在属性不存在时触发 |
| 深层子包嵌套 (a.b.c.d) | O(层数) 逐级加载 | 每层都需初始化父包 |
知识关联
┌──────────────────────────────────────────────────────────────┐
│ 包的结构 知识关联图 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 本章(包的结构) │
│ │ │
│ ├── __init__.py 聚合导入 ─► 04-导入机制(相对导入) │
│ │ │
│ ├── __all__ 包级导出 ────► 01-模块基础(模块 __all__) │
│ │ │
│ ├── PEP 420 命名空间包 ──► 04-导入机制(Finder/Loader) │
│ │ │
│ ├── __path__ 属性 ──────► 04-导入机制(sys.meta_path) │
│ │ │
│ ├── 包目录结构 ─────────► 06-发布自己的包(pyproject) │
│ │ │
│ ├── __getattr__ 延迟导入 ─► 02-自定义模块(懒加载) │
│ │ │
│ └── 子包嵌套 ───────────► 04-导入机制(绝对/相对导入) │
│ │
└──────────────────────────────────────────────────────────────┘本章小结
┌─────────────────────────────────────────────────────────────┐
│ 包的结构 知识要点 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 包的概念: │
│ ✓ 包是包含模块的目录 │
│ ✓ 用于组织代码、避免命名冲突 │
│ │
│ 目录结构: │
│ ✓ 传统包必须有 __init__.py │
│ ✓ 可以有子包 │
│ │
│ __init__.py: │
│ ✓ 标识目录为包 │
│ ✓ 简化导入路径 │
│ ✓ 初始化代码 │
│ ✓ 控制 __all__ 导出 │
│ │
│ Python 3.3+ 命名空间包: │
│ ✓ 不需要 __init__.py │
│ ✓ 可以分散在多个目录 │
│ ✓ 但实际开发仍建议保留 __init__.py │
│ │
│ 最佳实践: │
│ ✓ 使用 src/ 目录结构 │
│ ✓ __init__.py 中定义 __version__ │
│ ✓ 延迟导入避免循环依赖 │
│ │
└─────────────────────────────────────────────────────────────┘