1. 为什么要动CPython的语法:一次“自找麻烦”的实验动机
很多人第一次听到“修改CPython语法”这个念头,第一反应都是:这不是吃饱了撑的吗?Python语法已经够简洁了,你加什么“非”和“前缀自增”,不是把语言搞乱吗?说实话,我一开始也是这么想的。但真正动手之后才发现,这个实验的价值根本不在于“给Python加两个语法糖”,而在于通过修改语法来彻底理解一门语言的编译链路——从词法分析、语法分析、AST构造、字节码生成到解释器执行,每一层你都得摸一遍。
这个实验的目标很明确:给CPython增加两个语法特性。第一个是“非”运算符,也就是用一个中文关键字或者符号来表示逻辑取反,替代或者补充现有的not。第二个是“前缀自增”,类似C语言里的++i,让变量在使用前先自增。这两个特性在语法层面都不复杂,但它们触及的编译阶段完全不同——“非”是一元运算符,涉及词法关键字识别和语法规则中的表达式优先级;而“前缀自增”是语句级或表达式级的前缀操作,涉及赋值语义和副作用处理。
为什么选这两个?因为它们刚好代表了两种典型的语法扩展场景:一种是纯表达式层面的运算符替换,另一种是带副作用的操作符引入。前者相对安全,后者则要小心处理求值顺序和变量绑定。把这两个都跑通,你基本就掌握了CPython语法改动的完整套路。
这个实验适合谁?适合已经能熟练写Python,但对CPython内部实现好奇的开发者;适合想理解“一门语言是怎么被造出来”的计算机专业学生;也适合那些想给Python加自己私货语法、但又不想从头写一门语言的折腾党。前提是你得能编译CPython,会用gdb或者至少会看报错,不然改到一半卡在编译错误上会很痛苦。
我用的CPython版本是3.11.x,选择这个版本的原因是它的编译流程相对清晰,PEG语法解析器已经稳定,而且字节码指令集没有3.12那么多变动。整个实验在Linux环境下完成,编译器用gcc,构建工具就是CPython自带的configure和make。下面我会把每一步的动机、操作和踩坑都摊开讲,你照着做基本能复现。
2. 动手前的环境准备与源码结构摸底
2.1 拉取源码与构建基线
第一步没什么好说的,从官方仓库拉一份CPython源码下来。我习惯用git clone,然后切到一个稳定的tag,比如v3.11.9。不要直接用main分支,因为main分支的语法文件可能已经变了,你照着老教程改会对不上。
git clone https://github.com/python/cpython.git cd cpython git checkout v3.11.9拉下来之后先别急着改,先编译一遍,确认基线是通的。这一步很重要,因为如果你改完编译失败,你至少知道是改出来的问题,而不是环境本身有问题。
./configure --prefix=$HOME/cpython-exp --with-pydebug make -j$(nproc) make install这里我加了--with-pydebug,因为调试版本会打开断言和额外的检查,改语法的时候如果哪里不对,调试版本会直接崩给你看,比release版本默默出错要好得多。--prefix指定安装到用户目录,避免污染系统Python。
编译完成后,用$HOME/cpython-exp/bin/python3.11跑一下,确认能正常进入交互环境。然后记下这个路径,后面所有测试都用这个自己编译的Python。
2.2 语法改动涉及的核心文件
CPython的语法改动不是改一个文件就完事,它是一条链。你得先知道每个文件管什么,不然改完不知道去哪找问题。下面这张表是我整理的核心文件清单,按编译流程顺序排列:
| 文件路径 | 作用 | 改动频率 |
|---|---|---|
Grammar/python.gram | PEG语法规则定义,描述语法结构 | 高 |
Parser/tokenizer.c | 词法分析器,识别关键字和符号 | 中 |
Include/token.h | token类型枚举定义 | 低 |
Lib/token.py | Python层面的token常量 | 低 |
Python/compile.c | 字节码生成,把AST编译成指令 | 高 |
Python/ast.c | AST节点构造与验证 | 中 |
Include/internal/pycore_ast.h | AST节点结构定义 | 低 |
Python/ceval.c | 字节码解释执行 | 中 |
对于“非”运算符,主要动的是python.gram和tokenizer.c,因为它是表达式层面的替换,AST节点可以复用现有的UnaryOp。对于“前缀自增”,除了语法规则,还得在compile.c里生成正确的字节码序列,因为自增涉及“读-改-写”三个步骤,不能简单复用现有指令。
提示:每次改完
python.gram,都需要重新生成解析器。CPython的构建系统会自动调用pegen来生成Parser/parser.c,但如果你改了grammar文件后编译报错说找不到某个规则,先检查是不是make没有触发重新生成。可以手动跑make regen-pegen强制重新生成。
2.3 编译加速与增量构建技巧
CPython全量编译一次大概要几分钟到十几分钟,取决于机器。如果你每改一行就全量编译,一天下来时间全浪费在等编译上。我的做法是:只改语法文件时,用make regen-pegen重新生成解析器,然后只编译Parser/和Python/目录下的目标文件。具体来说:
make regen-pegen make -j$(nproc) Parser/parser.o Python/compile.o Python/ceval.o这样只重新编译受影响的目标文件,最后链接一次,通常几十秒就能搞定。但要注意,如果你改了头文件里的结构体定义,那依赖这个头文件的所有文件都得重编,这时候增量构建可能反而更慢,不如直接全量。
另外,调试的时候我强烈建议用gdb挂上去。比如你想看某个语法规则有没有被匹配到,可以在Parser/parser.c生成的对应函数里下断点。虽然生成的代码可读性一般,但配合python.gram里的规则名,还是能定位到具体位置的。
3. “非”运算符的落地:从词法到字节码的完整链路
3.1 词法层:让tokenizer认识新符号
“非”运算符我选择用一个中文关键字来实现,因为这样最直观,也最能体现“语法扩展”的感觉。当然你也可以用符号比如!,但!在Python里已经被!=占用了,单独一个!会引入歧义。用中文关键字“非”的好处是它不会和现有语法冲突,词法分析器只要把它当成一个关键字token就行。
在Parser/tokenizer.c里,关键字识别是通过一个叫keyword的哈希表或者查找函数完成的。CPython 3.11用的是_PyToken_OneChar和_PyToken_TwoChars这类函数来处理符号,关键字则在Parser/keywordgen.py生成的Parser/parser.c里有对应的关键字列表。但更直接的做法是在Grammar/python.gram里定义一个新的关键字token,然后在tokenizer.c里注册。
具体操作:在Grammar/python.gram顶部的token定义区域,加一行:
NON_KEYWORD: "非"然后在语法规则里引用这个token。但光这样还不够,tokenizer需要知道“非”是一个关键字而不是普通标识符。CPython的关键字识别是在Parser/string_parser.c和Parser/tokenizer.c里通过_PyUnicode_EqualToASCIIString之类的比较来完成的。对于中文关键字,需要确保源文件用UTF-8编码,并且tokenizer能正确读取多字节字符。
这里有个坑:CPython的tokenizer默认按字节处理,中文是多字节的,如果你直接在tokenizer.c里用strcmp比较,会匹配失败。正确做法是用PyUnicode相关的比较函数,或者在grammar层面让pegen生成的关键字匹配逻辑来处理。我实测下来,最稳妥的方式是在python.gram里把“非”定义为一个soft keyword,然后在Parser/parser.c生成的关键字表里注册它的UTF-8编码。
注意:改完tokenizer后,一定要用
make regen-token重新生成token定义,否则Include/token.h里的token编号不会更新,编译时会报未定义的token类型。
3.2 语法层:在PEG规则里插入一元运算符
“非”是一个一元前缀运算符,它的优先级应该和not一致,或者略高。在python.gram里,表达式规则是分层的,从低到高依次是or_test、and_test、not_test、comparison等。not_test就是处理not的地方:
not_test: | 'not' a=not_test { _PyAST_UnaryOp(Not, a, EXTRA) } | comparison我要做的就是在这个规则里加一个分支,让“非”也能触发同样的UnaryOp节点:
not_test: | 'not' a=not_test { _PyAST_UnaryOp(Not, a, EXTRA) } | NON_KEYWORD a=not_test { _PyAST_UnaryOp(Not, a, EXTRA) } | comparison这样非 x就会被解析成和not x完全一样的AST节点。注意这里我复用了Not操作符,没有新建AST节点类型,因为语义完全一样,没必要增加复杂度。如果你想让“非”有不同于not的语义(比如按位取反),那就得新建AST节点并在compile.c里生成不同的字节码。
改完grammar后,跑make regen-pegen,然后重新编译。如果编译通过,用自己编译的Python跑一下:
>>> 非 True False >>> 非 False True >>> 非 (1 > 2) True如果输出正确,说明词法和语法层都通了。如果报SyntaxError,大概率是关键字没注册成功,检查tokenizer.c里的关键字表。
3.3 字节码层:确认生成的指令序列
语法通了不代表字节码正确。用dis模块看一下非 x生成的字节码:
import dis def f(x): return 非 x dis.dis(f)你应该看到类似这样的输出:
2 0 LOAD_FAST 0 (x) 2 UNARY_NOT 4 RETURN_VALUEUNARY_NOT就是not对应的字节码指令。因为我们在AST层面复用了Not节点,所以compile.c里现有的UnaryOp编译逻辑会自动处理,不需要额外改动。这就是复用AST节点的好处——字节码生成完全不用动。
但如果你新建了AST节点,那就得在Python/compile.c的compiler_unaryop函数里加一个case,生成对应的指令。比如你想让“非”生成UNARY_INVERT(按位取反),那就得改compile.c。我建议第一次实验先复用,跑通了再尝试自定义。
3.4 实测中的意外:中文关键字的编码问题
我在第一次尝试时遇到了一个很隐蔽的问题:在交互式环境里输入非 True能正常工作,但把代码写到.py文件里再执行就报SyntaxError。排查了半天才发现,问题出在文件编码上。CPython默认源文件编码是UTF-8,但如果你的编辑器保存成了GBK或者其他编码,tokenizer读到的字节序列就不对,关键字匹配自然失败。
解决办法有两个:一是在文件头加# -*- coding: utf-8 -*-,二是确保编辑器保存为无BOM的UTF-8。我后来养成了一个习惯:所有测试脚本都用file命令确认编码,避免这种低级问题浪费时间。
另一个意外是,中文关键字在tokenizer.c里的匹配效率比ASCII关键字低,因为多字节比较更耗时。对于实验来说无所谓,但如果你真要在生产环境加中文关键字,得考虑性能影响。实测下来,对于普通脚本,差异可以忽略不计,但在大量循环里频繁使用“非”运算符,tokenizer的耗时会有可感知的增加。
4. 前缀自增的实现:副作用操作的编译挑战
4.1 为什么前缀自增比“非”复杂得多
“非”是纯表达式,没有副作用,求值一次就完事。但前缀自增++i不一样,它做了三件事:读取i的当前值、把i加1、返回加1后的值(或者加1前的值,取决于前缀还是后缀)。这意味着它不能简单映射到一个UNARY_指令,而是需要生成一串字节码:LOAD_FAST、LOAD_CONST、BINARY_ADD、STORE_FAST,还要保证返回正确的值。
更麻烦的是,++i只能作用于变量,不能作用于表达式。++(a+b)在C语言里也是非法的,因为自增需要有一个可写的存储位置。所以在语法层面,我得限制++后面只能跟一个名字(NAME),而不是任意表达式。这就需要在grammar里单独定义一条规则,而不是把它塞进通用的unaryop里。
4.2 语法规则的设计:限制操作数类型
在python.gram里,我新增了一条语句规则和一条表达式规则。语句规则用于++i单独成行的情况,表达式规则用于x = ++i这种嵌入表达式的情况。但为了简化,我先只实现表达式形式,因为语句形式可以通过表达式语句自动覆盖。
规则大概长这样:
incr_expr: | '++' a=NAME { _PyAST_AugAssign(a, Add, _PyAST_Constant(1, EXTRA), EXTRA) }但这里有个问题:AugAssign是语句节点,不是表达式节点,不能直接用在表达式位置。所以我需要新建一个AST节点,比如叫PrefixIncr,它包含一个目标变量名。然后在compile.c里为这个节点生成字节码。
新建AST节点的步骤比较繁琐:在Include/internal/pycore_ast.h里加结构体定义,在Python/ast.c里加构造函数和验证逻辑,在Python/compile.c里加编译函数,还要在Python/Python-ast.c里加序列化和反序列化支持。每一步漏了都会导致编译失败或者运行时崩溃。
我实际的做法是偷懒:不新建AST节点,而是把++i在语法层直接展开成i += 1的AST结构,但返回i的值。这需要构造一个NamedExpr(海象运算符)或者用comprehension来模拟。但这样会引入额外的复杂性,而且语义上不完全等价。最终我还是老老实实新建了AST节点,虽然麻烦,但逻辑最清晰。
4.3 字节码生成:手写指令序列
在Python/compile.c里,我为PrefixIncr节点写了一个编译函数。核心逻辑是:
- 加载变量当前值:
LOAD_FAST i - 加载常量1:
LOAD_CONST 1 - 执行加法:
BINARY_OP + - 复制栈顶值(因为要返回新值,同时要存回变量):
COPY 1 - 存回变量:
STORE_FAST i - 此时栈顶是自增后的值,作为表达式结果返回
对应的C代码大概是这样:
static int compiler_prefix_incr(struct compiler *c, expr_ty e) { // e->v.PrefixIncr.name 是变量名 if (!compiler_nameop(c, e->v.PrefixIncr.name, Load)) return 0; ADDOP_LOAD_CONST(c, PyLong_FromLong(1)); ADDOP(c, BINARY_OP, Add); ADDOP(c, COPY, 1); if (!compiler_nameop(c, e->v.PrefixIncr.name, Store)) return 0; return 1; }这里的关键是COPY 1指令,它把栈顶的值复制一份,这样STORE_FAST消耗一份,另一份留在栈上作为表达式结果。如果没有COPY,存回变量后栈就空了,表达式就没有返回值了。
注意:
COPY指令在CPython 3.11里是有的,但如果你用的是更早的版本,可能需要用DUP_TOP。指令集在不同版本间有变化,改之前先查一下你用的版本的字节码定义。
4.4 测试与验证:边界情况处理
实现完之后,我写了一系列测试用例:
i = 5 x = ++i print(i, x) # 期望输出 6 6 j = 0 while ++j < 3: print(j) # 期望输出 1 2第一个用例验证基本功能,第二个用例验证在条件表达式里的行为。实测下来,基本功能没问题,但while ++j < 3这种用法有个坑:每次循环条件判断时j都会自增,所以循环体会执行两次(j从0变成1,1<3成立;j从1变成2,2<3成立;j从2变成3,3<3不成立)。这个行为和C语言一致,符合预期。
但有一个边界情况我一开始没处理好:如果变量未定义,++i应该报NameError还是UnboundLocalError?因为我的实现是先LOAD_FAST再STORE_FAST,所以未定义时会报UnboundLocalError,这和i += 1的行为一致。如果你希望报NameError,那就得在编译时做更多检查,但我觉得没必要,保持和现有语义一致就好。
另一个坑是全局变量和局部变量的处理。LOAD_FAST和STORE_FAST只适用于局部变量,如果++作用于全局变量,得用LOAD_GLOBAL和STORE_GLOBAL。我的实现里用了compiler_nameop,它会根据作用域自动选择正确的指令,所以全局变量也能正常工作。但如果你手写指令,就得自己判断作用域,不然会出错。
5. 踩坑实录:从编译失败到运行时崩溃的排查链路
5.1 语法规则冲突导致的解析器生成失败
第一次改python.gram时,我直接把++加到了unaryop规则里,结果make regen-pegen报了一堆冲突警告,生成的解析器在解析++i时行为不确定。原因是++和现有的+运算符在词法层面有歧义:tokenizer看到++时,不知道应该识别成两个+还是一个++。
CPython的tokenizer是贪婪匹配的,它会优先匹配最长的token。所以++会被识别成一个单独的token,而不是两个+。但问题是,我在grammar里没有定义++这个token,所以tokenizer会报SyntaxError。解决办法是在tokenizer.c里注册++为双字符运算符,类似**、//、==这些。
具体操作是在Parser/tokenizer.c的_PyToken_TwoChars函数里加一个case:
case '+': if (c == '+') return PLUSPLUS; break;然后在Include/token.h里加PLUSPLUS枚举值,在Lib/token.py里加对应的常量。这一套下来,tokenizer才能正确识别++。
5.2 AST节点未注册导致的段错误
新建PrefixIncr节点后,我忘了在Python/Python-ast.c里加对应的ast2obj和obj2ast函数。结果编译能过,但一跑++i就段错误。用gdb回溯发现是在AST序列化时访问了空指针。补上序列化函数后问题解决。
这个坑的教训是:CPython的AST节点不是加个结构体就完事,它有一整套配套的序列化、反序列化、验证、访问者模式代码。漏掉任何一个,都可能在某个特定场景下崩溃。最稳妥的做法是参考现有节点的实现,把所有相关函数都补全。
5.3 字节码栈不平衡导致的解释器崩溃
COPY 1指令我一开始写成了COPY 0,以为0表示复制栈顶。实际上COPY n表示复制栈顶往下第n个元素,COPY 1才是复制栈顶。写成COPY 0后,栈上多了一个元素,导致函数返回时栈不平衡,解释器直接崩了。
排查这个问题花了我不少时间,因为崩溃信息不直观。后来用dis反汇编看到字节码序列不对,才定位到COPY的参数。这个经历告诉我:字节码指令的参数含义一定要查文档,不能凭感觉猜。CPython的Python/bytecodes.c里有每个指令的详细说明,改之前先读一遍。
5.4 中文关键字在Windows下的编码兼容性
虽然我的实验环境是Linux,但我把改动同步到一台Windows机器上测试时,发现中文关键字“非”在Windows的默认代码页下无法正确识别。原因是Windows的终端和文件系统默认用GBK编码,而CPython的tokenizer期望UTF-8。解决办法是在Windows上设置PYTHONUTF8=1环境变量,强制UTF-8模式。
这个坑提醒我:语法扩展如果涉及非ASCII字符,跨平台兼容性是个大问题。如果只是自己玩,无所谓;但如果想给别人用,最好还是用ASCII符号,比如用!代替“非”,用++这种纯ASCII的组合。
6. 实验后的思考:语法扩展的边界与代价
6.1 语法糖的代价:可读性与工具链兼容
加完这两个语法特性后,我试着用它们写了一段小脚本,发现一个尴尬的问题:我的代码编辑器不认识这些语法,语法高亮全乱了,自动补全也失效。这是因为编辑器的Python解析器是独立的,不会因为我改了CPython就跟着改。同样,flake8、mypy这些工具也会报语法错误。
这让我意识到:语法扩展的代价不只是编译器的改动,还有整个工具链的适配。如果你只是自己玩,无所谓;但如果想让别人也用,那就得改编辑器插件、改linter、改格式化工具,工作量远超编译器本身。这也是为什么主流语言很少加新语法——不是技术上做不到,而是生态成本太高。
6.2 前缀自增的语义争议:返回值到底该是什么
在实现前缀自增时,我纠结了很久:++i应该返回自增后的值,还是自增前的值?C语言里前缀++i返回自增后的值,后缀i++返回自增前的值。但Python没有后缀自增,所以我只实现了前缀形式,返回自增后的值。
但有人可能会问:为什么不让++i返回自增前的值?这样x = ++i就等于x = i; i += 1,和某些语言的行为一致。这其实是个设计选择,没有绝对的对错。我选择返回自增后的值,是因为这样更符合“前缀”的直觉——前缀操作先执行再返回值。如果你要实现自己的版本,可以先想清楚这个语义,再动手写代码。
6.3 从实验到生产:什么情况下值得改CPython语法
说实话,绝大多数情况下都不值得。Python的语法已经足够表达绝大多数逻辑,加语法糖带来的收益很小,但维护成本很高。每次CPython升级,你的改动都得重新适配,因为grammar文件和字节码指令集都可能变。
但有两种情况例外:一是你在做领域特定语言(DSL),需要嵌入Python并加一些领域特定的语法;二是你在做教学或研究,想通过修改语法来理解编译原理。对于前者,更推荐用ast模块做源码转换,而不是直接改CPython;对于后者,这个实验本身就是目的,改完就学到了,不需要维护。
6.4 后续可以继续折腾的方向
如果你把这个实验跑通了,还可以继续尝试更有挑战性的改动。比如加一个unless语句(unless cond: ...等价于if not cond: ...),这涉及语句级语法规则和AST节点的新建。或者加一个管道运算符|>,让x |> f |> g等价于g(f(x)),这涉及运算符优先级和结合性的调整。
另一个方向是改字节码而不是语法,比如加一个新的字节码指令来优化某种常见模式。这比改语法更底层,但也更危险,因为字节码是解释器的核心,改错了容易崩溃。我建议先从语法层入手,熟悉了整个编译链路后,再往下走。
最后分享一个我在这个实验里学到的最重要的经验:改CPython语法最难的不是写代码,而是理解现有代码的组织方式。CPython的代码库有几十年历史,很多地方的设计不是一眼能看懂的。我的建议是每次只改一个小点,改完立刻测试,确认通了再改下一个。不要一次改好几个地方,不然出了问题根本不知道是哪个改动导致的。这个原则适用于任何大型开源项目的修改,不只是CPython。