为什么Python编译模块但不编译正在运行的脚本?
为什么Python会编译在脚本中使用的库,但不编译被调用的脚本本身呢?
举个例子,
如果有一个叫 main.py
的文件和一个叫 module.py
的文件,当你运行 python main.py
时,Python会生成一个编译后的文件 module.pyc
,但不会为 main.py
生成一个编译文件。为什么会这样呢?
如果有人说这是因为
main.py
所在的文件夹可能没有写权限,那为什么Python还会编译模块呢?模块同样可能(甚至更有可能)放在用户没有写权限的地方。如果main
是可以写的,Python就可以编译它,或者也可以在其他目录下编译。如果原因是编译的好处不大,那我们可以考虑一种情况,就是这个脚本会被使用很多次(比如在CGI应用中)。
7 个回答
教学法
我对像这样的提问既爱又恨,因为这类问题涉及复杂的情感、观点和一些猜测,大家开始变得有些不耐烦,结果是每个人都忘记了实际的事实,甚至最终偏离了最初的问题。
在StackOverflow上,很多技术问题至少有一个明确的答案(比如可以通过执行验证的答案,或者引用权威来源的答案),但这些“为什么”的问题往往没有单一的、明确的答案。在我看来,有两种方法可以明确回答计算机科学中的“为什么”问题:
- 指向实现相关内容的源代码。这从技术上解释了“为什么”:需要什么前提条件才能引发这种行为?
- 指向开发者写的可读文档(评论、提交信息、邮件列表等),这些文档解释了他们做出该决定的原因。这是我认为提问者真正感兴趣的“为什么”:为什么Python的开发者会做出这个看似随意的决定?
第二种答案更难以验证,因为这需要了解编写代码的开发者的想法,尤其是当没有容易找到的公共文档来解释某个特定决定时。
到目前为止,这个讨论串有7个答案,专注于解读Python开发者的意图,但整个讨论中只有一个引用。(而且它引用的Python手册的某一部分并没有回答提问者的问题。)
这是我尝试回答“为什么”问题两个方面的尝试,并附上引用。
源代码
什么条件会触发.pyc文件的编译?让我们看看源代码。(烦人的是,GitHub上的Python没有任何发布标签,所以我告诉你我查看的是715a6e
。)
在load_source_module()
函数的import.c:989
中有一些有希望的代码。为了简洁起见,我在这里省略了一些部分。
static PyObject *
load_source_module(char *name, char *pathname, FILE *fp)
{
// snip...
if (/* Can we read a .pyc file? */) {
/* Then use the .pyc file. */
}
else {
co = parse_source_module(pathname, fp);
if (co == NULL)
return NULL;
if (Py_VerboseFlag)
PySys_WriteStderr("import %s # from %s\n",
name, pathname);
if (cpathname) {
PyObject *ro = PySys_GetObject("dont_write_bytecode");
if (ro == NULL || !PyObject_IsTrue(ro))
write_compiled_module(co, cpathname, &st);
}
}
m = PyImport_ExecCodeModuleEx(name, (PyObject *)co, pathname);
Py_DECREF(co);
return m;
}
pathname
是模块的路径,而cpathname
是同一路径但带有.pyc扩展名。唯一直接的逻辑是布尔值sys.dont_write_bytecode
。其余的逻辑只是错误处理。因此,我们想要的答案不在这里,但至少我们可以看到,任何调用此函数的代码在大多数默认配置下都会生成一个.pyc文件。parse_source_module()
函数与执行流程没有真正的关联,但我在这里展示它,因为我稍后会提到它。
static PyCodeObject *
parse_source_module(const char *pathname, FILE *fp)
{
PyCodeObject *co = NULL;
mod_ty mod;
PyCompilerFlags flags;
PyArena *arena = PyArena_New();
if (arena == NULL)
return NULL;
flags.cf_flags = 0;
mod = PyParser_ASTFromFile(fp, pathname, Py_file_input, 0, 0, &flags,
NULL, arena);
if (mod) {
co = PyAST_Compile(mod, pathname, NULL, arena);
}
PyArena_Free(arena);
return co;
}
这里的关键点是,该函数解析并编译一个文件,并返回字节码的指针(如果成功的话)。
现在我们仍然处于死胡同,所以让我们换个角度来看。Python是如何加载参数并执行的?在pythonrun.c
中,有几个函数用于从文件加载代码并执行。PyRun_AnyFileExFlags()
可以处理交互式和非交互式文件描述符。对于交互式文件描述符,它委托给PyRun_InteractiveLoopFlags()
(这是REPL),而对于非交互式文件描述符,它委托给PyRun_SimpleFileExFlags()
。PyRun_SimpleFileExFlags()
检查文件名是否以.pyc
结尾。如果是,它会调用run_pyc_file()
,直接从文件描述符加载编译后的字节码并运行它。
在更常见的情况下(即将.py
文件作为参数),PyRun_SimpleFileExFlags()
会调用PyRun_FileExFlags()
。这就是我们开始找到答案的地方。
PyObject *
PyRun_FileExFlags(FILE *fp, const char *filename, int start, PyObject *globals,
PyObject *locals, int closeit, PyCompilerFlags *flags)
{
PyObject *ret;
mod_ty mod;
PyArena *arena = PyArena_New();
if (arena == NULL)
return NULL;
mod = PyParser_ASTFromFile(fp, filename, start, 0, 0,
flags, NULL, arena);
if (closeit)
fclose(fp);
if (mod == NULL) {
PyArena_Free(arena);
return NULL;
}
ret = run_mod(mod, filename, globals, locals, flags, arena);
PyArena_Free(arena);
return ret;
}
static PyObject *
run_mod(mod_ty mod, const char *filename, PyObject *globals, PyObject *locals,
PyCompilerFlags *flags, PyArena *arena)
{
PyCodeObject *co;
PyObject *v;
co = PyAST_Compile(mod, filename, flags, arena);
if (co == NULL)
return NULL;
v = PyEval_EvalCode(co, globals, locals);
Py_DECREF(co);
return v;
}
这里的关键点是,这两个函数基本上执行与导入器的load_source_module()
和parse_source_module()
相同的功能。它调用解析器从Python源代码创建抽象语法树(AST),然后调用编译器生成字节码。
那么这些代码块是冗余的,还是各自有不同的目的?区别在于一个代码块是从文件加载模块,而另一个代码块是将模块作为参数。在这种情况下,该模块参数是__main__
模块,它是在初始化过程中使用低级C函数创建的。__main__
模块没有经过大多数正常的模块导入代码路径,因为它是如此独特,因此它没有经过生成.pyc
文件的代码。
总结一下:__main__
模块没有编译成.pyc的原因是它没有被“导入”。是的,它出现在sys.modules中,但它是通过与真实模块导入截然不同的代码路径到达那里的。
开发者意图
好吧,现在我们可以看到,这种行为更多与Python的设计有关,而不是源代码中明确表达的理由,但这并没有回答这是一个有意的决定,还是只是一个不值得改变的副作用。开源的一个好处是,一旦我们找到感兴趣的源代码,我们可以使用版本控制系统(VCS)追溯到导致当前实现的决策。
这里的一行关键代码(m = PyImport_AddModule("__main__");
)可以追溯到1990年,是BDFL本人Guido写的。虽然在这几年间进行了修改,但这些修改是表面的。当它首次编写时,脚本参数的主模块是这样初始化的:
int
run_script(fp, filename)
FILE *fp;
char *filename;
{
object *m, *d, *v;
m = add_module("`__main__`");
if (m == NULL)
return -1;
d = getmoduledict(m);
v = run_file(fp, filename, file_input, d, d);
flushline();
if (v == NULL) {
print_error();
return -1;
}
DECREF(v);
return 0;
}
在Python引入.pyc
文件之前就存在这个!难怪当时的设计没有考虑到脚本参数的编译。该提交信息神秘地说:
“编译”版本
这是在三天内的几十个提交中的一个……看起来Guido当时正忙于一些重构,这是第一个恢复稳定的版本。这个提交甚至在创建Python-Dev邮件列表之前大约五年!
保存编译字节码是在1991年六个月后引入的。
这仍然是在邮件列表之前,所以我们并不知道Guido当时在想什么。看起来他只是认为导入器是缓存字节码的最佳位置。至于他是否考虑过对__main__
做同样的事情,尚不清楚:要么是他没有想到,要么是他认为这麻烦得不值得。
我在bugs.python.org上找不到与主模块的字节码缓存相关的任何错误,也找不到邮件列表上的相关消息,因此显然没有其他人认为值得尝试添加它。
总结一下:所有模块都编译成.pyc
,除了__main__
的原因是历史的怪癖。__main__
的工作方式的设计和实现是在.pyc
文件甚至存在之前就已经写入代码中的。如果你想知道更多,你需要给Guido发邮件问问。
Glenn Maynard的回答说:
似乎没有人想说这个,但我很确定答案就是:没有坚实的理由解释这种行为。
我100%同意。这个理论有间接证据支持,而这个讨论中没有其他人提供任何证据支持其他理论。我给Glenn的回答点了赞。
似乎没有人想说这个,但我觉得答案很简单:没有什么特别的理由导致这种行为。
到目前为止,给出的所有理由基本上都是错误的:
- 主文件并没有什么特别之处。它被当作一个模块加载,并且像其他模块一样出现在
sys.modules
中。运行一个主脚本其实就是用模块名__main__
导入它而已。 - 如果因为目录是只读的而无法保存 .pyc 文件,这并不是问题;Python 会直接忽略这个情况,继续往下执行。
- 缓存一个脚本的好处和缓存任何模块的好处是一样的:就是不浪费时间每次运行时都重新编译脚本。文档中也明确提到这一点(“因此,脚本的启动时间可能会减少...”)。
还有一个需要注意的问题:如果你运行 python foo.py
而 foo.pyc 文件存在,它 不会被使用。你必须 明确 输入 python foo.pyc
。这样做非常糟糕:这意味着当 .py 文件发生变化时,Python 不会自动重新编译 .pyc 文件,所以对 .py 文件的修改在你手动重新编译之前是不会生效的。如果你升级了 Python,而 .pyc 文件格式不再兼容,它还会直接报 RuntimeError,这种情况是经常发生的。通常,这些事情都是自动处理的。
你不应该需要把脚本移到一个虚拟模块中,并设置一个引导脚本来让 Python 进行缓存。这是一种不太靠谱的解决方法。
我能想到的唯一可能的(但非常不令人信服的)理由,就是为了避免你的主目录里堆满一堆 .pyc 文件。(这并不是真正的理由;如果这是个实际问题,那么 .pyc 文件应该保存为点文件。)这绝对不是不让你有一个 选项 来这么做的理由。
Python 当然应该能够缓存主模块。
文件在导入时会被编译。这并不是出于安全考虑,而是因为当你导入一个文件时,Python会保存它的输出。你可以看看Fredrik Lundh在Effbot上的这篇文章。
>>>import main
# main.pyc is created
当你运行一个脚本时,Python不会使用*.pyc文件。如果你有其他原因想要提前编译你的脚本,可以使用compileall
模块。
python -m compileall .
compileall的使用
python -m compileall --help
option --help not recognized
usage: python compileall.py [-l] [-f] [-q] [-d destdir] [-x regexp] [directory ...]
-l: don't recurse down
-f: force rebuild even if timestamps are up-to-date
-q: quiet operation
-d destdir: purported directory name for error messages
if no directory arguments, -l sys.path is assumed
-x regexp: skip files matching the regular expression regexp
the regexp is searched for in the full path of the file
如果有人提到可能是
main.py
目录的磁盘权限问题,为什么Python还要编译模块呢?
模块和脚本是一样对待的。导入操作会触发输出被保存。
如果认为好处不大,可以考虑脚本被多次使用的情况(比如在CGI应用中)。
使用compileall并不能解决这个问题。通过Python执行的脚本不会使用*.pyc
文件,除非你明确调用它。这会带来一些负面影响,Glenn Maynard在他的回答中对此进行了很好的说明。
提到的CGI应用的例子,实际上应该使用像FastCGI这样的技术。如果你想消除编译脚本的开销,可能还需要消除启动Python的开销,更不用说数据库连接的开销了。
可以使用一个轻量级的引导脚本,甚至可以用python -c "import script"
,但这些方法的风格可能有点问题。