前一篇我们介绍了CPython VM
的运行机制,它基于一系列字节码指令来实现程序逻辑。
不过,Python
字节码在完整描述代码功能上存在局限性,于是代码对象 应运而生。像模块、函数这类代码块的执行,本质上就是对应代码对象 的运行,代码对象涵盖了字节码、常量、变量名以及各类属性信息。
实际开发Python
程序时,编写的是常规Python代码,而非字节码或直接创建代码对象。
这就需要CPython
编译器发挥作用,将源代码转换为代码对象。
本篇中,我们将探究CPython
编译器的工作流程,尝试解析其如何完成编译的任务,从而理解Python
程序的底层执行逻辑。
1. 编译器概述
从广义上看,编译器 是就是一个程序,负责将源代码 从一种编程语言转换成另一种语言。
编译器种类繁多,但在多数情况下,通常所指的编译器是静态编译器,这类工具专门用于将高级编程语言编写的程序转换为可以直接被计算机硬件执行的机器码。
传统的编译器如下图所示,一般分为三个部分:前端,优化器,后端。
编译器的前端 负责将源代码转换为一种中间表示(Intermediate Representation
, IR
)。
随后,优化器 接收该IR
,执行一系列优化操作,并将优化后的IR
传递至负责生成目标机器代码的后端。
这里为什么不直接将源代码 编译成机器码 ,而是采用这种前端->优化器->后端的三阶段设计呢?
其中还要多设计一种中间语言IR
,是否多此一举呢?
其实编译器采用这种架构有显著的优势 ,其中中间语言IR
设计得既不依赖于特定的源语言也不绑定于具体的目标架构,当编译器需要支持新的编程语言时,仅需开发相应的前端模块;
当编译器扩展对新型目标硬件的支持,只需增加对应的后端模块即可。
这样不仅提升了编译系统的灵活性,还极大地简化了其维护与升级过程。
CPython
编译器也是采用的这种三阶段设计,只不过,它的编译器前端针对的是Python源码,中间代码是抽象语法树 (AST
),最后生成的不是直接针对硬件的机器码,而是代码对象 (Code Object
)。
2. 编译器关键组件
接下来,来看看CPython
编译器中的关键组件,它们是完成从Python源码 到代码对象的核心部分。
扩展上一节中的图,将编译器中的组件加入其中。
图中关键的组件是词法分析 (拆分源码,生成Token
),语法分析 (从Token
生成AST
)以及编译 (从AST
到代码对象 CodeObject
)三个部分。
2.1. 词法分析
这个步骤中,编译器将源代码 拆分为有意义的标记Token
(如标识符、关键字、运算符等),方便后续的语法分析处理。
词法分析 在英文中成为tokenizer
,它在CPython源码中的位置:Parser/tokenizer.h
和Parser/tokenizer.c
。
词法分析 阶段,将我们的Python
源代码转换为一系列由CPython
定义的Token
流。
Token
的定义可参考:Parser/token.c
c
/* Token names */
const char * const _PyParser_TokenNames[] = {
"ENDMARKER",
"NAME",
"NUMBER",
"STRING",
"NEWLINE",
"INDENT",
"DEDENT",
"LPAR",
"RPAR",
// 省略... ...
"NL",
"<ERRORTOKEN>",
"<ENCODING>",
"<N_TOKENS>",
};
下面我们写一段简单的代码,然后看看词法分析 后生成的是什么,直观的来了解下词法分析的结果。
python
def max(x, y):
if x >= y:
return x
else:
return y
这是一个很简单的函数max
,就是从x, y
两个参数中选择一个大的返回。
查看词法分析的结果,在命令行中执行如下命令:
bash
$ python.exe -m tokenize .\cpython-compiler.py
0,0-0,0: ENCODING 'utf-8'
1,0-1,3: NAME 'def'
1,4-1,7: NAME 'max'
1,7-1,8: OP '('
1,8-1,9: NAME 'x'
1,9-1,10: OP ','
1,11-1,12: NAME 'y'
1,12-1,13: OP ')'
1,13-1,14: OP ':'
1,14-1,15: NEWLINE '\n'
2,0-2,4: INDENT ' '
2,4-2,6: NAME 'if'
2,7-2,8: NAME 'x'
2,9-2,11: OP '>='
2,12-2,13: NAME 'y'
2,13-2,14: OP ':'
2,14-2,15: NEWLINE '\n'
3,0-3,8: INDENT ' '
3,8-3,14: NAME 'return'
3,15-3,16: NAME 'x'
3,16-3,17: NEWLINE '\n'
4,4-4,4: DEDENT ''
4,4-4,8: NAME 'else'
4,8-4,9: OP ':'
4,9-4,10: NEWLINE '\n'
5,0-5,8: INDENT ' '
5,8-5,14: NAME 'return'
5,15-5,16: NAME 'y'
5,16-5,17: NEWLINE '\n'
6,0-6,0: DEDENT ''
6,0-6,0: DEDENT ''
6,0-6,0: ENDMARKER ''
其中,cpython-compiler.py
文件中就是上面max
函数的代码。
从上面可以看出,CPython
在第一行自动为我们添加了utf-8
的说明,也就是说,如果你使用的是Python3,
那么,不需要像以前Python2时那样,在代码第一行指定# -*- coding: utf-8 -*-
。
此外,词法分析 只是简单的解析源码,并转换为CPython
中Token
,它并不管代码的语法是否正确。
比如,我把上面的Python代码改为:
python
defff max(x, y):
ifaa x >= y:
return x
elsebb:
return y
这里面的关键字def
改成defff
,if
改成ifaa
,else
改成了elsebb
,明显这是错误的Python
代码,但是不影响词法分析。
依然可以正常的词法分析并生成Token
。
2.2. 语法分析
语法分析 的工作首先是检查上一步生成的输入Token
流是否是语法正确的Python
代码。
比如上一节中最后的那段错误的Python
代码,虽然可以进行词法分析 ,但是在语法分析 阶段生成AST
的时候会报错。
下图就是生成AST
的时候,提示了语法错误,并且无法生成AST
。
生成AST
的命令:python.exe -m ast <file>
语法分析 的过程远比词法分析 复杂很多很多,CPython中的语法分析代码请参考:Parser/parser.c
把语法错误改成最初的正确语法之后,再次生成AST
:
python
def max(x, y):
if x >= y:
return x
else:
return y
这样就将代码变成了一棵抽象语法树 (AST
)。画成示意图大致如下:
语法分析之后,得到了AST
,也就是CPython编译器 的中间代码 (IR
),
接下来经过CPython编译器 的优化之后生成优化的AST
,最后进入后端处理。
2.3. 编译
编译 是CPython编译器 3个关键组件中的最后一个,经过编译之后,将生成字节码 ,保存在.pyc
文件中。
再次提醒,CPython编译器 和传统静态语言(C/C++
, Rust
等)的编译器不一样,它生成的不是针对特定硬件平台的机器码。
我们运行Python
程序时,实际是由Python
解释器逐条执行编译之后生成的字节码。
编译Python
文件使用如下的命令:
bash
$ python.exe -m compileall .\cpython-compiler.py
Compiling '.\\cpython-compiler.py'...
$ ls .\__pycache__\
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2025/02/01 21:16:14 248 cpython-compiler.cpython-312.pyc
执行命令之后,可以看到生成了一个__pycache__
文件夹,其中有编译之后的字节码文件,即.pyc
文件。
编译 相关的CPython
源码请参考:Python/compile.c
。
编译 之后生成的.pyc
文件中的字节码其实就是代码对象 (CodeObject
),上一篇中介绍了代码对象。
只是这个文件是二进制的,无法直接打开查看,想看字节码的话,可以用如下的命令:
bash
$ python.exe -m dis .\cpython-compiler.py
0 0 RESUME 0
1 2 LOAD_CONST 0 (<code object max at 0x00000207FC2ADB50, file ".\cpython-compiler.py", line 1>)
4 MAKE_FUNCTION 0
6 STORE_NAME 0 (max)
8 RETURN_CONST 1 (None)
Disassembly of <code object max at 0x00000207FC2ADB50, file ".\cpython-compiler.py", line 1>:
1 0 RESUME 0
2 2 LOAD_FAST 0 (x)
4 LOAD_FAST 1 (y)
6 COMPARE_OP 92 (>=)
10 POP_JUMP_IF_FALSE 2 (to 16)
3 12 LOAD_FAST 0 (x)
14 RETURN_VALUE
5 >> 16 LOAD_FAST 1 (y)
18 RETURN_VALUE
3. 总结
本篇主要从比较宏观的角度介绍了CPython
如何编译Python
代码的。
具体的编译过程 和优化过程并没有详细说明,这需要对编译原理有深入的认识,而且限于自己的能力,我也无法通过一篇文章就说明清楚。
感兴趣的朋友可以研究研究github
上CPython
的源码,本文参考的源码是CPython 3.12分支。
最后,总结一下本文的主要内容。
首先,CPython编译器 的架构沿袭了传统的设计理念,其主要组成部分包括前端 和后端。
前端 通常被称为解析器 ,其核心职责是将源代码转换为抽象语法树(Abstract Syntax Tree, AST
)。
这一过程主要包括词法分析 和语法分析 ,词法分析负责从输入的文本中生成一系列具有语言意义的基本单元,即标记(Tokens)。
语法分析 主要生成解析树以及将其转换为AST
。
后端 ,有时也被称作编译器 ,接收前端 生成的AST
作为输入,据此生成代码对象,并进行优化处理。
最终,生成的代码对象即可用于后续执行。