1024 字节能装下一个 Python 解释器吗?Austin Henley 的答案是:能,而且它能跑完一整段FizzBuzz。这个名为 python1024 的项目用 1024 字节的 C 代码实现了一个"看起来很像 Python"的解释器,源码以 MIT 协议挂在 GitHub 上。作者不是无名之辈,他是 Teeny Tiny compiler 的作者,写小型编译器是他的老本行,这次的挑战是把"能跑 Python 风格代码"这件事压缩到极限。项目登上了 Hacker News 首页,讨论区里 84 条评论吵得热火朝天。

CPython 与它的 1024 字节表亲
要看懂这个项目的小,先得看 CPython 有多大。真实的 CPython 解释器执行一条 Python 语句要经过完整的流水线:先做词法分析把源码切成 token,再解析成抽象语法树(AST),接着做变量作用域分析和常量折叠等优化,然后编译成字节码,最后由一个虚拟机逐条解释执行字节码。这条流水线上的每个环节都是独立的子系统,加起来是几十万行 C 代码。
python1024 把这条流水线整个扔掉了。它没有 token 化,没有 AST,没有字节码,没有任何中间表示。整个解释器的全部状态只有五个全局变量:
char src[999]; /* 存放整段源码 */
int vars[256]; /* 符号表,变量名直接当数组下标 */
int pos; /* 当前读到的源码位置 */
int ch; /* 当前字符 */
int line_start; /* 当前行起点 */表达式用最经典的递归下降法解析:parse_sum 处理加减,parse_term 处理乘除和取模,parse_atom 处理数字和变量。特殊的地方在于,解析的同时直接求值,边读边算,算完就扔。这也是它不需要 AST 的原因:表达式的值在递归下降的返回路径上就地产生。
变量系统的设计最能体现压缩思路:变量名只允许一个小写字母,于是符号表退化成一个 256 长度的整型数组,变量名(ASCII 码)直接当下标,一次数组访问完成查找。不需要哈希表,不需要字符串比较,代价是程序里最多同时存在 26 个单字母变量。FizzBuzz 里用的 n 正好在这个框架里。
缩进、循环和函数:借 C 的栈还 Python 的魂
Python 语法最有辨识度的部分是缩进定义代码块。CPython 为此维护了专门的缩进栈,在词法阶段生成 INDENT 和 DEDENT token。python1024 的处理方式粗犷得多:执行一个块时记下当前行的缩进量,逐行读入,只要新行的缩进大于等于这个值就继续执行,一旦缩进回退就返回。
真正巧妙的是递归的用法。块的嵌套靠 C 函数的递归调用来实现:碰到 if: 或 for:,run_block 调用自己进入子块,C 程序的调用栈天然记住了"我在哪一层"。整个解释器没有为块结构设计任何数据结构,C 栈就是缩进栈。
循环的实现暴露了这个项目"边解析边执行"的本质。循环体每执行一轮,解释器把源码指针拨回循环条件的起始位置,重新走一遍解析流程。for 循环每次迭代都重新解析一遍条件表达式和循环体,while 循环同理。函数调用也是同一种戏法:解析 def 时把函数体在源码中的位置记进符号表,调用函数时保存当前指针、跳转到函数体、执行完再跳回来。CPU 时间在这里被毫无顾忌地挥霍,换来的是代码体积的极限压缩,这个取舍在后文的数据里能看得很清楚。
从 4800 到 1024:code golf 的减法艺术
可读版的完整实现有 4855 字节。从 4800 到 1024,靠的是一套系统性的减法。作者在博客里列了几个代表性手法:
变量名和空白符的压缩是基本功,真正的空间来自语义层面的合并。比如处理加减法的 parse_sum 函数,可读版有 11 行,golf 版压成一行:
e(){for(z=t();c-43u<3;)y=44-c,z+=y*t();return z;}这行代码里藏着两个技巧。c-43u<3 利用了 C 语言无符号整数回绕的特性:43 是 '+' 的 ASCII 码,c-43u 在 c 为 '+' 或 '-'(45)时落在 0 到 3 的区间内,一个比较表达式同时检测了两种运算符。y=44-c 则把加减法合而为一:'+' 时 y 等于 1,'-' 时 y 等于 -1,加法统一写成 z += y * term(),减号不再需要独立分支。
跳到行尾的 skip_to_eol 函数从 5 行压成 Y(){c&&c-10&&Y(G());}:用 && 短路替代 if 语句,把递归调用塞进条件表达式,最后再省掉一个分号。
还有一些技巧依赖 GNU C89 的"特性":函数返回类型和参数类型可以省略(默认 int),这让每个函数定义都省掉 int 关键字。作者特意在博客里说明这是老标准下的常规写法,不是宏黑魔法。减到最后,整份源码恰好 1024 字节,一个字节不多,一个字节不少。
被砍掉的特性同样值得看。比较运算符是最后被牺牲的功能之一,因为 if 语句没有比较也能工作:if n%15: 依赖整数到布尔的隐式转换,非零即真。作者估算,如果目标只是让 FizzBuzz 跑起来,压缩到 800 字节以内是可能的。
本机实测:它能做什么,不能做什么
源码仓库用的是 GNU C89 方言,macOS 自带的 clang 默认拒绝隐式 int 声明(C99 起废除的语法),需要在编译时放宽检查,同时 main 函数的 K&R 参数签名也要适配。我实际编译运行验证了一遍它能吃下什么、吐出什么。
FizzBuzz 是仓库自带的测试用例,输出 0 到 100 的完整序列,101 行,Fizz、Buzz、FizzBuzz 的位置全部正确。while 循环、if/else 分支、==/>=/< 比较、四则运算优先级(1 + 2 * 3 - 4 正确算出 3)、取模(10 % 3 得 1)都按预期工作。递归也能跑通,但有个前提:函数参数不绑定。写 def r(n) 然后 r(4),函数体里的 n 永远是 0。变通办法是用全局变量传递值,下面的代码能正确倒数:
n = 4
def r():
if n:
print(n)
n = n - 1
r()
r()输出的确是 4、3、2、1。递归本身没问题(C 调用栈在撑着),只是没有参数传递,每次调用共享同一组全局变量。
还有几个隐藏边界。标识符的首字母如果是 f、i、w、d、p,会撞上关键字分支:解释器只看行首第一个字符来判断语句类型,f(3) 会被当成 for 循环解析,i = 0 会被当成 if 语句。变量首字母 p 也有坑,p() 会直接触发 print 逻辑而不是函数调用。另外 ** 幂运算符不存在(2 ** 5 输出 0),浮点数不存在,所有数字都是 int。
每次循环都重新解析,性能账怎么算
把循环写成"回跳重解析"有一个明确的性能后果:for 循环跑 101 次迭代,循环体就被完整解析了 101 次。在 FizzBuzz 这种体量的程序里没人会在意,但这套架构的可扩展性上限是清晰可见的:循环体越大、迭代越多,浪费在重复解析上的时间越多。CPython 前端一次性把源码编译成字节码,之后每次循环执行的都是廉价的字节码分发,python1024 则把解析成本摊到了每一次迭代上。
这个对比其实划出了一条清晰的线:CPython 的复杂度花在让 100 万行的项目跑得快,python1024 的极简花在让 10 行的 demo 能跑起来。两者没有谁更好,只有服务对象不同。教学价值是后者真正的产出:读完这份 1024 字节的源码,递归下降解析、缩进块处理、函数调用这些概念全部落到了能亲手跑的代码上。对想入门编译原理的开发者来说,先读懂这 1024 字节再去看 CPython 的 AST 和字节码,路径比直接啃 CPython 源码短得多。
动手跑一份的成本也低:clone 仓库,gcc -std=gnu89 -w python1024.c 编译,./a.out < fizzbuzz.py 就能看到输出。仓库里可读版和 golf 版并排放着,对照着读,每个函数名都能在博客里找到对应的讲解。作者在文章结尾留了一句邀请:轮到你了,你的 1024 字节 Python 长什么样?
来源:
- Austin Henley, Making a Python interpreter in 1024 bytes
- 源码仓库:GitHub AZHenley/python1024(MIT License)
- 本文所有运行结果为本机实测