Hacker News 中文摘要

RSS订阅

1024字节写出Python解释器 -- Making a Python interpreter in 1024 bytes

为了感觉自己是人,周末我手写代码。最新挑战是用1024字节的C代码写一个Python解释器,不用宏,也不用库函数。解释器这么小,当然装不下完整Python,所以我只想让它能跑一个看起来像Python的FizzBuzz:有def、冒号、缩进,if不加括号;还得加更多限制。第一次用了512字节,根本不够。递归下降解析器我写过不少,以为不难,就从最基础的代码写起。

正文

我的最新挑战:用 1024 字节的 C 代码写一个 Python 解释器——一开始想塞进 512 字节,后来放宽了;不能用宏,也不能拿库来耍花招。

1024 字节 golfed C 代码的截图

1024 字节当然装不下整个 Python,所以目标只是让程序“看起来像 Python”。比如经典的 FizzBuzz:从 0 到 100,用 def、冒号和缩进分块,if 后不加括号,按 n%15、n%3、n%5 打印 FizzBuzz、Fizz、Buzz 或数字——一眼看去就是 Python。当然,除了语法子集,还得加不少限制。头一版并不成功。

第一次尝试:512 字节不够

我写过不少递归下降解析器,起初以为这不过是类似的事。从 1 + 2 开始,再加码到 x = 1 + 2 * 3,然后试着塞语句 if x > y: z = 3。结果我只能得到一个计算器,而且代码量已经超过 512 字节。我列了一份“哪些元素看起来像 Python”的清单,也承认自己的代码高尔夫水平不够。那就改成 1024 字节:先让它跑起来,再往小里压。

解析器

真正的 CPython 会先分词、建抽象语法树、做分析和优化、生成字节码,再解释执行;这个解释器全都不做。状态放在几个全局变量里:char src[999] 存几乎去掉空格的源码,int vars[256] 当符号表,pos、ch、line_start 分别记录读位置、当前字符和行首。表达式还是用递归下降解析,边解析边求值。代码没有任何错误处理,完全假设源码正确:关键字不能拼错,token 边界也要正确。解释器会移除大部分空白,但保留缩进和字符串里的空格。变量名只能是一个小写字母,所以查符号表直接写 vars[ch]。

控制流的魔法

执行一个缩进块的函数会一直读到缩进变小才返回,由调用方接着处理下一行,所以嵌套块直接靠 C 的调用栈递归。循环则通过跳回源码来工作:while 和 for 都会记住条件表达式的位置,循环体执行完就跳回那里重新解析。函数也一样——定义时在符号表里记住函数体的源码位置;调用时保存调用者的位置,跳到函数体执行,结束后恢复调用者的位置。没有中间表示也能做到这些,而且解释器要维护的状态很少。

把代码压小!

我不常玩代码高尔夫。缩短变量名、去掉空白谁都想得到,真正难的是省下大块字节。Stack Overflow 上那篇《Tips for golfing in C》给了我不少招。

Stack Overflow 上代码高尔夫技巧帖的截图

规矩只存在于想象之中,所以我确实用了不少 GNU C89 特有的“特性”。这不是瞎搞,这是正经地胡闹。用到的招数有:

  • 单字母变量名和函数名
  • 假定编译器会自动链接 libc
  • 用全局变量当临时变量
  • 全局变量默认零初始化
  • C89 允许省略变量类型,也假定函数返回 int
  • 把函数参数当临时变量,让调用栈替我们保存
  • 用 ASCII 数值代替字符字面量
  • 用三目运算符和逗号运算符
  • 用位运算代替逻辑运算

例如 parse_sum 被压成了 e(){for(z=t();c-43u<3;)y=44-c,z+=y*t();return z;},用 ASCII 数值省下几字节。一个跳过行尾的辅助函数也从递归版变成 Y(){c&&c-10&&Y(G());}:先判断读到 0,减 10 判断换行,用 && 代替 if,再用 Y(G()) 代替 G();Y(); 省一字节。

压完刚好 1024 字节,可读版超过 4800 字节。最初还有更多功能,为了装下一直被砍。如果要继续压,比较表达式会是下一个被砍的对象,因为去掉它也能靠整数真值工作,比如 if n%15:。如果只求 FizzBuzz 能跑,我估计能压到 800 字节以下;应该也还有别的 golf 技巧。

终端检查字节数、编译并运行 fizzbuzz 的截图

最终这个版本支持:

  • 单字母整数变量和字面量
  • 变量赋值
  • 带优先级的 + - * %(一元正负号只在表达式开头有效)
  • 比较 < > <= >= ==(每个表达式只能有一个)
  • 整数真值
  • if/else
  • while 循环(含 else)
  • for x in range(y) 循环(含 else)
  • 无参函数定义
  • 函数调用,可递归
  • 基于缩进的块(但没有作用域)
  • print 单个字符串字面量或单个整数表达式
  • 注释

我短期内不想再碰代码高尔夫挑战。整个过程很磨人:要在“压到一半的版本”和原版之间来回对照,才能看懂两分钟前自己改了什么。两个版本都放在 GitHub。现在轮到你了——你的 1024 字节 Python 长什么样?

评论

讨论整体氛围偏欣赏作者的代码高尔夫创意,但存在明显分歧:部分人认为这是极简实现的乐趣展示,另一部分人则对该项目是否能被称为“微型 Python”表示失望。

认为这是一次有趣的代码高尔夫与极简实现展示

  • 不少人赞赏文章写作用心,整个实现过程读起来很有意思,能从中看到哪些功能是可以被舍弃的,这种探索本身很有乐趣(较多人)
  • 有读者表示自己从业多年才知道代码高尔夫的存在,认为这种纯手工实现的极简项目非常惊艳(较多人) > 这真的很酷!看到你能实现什么、哪些是可以省略的特别有意思。用源码本身作为函数调用和循环的目标,这个思路我之前没见过,虽然很合理但我自己绝对想不到(@anitil)
  • 有人将其与 SectorLISP 等极简语言项目归为一类,认为这种极致的代码压缩很有意思(少数人)

对该项目能否称为“微型 Python”表示质疑

  • 有评论认为这只是一个虚构的小语言解释器,和真正的 Python 差距极大,根本不在一个数量级,因此感到失望(少数人)
  • 有人指出这个实现过于简陋,关键词全部硬编码为单字母,没有任何错误处理,比同样极简的 Sector C 还要“粗糙”,连列表和字典都不支持,作为“微型 Python”过于简单(少数人) > 这段代码让我发笑,因为它太“恶劣”了。它不像 C4 那样是完整且带检查的 C 编译器子集,而是比 Sector C 还过分,直接假定源码一切都对。这个“Python”把所有 f 当 for、w 当 while、i 当 if……太恶劣了(@jrdres)

从实现技术角度提出疑问或补充

  • 有实现者关心 Python 用空白符作为作用域的做法,是否会让词法分析器的编写明显更复杂(少数人)
  • 有人指出这是 1024 字节的 C 源码,编译后体积会大很多,实际只实现了 Python 的极小子集;同时还提到其循环是靠回退并重新解析源码实现的,类似 DOS 批处理的机制(少数人) > 准确说这是 1024 字节的 C 代码,编译出的二进制会大很多,且只实现了极小的 Python 子集。循环是靠向后跳转并在每次迭代时重新解析源码实现的,DOS 的 .bat 就是这样工作的(@userbinator)
  • 有人顺带推荐了面向极小内存处理器的 Snek 语言,适合真正需要在生产环境用极简嵌入语言的场景(少数人)

有价值的补充

  • 数组编程社区普遍将这种极高密度的代码实现视为常态,可参考 J 语言早期极其紧凑的解释器实现(Incunabulum)作为同类案例
  • 如果愿意多付出几 KB 的空间,至少加入列表和字典后,这种极简语言会更有实用价值,Lisp 就能做到这一点

社区情绪类玩笑

  • 有评论调侃,既然要做 Python 解释器,为什么不直接让人工智能来写,质疑这个项目的实际意义(少数人)