为什么Python编译模块但不编译正在运行的脚本?

46 投票
7 回答
13397 浏览
提问于 2025-04-16 13:28

为什么Python会编译在脚本中使用的库,但不编译被调用的脚本本身呢?

举个例子,

如果有一个叫 main.py 的文件和一个叫 module.py 的文件,当你运行 python main.py 时,Python会生成一个编译后的文件 module.pyc,但不会为 main.py 生成一个编译文件。为什么会这样呢?

  1. 如果有人说这是因为 main.py 所在的文件夹可能没有写权限,那为什么Python还会编译模块呢?模块同样可能(甚至更有可能)放在用户没有写权限的地方。如果 main 是可以写的,Python就可以编译它,或者也可以在其他目录下编译。

  2. 如果原因是编译的好处不大,那我们可以考虑一种情况,就是这个脚本会被使用很多次(比如在CGI应用中)。

7 个回答

14

教学法

我对像这样的提问既爱又恨,因为这类问题涉及复杂的情感、观点和一些猜测,大家开始变得有些不耐烦,结果是每个人都忘记了实际的事实,甚至最终偏离了最初的问题。

在StackOverflow上,很多技术问题至少有一个明确的答案(比如可以通过执行验证的答案,或者引用权威来源的答案),但这些“为什么”的问题往往没有单一的、明确的答案。在我看来,有两种方法可以明确回答计算机科学中的“为什么”问题:

  1. 指向实现相关内容的源代码。这从技术上解释了“为什么”:需要什么前提条件才能引发这种行为?
  2. 指向开发者写的可读文档(评论、提交信息、邮件列表等),这些文档解释了他们做出该决定的原因。这是我认为提问者真正感兴趣的“为什么”:为什么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的回答点了赞。

31

似乎没有人想说这个,但我觉得答案很简单:没有什么特别的理由导致这种行为。

到目前为止,给出的所有理由基本上都是错误的:

  • 主文件并没有什么特别之处。它被当作一个模块加载,并且像其他模块一样出现在 sys.modules 中。运行一个主脚本其实就是用模块名 __main__ 导入它而已。
  • 如果因为目录是只读的而无法保存 .pyc 文件,这并不是问题;Python 会直接忽略这个情况,继续往下执行。
  • 缓存一个脚本的好处和缓存任何模块的好处是一样的:就是不浪费时间每次运行时都重新编译脚本。文档中也明确提到这一点(“因此,脚本的启动时间可能会减少...”)。

还有一个需要注意的问题:如果你运行 python foo.py 而 foo.pyc 文件存在,它 不会被使用。你必须 明确 输入 python foo.pyc。这样做非常糟糕:这意味着当 .py 文件发生变化时,Python 不会自动重新编译 .pyc 文件,所以对 .py 文件的修改在你手动重新编译之前是不会生效的。如果你升级了 Python,而 .pyc 文件格式不再兼容,它还会直接报 RuntimeError,这种情况是经常发生的。通常,这些事情都是自动处理的。

你不应该需要把脚本移到一个虚拟模块中,并设置一个引导脚本来让 Python 进行缓存。这是一种不太靠谱的解决方法。

我能想到的唯一可能的(但非常不令人信服的)理由,就是为了避免你的主目录里堆满一堆 .pyc 文件。(这并不是真正的理由;如果这是个实际问题,那么 .pyc 文件应该保存为点文件。)这绝对不是不让你有一个 选项 来这么做的理由。

Python 当然应该能够缓存主模块。

30

文件在导入时会被编译。这并不是出于安全考虑,而是因为当你导入一个文件时,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",但这些方法的风格可能有点问题。

撰写回答