相信很多人刚开始学习Python的时候都有这样的疑问,为什么Python不像C语言那样有自增(++
)和自减(--
)运算符?
如果网上搜索这个问题可能会得到不同的答案,比如Python的整型是不可变对象,比如抽象度高不需要考虑机器指令(INC、DEC),再比如自增和自减容易对阅读代码造成误解。总之,这些答案或官方或民间给出了Python不能这样做的理由。
实际上,通过魔改CPython的源码还是可以实现自增和自减运算符。本篇文章就带你一步步实现一个支持自增和自减的Python。
本篇文章属于《CPython开发实战》系列。不同于之前的文章,本次实战涉及到Python字节码的修改,建议提前阅读之前的文章------CPython开发实战:添加loop语法、CPython开发实战:魔改lambda函数(一)、CPython开发实战:实现None感知运算符?.和??。
在实战开始前需要准备开发环境。由于是基于Windows平台开发的,在实战之前需要准备以下几个工具------
-
GIT:用来下载cpython依赖,比如sqlite、bzip、zlib等
-
MSVC:用来编译cpython项目,可以直接下载Visual Studio 2022,也可以下载Build Tools
-
低版本的Python:用来生成部分编译文件
-
Visual Studio 2022(可选):用来debug,打断点
另外本次实战采用了GitHub上当前最新的cpython分支,Python 3.13.0 alpha2版本。
1. 在命令行中运行PCBuild/build.bat
编译cpython
第一次克隆下来的cpython需要执行这条命令,确保在本地环境可以编译通过。编译中需要用到git、MSVC和旧版本的Python,并且需要保持网络通畅。
2. 在Grammar/Token
文件中第46行添加如下代码
arduino
DOUBLEPLUS '++'
DOUBLEMIN '--'
该文件是记录了Python语法中所有的Token,我们会用脚本读取这个文件生成词法分析器。由于本次实战是给Python添加自增和自减运算符,因此需要在这个文件中添加这两个Token,并命名为DOUBLEPLUS
和DOUBLEMIN
。
3. 在Parser/Python.asdl
文件中第83行添加如下代码
scss
| Inc(expr value)
| Dec(expr value)
asdl文件描述了python的抽象语法树(AST)的结构。像其他语言一样,Python也是通过AST来组织语法结构的。通过解析源码生成AST树,cpython可以更方便的用递归遍历多叉树的方式访问每个语法节点,从而生成字节码。
由于自增和自减运算属于表达式,所以在expr
父节点下插入这两个节点。
4. 在Grammar/python.gram
文件中第821行添加如下代码
ini
| a=primary '++' { _PyAST_Inc(a, EXTRA) }
| a=primary '--' { _PyAST_Dec(a, EXTRA) }
gram文件描述了Python的语法。和asdl文件一样,它也会被脚本读取生成cpython的语法分析器。和asdl不同在于它关注Python源码是否有语法错误,比如判断try
语句是否有catch
等。
在整个Python语法中,atom
是最小不可分语法元素,也是优先级最高的语法元素,比如NAME
token、True
对象、False
对象和None
对象等。比它稍微弱一点的就是primary
语法元素,比如切片运算([]
)、取属性运算(.
)等。
本次实战的自增(++
)和自减(--
)运算也被设计到primary范畴,说明它有非常高的优先级。因此,和数学运算、位运算、与或非运算相比,自增自减运算更高。
5. 在命令行中运行PCBuild/build.bat --regen
生成词法分析程序
更新代码后需要运行脚本生成对应的词法分析器,该词法分析器会融入cpython项目流程参与Python代码的编译。
运行git status
可以看到涉及词法分析的文件都被修改了。
6. 在命令行中运行PCBuild/build.bat
生成Python程序
将上一步生成的词法分析器代码和其他剩余代码一起编译,生成新的Python解释器。这样我们通过一个脚本尝试运行一下来验证之前的步骤。
python
a = 5
a ++
print(a)
然后运行python.exe -m ast test.py
,通过ast模块解析该代码的AST。如果生成的结果如下说明是成功的:
7. 在Python/symtable.c
文件中第2152行添加如下代码
c
case Inc_kind:
VISIT(st, expr, e->v.Inc.value);
break;
case Dec_kind:
VISIT(st, expr, e->v.Dec.value);
break;
在生成AST树后,cpython会遍历各个节点生成符号表。
由于自增和自减表达式只包含一个value节点,所以只需要VISIT
它的value。除此以外,没有任何操作。
8. 在Python/compile.c
文件中第6295行添加如下代码
c
case Inc_kind:
VISIT(c, expr, e->v.Inc.value);
ADDOP(c, loc, INC);
break;
case Dec_kind:
VISIT(c, expr, e->v.Inc.value);
ADDOP(c, loc, DEC);
break;
Python字节码是在第二次遍历AST树过程中生成的。当遍历到自增或自减节点时,我们应该先VISIT
该节点的value,然后发射INC
或DEC
字节码。注意,这个两个字节码是我们等会要在后续步骤中添加的字节码,它们分别对栈顶元素做加一和减一操作。而由于在发射这两个字节码之前会先遍历value的内容,所以栈顶一定会有一个元素。
9. 在Python/bytecodes.c
文件第1073行添加如下代码
c
inst(INC, (value1 -- value2 )) {
assert(PyLong_Check(value1) != NULL);
value2 = PyLong_FromLong(PyLong_AsLong(value1) + 1L);
}
inst(DEC, (value1 -- value2 )) {
assert(PyLong_Check(value1) != NULL);
value2 = PyLong_FromLong(PyLong_AsLong(value1) - 1L);
}
虽然这是一个c文件,但是它本质和前文的asdl、gram文件一样,并不会参与cpython的编译。在cpython项目中,位于Tools/cases_generator
的generate_cases.py
脚本会读取这个c文件生成Python/generated_cases.c.h
文件。该文件会直接以include的形式引入到Python虚拟机的核心文件ceval.c
文件内。
由于cop-and-patch JIT的唐突引入,
generate_cases.py
文件可能被拆分成不同的几个脚本,从而对应生成对应不同tire的字节码。如果需要生成Python/generated_cases.c.h
的话可以执行PCBuild/build.bat --regen
Python/bytecodes.c
文件格式如下------
lua
字节码指令类别(字节码指令名称, ( 输入 -- 输出 )) {
字节码指令执行逻辑
}
- 字节码指令类别将字节码划分为如下几个范畴
inst
:最基本的字节码指令,可以消费栈顶的零个或多个项目,也可以生产零个或多个项目到栈顶。如果既不消费也不生产项目说明是过度指令,比如Python3.13的EXTENDED_ARGS
字节码指令。op
:也是最基本的字节码指令,与inst
不同地方是它可以作为macro
的子字节码指令,比如_TO_BOOL
。macro
:符合字节码指令,可以使用op
的字节码指令组合,比如END_FOR
。
- 字节码指令名称是以非Python和C保留字的ASCII大写字母表示的,比如
LOAD_CONST
、STORE_NAME
等。 - 输入和输出是指字节码指令的副作用(side-effect),其中输入指需要消耗的栈顶的项目,输出是需要压入栈顶的项目。
- 字节码执行逻辑即利用输入和输出的操作逻辑,直接用C语言描绘。
举个例子,比如如果这样写bytecodes.c
文件------
c
inst ( STORE_FAST, (value --) ) {
SETLOCAL(oparg, value);
}
它会在generated_cases.c.h
文件中生成如下代码------
c
TARGET(STORE_FAST) {
PyObject *value = PEEK(1);
SETLOCAL(oparg, value);
STACK_SHRINK(1);
DISPATCH();
}
其中TARGET
宏和DISPATCH
宏本质是case语句和goto语句,其定义如下
arduino
#define TARGET(OP) case OP
#define DISPATCH goto dispatch
所以STORE_FAST
这个字节码指令就是获取栈顶项目,然后将其快速存入符号表队列,并减少队列的长度。
除了以上三类字节码指令类别,cpython还提供两个指令"宏"------
family
:这个"宏"将相似的字节码指令归为一个族,方便开发。比如------
scss
family(load_attr) = { LOAD_ATTR, LOAD_ATTR_INSTANCE_VALUE, LOAD_SLOT };
pseudo
:和family
类似,它将字节码指令细化成具体的某些指令。比如------
scss
pseudo(JUMP) = { JUMP_FORWARD, JUMP_BACKWARD };
10. 在命令行中运行PCBuild/build.bat --regen
生成generated_cases.c.h
代码
如果不出意外,项目应该可以成功编译。这时候运行git status
可以看到脚本修改了很多文件。
11. 删除旧.pyc
文件
这时候如果直接生成Python程序的话可能启动不起来,因为Python启动的时候会预加载内置库的缓存。我们需要在bash下运行如下命令删除内置库的缓存,让它重新生成。
sh
find . -name '*.py[co]' -exec rm -f '{}' +
12. 在命令行中运行PCBuild/build.bat --regen
生成Python程序
生成好Python程序后可以用第六步的代码检测一下。执行python -m dis .\PCbuild\amd64\test.py
可以看到成功生成了INC字节码。
然后直接使用++
运算符可以看到5自增成为6了。
本次实战就在此告一段落了。相比之前的实战,它经历了完整的cpython前端和后端流程。结合之前的文章,相信读者对整个cpython项目有了深刻的理解。