SIMPL 这个名字第一次出现在我笔记里的时候,只是一个随手写的目录名。当时我的需求很朴素:想给一套配置文件加一点"能算、能判断、能循环"的能力,但又不愿意为此引入一整门通用语言。于是就有了这个小小的解释器项目——一门几百行代码就能跑起来的轻量脚本语言。它没有宏系统,没有闭包,没有面向对象,连垃圾回收都是白嫖宿主语言的。但它能完成大部分"贴一段逻辑进配置里"的活儿,而且代码量小到你可以在一个下午通读完,出问题的时候能凭直觉猜出在哪一行。
这篇内容写给两类人:一类是写过业务代码、但从来没自己动手实现过词法分析和语法分析,想找个最小可跑的例子把整条链路走通的人;另一类是已经写过玩具解释器,但卡在"能跑"和"好用"之间那段路上的人——比如错误信息只有一句"语法错误",比如加个新运算符要改五个文件。我会把 SIMPL 的四个阶段拆开讲,每一处都说明为什么这么选、不这么选会付出什么代价,然后把我在实际调试里撞到的几个坑原样摊开,包括那些当时看着莫名其妙、事后想起来只能骂自己的问题。
1. 先给 SIMPL 划一条清楚的边界线
动手之前最该干的事不是敲代码,而是决定这门语言"不做什么"。我见过太多业余语言项目死在功能膨胀上:一开始只想算个表达式,后来觉得不加函数可惜,加了函数又想加闭包,加了闭包又想加类和继承,最后实现到一半发现作用域语义互相打架,整个项目就地烂尾。SIMPL 从第一版就写死了一份"能力清单",凡是清单外的需求,一律想办法在宿主语言那一侧解决。
1.1 "简化"不等于"砍功能",而是砍掉不可控的复杂度
这两件事经常被混为一谈。砍功能是把while删掉只留for,用户该做的事做不了;砍复杂度是保留while,但规定循环体必须是语句块、不支持break带标签、不支持continue跳到外层。前者影响表达力,后者只是限制写法。SIMPL 走的是第二条路:语法层面尽量贴近大家熟悉的写法,让写过两行代码的人不用查文档就能猜出意思;语义层面严格收紧,凡是会让实现变复杂、让错误变隐蔽的特性一律不要。
举一个具体的取舍。函数参数我做成值传递,不支持引用传递,也不支持默认参数。理由很实在:值传递的语义在写求值器的时候只需要一行env.define(name, evaluate(arg)),而引用传递需要引入"左值"和"右值"的区分,整个表达式求值函数都得重写成两种模式,代码量至少翻倍,还容易在a[i] = a[j]这种地方出诡异的结果。值传递确实会让某些写法变啰嗦,但它把一整类 bug 从可能性里删掉了。
再举一个:SIMPL 没有null。变量未定义就是未定义,读它会直接抛错;要表达"空",用空字符串或者空列表。看起来是个小决定,实际影响很大——求值器里所有if x == null的分支判断全消失了,逻辑分支数量降下来,调试时的可能性空间也跟着小了一大截。
1.2 SIMPL 到底支持什么:一份可以背下来的清单
我把最终版本的语法能力整理成了下面这张表。你看完应该能感觉到,它到底是"够用"还是"不够用",取决于你打算拿它干什么。
| 能力 | 是否支持 | 说明 |
|---|---|---|
| 数值与算术 | 支持 | 只有双精度浮点,+ - * / %和括号 |
| 字符串 | 支持 | 双引号字面量,转义只认\"和\\ |
| 布尔与比较 | 支持 | == != < <= > >=,逻辑and or not |
| 变量与赋值 | 支持 | 必须先声明,声明用let |
| 条件分支 | 支持 | if / else if / else,条件必须是布尔值 |
| 循环 | 支持 | 只有while,配合break和continue |
| 函数 | 支持 | 具名函数、递归、值传递,无闭包 |
| 内建函数 | 支持 | printlenstrnum四个,够用 |
| 类与对象 | 不支持 | 需要结构化数据就传多个参数或用列表 |
| 异常处理 | 不支持 | 运行时错误直接终止并打印行号 |
| 模块系统 | 不支持 | 所有代码在一个文件里 |
最后一行"所有代码在一个文件里"是刻意的。一旦拆文件就要处理导入顺序、命名冲突、循环依赖,这三件事随手一个都能吃掉你两天时间,而它带来的收益在脚本长度普遍不超过两百行的场景里几乎为零。
1.3 一条源码走完的四段路
理解任何解释器,抓住主线就够了:源码字符串进来,先被切成有意义的词(token),再被组织成树(AST),最后在环境里求值出结果。四个阶段,每段的输出是下一段的输入,边界清晰,谁出错就在谁那一层报错。
第一阶段是词法扫描,把let x = 1 + 2切成[let] [x] [=] [1] [+] [2],顺手记下每个词的行号和列号。第二阶段是语法解析,把扁平的 token 序列按语法规则拼成树,1 + 2变成Binary(+, 1, 2),而1 + 2 * 3变成Binary(+, 1, Binary(*, 2, 3)),树形结构本身就编码了优先级。第三阶段是求值,从根节点往下递归,遇到Binary就先算左右子树再套运算符。
第四阶段容易被忘掉但特别重要:错误报告。三个阶段都可能出错,但用户看到的必须是人话。我的做法是让每个阶段都抛同一种自定义异常,异常里带行号、列号和一句"你大概想干什么"。比如解析器遇到let = 5时,报的不是"意外的 token",而是"let后面需要一个变量名,但读到了="。
2. 词法扫描这一层,不要一上来就堆正则
很多人写扫描器第一反应是开一个正则列表,每个 token 类型一条,然后while循环从头开始逐个match。这条路在脚本语言规模下能跑通,但它有三个隐形成本:一是回溯性能不可控,二是匹配失败时的位置信息很难精确到字符,三是字符串字面量和注释这种"内部结构不能被其他规则干扰"的东西会互相打架。我第一版就是正则拼的,后来因为一个字符串里带//被当成注释截断的问题,整个推倒重写成了手写扫描。
2.1 扫描器的本质是一个带状态的游标
手写扫描器的核心其实只有一个变量:当前读到第几个字符。所有规则都是"看当前字符长什么样,决定吃掉几个字符,产出一个 token"。这个模型简单到可以用一张纸画出来,而且天然支持精确的位置追踪。
from dataclasses import dataclass from typing import Any @dataclass class Token: kind: str # NUMBER / STRING / IDENT / 运算符关键字本身 / EOF value: Any # 字面量的解析值 line: int col: intkind我用字符串而不是枚举,理由是调试的时候打印出来直接可读,而枚举还得手动映射一次。有人会说字符串比较慢,实测在一秒几万 token 的规模下,这点开销完全淹没在其他地方,不值得为它引入一层抽象。
扫描主循环长这样,骨架很短:
def tokenize(src: str): tokens, i, line, col = [], 0, 1, 1 n = len(src) while i < n: ch = src[i] if ch in " \t\r": i += 1; col += 1 continue if ch == "\n": i += 1; line += 1; col = 1 continue # 数字 / 标识符 / 字符串 / 运算符 各自分支 # ... tokens.append(Token("EOF", None, line, col)) return tokens注意最后那行EOF。这个哨兵 token 是我在调试中加上的,作用是让解析器不用到处写if self.pos < len(self.tokens),所有"还有没有下一个"的判断统一变成"当前位置的 kind 是不是 EOF"。少写二十个边界判断,少出二十个越界 bug。
2.2 数字和字符串的边界,是踩坑高发区
数字扫描最省事的写法是"只要当前字符是数字或者小数点就一直吃",但这会带来一个经典问题:1.2.3会被切成一个数字 token,值是1.23?还是报错?如果不处理,float("1.2.3")会抛异常,而你拿到的报错来自 Python 内部,行号列号全丢,用户看到的是个莫名其妙的东西。
我的做法是在扫描阶段就校验形式,只允许一个小数点,而且小数点后必须跟数字:
if ch.isdigit(): start_col, j, dot = col, i, False while j < n and (src[j].isdigit() or src[j] == "."): if src[j] == ".": if dot: raise ScanError(f"数字里出现了第二个小数点", line, col) dot = True j += 1 tokens.append(Token("NUMBER", float(src[i:j]), line, start_col)) col += j - i i = j continue字符串更麻烦。第一版我用的规则是"从"开始,遇到下一个"结束",结果遇到"他说\"你好\""就直接崩了。后来补了转义处理,只支持两个转义序列,并且明确写入文档:反斜杠只对引号和反斜杠本身生效,其他情况原样保留。这个决定的好处是行为完全可预测,坏处是不支持\n换行符——但真有换行需求的时候,直接用多行字符串字面量就行了,不折腾转义。
还有一个隐蔽问题:未闭合字符串。如果用户写了"abc就结束了,扫描器会一路吃到文件末尾,最后要么崩溃要么产出一个巨长的字符串。正确做法是在循环体内检查j有没有越界,一旦越界立刻抛第 X 行字符串未闭合,并且把起始行号带出去——因为报"文件末尾未闭合"对用户毫无帮助。
提示:扫描阶段的错误一定要带起始位置,而不是当前位置。字符串和注释这两类跨行的 token,起始行号和出错行号往往差好几十行。
3. 递归下降解析:把语法书写成函数
解析器我用的递归下降,没用任何生成器。选它的理由和前面一致:语法规则和函数一一对应,改语法的时候你清楚该动哪个函数;生成的解析器虽然写起来省事,但出问题的时候你面对的是一张巨大的状态表,排查成本远高于手写的那几百行。
递归下降的基本套路是每个非终结符对应一个方法,方法内部按语法的右端依次调用其他方法。表达式部分的关键是优先级要体现在调用层次上:优先级越低的运算符,对应的函数越靠外层。
3.1 优先级用"层层剥洋葱"实现,而不是查表
我见过有人用运算符优先级的数值表加调度场算法来算表达式,能跑,但可读性差,而且加个新运算符要维护三处数据。递归下降的写法是:
def parse_expr(self): # 最低:逻辑或 return self.parse_or() def parse_or(self): left = self.parse_and() while self.at("or"): self.advance() right = self.parse_and() left = {"kind": "Binary", "op": "or", "left": left, "right": right} return left def parse_and(self): left = self.parse_equality() while self.at("and"): ...从parse_expr一路调下去,顺序是or → and → equality → comparison → term → factor → unary → primary。每一层只负责自己这一档的运算符,其他的一律交给下一层。这个结构的好处是"加一个优先级"变成了"在某两层之间插一个函数",改动面极小;坏处是嵌套层级多,不过每一层都只有十几行,读起来并不费劲。
左递归是另一个必须处理的问题。算术语法里term -> term * factor | factor是左递归的,直接翻译成函数会无限递归。标准的消除办法是把左递归改写成循环——也就是上面代码里那个while。这个改写要理解成"先解析一个高优先级的东西,然后只要看到本层的运算符就再解析一个,把它组合到左边"。理解这一点之后,1 - 2 - 3为什么算出来是(1-2)-3也就顺理成章了。
3.2 语句解析:块和函数体是同一套逻辑
语句层面的解析比表达式简单,因为几乎没有优先级问题,难点在"块"。{ ... }里的内容是一串语句,而函数体本质上也是一个块,while的循环体也是。我把块解析抽成了一个独立方法,返回语句列表,其他地方统统复用它:
def parse_block(self): self.expect("{") stmts = [] while not self.at("}"): stmts.append(self.parse_statement()) self.expect("}") return {"kind": "Block", "body": stmts}这里有个小决定:块要不要引入新作用域。Python 的if不引入,Java 的if引入。我选了不引入,只有函数体引入新作用域。理由是实现简单——求值Block的时候直接复用当前环境,不需要创建子环境也不需要回收;代价是循环体内声明的变量会泄漏到外面,这一点我在踩坑章节里详细讲。
3.3 AST 用什么数据结构:类和字典的真实权衡
第一版我用的是类继承,Expr基类下面挂NumberExprBinaryExprCallExpr十几个子类。写到后面发现两个问题:一是每个类都要写__init__和__repr__,样板代码比逻辑还多;二是我经常需要临时打印整棵树看结构,继承体系下打印出来是一堆<BinaryExpr object at 0x...>,得额外实现__repr__才可读。
改成带kind字段的普通字典之后,代码量少了将近一半:
{"kind": "Number", "value": 42} {"kind": "Binary", "op": "+", "left": {...}, "right": {...}}好处是print(ast)直接能看到完整树结构,调试效率高出一个数量级;坏处是没有类型检查,字段名拼错要跑到运行时才发现。对这个规模的项目来说,我认为换来调试便利是划算的。如果你要做的是几百种节点的大型语言,那还是老老实实用类或者带校验的构造器。
4. 求值器:作用域链是最容易写错的部分
求值器看起来是四段里最简单的——遍历树,算值。实际上最容易出隐蔽 bug 的就是这一层,因为词法和语法错误会立刻崩给你看,而求值错误往往是"结果不对但程序不报错",得靠对比预期值才发现。
4.1 环境对象:一层字典加一个指向父级的指针
作用域的实现核心是一个环境对象,它至少要有三样东西:一个存变量的字典、一个指向父环境的引用、以及一套按名字查找和赋值的方法。
class Env: def __init__(self, parent=None): self.vars = {} self.parent = parent def define(self, name, value): self.vars[name] = value def get(self, name): if name in self.vars: return self.vars[name] if self.parent is not None: return self.parent.get(name) raise RuntimeError(f"变量 {name} 未定义") def assign(self, name, value): if name in self.vars: self.vars[name] = value elif self.parent is not None: self.parent.assign(name, value) else: raise RuntimeError(f"赋值给未声明变量 {name}")这三段逻辑里,get和assign必须分开,这是我最想强调的一点。合并成一个"取或设"的方法会让let和=的语义混在一起:声明应该只写当前层,赋值应该沿着链往外找。混在一起之后,一个笔误就能造成"在内层函数里意外创建了一个同名变量把外层遮住了",而且这种 bug 极难定位,因为程序完全正常运行,只是结果莫名其妙。
define不做父级查找也是有意的。let的语义是"在当前作用域创建",如果它偷偷改到外层变量,就变成了隐式全局变量,这在脚本语言里是灾难。
4.2 求值主函数:一个巨大的分派
求值函数本身是一棵if树,按kind分派:
def evaluate(node, env): k = node["kind"] if k == "Number" or k == "String" or k == "Bool": return node["value"] if k == "Var": return env.get(node["name"]) if k == "Binary": op = node["op"] left = evaluate(node["left"], env) right = evaluate(node["right"], env) return apply_binary(op, left, right) if k == "If": cond = evaluate(node["cond"], env) if not isinstance(cond, bool): raise RuntimeException(f"if 的条件必须是布尔值,实际是 {type(cond).__name__}", node["line"]) branch = node["then"] if cond else node["else"] return evaluate(branch, env) if branch else None ...注意If分支里那个类型检查。这是我后来加上的,因为 SIMPL 里if 1 { ... }到底算什么?借 Python 的真值规则,0 是假、非 0 是真?还是干脆报错?我选了报错,理由跟前面一样:隐式真值转换会让"我明明写了条件但它就是不进分支"这类问题变得极难排查,而显式写if x != 0只多敲几个字符。
apply_binary里还要处理类型。+的双重语义(数字加法和字符串拼接)是最典型的麻烦:如果两边类型不一致,到底是自动转成字符串,还是报错?SIMPL 的规则是类型必须严格匹配,+不做隐式转换。要拼接就显式写str(a) + b。这条规则消灭了一大类"我以为是加法结果是拼接"的困惑。
4.3 错误信息:多写十个字,省下两小时
求值阶段的错误如果只报"类型错误",用户得靠自己翻代码找位置。所以每个 AST 节点我都带一个line字段,报错时拼进去:
raise RuntimeException( f"第 {node['line']} 行:运算符 '+' 不能用于 {type(left).__name__} 和 {type(right).__name__}", node["line"] )三层信息缺一不可:位置、操作、实际类型。少任何一层,排查时间至少翻倍。实际的教训是,我一度为了省事只报了位置和操作,结果遇到"abc" + 5的时候以为是两边都错,查了半小时才发现是字符串那边。把类型名打进消息里,问题一眼就明了。
5. 让它从"能跑"到"敢用"的几处关键改造
解释器能正确算1+1之后,离"敢往生产配置里贴"还有一段距离。这段距离由三件事构成:内建函数、函数调用与递归的正确实现、以及性能上不做无用功。
5.1 内建函数:宿主能力和脚本世界之间的窄桥
内建函数就四个:printlenstrnum。实现方式是环境里直接塞 Python 可调用对象,求值时如果Call的目标不是一个用户定义的函数节点,就直接调用它:
def make_global_env(): env = Env() env.define("print", lambda *args: print(*[format_value(a) for a in args])) env.define("len", lambda x: len(x) if isinstance(x, (str, list)) else _err("len 只接受字符串或列表")) env.define("str", lambda x: format_value(x)) env.define("num", lambda x: _to_number(x)) return envformat_value负责把 SIMPL 的值转成显示文本:数字如果是整数就不显示小数点,布尔显示成true/false,字符串原样输出。这个小函数看着不起眼,但它是所有输出一致性的源头,值得单独写一个而不是到处str(x)。
num的行为要特别小心。把字符串"12abc"转数字,Python 的float会直接抛异常,而返回 0 又太宽松。我的做法是:能完整转就转,不能就抛运行时错误并带行号。这样用户写错数据格式时立刻知道,而不是拿到一个静默变成 0 的数继续往下算。
内建函数的数量要克制。每加一个,你就要为它定义错误语义、写测试、写文档。四个够用,是因为脚本里真正高频的只有输出和类型转换,其他需求都能用宿主语言在调用侧处理。
5.2 函数调用:递归能跑,但别指望它跑得深
用户定义函数的求值逻辑是:新开一个环境,把参数按位置绑到参数名上,然后在那个环境里求值函数体。
if k == "Call": fn = env.get(node["name"]) args = [evaluate(a, env) for a in node["args"]] if isinstance(fn, dict) and fn["kind"] == "Function": if len(args) != len(fn["params"]): raise RuntimeException( f"函数 {node['name']} 需要 {len(fn['params'])} 个参数,传入了 {len(args)} 个", node["line"]) local = Env(parent=fn["closure"]) for pname, aval in zip(fn["params"], args): local.define(pname, aval) return evaluate(fn["body"], local) if callable(fn): return fn(*args) raise RuntimeException(f"{node['name']} 不是可调用的函数", node["line"])这里closure指的是函数定义时所在的环境,也就是全局环境。SIMPL 不支持闭包,所以函数只能看到全局和自己的局部——这既简化了实现,也避免了一类非常隐蔽的问题:函数捕获了循环变量,循环结束后所有函数看到的是同一个值。不做闭包之后,这类问题从根上消失了。
递归能跑通,是因为调用时新开的环境链最终指向全局,而函数名本身就在全局里。朴素递归写个阶乘没问题。但深度递归会直接撞上宿主语言的递归上限,而且报出来的是宿主语言的栈溢出,用户完全看不懂。这一点我只能提醒:如果要拿它跑深度超过几百层的递归,得先把求值器改成显式栈的循环版本,工程量不小,不是必须就别碰。
5.3 性能:树遍历解释器慢在哪,什么时候该忍
树遍历解释器的开销主要在两处:一是每次求值都要做一次dict查kind,二是递归调用本身的函数调用开销。实测下来,一个简单循环一百万次,耗时大概在几十秒量级——对配置文件场景完全够用,对计算密集场景完全不够。
优化路径是有顺序的。第一步是减少字典查找,把 AST 节点从字典换成元组,索引访问比字符串哈希快,但可读性会掉一大截。第二步是编译成闭包,遍历一次 AST 把它转成一组 Python 闭包,后续执行不再判断kind。这是最容易见效的一招,通常能快三到五倍,代价是调试时看到的是一堆 lambda。
我做了一个折中:默认走树遍历,提供一个可选的编译模式,只在你确认某个脚本是性能瓶颈的时候手动开启。原因是编译模式下一旦求值出问题,你面对的是生成的闭包,定位难度明显高于对着 AST 单步。先让它对,再让它快,这个顺序在解释器项目里格外重要。
6. 调试实录:三个让我熬到后半夜的问题
这一节是我最想留下来的部分。下面三个问题都不是"代码写错了",而是"代码写对了但结果不对",排查过程比修复本身更有参考价值。
6.1 优先级写错,导致所有算术结果静默偏移
现象是1 + 2 * 3算出来是 9 而不是 7。第一反应是解析器某层调错了,但print(ast)出来的树是完全正确的Binary(+, 1, Binary(*, 2, 3))。于是问题在求值侧。盯着apply_binary看了十分钟,终于发现%的处理写成了:
if op == "%": return int(left) % int(right) # 错误的写法int()截断会把手算的浮点结果改掉,而1 + 2 * 3里根本没有%。绕了一圈才找到真正的元凶:if op == "+"分支里,我为了"顺手处理字符串拼接"写了return str(left) + str(right),导致1 + 2得到"12",后续参与乘法时……不对,这会让1 + 2 * 3变成"1" + 6="16",str和数字比较又出问题。
排查的关键动作是把中间值打出来。我在apply_binary入口加了一行临时日志,输出op / left / right / 结果类型,跑了一次立刻定位。教训是:表达式求值的 bug 不要盯着代码猜,让程序自己把每个中间步骤打出来,五分钟解决的事情别猜半小时。
6.2 循环里的变量跑到外面去了
现象:
let i = 100 let n = 0 while n < 3 { let i = n n = n + 1 } print(i)期望打印100(外层i没被改),实际打印2。这正是前面说的"块不引入新作用域"的代价——循环体里的let i直接写进了当前环境,把外层的i覆盖了。
修法有两个方向。一是让Block求值时新开一个子环境,语义上更接近大多数语言的直觉,代价是循环里每次迭代都要新建一个环境对象,性能有损耗,而且产生了一个我从没打算支持的语义:"块内声明的变量在块外不可见"——这会让写脚本的人困惑。二是我最终选的方案:在解析阶段就禁止在非函数体的块里重复声明已经存在的名字,直接报语法错误。
这个选择的逻辑是,SIMPL 的定位是"配置里贴一段逻辑",脚本都很短,变量名冲突在这种规模下几乎一定是笔误,早报错比晚报错好。用解析期检查换来一个几乎零成本的语义保证,我认为是这笔交易里最划算的一笔。
6.3 字符串拼接在循环里变成平方级
这个坑跟解释器本身无关,是用户脚本的写法问题,但被我的实现放大了。有段脚本在循环里做s = s + "x",构造一个几千字符的字符串,在几百次迭代量级下完全正常,到几万次就明显卡顿。
原因是字符串不可变,每次+都要复制整个字符串,总复杂度是平方级。解决方式有两个层面:脚本层面建议改成先收集到列表再join,但 SIMPL 没有列表,所以只能改解释器层面——我给+的字符串分支加了一个判断:如果左边是字符串且总长度超过某个阈值,就改用内部的字符串缓冲区。
这个优化写起来不难,但它引出一个更重要的问题:解释器的性能特征会直接暴露给用户。你在实现里做的每个小决定——字符串是否可变、列表是否共享引用、环境对象是否复用——都会变成脚本作者需要知道的知识。所以文档里必须明确写出这些"性能注意点",否则用户会以为是自己的逻辑写错了。
7. 给 SIMPL 加语法时的标准动作清单
写到后面,我加了几个语法特性,比如break/continue、复合赋值+=。每次改动都遵循同一套流程,固定五步。这套流程本身比具体特性更有价值,因为它把"改语法"从一个容易漏改的模糊任务变成了一个清单式的机械动作。
第一步是在扫描器里加 token。新的关键字或运算符必须能被切出来。如果新符号和已有符号前缀相同(比如=和==),一定要把长的规则放在前面判断,否则==永远匹配不到。这个顺序问题我至少犯过两次。
第二步是在解析器里加产生式。如果是表达式级别的运算符,插到正确的优先级层级;如果是语句,加到parse_statement的分派里。这一步完成后,先不要求值,直接print(ast)看看树对不对。
第三步是加 AST 节点定义。用字典的话就是约定好kind和必需的字段名,建议在文件顶部集中写一段注释,列出所有节点及其字段,不然过两周自己都记不住。
第四步是在求值器里加分支。如果是控制流类的特性(break),这里要用异常做跳转——break和continue各抛一个自定义异常,在循环求值的地方捕获。用异常而不是返回值,是因为break可能出现在多层嵌套的块里,返回值要一路透传上去,代码会变得很脏。
第五步是补测试用例。至少三个:正常路径、边界情况、错误路径。错误路径的用例最容易被省略,但它恰恰是后面最常触发的地方。我现在的习惯是,写完任何语法特性,先写一个"故意写错"的用例,确认报错信息里位置和原因都对。
按照这个清单走,加一个+=从开始到测试通过大概二十分钟。如果哪一步卡了很久,通常说明前面某一步的设计有问题,该回头看看而不是硬扛。
这套东西做到现在,我自己最深的体会是:解释器项目的难点从来不在算法,而在边界。数字和字符串的交界、作用域和赋值语句的交界、语法错误和运行时错误的交界,每一条交界线上都埋着一堆"看起来应该这样,实际不是"的细节。我现在的习惯是,每实现一个新特性,先花五分钟只想一件事:这个特性跟已有特性的交互里,有哪几处会产生歧义?把这几处列出来挨个定死语义,写进注释,后面能省掉大量"跑起来才发现语义不对"的时间。至于功能本身,能不加就不加——一门语言的价值不在于它能表达多少东西,而在于它表达不了的东西是不是恰好都是你不想要的。