Python3 基础教程

Python3 模块、包与动态导入

🎉摘要:深入讲解Python模块与包的核心概念,包括模块定义、搜索路径、包的组织方式、__init__.py实用技巧、__all__导出控制、importlib动态导入与reload、相对导入语法及陷阱。适合Python开发者构建大型项目。

Python 的模块系统允许将代码拆分到独立 .py 文件;包系统支持以目录形式批量组织多个模块。熟练掌握模块、包、相对导入与动态导入机制,是搭建大型项目、实现插件化架构的重要基础。

模块(Module)基础

什么是模块?

一个 .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 的构建顺序:

  1. 当前脚本所在目录(或空字符串表示当前目录)

  2. PYTHONPATH 环境变量中的目录

  3. 标准库目录

  4. .pth 文件指定的目录

临时添加路径:

import sys
sys.path.insert(0, r"D:\my_libs")   # 插入到最前面,优先查找

注意:脚本运行时当前目录会被加入 sys.path,但直接运行模块(python -m mypkg.mod)时当前目录可能不同,这是相对导入失败的最常见原因。

包(Package)与目录组织

包的基本结构

包是一个包含 __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+)

从 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 的实用技巧

下面给出一个实际项目中的 __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 上下文)。

不需要导入包就直接构造,等到业务真正使用时再创建。

__all__ 导出列表

from module import * 的行为

默认情况下,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

包级别的 __all__

# utils/__init__.py
__all__ = ["string_utils", "file_utils"]   # 控制 from utils import * 导出哪些子模块

建议:

  • 每个公共模块都应定义 __all__,明确公开 API 边界

  • __all__ 中的字符串名称必须与模块中实际定义的名称一致,否则 import * 会报错

动态导入 importlib

importlib.import_module() 按字符串导入

当模块名在运行时才能确定,或需要从配置文件中加载时,使用 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

importlib.reload() 重新加载模块

开发阶段修改模块后,不需要重启解释器,可以热重载。例如:

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.py

core/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 根查找包重命名后需批量修改顶层入口、项目根脚本
相对导入包可重命名,不依赖包名不能直接运行文件包内子模块互相引用

sys.modules 与模块缓存

模块首次 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


说说我的看法
全部评论(
没有评论
关于
本网站专注于 Java、数据库(MySQL、Oracle)、Linux、软件架构及大数据等多领域技术知识分享。涵盖丰富的原创与精选技术文章,助力技术传播与交流。无论是技术新手渴望入门,还是资深开发者寻求进阶,这里都能为您提供深度见解与实用经验,让复杂编码变得轻松易懂,携手共赴技术提升新高度。如有侵权,请来信告知:hxstrive@outlook.com
其他应用
公众号