连着几周晚上都在折腾同一件事:写一个能真正跑起来的解释器。今天想把这个过程里最核心、也最容易被教材绕晕的部分拆开讲清楚。这篇文章解决的是一个实际痛点——很多人看过龙书、看过各种编译原理资料,但真正上手时依然不知道"从字符串到程序跑出结果"这一路上到底要经过哪些关卡。这里给的是一条更朴素的路径:不借助任何解析器生成器,从零手写一个能处理变量、表达式、if/while、自定义函数的最小解释器。适合两类人:一是想彻底搞懂编程语言运行时行为的人,二是想给自己的领域特定语言做原型验证、又不想引入重型工具链的开发者。
先说结论:解释器没有你想象的那么高不可攀。它本质上就是一条数据流水线,把源代码从纯文本一步步变成可执行的数据结构,最后在内存里把结果算出来。本篇会带着你把这个流水线的每一段都亲手搭一遍。
1. 为什么一个普通开发者需要亲手写解释器
1.1 大多数人卡住的原因:把解释器想成了编译器
很多朋友一听"写解释器"第一反应就是编译原理那套东西:文法、LL(1)、LR(1)、自动机、语义分析……好像不把大学四年课本搬出来就动不了手。实际上解释器的活要轻很多。编译器要把源代码翻译成机器码或者字节码,需要做的分析、优化、寄存器分配等一大堆事情;而解释器只需要理解源代码的"意思",然后立刻执行这个意思——你可以完全不生成任何中间代码,直接构造一颗内存里的树,然后遍历这棵树求值。
我做的这个最小解释器,把整条链路的四个阶段走成了"只需要四五个小时就能跑通demo"的程度:词法分析、语法分析、AST构建、树遍历求值。第四个阶段在编译器里是不存在的,因为编译器做到语法分析之后会转到代码生成。这反而是解释器最简单的优势:它不需要考虑目标平台,只需要考虑"语义是什么"。
1.2 一个能跑起来的迷你语言长什么样
我给自己要写的语言起名叫miniL(全名很随意,就是mini language)。它支持的东西不多,但五脏俱全:
// miniL 示例代码 var x = 10; var y = 3; print x + y * 2; // 输出 16,y*2 先算 if (x > 5) { print "big"; } else { print "small"; } func square(a) { return a * a; } print square(x); // 输出 100这段代码用我写的解释器跑出来,结果是:
16 big 100从文本到这三行输出,中间发生了不少事。如果你能跟着做出一份一样的产出,你对"编程语言是怎么工作的"会有种踏实的掌控感,而不是停留在玄学层面——很多常见的编程困惑(作用域、变量提升、运算符优先级)会在写了解释器之后不攻自破。
1.3 做这件事的额外回报
写解释器的过程里,你会被迫把很多平时"会用的语法"重新问一遍为什么。比如=为什么是右结合的?为什么-2 * 3会被解释成(-2) * 3而不是-(2 * 3)?为什么函数里可以访问外面定义的变量?这些问题的答案不再是"语言就是这么设计的",而会变成"因为我在AST上做的处理是这样的"。
我的体验是,写完这一个解释器之后,再学任何新语言都像在翻旧账:你看到语法,脑子会自动把它拆成token、AST节点、求值路径。这种"看穿一层"的感觉,是刷一百道算法题都给不了的。所以就算你不打算做语言设计,我也建议你亲手走一遍这条流水线。
2. 架构先搭起来:一条从字符串到输出的四段流水线
2.1 四个阶段各自做什么
整条流水线用一句话概括:源代码字符串 -> 词法单元流 -> 抽象语法树 -> 求值结果。我给每段起个更直觉的名字,方便后面引用:
拆词器(Lexer):把字符串切成一组有语义的最小单元。比如
print x + 1;会被切成print、x、+、1、;这五个token。关键点是"切"这个动作要保留位置信息,方便报错时告诉用户是哪一行哪一个字符出了问题。组句器(Parser):按语法规则把token流组织成嵌套结构。这个阶段会彻底失去"代码长什么样"这种表层信息,保留的是"程序在逻辑上要做什么"。
结构树(AST):Parser的产物就是AST。它是一棵内存里的树:根节点是整个程序,子节点是语句,语句下面再挂表达式。
求值器(Evaluator):遍历AST,把每个节点的语义执行出来。比如遇到
+节点,就递归算出左右子树的值再相加。
这四段里,词法分析相对机械,语法分析最容易写错,求值器最需要想清楚语义。下面我们一段一段过。
2.2 为什么我坚持不用解析器生成器
因为要写的是"自己的解释器",不是"学会调用某个工具"。解析器生成器把语法分析变成了声明式配置,写起来快,但你会丢失大量手感——尤其是优先级、结合性、错误恢复这些真正考验理解力的地方,工具替你遮住了。
手写递归下降解析虽然代码稍微多一些,但它有一个巨大好处:报错位置你自己控制,语法规则可以随时改,调试的时候能一步步跟进去看。介于今天很多读者都是第一次写解释器,我强烈建议第一版用纯手写的方式,把每个环节都攥在自己手里。
2.3 miniL的语法约定先定好
写代码之前得先有个语法契约。miniL的语法我压到了最简,让它仍然有意义但不会变成苦力活:
| 语法类别 | 具体规则 | 示例 |
|---|---|---|
| 字面量 | 整数、字符串 | 42、"hello" |
| 变量 | 字母开头,可包含数字下划线 | x、total_1 |
| 一元运算 | 负号 | -5、-x |
| 二元运算 | 加减乘除、比较 | 1+2*3、a>b |
| 语句 | var声明、print、if/else、赋值、while、return、函数定义 | var x=1; |
| 表达式 | 算术、比较、函数调用、括号 | (x+1)*2、max(1,2) |
这份语法并不完善——比如没有字符串拼接、没有数组、没有递增++——但足够支撑一整篇讲解,也足够做实验。等你看完这篇真想扩展,加特性不难,难的恰恰是现在这些基础部分要稳。
3. 拆词器:把字符串切成token,别用正则硬怼
3.1 token类型清单
第一步是对源代码做词法扫描。miniL的token类型不多,我用一个Python枚举列出来:
class TokenType(Enum): NUMBER = 'NUMBER' STRING = 'STRING' IDENT = 'IDENT' KEYWORD = 'KEYWORD' OP = 'OP' LPAREN = 'LPAREN' RPAREN = 'RPAREN' LBRACE = 'LBRACE' RBRACE = 'RBRACE' SEMI = 'SEMI' EOF = 'EOF'这里有个关键设计:=、==、+、-这些我都归为OP,但通过value字段区分具体是哪个运算符,而if、else、func、print这些保留字单独归为KEYWORD。为什么保留字要单独一类?因为语法分析阶段需要快速区分"这是一个变量名还是控制关键字",单独成类可以把判断逻辑写得很清晰。
3.2 手写扫描循环的思路
扫描器的主循环长这样:
def tokenize(self, code: str) -> list[Token]: tokens = [] i = 0 while i < len(code): ch = code[i] if ch.isspace(): i += 1 continue if ch.isdigit(): j = i while j < len(code) and code[j].isdigit(): j += 1 tokens.append(Token(TokenType.NUMBER, int(code[i:j]), i)) i = j continue if ch.isalpha() or ch == '_': j = i while j < len(code) and (code[j].isalnum() or code[j] == '_'): j += 1 word = code[i:j] if word in {'var', 'print', 'if', 'else', 'func', 'return', 'while'}: tokens.append(Token(TokenType.KEYWORD, word, i)) else: tokens.append(Token(TokenType.IDENT, word, i)) i = j continue # 处理运算符和括号 ...你可能觉得这种逐字符判断笨拙,但它是理解词法分析本质的最佳路径。相比正则匹配,手写循环的好处是完全控制边界条件:比如识别数字时可以顺手记录下这个数字在源码里的起始位置,这在报错时能做到"第3行第5列"级别的定位。
3.3 拆词阶段最容易踩的两个坑
第一个坑是负号到底算不算数字字面量的一部分。-5如果拆成-和5两个token,那语法分析阶段需要额外规则来理解"一元负号";如果拆成负数字面量,那-5+3又得小心处理优先级。我最终选择让lexer永远输出独立的-运算符,把"这是减号还是一元负号"的决策完全留给parser。这种决策可以让lexer保持纯粹,parser那边反而更好写。
第二个坑是==和=的切分。扫描到=时不能直接当成赋值号,必须先看一眼下一个字符是不是=,如果是就把==作为一个token输出。我第一版漏了这个,导致x == 3被解析成"给x赋值=3",报错信息牛头不对马嘴。解决方案也简单:最长匹配原则,运算符先匹配长的、再匹配短的。
做完拆词器,你会得到一长串有类型有值的token。这就是语法分析阶段的输入。检验拆词器是否合格的办法是输出token流打印一遍,人工检查。我建议你花两分钟写个debug方法,后面所有怪问题都会依赖它。
4. 递归下降解析:表达式优先级靠"分级打架"实现
4.1 从token流到AST的映射关系
语法分析是整个解释器里"最像编程"的部分。它把扁平token流变成一棵树,靠的是规则嵌套。miniL的AST节点不需要太复杂,我定义了这样几个核心节点:
class ASTNode: pass class Program(ASTNode): def __init__(self, statements): self.statements = statements class VarDecl(ASTNode): def __init__(self, name, init_expr): ... class PrintStmt(ASTNode): def __init__(self, expr): ... class IfStmt(ASTNode): def __init__(self, cond, then_branch, else_branch): ... class BinaryExpr(ASTNode): def __init__(self, op, left, right): ... class UnaryExpr(ASTNode): def __init__(self, op, operand): ... class CallExpr(ASTNode): def __init__(self, name, args): ... class NumberLit(ASTNode): def __init__(self, value): ...你会发现AST节点的设计其实是在定义"语言在概念层有哪些要素",跟具体的代码文本已经脱钩了。比如print x + 1;变成AST之后,不存在分号这个节点,不再有括号这个节点。括号在语法分析阶段的作用仅仅是改变树的结构,它本身不进入树里。
4.2 优先级怎么处理:从最低到最高逐层下沉
表达式优先级是递归下降解析最核心也最容易讲不清楚的地方。实际上思路特别朴素:把运算符按优先级从低到高分成几层,每一层写一个函数,低优先级的函数调用高优先级的函数。
miniL的优先级表如下:
| 优先级层级 | 运算符 | 结合性 |
|---|---|---|
| 最低:比较 | ><>=<===!= | 左结合 |
| 中间层:加减 | +- | 左结合 |
| 最高层:乘除 | */ | 左结合 |
| 最高:一元 | -(负号) | 右结合 |
| 原子 | 数字、标识符、括号、函数调用 | — |
解析表达式时的入口从最低层开始,也就是比较运算那一层。每一层函数的典型结构是:先解析下一层的内容,然后循环看当前token是不是本层运算符,如果是就不断地把结果往左操作数上拼:
def parse_additive(self): node = self.parse_multiplicative() while self.match_op('+') or self.match_op('-'): op = self.previous().value right = self.parse_multiplicative() node = BinaryExpr(op, node, right) return node注意这个写法天然实现了左结合:1 - 2 - 3会解析成(1-2)-3,而不是1-(2-3)。道理就在于循环里每次拿到新的右操作数后,立刻把它跟已有的左节点组成新节点,然后再把新节点当作下一轮的左节点。这个结构不复杂,但很值得在纸上推演一遍,因为后面几乎所有的优先级问题都能靠"调整层数顺序"解决。
4.3 括号、函数调用和一元负号的特别处理
括号的处理在parse_primary这个原子层。遇到(,先递归调用parse_expression,然后期待一个)。这里有一个我没少吃亏的地方:如果解析完表达式后下一个token不是),说明用户漏了右括号,错误信息里必须带上token的位置和期望值。不然用户会看着print (1+2;这种代码发呆半天。
函数调用我放在了parse_postfix里:解析完一个标识符之后,如果下一个token是(,就进入参数列表解析。参数之间用逗号分隔。
一元负号要单独处理,因为它是右结合的,而且它跟减法共用一个token-。处理方式是在解析乘除之前先检查是不是-开头,如果是就吃掉这个token,然后递归解析一元表达式。这样-2 * 3会被正确解析成(-2) * 3,而不会企图先算2*3再取负。
解析器完成之后,我想给你一个非常实用的建议:立刻写一个AST打印函数。把树结构用缩进方式打印出来,一行是一个节点。这玩意儿在调试阶段就是你的眼睛,所有的解析bug都能靠它暴露。
5. 求值器:把AST节点变成真实结果的最后一公里
5.1 节点分发的三种写法
AST建好了,求值器反而简单,因为它的骨架就是"对不同类型的节点执行不同操作"。我推荐用一个evaluate(node, env)函数加递归分发。最简单的实现是if-elif链:
def evaluate(node, env): if isinstance(node, NumberLit): return node.value if isinstance(node, BinaryExpr): left = evaluate(node.left, env) right = evaluate(node.right, env) return apply_op(node.op, left, right) if isinstance(node, VarDecl): value = evaluate(node.init_expr, env) env[node.name] = value return None ...这个写法直白,但分支一多会乱。等节点类型超过十几个以后,我建议改成"方法表"模式:给每种节点类型注册一个处理函数,用type做键直接查表调用。两种写法没有性能差异,纯看个人维护习惯。
5.2 作用域和环境:函数定义时到底保存了什么
求值器的核心数据结构是环境env。它最简单就是一个字典:变量名->值。但真正有价值的是作用域链。miniL的函数需要能够访问函数外面定义的变量,这叫词法作用域。
我的做法是环境不单纯是字典,而是一个带父指针的链:
class Env: def __init__(self, parent=None): self.vars = {} self.parent = parent def get(self, name): env = self while env: if name in env.vars: return env.vars[name] env = env.parent raise NameError(f'undefined variable: {name}') def set(self, name, value): env = self while env: if name in env.vars: env.vars[name] = value return env = env.parent self.vars[name] = value这里有两个设计决策值得说。
一个是读取变量时沿着链向上找,所以内层函数能看到外层变量。另一个是赋值变量时,如果外层已经有同名变量,就直接更新外层的值,而不是在内层新建一个。这是JavaScript里容易让人困惑的"没有var声明就直接赋值会污染外层"问题的根源。miniL里我让赋值遵循"就近更新"策略,这跟很多语言的行为一致,也最容易解释清楚。
函数定义这块有些初学者会犯迷糊:定义函数时,保存的不只是函数体AST,还要保存定义时的环境快照。调用函数时,要为参数创建新的子环境,并把这个子环境的父指针指向定义时的环境。这个"定义时环境"就是闭包的本质。我用代码说明:
class FunctionValue: def __init__(self, name, params, body, closure): self.name = name self.params = params self.body = body self.closure = closure def call_function(func, args): call_env = Env(parent=func.closure) for param, arg in zip(func.params, args): call_env.vars[param] = arg return evaluate_block(func.body, call_env)如果你漏了"closure"这个字段,直接把父指针指向全局环境,那函数内部就无法访问函数外最近一层的局部变量,跨域访问就会出bug。这个坑很隐蔽,值得先在心里埋个印象。
5.3 if/while的实现:把"控制流"拆成普通求值
控制流的求值没有魔法。if的求值逻辑是:先计算条件表达式的值,然后根据真值决定求值哪个分支的语句块。问题出在“块”的求值要创建子环境,否则块内var声明的变量会泄漏到块外。这也是为什么我说作用域链是核心——块级作用域靠的就是给每个块new一个子环境,执行完丢弃。
while类似,不过多一个循环:先算条件,如果为真就求值块,然后再算条件,直到条件为假。这里有个细节:每次循环都要新建环境,不然循环体里声明的变量会在多次迭代之间互相污染。这个问题我在调试阶段遇到的时候,现象特别诡异:第一次循环里var t = 1;,第二次循环时t仍然存在且保留了旧值,直接导致死循环。
求值器完成后,你的解释器已经可以执行miniL的全部语句了。我建议用几个自测脚本从简到难验证:算术优先级、变量声明与读取、if真假分支、while累加、函数调用、函数内访问外部变量。按这个顺序测试,每一关过了再进入下一关,比一次性写一个复杂脚本然后慢慢debug效率高得多。
6. 我调试解释器时踩过的三个高频坑
6.1 现象一:-2 * 3 求值结果变成 6 还是 -6?负号引发的大混乱
前面说lexer把负号和减号输出成同一个token,但parser层面要区分。我第一版偷懒,把-直接当成parse_additive的运算符,结果-2 * 3成了-(2*3),输出是-6。看起来数字碰巧没错,但换成-2 + 3就变成-(2+3)=-5,直接翻车。
排查过程是:我先打印AST,发现-2+3的树根是减号,左子节点是2,右子节点是(2+3)。这才意识到解析时把一元负号吞进了加法的右操作数。修复方法是把一元负号的解析放到比乘除更高的优先级层,单独写一个parse_unary,这样-会优先绑定到紧跟其后的原子表达式上。经此一役我养成了习惯:任何优先级问题,先打印AST,不要靠猜。
6.2 现象二:链式赋值神秘失败
miniL支持a = b = 1;这种链式赋值。我的第一版赋值语句实现是:先计算右值,然后写入左变量名。问题在于b = 1这个表达式被求值后返回None,于是a = None。这跟多数编程语言的行为不一致:赋值表达式本身应该返回被赋的值。
修复方案有三条路:一是让赋值表达式返回右值,二是限制链式赋值直接报语法错,三是在求值器里把赋值节点的返回值固定为右值。我选了第三条,因为最符合"赋值也是表达式"的直觉。如果你的语言设计文档里明确说不支持链式赋值,那第二种也合理——关键是设计决策要前置,别让用户遇到半吊子行为。
6.3 现象三:函数参数名字跟全局变量重名时,函数内部乱套
这个坑来自环境查找逻辑。我最初实现get时,如果当前环境找不到就直接从全局环境里找,跳过了中间层。这样写其实也能工作,但一旦两层环境里出现了同名变量,内层函数实际上取到的是全局变量,而不是外层函数里的局部变量。
这个问题在print AST时完全看不出来,它是运行时语义问题。排查思路是给Env加一个trace方法,打印查找路径:从哪个环境开始找、在哪一层命中。后来我把get改成逐层向上找,这才正常。顺带一提,这个"逐层向上找"的写法正是词法作用域区别于动态作用域的地方——很多脚本语言的坑都是从这里来的。
6.4 排查利器:给解释器加AST可视化开关
调试解释器最爽的做法不是断点调试,而是给解析器加一个环境变量开关。MINI_DEBUG=ast的时候,解析完成后把整棵AST打印出来;MINI_DEBUG=trace的时候,evaluate执行过程中每进出一个节点都打印一条缩进日志。这两个开关能解决我遇到的百分之九十的问题。
原因很简单:解释器的bug分成两类,一类是AST建错了(语法阶段),一类是求值路径不对(语义阶段)。AST打印解决前者,执行trace解决后者。有了这两个工具,你根本不需要去猜内部状态,直接把中间产物亮出来,问题一目了然。
7. 如果继续做下去,下一步该怎么走
7.1 闭包与一等函数:把函数当值传来传去
miniL目前函数只能通过名字调用。真正常见语言会支持把函数赋值给变量、作为参数传递。实现这一步需要让FunctionValue成为跟数字、字符串平级的"值",可以被存储在变量里。改动量不大,但它是把miniL从"壳语言"推向"实用语言"的关键一步。顺带可以思考一个问题:如果函数可以作为参数,那么函数返回函数时,它记住的环境是什么样的?这个问题想通了,闭包就彻底通了。
7.2 更友好的错误信息:把"报错"做成产品功能
我见过太多解释器demo只要能跑通happy path就收工。但真实使用场景里,用户敲错代码是常态。一个让人愿意用的解释器,必须在报错信息上花功夫:报错要带行号列号、要提示期望什么、实际看到什么、最好还能给出修复建议。这块工作不性感,但价值极大。做一个解释器到后期,你会发现真正的难点不是语法,是"怎么在用户把代码写错时帮他快速找到问题"。
7.3 性能优化:从树遍历到字节码
如果miniL继续长大,你迟早会遇到性能瓶颈。树遍历解释器每条指令都要做类型判断和函数调用,开销很大。下一步可以先做个简单的字节码虚拟机:把AST编译成一条条指令,比如LOAD_CONST、ADD、STORE_FAST,然后用一个巨大的switch循环执行。这个方向做出来,你对"虚拟机"这三个字的理解会深刻很多,也给未来做JIT留了路子。
我个人在这条路上的体会是:解释器是一个"可生长的玩具"。你今天做完最小版本,明天加个新特性花半小时,后天优化一个细节又花半小时,但每次改完都能立刻看到效果,这种正反馈在纯业务开发里很难遇到。希望你也能从这个过程里获得同样的乐趣。
最后还是想强调一个习惯:每做完一段就写个测试脚本,并且保留它们。我所有的demo脚本都放在一个examples目录里,每次改动解释器就全量跑一遍。刚开始觉得麻烦,后来所有回归bug都是靠这一手逮住的。写解释器最大的成就感不在于最终跑得多快,而在于你能清楚地知道,每一条规则、每一个坑、每一处行为都是你亲手编织出来的。这个"从零到一"的过程本身,就是最大的回报。