【Python】`__init__.py` 文件详解

本文针对Python开发者, 详细描述了__init__.py文件在参与包、模块、命名空间导入/重命名/初始化时的使用方法,以及其中代码的执行机制。
本文使用边执行案例,边分析,边给结论的方法,描述了__init__.py文件的四大作用。

  1. 模块搜索标记
  2. 初始化命名空间, 空间名称即为目录名
  3. 设置__all__ 通配符导入目标
  4. 为同一目录的其他模块定义命名空间。

__init__.py 文件是每一个Python开发者都非常熟悉的文件。

这个文件究竟是干什么用的? 除了空文件以外,它里面可以有什么样的代码?我查阅了很多关于Python的文档,始终没有找到非常令人信服的详细文档资料。 即使是在Python的官方文档中,有的时候我也不知道究竟怎么解释才是正确的。

所以,以下是我对于__init__.py文件的一个总结,里面包含了一些案例,以及相应的步骤。 当然,本文的缺点是:有点长……:(

1. 模块、包和命名空间

module, package and namespace

要想搞清楚__init__.py文件,就必须要搞清楚模块、包和命名空间之间的关系

1.1. 模块

module

从一个宽泛的意义上来说,任何一个python文件都是一个模块。 可以被单独执行,也可以通过import语句被其他的Python程序所调用。 在模块内,python代码可以去调用其他的模块。

我们都知道python是可以在控制台中去一行一行去执行的。 那么,可以说,模块是将这些命令组织成为”程序“的基本组织单位了。

官方参考资料:3.11.1 Documentation » Python 教程 » 6. 模块

1.2. 包

package

包由多个文件和一个目录结构组成。 一个包可以包含模块、子包等内容。 在官方文档上有这样一句话:

要注意的一个重点概念是所有包都是模块,但并非所有模块都是包。 或者换句话说,包只是一种特殊的模块。 特别地,任何具有 __path__ 属性的模块都会被当作是包。

当然,这句话对于初学者有点拗口。 我们来搞一个实际的例子试试:

在PyCharm创建项目test01,目录如下

./
├─ tdouya2.py
├─ test01.py
└─ tdouya
    └─ __init__.py

test01.py是唯一的入口文件。 目录tdouya是一个包,而tdouya2.py是一个模块。

__init__.pytdouya2.py 都是空文件。 入口文件test01.py的代码为

import tdouya
import tdouya2


if __name__ == "__main__":
    print("tdouya.{}".format(tdouya.__path__))
    print("tdouya2.{}".format(tdouya2.__path__))

执行结果:

D:Python-grpminiconda3_dataenvpy3.10python.exe E:developpythontest01test01.py 
tdouya.['E:\develop\python\test01\tdouya']
Traceback (most recent call last):
  File "E:developpythontest01test01.py", line 7, in <module>
    print("tdouya2.{}".format(tdouya2.__path__))
AttributeError: module 'tdouya2' has no attribute '__path__'. Did you mean: '__name__'?

进程已结束,退出代码1

我们发现,果然tdouya被识别为一个包。 而tdouya2并不认为一个包,只是一个文件/模块。

1.3. 命名空间

namespace

命名空间实际上是对名称空间的一个划分,防止模块和包中各种各样的名称发生冲突。 比如builtins.open()sys.open(),通过命名空间,无论是人还是机器都能够清晰的知道究竟是哪个模块来实现。

示例

import os.path

os.path.join('/home',user)

2. 模块和层次结构

2.1. 单文件的模块

国外的资料中常称之为simple project

在PyCharm中创建项目test02,入口文件test02.py,模块module02.py,同级目录

./
├─ module02.py
└─ test02.py

module02.py文件内容

def hello():
    print('hello, it's module02.py')

test02.py文件内容

import module02

if __name__ == "__main__":
    module02.hello()

执行结果(3.10):

D:Python-grpminiconda3_dataenvpy3.10python.exe E:developpythontest02test02.py 
hello, it's module02.py

进程已结束,退出代码0

执行结果(2.7)

D:Python-grpminiconda3_dataenvpy2.7python.exe E:developpythontest02test02.py 
hello, it's module02.py

进程已结束,退出代码0

在Python2.7和Python3.10 的版本下执行,结果是一样的。

此时,没有必要创建__init__.py文件。module02.py 文件名去掉 .py 即为模块名。

2.2. 目录层次结构和命名空间

如果项目变得更加的复杂。 模块被按照目录管理了。 那么此时,我们来看下面的工程。

在PyCharm中创建项目test03,目录结构为:

./
├─ test03.py
└─ tdouya/
    └─ module03.py

module03.py 文件内容为:

def hello():
    print("hello world, this is module03.py")

入口文件test03.py内容为:

import tdouya.module03

if __name__ == "__main__":
    tdouya.module03.hello()

此时执行结果: python=3.10

D:Python-grpminiconda3_dataenvpy3.10python.exe E:developpythontest03test03.py 
hello world, this is module03.py

进程已结束,退出代码0

而在python=2.7下面, 执行结果是:

D:Python-grpminiconda3_dataenvpy2.7python.exe E:developpythontest03test03.py 
Traceback (most recent call last):
  File "E:developpythontest03test03.py", line 16, in <module>
    import tdouya.module03
ImportError: No module named tdouya.module03

进程已结束,退出代码1

这里,项目test03 在python3.10下正常工作,而在2.7下报错。 这时就需要__init__.py文件大显身手了。

我们在tdouya目录下,建立__init__.py空文件。 那么此时在2.7下执行项目test03结果为:

D:Python-grpminiconda3_dataenvpy2.7python.exe E:developpythontest03test03.py 
hello world, this is module03.py

进程已结束,退出代码0

这里的原因是从python=3.3 版本开始, 即使没有__init__.py文件, 目录也可以当作模块调用,即所谓的“Namespace Packages”。 但在“普通包”中, __init__.py文件应该是有的。

这里可以参考官方文档:

这里刚才提到了“普通包”的概念, 你可以理解为用__init__.py文件定义的包。 Python中还有一类包,称之为“命名空间包”, 这种包不在本文的讨论范围中。有兴趣的可以看刚才的参考文件:PEP 420 – Implicit Namespace Packages

2.3. 目录和命名空间映射

在刚才的示例中(项目test03),我们看到有一个叫做tdouya(田豆芽)的包。然后里面有一个模块module03.py

但是,tdouya 这个包里直接下面就没有代码了么? tdouya(田豆芽)的包,就不配有个hello()方法么? 这时候,__init__.py文件的作用就凸显出来了。

我们直接在项目test03tdouya/__init__.py文件中,增加代码代码如下:

def hello():
    print('hello world from tdouya')

入口文件test03.py中加入代码

import tdouya.module03

if __name__ == "__main__":
    tdouya.module03.hello()
    tdouya.hello()  # 新增代码

执行结果:(python=2.7 和 python=3.10 直接结果相同)

D:Python-grpminiconda3_dataenvpy2.7python.exe E:developpythontest03test03.py 
hello world, this is module03.py
hello world from tdouya

进程已结束,退出代码0

可以看出,tdouya包下面直接的hello()方法可以被直接调用并正确显示。

田老师并没有找到关于最初设立__init__.py文件的时候,开发者的意图。 但是,我总觉得,这至少是最开始设立这个文件之一。

3. __init__.py文件的作用

综合以上的讨论,以及一些编程实践的案例。 __init__.py文件应该有以下四个作用

  1. 模块搜索标记
  2. 初始化命名空间, 空间名称即为目录名
  3. 设置__all__ 通配符导入目标
  4. 为同一目录的其他模块定义命名空间。

下面,我们逐一说明:

3.1. 模块搜索标记

__init__.py用作在层次结构中定位模块的标记。必须存在才能加载目录分层模块。(注:‘命名空间包’ 不在本文的讨论范畴内)

3.2. 命名空间初始化

import一个包的时候, 首先执行__init__.py的代码, 在导入某些子模块之前,如果需要进行某些初始化操作,那么写入__init__.py是一个好方法。

3.3. 设置__all__ 通配符导入目标

我们在导入模块的时候,经常有这样的写法:from tdouya import * 意思是导入tdouya 这个包中的所有模块。

如果我们什么都不做,那么依赖的是python自己的找包机制,我们来试一下。

用PyCharm创建项目test04,目录结构如下:

./
├─ test04.py
└─ tdouya
    └─ __init__.py
    └─ art.py
    └─ develop.py
    └─ manage.py
    └─ training.py

__init__.py是空文件,tdouya包下面的所有其他四个文件的代码是相同的,如下。 这样设定,每个文件在输出的时候,都会加上自己的模块名。

def hello():
    print("hello, this is {}".format(__name__))

入口文件 test04.py 内容如下:

from tdouya import *

if __name__ == "__main__":
    art.hello()
    develop.hello()
    manage.hello()
    training.hello()

我想,这里的意图很清楚,通过from tdouya import * 自动导入包的四个模块,然后执行他们的hello()方法。但是执行结果是:

D:Python-grpminiconda3_dataenvpy3.10python.exe E:developpythontest04test04.py 
Traceback (most recent call last):
  File "E:developpythontest04test04.py", line 20, in <module>
    art.hello()
NameError: name 'art' is not defined

进程已结束,退出代码1

也就是说,并不能找到要导入的模块。

此时,我们需要指定__all__指定import *到底处理哪些模块。

我们修改__init__.py文件(我们故意少放一个training模块):

__all__ = ['art','develop','manage']

执行结果:

D:Python-grpminiconda3_dataenvpy3.10python.exe E:developpythontest04test04.py 
hello, this is tdouya.art
hello, this is tdouya.develop
hello, this is tdouya.manage
Traceback (most recent call last):
  File "E:developpythontest04test04.py", line 23, in <module>
    training.hello()
NameError: name 'training' is not defined. Did you mean: 'Warning'?

进程已结束,退出代码1

果然,如我们所料, 前面三个import *成功, 最后一个没有导入。

3.4. 为其他模块定义命名空间

在复杂项目中, 如果需要调用很多模块,甚至模块名都有重名的情况。 我们可以通过__init__.py重新为模块指定新的名字。

还是在刚才的项目test04中, 我们将刚才未加入__all__ 的training模块换个名字导入。

修改__init__.py 文件

import tdouya.training as train		# 在这里,我们将training(名词)改为了它的动词形式train

__all__ = ['art','develop','manage','train']	# 然后,我们将修改后的模块名,放入__all__

修改入口文件 test04.py

from tdouya import *

if __name__ == "__main__":
    art.hello()
    develop.hello()
    manage.hello()
    # training.hello() # 本句注释掉
    train.hello() # 用新的名字调用tdouya.training模块的hello()方法。

运行结果:python=3.10 和 python=2.7 运行结果一致。

D:Python-grpminiconda3_dataenvpy3.10python.exe E:developpythontest04test04.py 
hello, this is tdouya.art
hello, this is tdouya.develop
hello, this is tdouya.manage
hello, this is tdouya.training

进程已结束,退出代码0

这样就实现了,在使用的时候,我们可以修改模块的名字。 但是功能上不会发生变化, 比如上面的运行结果。 我们知道,模块实际的名字仍然是training, 所以最终输出的文本中,也是training,而不是 train

4. 总结

  1. 相对复杂的,命名空间分层次的模块,使用__init__.py文件是必要的。
  2. 包里面的模块使用前, 需要进行一些初始化操作的模块,使用__init__.py文件是必要的。
  3. __init__.py文件里面的程序, 只在模块被识别/找到模块名的时候被执行一次。 即使你多次导入这些模块,也只会执行一次。 除非你同时使用importlibimport.reload()的时候,才会被显式的被执行。