对pyc和pyo文件的理解

1. python的基本运行机制

作为Python爱好者,需要了解.py脚本的基本运行机制特性:在很多工作上Python的运行流程基本上取决于用户,因此源码不需要编译成二进制代码(否则无法实现大部分贴近用户的特性),而直接从源码运行程序。当我们运行python文件程序的时候,Python解释器将源码转换为字节码,然后再由解释器来执行这些字节码。
因此总的来说,它具有以下三条特性:

  1. 源码距离底层更远(根据官方文档的解释。不说,也感觉得到)
  2. 运行时都需要生成字节码,交由虚拟机执行。(虚拟机在哪儿!你们也不看看各自都是用什么软件执行的!没错,就是解释器,别和我说是IDLE啊。虚拟机具体实现了由switch-case语句构成的框架函数PyEval_EvalFrameEx,刚刚说的字节码就是这货执行的)
  3. 每次执行脚本,虚拟机总要多出加载和链接的流程

2. 执行脚本的过程

那么,有人要问了:不是说,运行时总要生成字节码么!那,字节码都去哪儿了?先说说,虚拟机它是怎么执行脚本的

  1. 完成模块的加载和链接;
  2. 将源代码翻译为PyCodeObject对象(这货就是字节码),并将其写入内存当中(方便CPU读取,起到加速程序运行的作用);
  3. 从上述内存空间中读取指令并执行;
  4. 程序结束后,根据命令行调用情况(即运行程序的方式)决定是否将PyCodeObject写回硬盘当中(也就是直接复制到.pyc.pyo文件中);
  5. 之后若再次执行该脚本,则先检查本地是否有上述字节码文件。有则执行,否则重复上述步骤。

在我们点击(或输入命令)运行脚本,并悠闲地喝咖啡时,虚拟机做了这么多的事情。不过,你有没有发现.pyc.pyo文件是否生成,是取决于我们如何运行程序的。

3. pyc和pyo文件的生成

虚拟机也是讲究效率的。毕竟对于比较大的项目,要将PyCodeObject写回硬盘也是不可避免地要花些时间的,而且它又不知道你是不是也就只执行一次,之后就对刚刚跑完的脚本弃之不顾了呢。不过,它其实也有贴心的一面。比如,

  • 若你在命令行直接输入python path/to/projectDir(假设projectDir目录含有__main__.py文件,以及其他将要调用的模块),那么程序运行结束后便自动为当前目录下所有的脚本生成字节码文件,并保存于本地新文件夹__pycache__当中。(这也有可能是IDE写小项目时自动生成.pyc文件的原因,不过问题描述略微暧昧。详情参见上面知乎问题板块)

或者是,在命令行输入python path/to/projectDir/__main__.py,则生成除__main__.py外脚本的字节码文件。不过总的来说,上述这两种行为都大大缩短了项目运行前的准备时间(毕竟分工明确的程序,规模应该不会太小,复用率也不会太低。除非吃饱了撑着,搞出这么多事情

  • 模块在每次导入前总会检查其字节码文件的修改时间是否与自身的一致。若是则直接从该字节码文件读取内容,否则源模块重新导入,并在最后生成同名文件覆盖当前已有的字节码,从而完成内容的更新(详见import.py)。这样,就避免了修改源代码后与本地字节码文件产生冲突(当然,设计者也不会这么傻。
  • .pyc文件是由.py文件经过编译后生成的字节码文件,其加载速度相对于之前的.py文件有所提高,而且还可以实现源码隐藏,以及一定程度上的反编译。比如,Python3.3编译生成的.pyc文件,Python3.4就别想着去运行啦!
  • .pyo文件也是优化(注意这两个字,便于后续的理解)编译后的程序(相比于.pyc文件更小),也可以提高加载速度。但对于嵌入式系统,它可将所需模块编译成.pyo文件以减少容量

    但总的来说,作用上是几乎与原来的.py脚本没有区别的,也就是然并卵 (当然,并非毫无作用。比如,我个人觉得用处最大的地方就是防止别人偷看我的代码。毕竟.py源文件是直接以源码的形式呈现给大家的)。呃…这么说,好像又有点自相矛盾的赶脚。

Python选项

  • -O,表示优化生成.pyo字节码(这里又有优化两个字,得注意啦!)
  • -OO,表示进一步移除*-O选项生成的字节码文件中的文档字符串(这是在作用效果上解释的,而不是说从-O*选项得到的文件去除)
  • -m,表示导入并运行指定的模块

对此,我们可以使用如下格式运行.py文件来生成.pyc文件(以下调用均假设/path/to目录含有.py脚本):

1
2
3
4
python -m py_compile /path/to/需要生成.pyc的脚本.py 
#若批量处理.py文件
#则替换为/path/to/{需要生成.pyc的脚本1,脚本2,...}.py
#或者/path/to/

效果等同于下面:

1
2
3
4
import py_compile
py_compile.compile(r'/path/to/需要生成.pyc的脚本.py')
#同样也可以是包含.py文件的目录路径
#此处尽可能使用raw字符串,从而避免转义的麻烦。比如,这里不加“r”的话,你就得对斜杠进行转义

3. py_compile模块

py_compilePython的自带模块,这里面就两个函数。其下的py_compile.compile*(*file*[, *cfile*[, *dfile*[, *doraise*]]])可将.py文件编译生成.pyc文件,对应的参数解释如下

  1. file,表示需要生成.pyc.pyo文件的源脚本名(字符串);
  2. cfile,表示需要生成.pyc.pyo文件的目标脚本名。呃…好像没有区别,也就是源脚本——-目标脚本。当然,它默认是以.pyc为扩展名的路径名的字符串。此外,当且仅当所使用的解释器允许编译成.pyo文件,才能以.pyo结尾。
  3. dfile,表示编译出错时,将报错信息中的名字file替换为dfile
  4. doraise设置是否忽略异常。若为True,则抛出PyCompileError异常;否则直接将错误信息写入sys.stderr(什么!不知道sys.stderr!温馨提示:sys.stderrPython自带的标准错误输出)
1
python -O -m py_compile /path/to/需要生成.pyo的脚本.py

那么,有人要问了:为什么不是像生成.pyc文件那样采用python -O /path/to/需要生成pyo的脚本.py形式的调用?
忘记说明这一点了,很多博客以及书籍都像我上面那样解释-O选项的作用,但详细来解释的话是:

-O 选项,将.pyc文件优化(注意我一直强调的优化二字,这里就用到啦!).pyo文件,而不是将.py文件优化编译为.pyo文件。(其直接的结果是优化编译后的文件略微小于.pyc文件,也就是减肥了。现在,大家知道.pyo文件为什么小的原因了吧!)

4. 注意

以上无论是生成.pyc还是.pyo文件,都将在当前脚本的目录下生成一个含有字节码的文件夹__pycache__

可能还有人会问,pyd文件又是什么,别在意,那只是Python的动态链接库。如果要深究,还得扯上C++的知识

再啰嗦一句:生成字节码的方法多了去了,不止以上这几种。比如,你们不妨试试将上面命令行调用中的py_compile改成compileall,而代码行中的`py_compile.compile改成*compileall.compile_file*或*compileall.compile_dir`*,又或者直接使用带有编译功能的IDE生成字节码。

再再啰嗦一句:知道Python运行机制,并不是我们一般人所必须的。但是,了解其加速程序运行以及优化代码的设计思想,对于我们在日后构造缓存系统、如何减少不必要的运行时间,以及同步更新工作内容等问题上起到很大的借鉴作用。