当前位置:首页>python>第3.3章:深入理解Python的模块与包

第3.3章:深入理解Python的模块与包

  • 2026-10-11 06:58:26
第3.3章:深入理解Python的模块与包

《Python AI 应用开发入门》第 3.3 节。

理解 import,将单文件程序拆分为模块和包,并建立稳定的启动入口。

本节目标

学完本节后,你应当能够:

  1. 1. 区分模块、包和第三方发行包。
  2. 2. 使用不同形式的 import。
  3. 3. 说明导入模块时 Python 会执行什么。
  4. 4. 使用 __name__ == "__main__" 保护入口代码。
  5. 5. 创建包含 __init__.py 的包。
  6. 6. 使用绝对导入和包内相对导入。
  7. 7. 使用 python -m 从稳定入口启动程序。
  8. 8. 识别同名文件冲突、循环导入和导入副作用。

1. 什么是模块

一个 .py 文件就是一个 Python 模块。例如:

prompts.py

其中定义:

def build_prompt(topic: str) -> str:
    return
 f"请解释:{topic}"

另一个文件可以导入:

import prompts

prompt = prompts.build_prompt("模块")
print
(prompt)

使用 模块名.成员名 能清楚看出函数来自哪里。

2. 为什么拆分模块

前一章的聊天程序可能同时负责:

  • • 显示菜单。
  • • 验证输入。
  • • 构造 Prompt。
  • • 保存消息。
  • • 统计历史。

全部放在 main.py 中会越来越难维护。可以按职责拆分:

chat_app/
├── main.py       # 程序入口和菜单
├── prompts.py    # Prompt 构造
├── storage.py    # 聊天历史读写
└── config.py     # 配置读取和验证

拆分标准不是“每个函数一个文件”,而是把高内聚、同一职责的功能放在一起。

3. 常见导入形式

导入模块

import prompts

print
(prompts.build_prompt("函数"))

优点是来源明确。

导入指定成员

from prompts import build_prompt

print
(build_prompt("函数"))

适合频繁使用且名称不冲突的成员。

使用别名

import json as json_module

标准库中最常见的别名示例是:

from pathlib import Path

别名应提高可读性,不要创建难懂缩写。

避免星号导入

# from prompts import *

它会把多个名字直接放入当前模块,来源不清楚,也更容易覆盖已有名字。

4. 导入时会执行模块

第一次导入模块时,Python 会从上到下执行模块顶层代码:

# prompts.py
print
("prompts 模块正在加载")

def
 build_prompt(topic: str) -> str:
    return
 f"请解释:{topic}"

导入它会产生输出:

import prompts

因此模块顶层适合放:

  • • 函数和类定义。
  • • 常量。
  • • 少量必要初始化。

不适合在导入时:

  • • 启动交互菜单。
  • • 读取用户输入。
  • • 修改业务文件。
  • • 发起网络请求。

这些导入副作用会让测试和复用变得困难。

5. name 与程序入口

5.1 name 是什么

Python 执行一个 .py 文件时,会先为它创建一个模块对象和一组全局名字。__name__ 就是 Python 自动放入模块全局作用域的特殊变量,用来表示这个模块当前使用的名称。

print(__name__)

名称前后各有两个下划线。此类名字常被称为“双下划线名字”或 dunder name。__name__ 由 Python 设置,程序通常只读取它,不需要手动赋值。

它的值取决于 Python 怎样加载这个文件,而不仅仅取决于文件名:

加载方式
__name__
 的值
python main.py"__main__"
import main"main"
import chat_app.main"chat_app.main"
python -m chat_app.main"__main__"

这里最重要的一点是:

"__main__" 不是某个固定文件的名称,而是 Python 赋给“本次程序入口模块”的特殊名称。

直接运行 main.py 时,这个文件就是程序入口,所以 __name__ 是 "__main__"。导入它时,它的身份是一个可复用模块,所以 __name__ 是导入使用的模块名。

5.2 用实验观察两种结果

新建 greeting.py:

# greeting.py
print
(f"greeting.py 中的 __name__:{__name__}")

def
 main() -> None:
    print
("聊天程序启动")

if
 __name__ == "__main__":
    main()

直接运行:

python greeting.py

输出:

greeting.py 中的 __name__:__main__
聊天程序启动

Python 把 greeting.py 作为程序入口,将 __name__ 设置为 "__main__",所以条件成立并调用 main()。

再新建 test_import.py:

# test_import.py
import
 greeting

print
(f"导入后看到的模块名:{greeting.__name__}")

运行:

python test_import.py

输出:

greeting.py 中的 __name__:greeting
导入后看到的模块名:greeting

这次 greeting.py 以普通模块身份加载,__name__ 是 "greeting",所以不会调用 main()。不过,入口保护之外的 print(...) 仍属于顶层代码,导入时照常执行。

5.3 入口保护到底保护了什么

下面这段代码通常称为入口保护:

if __name__ == "__main__":
    main()

它不是特殊语法,只是一个普通的 if 条件判断:

  • • 当前文件作为程序入口运行时,条件成立,调用 main()。
  • • 当前文件被其他模块导入时,条件不成立,不调用 main()。

这样,同一个文件既能直接运行,也能安全地被其他代码或测试导入,而不会在导入时意外启动交互菜单。

main 不是 Python 关键字,Python 不会因为函数叫 main() 就自动执行它。它只是常用的入口函数名,必须在条件成立时显式调用。

可以把整段机制概括为:

Python 用 __name__ 记录模块当前的加载身份;入口保护根据这个身份决定是否启动程序。

6. 什么是包

包是组织多个模块的目录。初学阶段建议放置 __init__.py:

stage2/
└── chat_app/
    ├── __init__.py
    ├── main.py
    ├── prompts.py
    ├── storage.py
    └── config.py

init.py 的作用

__init__.py 是包的初始化文件。第一次执行 import chat_app 时,Python 会加载并执行 chat_app/__init__.py,然后再把 chat_app 作为包提供给程序使用。

这个文件可以完全为空。即使为空,它也能明确表示“这个目录是一个 Python 包”,并兼容更多编辑器和开发工具。

也可以只放少量与整个包有关的内容,例如包说明或版本号:

"""聊天应用包。"""

__version__ = "0.1.0"

不要在 __init__.py 中放交互菜单、网络请求或复杂业务逻辑,否则仅仅导入包就会产生意外行为。

现代 Python 还支持不含 __init__.py 的命名空间包,但这是更高级的组织方式。本课程中的普通项目统一保留这个文件。

术语要区分:

  • • 模块:一个 .py 文件。
  • • 包:包含多个模块的可导入目录。
  • • 第三方发行包:通过 pip 安装的项目,可能包含一个或多个导入包。

pip 安装名和 import 名不一定相同。

7. 包内导入

main.py 可以使用绝对导入:

from chat_app.prompts import build_prompt
from
 chat_app.storage import load_history, save_history

包内也可以使用相对导入:

from .prompts import build_prompt
from
 .storage import load_history, save_history

开头的点表示当前包。

建议:

  • • 应用入口使用完整绝对导入,来源最清楚。
  • • 包内部可以统一使用相对导入。
  • • 同一项目保持一致,不要随意混合。

8. 使用 python -m 启动

从 stage2 目录运行:

python -m chat_app.main

-m 表示按照模块名运行。Python 会把当前目录作为查找包的起点,并以包上下文加载 chat_app.main。

不要依赖:

python chat_app/main.py

直接按文件路径运行时,包内相对导入可能失败:

ImportError: attempted relative import with no known parent package

稳定做法是:

  1. 1. 进入包的父目录。
  2. 2. 使用 python -m 包名.模块名。

9. Python 按什么顺序查找模块

执行 import prompts 时,负责定位模块的是 Python 运行时的导入系统,不是编译器。找到 Python 源文件后,Python 才可能把它编译成字节码并执行。

下面先讨论 import prompts 这种顶层模块导入。

9.1 模块查找顺序

Python 大致按以下顺序处理:

  1. 1. 检查模块是否已经存在于 sys.modules。
  2. 2. 检查它是不是 Python 内置或冻结模块。
  3. 3. 按照 sys.path 中路径的先后顺序逐个查找。
  4. 4. 所有位置都找不到时,抛出 ModuleNotFoundError。

sys.modules 是已经加载模块的缓存。模块存在于其中时,Python 通常会直接复用,不再重新查找和执行该模块:

import sys
import
 json

print
("json" in sys.modules)  # True

如果缓存中没有,导入系统再查找内置模块和磁盘上的模块。查找磁盘模块时,sys.path 是最重要的路径列表。

9.2 sys.path 中有什么

sys.path 是一个有顺序的字符串列表。正常情况下,它通常包含:

  • • 启动脚本所在目录;使用 python -m 或交互式解释器时,则与当前工作目录有关。
  • • PYTHONPATH 环境变量额外配置的目录(如果设置了)。
  • • Python 标准库目录。
  • • 当前解释器或虚拟环境的 site-packages 目录。
  • • Python 环境或工具添加的其他目录。

不同操作系统、Python 环境和启动方式产生的内容可能不同,因此最可靠的方法是直接打印:

import sys

for
 index, path in enumerate(sys.path):
    print
(index, repr(path))

使用 repr() 可以让空字符串显示为 ''。在某些启动方式中,'' 表示当前工作目录。

Python 会从索引 0 开始依次检查。查找 prompts 时,会在每个路径下寻找能够表示该模块的内容,例如:

prompts.py
prompts/
└── __init__.py

找到第一个匹配项后就停止继续查找。因此,sys.path 的顺序会直接影响最终导入哪个模块。

9.3 查看实际导入位置

可以通过模块的 __file__ 查看它实际来自哪个文件:

import json
import
 sys

print
(f"当前解释器:{sys.executable}")
print
(f"json 模块:{json.__file__}")

sys.executable 能确认正在使用哪个 Python 解释器,json.__file__ 能显示本次导入的 json 文件位置。少数内置模块没有对应的 .py 文件,因此不一定拥有 __file__。

如果项目目录中存在自己的 json.py,而项目目录排在标准库目录之前,import json 就可能先找到这个文件。这正是下一节“同名冲突”的原因:Python 不会根据你的意图选择模块,而是按照导入规则使用最先找到的匹配项。

不要通过随意调用 sys.path.append(...) 掩盖结构问题。遇到导入错误时,应先检查启动目录、包结构、当前解释器和依赖安装位置。

10. 避免同名冲突

不要把自己的文件命名为标准库或第三方包名称:

json.py
pathlib.py
typing.py
httpx.py

例如创建 json.py 后再写:

import json

Python 可能导入自己的文件而不是标准库,产生难以理解的错误。

还要删除重命名前生成的 __pycache__ 缓存,并重新启动解释器。

11. 循环导入

如果两个模块互相导入:

prompts.py → 导入 storage.py
storage.py → 导入 prompts.py

其中一个模块可能在尚未完成初始化时被另一个读取,导致部分成员不存在。

解决思路:

  • • 重新划分职责,减少相互依赖。
  • • 把共享的数据结构移到第三个模块。
  • • 让依赖方向保持单向。
  • • 不要先用函数内部导入掩盖结构问题。

理想依赖方向:

main.py → prompts.py
main.py → storage.py
main.py → config.py

三个业务模块不需要互相导入。

12. 拆分聊天程序

prompts.py:

def build_prompt(topic: str, style: str = "简洁") -> str:
    """构造练习 Prompt。"""

    cleaned_topic = topic.strip()
    if
 not cleaned_topic:
        raise
 ValueError("主题不能为空")
    return
 f"请用{style}风格解释:{cleaned_topic}"

main.py:

from chat_app.prompts import build_prompt

def
 main() -> None:
    topic = input("请输入主题:")
    prompt = build_prompt(topic)
    print
(prompt)

if
 __name__ == "__main__":
    main()

在 stage2 目录运行:

python -m chat_app.main

13. 导入问题排查顺序

遇到 ModuleNotFoundError 或 ImportError 时:

  1. 1. 确认当前激活的 Python 环境。
  2. 2. 确认终端处在包的父目录。
  3. 3. 确认模块和文件名拼写。
  4. 4. 确认包中存在 __init__.py。
  5. 5. 使用 python -m chat_app.main。
  6. 6. 检查是否有 json.py 等同名文件。
  7. 7. 检查循环导入。
  8. 8. 阅读完整 Traceback,确认实际导入了哪个文件。

动手练习

练习 1:创建包

创建 chat_app 包和 __init__.py,把 Prompt 函数移到 prompts.py,从 main.py 导入并调用。

练习 2:验证入口保护

分别:

  1. 1. 直接运行一个带入口保护的模块。
  2. 2. 从另一个模块导入它。

记录两种情况下 __name__ 的值以及 main() 是否自动执行。

练习 3:制造并修复导入错误

尝试从错误目录运行 python -m chat_app.main,再回到包的父目录运行。记录错误、原因和修复方式。

随堂小测

  1. 1. 模块与包有什么区别?
  2. 2. 为什么 import module 的来源通常比星号导入清楚?
  3. 3. 第一次导入模块时会发生什么?
  4. 4. if __name__ == "__main__": 解决什么问题?
  5. 5. __init__.py 的作用是什么?
  6. 6. 为什么推荐从包的父目录使用 python -m?
  7. 7. 自己的文件为什么不应命名为 json.py?
  8. 8. 循环导入通常说明什么设计问题?

参考答案

  1. 1. 模块是 .py 文件,包是组织多个可导入模块的目录。
  2. 2. 成员通过模块名访问,能看出来源并减少名称冲突。
  3. 3. Python 查找模块、创建模块对象并执行其顶层代码。
  4. 4. 让入口代码只在直接运行时执行,被导入时不启动。
  5. 5. 明确包边界,也可以控制包初始化和公开接口;本课程中可以为空。
  6. 6. 它建立正确包上下文,使绝对和相对导入更稳定。
  7. 7. 它可能遮蔽标准库 json,让导入指向错误文件。
  8. 8. 模块职责和依赖方向可能没有划分清楚。

本节完成检查

  • • 我能区分模块、包和第三方发行包。
  • • 我会使用清楚的导入形式。
  • • 我避免在模块导入时执行交互和业务操作。
  • • 我理解 __name__ 和入口保护。
  • • 我创建了包含 __init__.py 的 chat_app。
  • • 我能使用 python -m chat_app.main 启动。
  • • 我能排查同名文件和循环导入。
  • • 我完成了三个动手练习。

最新文章

随机文章