为什么 Python 需要 if __name__ == "__main__" 语句
为什么这么设计(Why’s THE Design)是一系列关于计算机程序设计中决策的文章,在这个系列的每一篇文章中都会提出一个具体的问题,并从不同的角度讨论这种设计的优缺点、对具体实现造成的影响。
🙋♂️ 简要回答
TL;DR
这样的机制是保护用户在无意中调用脚本中的代码。以下面的脚本为例子:
💻 示例代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# file_utils_without_a_name_eq_main_sentry.py
import sys
from pathlib import Path
def rmdir(directory):
directory = Path(directory)
for item in directory.iterdir():
if item.is_dir():
rmdir(item)
else:
item.unlink()
directory.rmdir()
print(f"__name__ is {__name__}")
# ! NOTE: 没有判断 __name__ == "__main__"
# argv[1] is the first command line argument passed to the script
rmdir(Path(sys.argv[1]))
上面的代码的初衷是暴露一个 rmdir
方法——它可以用来递归地删除指定目录下的所有文件。但是同时又想要在同一个代码文件中,测试该函数能不能正常使用。也就是说,它包含了两种使用场景:
- 外:作为函数库,将公用函数接口提供给外界使用——别人
import
这个库 - 内:函数开发者,内部用来调试或验证功能正确性——作者
debug/test
该库
问题就出在,两种意图混在一起,代码上没有做判断执行前的防护。如果你在另一个脚本 app.py
中导入了无防护的脚本(比如导入 file_utils_without_a_name_eq_main_guard.py
),那么 app.py
会在导入时触发第一个脚本的运行,并使用app.py 的命令行参数。若是 app.py
的第一个命令行参数是指向项目目录或重要的用户数据目录,这是灾难性的结果,绝不亚于 rm -rf / tmp/
(多打了个空格😨)。
🧯 问题实测
为了 debug 该问题,我们在本地构建一个测试环境,文件的树形结构如下
│ app.py <===== 应用入口
│ file_utils_without_a_name_eq_main_sentry.py <====== 问题源头
│
└─foo <====== 待删除的文件夹
│ bar.txt
│
└─tex
simple.txt
app.py
的定义比较简单,就是从问题所在的 py 文件中导入函数,该函数将会被用于接口的函数体中,比如用户发起 DELETE /api/v1/dataset/1
,便将数据集对应的文件夹删除。也就是说删除操作是用户手动触发,而不是程序刚启动时就自动执行。
此处刻意做了简化处理,读者感兴趣可自行了解如何设计 RESTful 接口1,这里不再展开。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# file: app.py
from file_utils_without_a_name_eq_main_sentry import rmdir
def app():
# rmdir is a callback function of app
# rmdir 是在 App 某一回调函数或接口使用
pass
app()
当我们在命令行在运行 python app.py 时,会意外地发现,程序刚一运行,第一个命令行参数所指向的目录就被删除了,真是人在家中坐,锅从天上来🤕。
🔥 问题源头
众所周知,Python 中是没有 main 函数,也就是没有唯一的入口函数定义。故而,当 python 文件执行时,所有缩进等级为 0 的代码都会被无差别地执行,这就是上面问题的源头。官方为了解决这一问题,增加了 __name__
这一特殊的变量:
按照官方的定义2,分为两种情况:
- 被导入时
- 若是导入一整个 Py 模块或文件,那么它的
__name__
被设置为模块名(文件夹名)或文件名(去掉.py
后缀后的名称) - 若是从包中导入某个函数、标识符(变量,类等),只是包的一部分,那么它的
__name__
被定为包目录到该模块的相对文件路径,其中路径分隔符为.
,而不是文件系统常见的/
(Unix) 或\
(Windows)
- 若是导入一整个 Py 模块或文件,那么它的
- 直接运行时,import 别人,而不是被别人 import,那么其
__name__
为 "main"
🐛 代码藏毒?
🌚 隐蔽关联
我们再讨论另一种更加隐蔽的场景,需要有三个触发要素:
- 在脚本中,直接调用了有副作用的方法(比如删除目录,断开连接,关闭通道等),也就是无卫兵保护
- 在无
__name__
卫兵保护的脚本中,再创建了一个自定义的类或方法,供外界调用 - 并将该类或函数使用
pickle.dump
保存在一个pickle
文件中
那么在另一个脚本中反序列化它,就会隐式地导入无卫兵脚本(在系统底层,类似于创建了个符号链接,关联到了原来的所在文件),其产生问题与上一节所述的一样严重,但是更加隐蔽得多——无形之刃,最为致命。
(TODO: 补一组实验截图)
📝 总结
我们从一份内有副作用函数调用的无 __name__
卫兵保护的 Python 脚本说起,经实验测试,无需直接执行该脚本,只需 import 该脚本,就会导致有副作用的函数被间接调用。至此,我们知道了为什么官方要提出 __name__
这一特殊的魔术变量,正是为了弥补没有 main 入口函数导致代码无差别执行的缺陷。最后,作者附上了一种更加隐蔽的漏洞利用方式,意在警醒各位读者,做好必要的卫兵防护。