写过几个月 Python 的人大概都遇到过这个问题------为什么有的功能是 import os 直接搞定,有的却要写成 import numpy.random 这种带点号的形式?这背后其实藏着 Python 组织代码的两套逻辑:模块 和包 。搞懂它们的区别,再顺手理解一下那个总是安安静静躺在文件夹里的 __init__.py,你对 Python 项目结构的认知会一下子清晰很多。
🧩 模块是什么:最小的代码单元
模块说白了就是一个 .py 文件 。你写的任何一个 Python 脚本,只要能被别的地方 import 进去,它就是一个模块。官方文档给的定义也很朴素------模块是包含 Python 定义和语句的文件,文件名就是模块名加上 .py 后缀。
举个例子,你写了个 math_utils.py,里面定义了几个函数:
python
# math_utils.py
def add(a, b):
return a + b
def square(x):
return x ** 2
在另一个文件里,你可以这样用:
python
import math_utils
print(math_utils.square(4)) # 16
这就是模块最基本的作用------把相关的代码放在一个文件里,方便复用和管理,而不是把所有逻辑塞进一个几千行的巨型脚本里让自己和同事抓狂。
从数学角度看,模块和普通脚本的区别其实很微妙。当一个 .py 文件被直接运行时,Python 会把它的 __name__ 变量设为 "__main__";而当它被当作模块导入时,__name__ 则是文件名本身。这也是为什么你经常能在代码里看到:
python
if __name__ == "__main__":
# 只有直接运行这个文件时才会执行
...
这是一种判断当前文件角色的经典写法,模块和脚本的双重身份就靠它区分。
📦 包是什么:模块的文件夹式集合
如果说模块是单个文件 ,那包就是装了很多模块的文件夹,而且这个文件夹通常还带点组织结构。Python 官方的说法是------包是一种通过使用点号分隔的模块名来构造 Python 模块命名空间的方式。
听起来有点绕,翻译成大白话就是:当你的项目变大,模块多到几十上百个的时候,光靠平铺一堆 .py 文件根本管不过来,这时候就需要用文件夹把相关模块归类,这个文件夹就是包。
一个典型的包结构长这样:
markdown
mypackage/
__init__.py
module_a.py
module_b.py
subpackage/
__init__.py
module_c.py
用 mermaid 图直观展示一下这种层级关系:

导入的时候你就能用点号一路找下去:
python
from mypackage import module_a
from mypackage.subpackage import module_c
这种点号命名法的好处很明显------不同包里即使有同名模块也不会打架,因为它们的完整路径 是唯一的,就像 mypackage.module_a 和 otherpackage.module_a 完全是两个不同的东西。
🔍 模块 vs 包:一张表看清区别
两者的核心差异可以归纳成下面这张表:
| 维度 | 模块(Module) | 包(Package) |
|---|---|---|
| 本质 | 单个 .py 文件 |
包含模块的文件夹 |
| 命名空间 | 单层 | 多层,用点号分隔 |
| 是否含子结构 | 不含 | 可以嵌套子包 |
| 典型标志 | 文件本身 | 文件夹内曾经必须有 __init__.py(现在不是硬性要求) |
| 导入方式 | import module_name |
import package.module_name |
这张表的最后一行藏着一个历史演变,接下来重点讲讲。
⚙️ __init__.py 到底在干什么
这个文件是很多新手心里的谜团------空空如也,放在文件夹里,好像什么都没做,但删掉它有时候程序就报错了。它的作用其实分成两层。
第一层:告诉 Python 这是个包
在 Python 3.3 之前,__init__.py 是强制性 的------没有这个文件,Python 根本不认这个文件夹是包,import 会直接失败。它就像一个门牌号,贴上了,Python 才知道这里住着一个包。
第二层:包的初始化代码
即使不考虑门牌号的作用,__init__.py 本身也可以写代码。它在包被首次导入时自动执行,常见用途包括:
- 设置
__all__变量,控制from package import *时哪些名字会被导入 - 提前导入包内的子模块,让用户可以直接
from mypackage import func而不用写完整路径 - 做一些初始化工作,比如注册插件、建立类结构映射
一个真实项目里的 __init__.py 可能长这样:
python
# mypackage/__init__.py
from .module_a import add
from .module_b import Calculator
__all__ = ["add", "Calculator"]
这样用户就能直接 from mypackage import add,而不需要知道 add 具体藏在 module_a 里,体验会顺畅很多。
🌀 一个反转:现在其实可以不写 __init__.py
这里有个挺多人不知道的细节。从 Python 3.3 开始,PEP 420 引入了隐式命名空间包 (implicit namespace packages),彻底放开了必须要有 __init__.py 的限制。也就是说,只要一个文件夹被放在 sys.path 能找到的位置,即使里面没有 __init__.py,Python 也能把它当作包来导入。
这在大型项目或者插件系统里特别有用,比如一个命名空间包可以跨越多个物理目录,把分散在不同地方的子包逻辑上拼成一个整体。不过要注意,这种命名空间包和传统的常规包(regular package)行为上有细微差异,比如在某些边界情况下的模块查找顺序会不一样。
所以现在业界的实际做法是------如果你只是写个普通项目,习惯上还是会老老实实放一个 __init__.py,哪怕是空的,图个清晰和兼容性;只有在做命名空间包这种特殊设计时才会刻意省略它。
💡 小结
模块和包的关系,本质上是文件 和文件夹 在 Python 导入系统里的映射------模块负责封装具体逻辑,包负责组织多个模块形成层级结构,而 __init__.py 则是包的初始化入口,兼顾着声明身份 和执行初始化代码两个角色。理解这套体系之后,再去看那些动辄几十个子模块的大型开源项目(比如 NumPy、Django),你会发现它们的目录结构其实都是这套逻辑的不断叠加和扩展。
参考资料
Python 3.14 官方文档 - Modules 教程, docs.python.org/3/tutorial/...
Stack Overflow - What is init .py for?, stackoverflow.com/questions/4...
PEP 420 -- Implicit Namespace Packages, peps.python.org/pep-0420/
Python Packaging User Guide - Packaging namespace packages, packaging.python.org/guides/pack...
Reddit r/Python - Packages no longer need init .py, www.reddit.com/r/Python/co...
Stack Overflow - Is init .py not required for packages in Python 3.3+, stackoverflow.com/questions/3...