模块与包:Python 代码组织的两层逻辑

152 阅读5分钟

写过几个月 Python 的人大概都遇到过这个问题——为什么有的功能是 import os 直接搞定,有的却要写成 import numpy.random 这种带点号的形式?这背后其实藏着 Python 组织代码的两套逻辑:模块和包。搞懂它们的区别,再顺手理解一下那个总是安安静静躺在文件夹里的 __init__.py,你对 Python 项目结构的认知会一下子清晰很多。


🧩 模块是什么:最小的代码单元

模块说白了就是一个 .py 文件。你写的任何一个 Python 脚本,只要能被别的地方 import 进去,它就是一个模块。官方文档给的定义也很朴素——模块是包含 Python 定义和语句的文件,文件名就是模块名加上 .py 后缀。

举个例子,你写了个 math_utils.py,里面定义了几个函数:

# math_utils.py
def add(a, b):
    return a + b

def square(x):
    return x ** 2

在另一个文件里,你可以这样用:

import math_utils
print(math_utils.square(4))  # 16

这就是模块最基本的作用——把相关的代码放在一个文件里,方便复用和管理,而不是把所有逻辑塞进一个几千行的巨型脚本里让自己和同事抓狂。

从数学角度看,模块和普通脚本的区别其实很微妙。当一个 .py 文件被直接运行时,Python 会把它的 __name__ 变量设为 "__main__";而当它被当作模块导入时,__name__ 则是文件名本身。这也是为什么你经常能在代码里看到:

if __name__ == "__main__":
    # 只有直接运行这个文件时才会执行
    ...

这是一种判断当前文件角色的经典写法,模块和脚本的双重身份就靠它区分。


📦 包是什么:模块的文件夹式集合

如果说模块是单个文件,那包就是装了很多模块的文件夹,而且这个文件夹通常还带点组织结构。Python 官方的说法是——包是一种通过使用点号分隔的模块名来构造 Python 模块命名空间的方式。

听起来有点绕,翻译成大白话就是:当你的项目变大,模块多到几十上百个的时候,光靠平铺一堆 .py 文件根本管不过来,这时候就需要用文件夹把相关模块归类,这个文件夹就是包。

一个典型的包结构长这样:

mypackage/
    __init__.py
    module_a.py
    module_b.py
    subpackage/
        __init__.py
        module_c.py

用 mermaid 图直观展示一下这种层级关系:

export_12qee.png

导入的时候你就能用点号一路找下去:

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_nameimport 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 可能长这样:

# 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…