返回

重温《让我们构建一个编译器》:经典教程的现代启示

本文深入回顾了Jack Crenshaw的经典教程《让我们构建一个编译器》,探讨其在现代编程教育中的持久价值。文章分析了该教程独特的自顶向下、递归下降的编译器构建方法,并将其与当今主流的编译器技术进行对比,旨在为现代开发者提供从经典中汲取智慧、理解编译器核心原理的实用指南。

文章摘要

Eli Bendersky的文章《重温“让我们构建一个编译器”》是对Jack Crenshaw于1988年至1995年间撰写的经典编译器构建教程的深度回顾与反思。文章的核心在于探讨这部以Pascal为目标语言、采用独特“自顶向下”方法的教学作品,为何在当今LLVM和复杂工具链盛行的时代,依然具有不可替代的教育价值。作者不仅梳理了教程的核心方法论——递归下降解析和即时(单趟)代码生成,还将其与主流编译器书籍(如“龙书”)的“自底向上”方式进行了对比,揭示了Crenshaw教程在降低认知负荷、强调实践与理解而非工具运用方面的独特优势。对于任何希望深入理解编译器工作原理,而不仅仅是使用现成工具的开发者而言,这篇文章提供了一条清晰而富有启发的学习路径。

背景与问题

在计算机科学领域,编译器的构建长久以来被视为一项深奥而复杂的“黑色艺术”。传统的编译器教育往往始于繁重的理论学习,涉及形式语言、自动机理论,然后才进入使用Lex/Yacc或类似生成器的实践。这种路径虽然严谨,但陡峭的学习曲线常常令初学者望而却步,在掌握强大工具的同时,可能忽略了语言翻译最本质、最直观的过程。

Jack Crenshaw的教程《让我们构建一个编译器》(“Let‘s Build a Compiler“)诞生于一个不同的时代。上世纪80年代末至90年代初,个人计算兴起,资源有限,开发者更需要理解底层原理以创造效率。Crenshaw另辟蹊径,选择使用Pascal语言,并采用了一种极其清晰、循序渐进的自顶向下方法。他引导读者从识别一个简单的数学表达式开始,逐步添加变量、控制流、函数等特性,最终构建出一个能工作的编译器。整个过程几乎不依赖任何外部解析器生成工具,而是手工编写递归下降解析器,并同步生成(或解释执行)代码。

Eli Bendersky提出重新审视这部经典,核心问题是:在2025年,拥有LLVM这样强大的中间表示和优化框架,以及Rust、Go等语言内置的现代化工具链的时代,学习一部30年前、基于Pascal的编译器教程还有意义吗? 这个问题触及了技术教育的本质:我们是应该追逐最新的工具和框架,还是应该夯实那些经久不衰的核心原理?理解“如何从零开始构建”与“如何使用现代工具构建”之间,存在怎样的价值差异?本文正是试图回答这些问题,论证经典教程在传授第一性原理系统性思维方面无可比拟的价值,为现代开发者提供一条理解编译器内部运作的捷径。

核心内容解析

3.1 核心观点提取

自顶向下、增量式的教学方法 Crenshaw教程最突出的特点是其教学路径。它不从词法分析或语法理论开始,而是让读者立即着手处理“解析一个数字”这样具体的任务,然后像搭积木一样,逐步加入加法、减法、变量、括号、控制语句等。这种方法创造了强烈的即时反馈和成就感,让学习过程变得直观且引人入胜。

递归下降解析(Recursive Descent Parsing)的核心地位 教程坚决采用了手工编写递归下降解析器的方法。每个语法规则直接对应一个(或一组)过程/函数。这种代码结构与语法的高度一致性,使得语法本身变得“可见”和易于理解。读者在编写解析函数的过程中,自然而然地内化了语法规则,理解了上下文无关文法的实际含义。

单趟编译与简单的代码生成 与多趟编译器(先构建完整语法树,再遍历生成代码)不同,Crenshaw的编译器是“单趟”的。它在解析表达式或语句的同时,就立即生成对应的目标代码(或类似P-code的中间指令,或直接解释执行)。这种方式虽然限制了优化能力,但极大地简化了编译器结构,让学习者能够专注于“翻译”这个核心动作,理解源代码如何一步步转化为可执行动作。

强调理解而非工具 教程几乎不使用任何自动化工具(如解析器生成器)。其哲学是,在学会使用“电动工具”之前,应该先学会用手工工具完成工作,这样才能深刻理解电动工具在解决什么问题、以及如何解决。这种理念培养的是对问题本质的洞察力,而非对特定工具的依赖。

Pascal作为实现语言的适切性 尽管Pascal今天已不流行,但Crenshaw选择它是深思熟虑的。Pascal语法清晰、结构严谨,非常适合于表达递归下降解析器这种高度结构化的逻辑。用Pascal编写的编译器代码本身就是一份关于编译器结构的清晰文档。

教育价值高于工程实用性 必须认识到,教程的目标是教育,而非生产一个能与GCC或Clang竞争的编译器。它牺牲了性能、优化和语言完整性,换来了无与伦比的清晰度和可接近性。理解这一点,是正确评价其价值的关键。

3.2 技术深度分析

Crenshaw教程的技术核心可以概括为“基于递归下降的自顶向下单趟编译”。我们来深入剖析其原理和实现。

递归下降解析的工作原理 递归下降解析器是预测性解析器的一种,它为语法中的每个非终结符(如expressiontermfactor)编写一个独立的函数。解析过程从语法的起始符号(通常是program)对应的函数开始,该函数根据当前读入的令牌(token),决定调用哪个子函数。

例如,对于一个简单的算术表达式语法:

expression ::= term { (‘+’ | ‘-’) term }
term       ::= factor { (‘*’ | ‘/’) factor }
factor     ::= NUMBER | ‘(’ expression ‘)’

对应的递归下降解析器伪代码大致如下:

procedure Expression;
begin
  Term;
  while (CurrentToken in [Plus, Minus]) do
  begin
    GetNextToken;
    Term;
    // 此处生成加法或减法代码
  end;
end;

procedure Term;
begin
  Factor;
  while (CurrentToken in [Multiply, Divide]) do
  begin
    GetNextToken;
    Factor;
    // 此处生成乘法或除法代码
  end;
end;

procedure Factor;
begin
  if CurrentToken = Number then
  begin
    // 处理数字,生成加载常量代码
    GetNextToken;
  end
  else if CurrentToken = LeftParen then
  begin
    GetNextToken;
    Expression;
    Expect(RightParen); // 消耗右括号
  end
  else
    Error(‘Expected number or left parenthesis’);
end;

这种写法的优势在于,代码本身就是语法规则的直接镜像,可读性极强。任何有编程经验的人都能看懂这段代码在做什么,进而理解语法。

单趟代码生成与“寄存器”模拟 在单趟编译中,没有显式的中间表示(如抽象语法树AST)。解析器在识别语法结构的同时,直接输出目标代码。Crenshaw的教程通常生成一种简单的汇编代码或直接操作一个虚拟的“累加器”(或少数几个寄存器)。

例如,解析表达式 2 + 3 * 4 时,流程如下:

  1. 进入Expression,调用Term处理2,生成 LOAD 2
  2. 看到+,调用Term处理3 * 4
    • 在嵌套的Term中,先处理3,生成 LOAD 3
    • 看到*,调用Factor处理4,生成 LOAD 4
    • Term的循环中,生成 MUL(将累加器中的3与4相乘)。
  3. 回到最外层的Expression循环,生成 ADD(将最初加载的2与累加器中的结果12相加)。

整个过程是边解析边生成,依赖一个隐式的“求值栈”或累加器模型。这种方式虽然难以进行复杂的优化(如公共子表达式消除),但它完美地展示了语法指导翻译的最基本形式:每个语法规则都附带一个语义动作(生成代码)。

与主流方法的对比 现代编译器教育或实践(尤其是“龙书”倡导的)通常采用自底向上的LR解析和多趟编译

  • LR解析:由解析器生成器(如Yacc/Bison)根据语法规范自动生成。它更强大,能处理更复杂的语法,但生成的解析表难以人工阅读和理解其工作原理。
  • 多趟编译:先构建完整的AST,然后进行多次遍历来完成语义分析、优化和代码生成。这种分离关注点的设计更模块化,适合构建复杂、优化的编译器,但增加了初始理解的复杂度。

Crenshaw的方法可以看作是**“理解优先”的路径,而主流方法是“能力优先”**的路径。前者让你亲手打造一辆自行车,理解每一个零件的作用;后者教你驾驶汽车,并了解发动机原理,但你可能永远不会亲手组装一台发动机。对于深刻理解“编译”这一概念而言,前者往往更有效。

3.3 实践应用场景

编程语言爱好者和教育者的宝库 对于想要设计一门领域特定语言(DSL)或教学语言的开发者,Crenshaw的教程是无价之宝。它提供了一套最小可行、可扩展的编译器骨架。你可以基于此,快速实现一个解释器或编译器原型,验证语言设计的想法,而无需立即陷入ANTLR或LLVM的复杂性中。

深入理解现有工具的基础 即使你的目标是使用现代工具链(如Rust的nom、Python的PLY、或LLVM),先学习递归下降和单趟编译也能让你更深刻地理解这些工具在抽象什么。当你使用一个解析器生成器时,你会更清楚它生成的代码在试图解决什么问题;当你使用LLVM IR时,你会更明白高级语言结构是如何被 lowering 到这一层的。

代码分析与转换的思维训练 编译器技术不仅是关于从源代码到机器码的翻译。许多现代开发工具,如代码检查器(Linter)、格式化工具、静态分析工具、代码重构引擎,其核心都是对源代码的解析、分析和转换。学习编译器构建,尤其是手工编写解析器,能极大地锻炼你分析和处理结构化文本(代码)的能力,这种能力在开发各种开发工具时至关重要。

嵌入式与资源受限环境 在微控制器或极度资源受限的环境中,你可能需要一个极其精简的脚本语言解释器。基于递归下降和单趟解释的方法,可以产生非常紧凑、易于理解和调试的代码,这正是Crenshaw方法在现代嵌入式系统或物联网设备中仍然可能发光的地方。

深度分析与思考

4.1 文章价值与意义

Eli Bendersky的这篇回顾文章,其价值远不止于怀旧。它在技术快速迭代的洪流中,充当了一个重要的“锚点”,提醒社区技术教育的根基所在。文章的意义在于:

首先,它捍卫了第一性原理学习的价值。在一个充斥着“快速入门”、“三分钟上手”框架的时代,文章论证了深入底层、理解系统如何从零构建的必要性。这种理解能培养开发者真正的自信和解决问题的能力,而非仅仅停留在API调用员的层面。

其次,它为编译器教育提供了一种经典的、经得起时间检验的替代方案。主流教材往往理论厚重,与实践结合有断层。Crenshaw的教程(以及本文对其的解读)证明,存在一条更平滑、更注重实践直觉的路径通往编译器知识的圣殿。这对教育者和自学者都是重要的启示。

最后,文章在历史与当代之间架起了桥梁。它没有简单地说“旧的就是好的”,而是理性分析了经典方法在当代语境下的适用性和局限性。它告诉现代开发者,你可以站在LLVM这样的巨人肩膀上,但了解巨人的骨骼和肌肉是如何生长出来的,会让你站得更稳、看得更远。

4.2 对读者的实际应用价值

对于读者,尤其是中级开发者,阅读原文和本文能带来多重实际价值:

系统性思维与分治能力的提升:构建编译器是一个经典的“分而治之”系统工程。读者将学习如何将一个庞大复杂的问题(理解一门语言)分解为词法分析、语法分析、语义分析、代码生成等可管理的子问题,并定义清晰的接口让它们协作。这种系统分解能力在任何大型软件开发中都至关重要。

对编程语言更深层的鉴赏力:在亲手实现过变量作用域、类型检查、函数调用机制之后,你再使用任何编程语言,都会有一种“透视”的能力。你会更理解为什么某些语言特性这样设计,它们的代价是什么,从而成为一个更高效、更少犯错的程序员。

调试与问题诊断能力的飞跃:编译器编写涉及大量对结构化数据的处理和对边缘情况的考虑。这个过程会极大地锻炼你的调试能力,尤其是当问题出现在复杂的逻辑链中时。你会学会设计清晰的中间输出、构建测试用例来隔离问题,这些技能直接适用于日常的软件开发调试。

为学习高级编译技术铺平道路:理解了递归下降和单趟编译,再去学习LR解析、抽象语法树、静态单赋值形式(SSA)、数据流分析等高级话题,就会有了坚实的认知基础。你知道这些更复杂的技术是为了解决简单方法中的哪些局限性(如优化能力差、语法限制多),学习动机和目标会更加明确。

4.3 可能的实践场景

个人学习项目:最直接的实践就是跟随Crenshaw的教程(或使用你熟悉的现代语言,如Python、Go、Rust重写一遍),构建一个简单的算术表达式计算器,然后逐步扩展它。这是一个完美的周末或假期项目。

定制化工具开发:在你的工作或开源项目中,如果需要解析一种自定义的配置文件格式、日志格式或查询语言,可以放弃使用正则表达式硬编码,而是尝试用递归下降的方法编写一个轻量级解析器。这会让你的工具更健壮、更易于扩展。

代码重构与自动化脚本:如果你需要对大量源代码进行模式化的修改(例如,重命名某个API,或改变代码风格),可以编写一个简单的解析器来识别相关代码结构,然后进行转换。这比使用文本替换或正则表达式要可靠得多。

作为面试准备的深度课题:对于求职高级开发或编译器相关岗位的工程师,深入理解并实践过一遍编译器构建的核心流程,是一个极具说服力的能力证明。它展示了你的计算机科学功底、系统思维能力和动手实践精神。

4.4 个人观点与思考

在赞同Eli Bendersky核心论点的同时,我认为现代学习者在重温经典时,可以采取一种“古今结合”的策略。

经典为体,现代为用:以Crenshaw教程的思想和脉络为骨架(自顶向下、递归下降、边解析边动作),但用一门现代语言(如Rust、Go或TypeScript)来实现。这不仅能学到编译器原理,还能锻炼现代语言的工程能力。例如,用Rust的Result和模式匹配来处理解析错误,会比Pascal的过程式错误处理更优雅和安全。

适时引入现代工具进行对比:在用手工方式实现了一个小语言后,可以尝试用ANTLR或类似工具为同样的语言生成一个解析器。对比两者在开发效率、错误信息、性能和维护性上的差异,这种亲身对比带来的认知远比单纯阅读要深刻。

关注“Why”而不仅仅是“How”:Crenshaw的教程展示了“How”。作为现代学习者,我们应该多问“Why”。为什么选择递归下降而不是表驱动的解析?为什么单趟编译在这里可行?它的局限性在哪里?当我想添加闭包或泛型时,当前架构会遇到什么挑战?这种批判性思考能将知识从“记忆”提升到“理解”和“创造”的层面。

警惕“银弹”思维的反面:我们反对盲目追求新工具而忽略基础,同样也要避免陷入“手写一切才是王道”的另一种极端。经典教程的价值在于教学和奠基,而非定义所有生产实践的标准。在真实项目中,合理利用LLVM这样的强大框架,是专业和高效的表现。关键在于,你知道你在用什么,以及为什么用它。

技术栈/工具清单

Crenshaw原始教程主要基于以下技术,现代重实践践则可以有更多选择:

原始技术栈

  • 实现语言:Turbo Pascal (或兼容的Pascal方言)。这是教程的原始语言,其清晰的语法非常适合表达解析器逻辑。
  • 目标输出:教程早期生成一种简单的汇编代码,后来版本转向生成直接可执行的代码或解释执行。没有使用复杂的中间表示或优化框架。
  • 开发环境:典型的90年代DOS/Windows下的IDE,如Turbo Pascal IDE。
  • 核心库/工具:无。完全从零开始,标准输入输出用于读取源文件和输出代码。

现代替代与增强工具栈

  • 现代实现语言推荐
    • Python:语法简洁,适合快速原型和教学,有强大的字符串处理能力。
    • Go:内置强大的工具链(如go/scannergo/parser),但你可以选择不用,手工实现。其并发特性也可用于探索并行编译。
    • Rust:强调安全性和性能。用Rust实现能深入学习所有权、模式匹配等现代语言特性,同时保证编译器的健壮性。
    • TypeScript/JavaScript:在Web环境或Node.js中运行,易于演示和分享,适合实现解释器。
  • 解析辅助(可选)
    • 手写词法分析器:推荐始终手写,作为练习的一部分。
    • 解析器生成器(供对比学习)ANTLR (多语言支持)、Lark (Python)、nom (Rust, 组合子库)。在学习手工方法后,可用它们重写语法以对比。
  • 目标代码与执行
    • 解释执行:最简单,在解析过程中直接计算值或执行操作。
    • 生成自定义字节码:实现一个简单的虚拟机来执行,这是很好的进阶练习。
    • 生成LLVM IR:这是连接经典与现代的桥梁。在学会基础后,可以尝试将生成的代码从自定义汇编改为LLVM IR,然后利用LLVM工具链优化并生成本地代码。这是极具价值的学习项目。
    • 生成WASM:一个非常现代且实用的目标,让你的编译器能在浏览器中运行。
  • 辅助工具
    • 版本控制:Git, 用于管理