用 1024 字节写一个 Python 解释器

查看原文 HN 讨论

文章摘要

微软研究员 Austin Z. Henley 在博客里记录了一次周末手工编码的极限挑战:用不超过 1024 字节的纯 C 代码,写一个”看起来像 Python”的解释器。他给自己定的规矩是不用宏耍花招、不依赖第三方库,全部手写——用他自己的话说,”为了还能感觉自己是个人,我周末坚持手写代码”。

从 512 字节的失败开始

作者最初的目标是 512 字节。他写过不少递归下降解析器(包括自己的 Teeny Tiny 编译器),本以为驾轻就熟。但从 1 + 2 到 x = 1 + 2 * 3,再到 if x > y: z = 3,他发现自己做出来的只是个计算器,而且字节数已经超标。于是他退一步,先列出”什么东西看起来最像 Python”,再把上限放宽到 1024 字节,遵循”先让它跑起来,再让它变小”的顺序。

他选定的验收标准是一段 FizzBuzz:有 def、有冒号、有缩进、if 后面不带括号——这几个特征足以让人一眼认出是 Python。

架构:没有 AST,没有字节码

真正的 CPython 会做词法分析、构建抽象语法树、做分析和优化、生成字节码、再解释执行。这个 1024 字节版本一样都不做。

全部状态就是几个全局变量:一个长度 999 的定长数组存放去掉大部分空格后的源码,一个 256 项的数组当符号表(变量名限定为单个小写字母,所以可以用字符的 ASCII 码直接索引),外加当前位置、当前字符、行首位置几个整型。表达式求值就是标准的递归下降,边解析边执行。

控制流的巧思

块的执行靠缩进判断:run_block(min_indent) 一直读下去,直到缩进减少就回退位置并返回,由调用方处理下一行——换句话说,直接借用 C 程序自己的调用栈来实现嵌套。

循环则更粗暴:因为什么都没编译,while 和 for 只是记住条件表达式在源码里的位置,循环体执行完就把游标跳回去重新解析一遍源码。函数也是同一套思路:定义时把函数体在源码中的偏移记进符号表,调用时保存现场、跳过去执行、结束后恢复现场。作者感叹,在完全没有中间表示的情况下还能做到这些,本身就挺漂亮。

零错误处理带来的激进假设

解释器完全不做任何检查,全靠假设源码是对的。最典型的是关键字识别:看到 f 就当成 for x in range(y):,然后直接 pos += 2 跳过 “or”、再 pos += 8 跳过 “inrange(“;看到 w 跳过 “hile”;看到 i 跳过 “f”。变量查找也是直接 vars[ch]。

代码高尔夫

压缩阶段作者承认自己是新手,主要参考了 Stack Overflow 上那篇经典的 “Tips for golfing in C”。用到的手段包括:单字母变量与函数名、假定编译器会链接 libc、用全局变量当临时变量(且全局变量自动零初始化)、利用 C89 隐式 int 声明、把函数参数当成会被调用栈保留的临时变量、用 ASCII 数值代替字符字面量、三元与逗号运算符、以及用位运算代替逻辑运算。

举两个例子:加减法的 parse_sum 被压成了 e(){for(z=t();c-43u<3;)y=44-c,z+=y*t();return z;};跳到行尾的辅助函数则变成 Y(){c&&c-10&&Y(G());}——检测 0、减 10 判断换行、用 && 代替 if,甚至靠把 G();Y(); 写成 Y(G()); 再省一个字节。

最终版本刚好 1024 字节,而可读版本超过 4800 字节。为了塞进去,比较表达式一度是下一个要砍的对象(因为整数真值判断 if n%15: 本来就够用)。作者估计如果只求跑通 FizzBuzz,压到 800 字节以下不成问题。

最终支持的特性:单字母整型变量与字面量、赋值、带优先级的 + - * % 算术、每个表达式一次的 < > <= >= == 比较、整数真值、if/else、带 else 块的 while 和 for x in range(y)、无参数函数定义与(可递归的)调用、基于缩进的块(但没有作用域)、print 单个字符串字面量或整数表达式、以及注释。

两个版本的源码都在 GitHub 上。作者在文末说,来回对照高尔夫中版本和原始版本的过程相当折磨,短期内不打算再碰代码高尔夫了。

HN 评论精华

这条帖子拿到 322 分、110 多条评论,讨论主要分成三股:Python 缩进语法之争、”这到底算不算 Python”的定义之争,以及代码高尔夫的怀旧与延伸。

缩进语法给词法分析带来多少麻烦(最大的一条支线)

TZubiri 开场就问作者:外界对 Python 的批评经常集中在”用空白做词法作用域”上,而这种批评通常来自语言的使用者;那么站在解释器实现者的角度,空白缩进是否真的让词法分析显著变复杂了?

pansa2 给了最技术性的回答:显著缩进确实要求更复杂的词法分析器,因为词法文法不再是正则的——lexer 不能只是有限状态机,必须维护一个缩进层级的栈。但他补充说,现代语言本来也很少有正则的词法文法,即使没有缩进敏感,光是字符串插值(比如 Python 的 f-string)就已经逼着 lexer 维护栈了。

nomel 指出更麻烦的是空白字符不止一种,<空格><空格><Tab><空格> 和 <空格><Tab><空格><空格> 是不同的,所以得记录每层实际的空白序列而不是简单计数。rmunn 提出两种规则:要么像多数空白敏感语言那样干脆禁止混用,要么定一条”空格可以跟在 Tab 后面,但 Tab 不能跟在空格后面”的规则,至少是无歧义的。

这条线随后演变成一场长达十几层的 Tab vs 空格拉锯战。fc417fc802 坚持编译器不该关心显示宽度:”Tab 从不代表任意数量的空格,显示方式会变,但含义只能由用法决定”,并认为任何非技术必需的限制都不合理,UTF 里有二十多个空白码位,凭什么由某个中心权威来规定它们能不能出现。rmunn 则用实战反驳:他见过团队里一人用 Tab 缩进、另一人用空格,第二个人在编辑器里看到的是对齐的,实际却混了制表符,Python 只能抛 TabError;他还举了 Visual Studio 把 <Tab><Tab><空格>... 自动改写成一串 Tab 的经历,说这正是他彻底放弃 Tab 字符的原因——”Tab 缩进、空格对齐”理论上很美,实践中主流编辑器靠不住。zephen 最后以”制表符与空格之战跟大小端之战一样古老,我们大概谁也说服不了谁”收场。

hmry 说这条讨论对他很有用,他自己正在设计一门空白敏感的语言,准备改用 rmunn 提的规则;他原本用的是”前缀比较法”:前一行的前导空白与新行比较,相同则层级不变,旧的是新的前缀就是缩进,反过来就是退缩,都不是就报错。rmunn 后来补充说自己那条规则其实不完整,两个 Tab 后面跟十二个空格到底算缩进还是退缩,取决于 Tab 宽度,前缀比较法才是必需的。

zephen 还顺手反驳了”批评来自用户”的前提,贴出一项针对零基础人群的研究,称他们在 Python 上表现更好;并提到 Scala 让空白变成可选时曾极具争议,如今却被广泛接受。

“这算不算 Python”

Scubabear68 直白表示失望:”这是在解释一门自己编的小语言,这不是 Python,甚至跟 Python 差着三个数量级。” SPBS 附和说标题应该写 “Python-like”。happycube 替作者辩了一句:那段 FizzBuzz 在真正的 CPython 里也能跑。tyilo 给出了最有力的反例——fxx i in rxxxx(10): pxxxx(i) 这段在这个解释器里能跑,在 Python 里不能,所以它根本不是 Python 的子集;benatkin 回应说那只是未处理的非法输入,作者已经承认没有错误处理。

hmry 用一个玩笑把这场定义之争推到极致:可以想象一个更小的”子集解释器”——它只解释所有会输出 “Hello World” 的 Python 程序,其他程序行为未定义,实现方式是忽略输入文件、直接打印 “Hello World”;附赠好处是它同时也是所有其他语言 Hello World 子集的解释器。Sophira 立刻指出这事真有人干过,并贴出 esolangs wiki 上的 Hello、Hello++ 和 HQ9+。

jrdres 的评价最生动:”这段代码让我笑,因为它很脏。”他逐条列出那些激进假设(任何 f 都是 for x in range(y),任何 p 都是 print(……),和真正做错误检查的 C4(一个小巧但完整的 C 编译器)作对比,并抱怨 GitHub 上的”可读版”注释还不如文章里的多。kristianp 补了一刀:这意味着连 w = 4 这么简单的语句都会失败。

MiroslavPokorny 认为取消错误检查算作弊——它不是一个自洽的解释器,因为需要人类事先验证输入正确。

同类项目与代码高尔夫文化

tempodox 说这和 Justine Tunney 的 SectorLISP 精神一致;forgotpwd16 做了一个区分:SectorLISP 是在追问”Lisp 精简到什么程度就不再是 Lisp”,而这个项目的目标是”用最少代码模仿 Python 的样子”,性质不同。gabrielsroka 提到 sectorc,并开玩笑说不知道 sectorc 能不能编译 python1024。

userbinator 给出一条重要的历史注脚:直接从源码解释、循环靠往回跳,这在 8 位微机的解释器上是标准做法——总共只有 64K 地址空间,先建 AST 太奢侈了;他认为 shell 执行脚本至今也是这样,DOS 的 COMMAND.COM 肯定是。shakna 补充说 bash 是按行缓冲的,所以往回改已执行部分不行,但可以自我追加让脚本无限跑下去。userbinator 还精确指出:这是 1024 字节的 C 源码,编译出来的二进制要大好几倍。

marcelo-earth 坦承当了十多年程序员才第一次知道有”代码高尔夫”这回事,引出一串经典链接:kylecazar 推荐 donut.c(旋转甜甜圈),Lerc 推荐 dwitter、nanochess 和 q1k3,NooneAtAll3 指向 codegolf.stackexchange.com。

AI 的插曲

ssfdg 问”要做 Python 解释器为什么不直接让 AI 写”,Donald 直接引用文章第一句”为了还能感觉自己是个人”作答,并附上《特洛伊罗斯与克瑞西达》的台词”已赢之事即已了结,欢愉之魂在于过程”。bblb 则好奇如果真有 AI 版代码高尔夫,机器会golf得比人更差还是更好。timbit42 一句话收尾:文章第一行就回答了这个问题。