逐行精读SNL编译器源码:从词法分析到递归下降
发布时间:2026/11/6 6:56:28 锦皓数字建站

简介面向编译原理课程学习者的 SNL 语言编译器完整源码覆盖词法分析、语法分析含 LL(1) 与递归下降两套实现、语义分析到目标代码生成的典型实验链路适合正在做课程设计、需要对照理解编译各阶段实现细节的本科生与自学者。压缩包共 231 个文件、约 77.55MB以 cpp、h 源文件为主体配 sln、vcxproj 等 Visual Studio 工程文件可直接打开编译exe、obj、pdb 为编译产物与调试符号tlog、log、ipch 记录了构建过程便于排查环境问题。资源已有 1179 人学习下载。读者可获得一套可运行的多阶段编译器参考实现对照词法分析、语法递归程序、语义处理等模块的代码组织方式快速定位实验中的报错与逻辑偏差也可作为二次修改扩展的基础框架。1. SNL 编译器源码为什么值得逐行读很多人学完编译原理的第一反应是正则表达式、FIRST 集、四元式这些名词我都认识但让我从零写一个能跑的语言实现还是不知道从哪下手。差距不在概念量而在一条把字符流一步步变成可执行动作的完整链路没有被真正走通过。SNL 语言编译器源码的价值就在这里它把这条链路压进几千行 C/C 代码里没有构建框架、没有第三方库兜底每一个if分支背后都对应教材里一句带过的推导。读它是从“知道有词法分析”变成“知道词法分析为什么不那么写”的唯一路径。2. 词法分析最长匹配是怎么把字符流切成 token 的2.1 手写扫描器与自动机状态机为什么最后变成了 switch教材推导词法分析的链路很标准正则表达式 → NFA → 子集构造 → DFA → 最小化 → 表驱动。要是照这条路走完你会得到一张二维状态转移表外加一个while (state ! DEAD) state table[state][ch];的主循环。但真去翻 SNL 这类课程级编译器的源码几乎清一色是手写扫描器用switch和while把状态转移铺开成代码连张转移表都找不到。常见的实现思路是把每个 token 类别的识别写成一个真代码里的小流程按优先级排列先判断字符大类再进入对应的子循环。这样做的直接好处有三个。一是 token 种类太少SNL 的保留字也就二十来个符号十几个手写代码量比配置生成器还短。二是最长匹配在表驱动里要额外维护状态栈和回退而手写版只要不提前return自然就匹配到最长的前缀。三是报错位置精确表驱动跑到 DEAD 状态时你已经丢失了“这个 token 是从哪一列开始”的信息手写版只要在入口记下start报错时直接给出起始列。/* 扫描主循环每次调用返回一个 token读指针只前进不后退 */ Token nextToken(Lexer *lx) { skipBlankAndComment(lx); /* 跳过空白与 { ... } 注释同时累计行号列号 */ if (lx-ch EOF) return makeToken(TOK_EOF, lx); if (isalpha(lx-ch)) { /* 标识符和关键字走同一个入口状态 */ const char *start lx-cursor; int len 0; while (isalnum(lx-ch) || lx-ch _) { advance(lx); len; } return makeIdentOrKeyword(lx, start, len); /* 最长匹配结束后才查关键字表 */ } if (isdigit(lx-ch)) return scanNumber(lx); switch (lx-ch) { /* 单字符与双字符运算符 */ case :: advance(lx); if (lx-ch ) { advance(lx); return makeToken(TOK_ASSIGN, lx); } return makeToken(TOK_COLON, lx); case : advance(lx); return makeToken(TOK_PLUS, lx); /* ... 其余符号同理 */ default: return makeError(lx, 非法字符); } }这段代码的关键点不在语法而在两个决策。advance(lx)里同时更新行号列号保证后续所有报错都能定位到行列不用再二次扫描源文件。makeIdentOrKeyword被放在最长匹配结束之后调用而不是在读到第一个字符时就查表这直接解决了integerx这种“关键字前缀 后缀”被误判成关键字的经典问题。很多手写扫描器翻车就翻在这里先匹配到integer就返回剩下的x变成下一个 token语法分析阶段报一个莫名其妙的错误。提示判断一个扫描器写得对不对最简单的办法是喂它integer1、if_、whilea三个输入合格的实现应该识别成三个标识符一个关键字都不出。2.2 token 结构与读入策略为什么要让 token 指向源缓冲token 结构体的设计看起来是琐碎细节实际决定了后面语法分析的写法和性能。新手最容易写成固定长度的char lexeme[64]然后strcpy拷贝进去。这份源码的正确做法是指针加长度让 token 直接指向源文件缓冲区的某一段。typedef enum { TOK_ID, TOK_NUM, TOK_KEYWORD, TOK_ASSIGN, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_SEMI, TOK_COMMA, TOK_EOF, TOK_ERROR } TokenKind; typedef struct { TokenKind kind; const char *start; /* 指向整份源文件缓冲不做拷贝 */ int length; /* 配合 start 使用比较时先比长度再 memcmp */ int line, col;/* 报错定位从 1 开始计数 */ long ival; /* kind 为 TOK_NUM 时有效扫描阶段就完成进制转换 */ } Token;指针加长度的好处在符号表查找时才显现出来SNL 的标识符平均长度不到 8 字节用length做哈希初筛桶内比较先比长度再memcmp绝大多数情况下连一次完整字符串比较都不需要。固定数组方案则逼着你在每次nextToken里做一次拷贝几千行源码扫下来就是几十万次无意义的memcpy。同时ival在词法阶段就完成转换把“字符1、2、3变成数值 123”这一步和语法分析彻底解耦语义分析阶段就不用再关心进制问题。读入策略上整文件一次读进内存是更常见的选择。fgetc每次调用都要走一次带锁的流操作几 MB 的源文件扫下来开销相当可观而把文件整个fread进缓冲后扫描器只需要维护一个const char *cursor前进就是自增。经典的“双缓冲”做法两块缓冲区轮换一块读满再处理另一块主要服务于流式输入场景对编译器这种一次性处理完整个文件、还要支持报错回溯的场景反而增加复杂度。理解了这一层你就明白那些看起来“不够教科书”的实现选择其实都是在具体约束下做出的取舍而不是作者没学过自动机理论。还有一个细节值得单独拎出来缓冲区末尾放一个\0哨兵。这样内层while (isalnum(*p)) p;循环就不用每次都判断p end省掉一次比较。对扫描器这种每字节都要跑一遍的热点代码这类微观优化累积起来的效果不小。2.3 关键字、标识符、常量的分流最长匹配之后还剩什么最长匹配解决的是“切多长”切完之后还要解决“切出来的是什么”。标识符和关键字在词法层面完全是同一类东西字母开头的字母数字串区别只在于是否出现在保留字表里。SNL 的保留字数量在二十上下线性查表最多二十次memcmp完全可接受如果追求极致可以按首字母建一个 26 项的索引数组先定位到对应首字母的小表再线性比较平均两三次就能命中。真正需要小心的是数字常量的收尾判断。扫描器读完一串数字后如果下一个字符还是字母说明遇到了123abc这种非法 token。这时有两种处理立刻报警并返回TOK_NUM让abc变成下一个标识符或者把整个非法串吞掉再报错。前一种做法能让扫描继续但会引发连锁报错用户看到的是“第 3 行 12 列数字错误”和“第 3 行 15 列未定义标识符 abc”后者纯属噪音。static Token scanNumber(Lexer *lx) { long val 0; int digits 0; while (isdigit(lx-ch)) { val val * 10 (lx-ch - 0); advance(lx); digits; } /* 数字后面紧跟字母说明这是一个非法 token吞掉整段再报错避免连锁报错 */ if (isalpha(lx-ch)) { while (isalnum(lx-ch) || lx-ch _) advance(lx); return makeError(lx, 数字常量后不能紧跟字母); } /* 溢出检查放在转换完成后统一处理比在循环里逐位判断更清晰 */ if (val INT32_MAX) return makeError(lx, 整型常量超出范围); return makeNumToken(lx, val); }这里有个容易被忽略的设计点while (isdigit(...))循环里不做溢出检查而是在退出循环后统一判断。原因是逐位检查会在每次迭代里增加一次比较而溢出是极低概率事件把它挪到冷路径上更划算。这类“把检查放到循环外”的写法在扫描器里到处都是本质上是用可读性换执行效率。3. 语法分析递归下降怎么把 EBNF 直接翻译成可执行代码3.1 FIRST 集落在哪一行代码里递归下降最迷人的地方是它把文法里抽象的选择关系直接变成源代码里的分支结构。每个非终结符对应一个parseXxx函数函数体内部就是根据当前 token 决定走哪个产生式决定依据的正是这个非终结符各个候选式的 FIRST 集。拿 SNL 的语句列表来说合法的语句首 token 只可能是标识符、if、while、read、write、return这六种这个集合就是FIRST(statement)。写成代码就是一次switch。/* statement 的 FIRST 集完整地翻译成 switch 的六个分支没有 default 之外的隐藏路径 */ AstNode *parseStmt(Parser *p) { switch (peek(p)-kind) { case TOK_ID: /* 标识符打头有两种可能赋值 ID : ... 或过程调用 ID(...)需要第二个 token */ if (peekAt(p, 1)-kind TOK_ASSIGN) return parseAssign(p); if (peekAt(p, 1)-kind TOK_LPAREN) return parseCall(p); return errorStmt(p, 标识符后应为 : 或 (); case TOK_IF: return parseIf(p); case TOK_WHILE: return parseWhile(p); case TOK_READ: return parseRead(p); case TOK_WRITE: return parseWrite(p); case TOK_RETURN: return parseReturn(p); default: return errorStmt(p, 语句不能以此符号开头); } }peekAt(p, 1)这一行值得多看两眼。它意味着解析器必须维护至少两个 token 的前瞻缓冲而不只是一个“当前 token”。教科书讲 LL(1) 时默认看到当前 token 就能决策但真实的手写解析器里标识符打头的两种语句结构完全不同必须多看一个才能分流。这就是为什么很多实现明明号称是 LL(1)代码里却总有个长度为 2 的lookahead数组——它说明文法在设计阶段为了书写简洁牺牲了一点点前瞻代价很小理解上却容易卡壳。同理声明部分要区分type、var、procedure三种声明类型定义里要区分:和:都遵循同一个模式FIRST 集里有什么switch里就有几个case。3.2 左递归为什么必须改写改写后又该怎么写表达式文法是递归下降最容易出问题的地方。教材写的产生式通常是左递归形式E - E T | T T - T * F | F F - ( E ) | id | num如果照着这份产生式逐行翻译parseE的第一件事就是调用parseE栈瞬间耗尽。改写是必须的E - T { (|-) T } T - F { (*|/) F } F - ( E ) | id | num改写后的形式用 EBNF 的{ }表示“重复零次或多次”落到代码里就是while循环。这一步转换是整套实现里最值得反复默写的地方因为一旦转换写错表现出来的问题往往是表达式结果错误与栈溢出完全不同。/* 左递归改写为循环后递归深度只与括号嵌套层数有关与表达式长度无关 */ AstNode *parseExpr(Parser *p) { AstNode *left parseTerm(p); while (peek(p)-kind TOK_PLUS || peek(p)-kind TOK_MINUS) { TokenKind op next(p)-kind; AstNode *right parseTerm(p); /* 右边的项先独立成树再和左边合并 */ left makeBinOp(op, left, right); } return left; }这个循环结构天然产生左结合语义a - b - c会先合并成(a-b)再和c合并成((a-b)-c)符合算术运算符的常规约定。如果换成右结合运算符比如幂运算就得把它写成真正的递归调用而不是循环。改写带来的一个副作用是表达式深度不再等于运算符个数而是等于括号嵌套层数。这对解释执行的实现极为重要——一份嵌套超过几百层的括号表达式就能把默认栈打穿而一个包含几千项的长表达式却只占用常数栈空间。3.3 AST 在哪一层构建边解析边建树还是事后转换解析器的产物有两种可能一棵完整的语法分析树每个非终结符对应一个节点或者一棵抽象语法树只保留语义相关的结构。后者省内存也省后续遍历代价是构建时机必须精确。常见的做法是在“归约点”建节点——所谓归约点就是这个产生式所有右部符号都已经被解析完、马上要返回的瞬间。以if语句为例parseIf返回前那一刻条件表达式、then 分支、可选的 else 分支都已经拿到手这时构造节点是最自然的。typedef enum { AST_NUM, AST_VAR, AST_BINOP, AST_IF, AST_WHILE, AST_ASSIGN, AST_CALL, AST_READ, AST_WRITE } AstKind; typedef struct AstNode { AstKind kind; int line; union { struct { long value; } num; struct { BinOp op; struct AstNode *lhs, *rhs; } binop; struct { struct AstNode *cond, *thenPart, *elsePart; } ifStmt; struct { char *name; struct AstNode *index; } var; /* 数组下标可为 NULL */ } u; } AstNode;union节省内存但也意味着节点的所有访问路径必须先用kind判断再取字段取错字段不会报错只会读到一段被解释成其它类型的字节。这类 bug 在调试时表现为“语义分析阶段某些节点类型检查莫名失败”而错误位置指向的源码看起来毫无问题。另一个常被忽略的问题是节点上的line字段。它的作用在错误报告但更实际的价值在调试编译器的中间表示一旦出错能手边有个对应的源码行号排查效率完全不同量级。3.4 错误恢复一次报错为什么不应该是雪崩任何一个真实用户写的代码都会有语法错误解析器遇到错误后的行为直接决定了工具好不好用。最简单粗暴的做法是exit(1)立刻终止稍好一点是抛异常跳到文件末尾真正可用的做法是“恐慌模式恢复”——跳过若干 token直到遇到一个明确能重新同步的位置。同步位置的来源是 FOLLOW 集。;、end、else这类符号在语法结构里扮演分隔角色它们几乎出现在所有语句级非终结符的 FOLLOW 集里所以是最自然的同步点。/* 跳过 token 直到遇到同步集合中的符号然后让上层函数重新开始解析 */ void syncTo(Parser *p, const TokenKind *syncSet, int n) { while (peek(p)-kind ! TOK_EOF) { for (int i 0; i n; i) if (peek(p)-kind syncSet[i]) return; advance(p); /* 关键必须消耗 token否则遇到 EOF 之前会死循环 */ } }advance(p)那一行是整套错误恢复的生命线。如果错误处理逻辑里存在“当前 token 不匹配就报错并返回”的路径而调用方又原样再次进入同一个函数解析器就会卡在同一个 token 上反复报错输出几百条一模一样的错误信息。这种情况在自研解析器里出现的频率高到可以当作新手检测器。提示测试错误恢复质量的办法是故意删掉一个;看编译器最终输出几条错误。一条到三条属于正常上百条说明同步逻辑没生效。4. 符号表与类型系统嵌套作用域是怎么被一条链串起来的4.1 作用域栈哈希桶加父指针还是单表加遮蔽SNL 支持过程嵌套声明这直接决定了符号表必须支持多层作用域。经典实现是“每个作用域一张哈希表表里存符号表本身组成一棵树查找时从当前结点沿父指针向上走”。typedef enum { SYM_VAR, SYM_TYPE, SYM_PROC } SymKind; typedef struct Symbol { char *name; SymKind kind; struct Type *type; /* 变量和参数的类型类型名指向被定义的类型 */ int level; /* 所在过程层数最外层 program 记为 0 */ int offset; /* 相对活动记录基址的偏移解释器可不用 */ struct Symbol *nextInBucket; } Symbol; typedef struct Scope { Symbol *bucket[BUCKET_N]; /* 每层独立一套桶退出作用域时整块释放 */ struct Scope *parent; /* 指向外层作用域查找时逐层上溯 */ int level; } Scope;查找逻辑很短在当前层的桶里找找不到就parent parent-parent直到NULL。插入只发生在当前层所以同名变量在内层会自然遮蔽外层不需要任何额外处理。退出作用域时把整个Scope连同它所有桶里的链表节点一次性释放即可唯一需要小心的是符号名可能指向源缓冲别顺手free了。另一种方案是全局只有一张哈希表每个桶维护一个“遮蔽链”同一个名字在链上按声明顺序排列查找永远只查最近的那个。这个方案的查找是 O(1)但作用域退出时必须精确地“弹出”本层声明的所有符号一旦往回恢复的顺序错了就会出现外层变量凭空消失的现象。两种实现的复杂度对比大致如下。维度分层哈希表 父指针单表 遮蔽链查找复杂度O(层数)通常不超过 3O(1)退出作用域释放整块 Scope简单需按声明逆序逐条弹出易错内存占用每层独立桶层数多时偏大单表占用稳定调试友好度高能直观 dump 某一层低链上的顺序靠打印推断嵌套深度通常不超过三层查找路径很短所以分层方案在课程级实现里更常见。理解这个取舍之后看任何一份编译器源码里的符号表模块都能很快判断出它走的是哪条路线。4.2 类型等价结构比较为什么绕不开类型系统的核心问题只有一个两处写出来的类型什么时候算同一个类型。SNL 里存在类型别名type MyInt integer;之后MyInt和integer必须等价同时又存在复合类型两个独立声明的array [1..10] of integer是否等价取决于采用名字等价还是结构等价。名字等价判定简单比较类型 id但表达力弱别名一多就处处不等。结构等价更符合直觉代价是要递归比较整棵类型树/* 结构等价数组逐维比较界标和元素类型记录逐字段比对名字与类型 */ bool typeEqual(Type *a, Type *b) { if (a b) return true; /* 指针相同直接短路覆盖自比较与共享情况 */ if (a-kind ! b-kind) return false; switch (a-kind) { case TY_INT: case TY_CHAR: return true; case TY_ARRAY: return a-u.array.low b-u.array.low a-u.array.high b-u.array.high typeEqual(a-u.array.elem, b-u.array.elem); case TY_RECORD: return fieldListEqual(a-u.record.fields, b-u.record.fields); default: return false; } }这段递归在类型结构是树的时候没问题但如果两个类型共享了大量子结构比较次数会按指数膨胀。SNL 一般不允许递归类型定义类型不能直接或间接引用自身所以指数膨胀不会发生但从工程角度看更稳妥的做法是类型构造时做一次哈希去重保证结构相同的类型在内存里只有一份之后所有等价判断退化成指针比较。这个技巧在很多成熟编译器里被称为“类型驻留”实现成本不高收益是把一个可能递归的函数变成一次比较。fieldListEqual里必须同时比较字段名和字段类型只比类型不比名字会让record x: integer; y: char end和record a: integer; b: char end判为等价而这在语义上是两个完全不同的记录类型。这条规则在实现时非常容易漏因为两个字段列表长度相同、类型序列也相同时直觉上就“应该相等”。4.3 静态链与 display 表内层过程怎么摸到外层变量嵌套过程带来的第二个问题是运行时访问。过程P里声明的变量在嵌套于P内部的过程Q里可以直接引用但Q被调用时P的活动记录并不在Q的记录里Q凭什么找到它答案是在活动记录里放一个指针指向外层过程最近一次激活的记录这就是静态链。变量在符号表里被记成(level, offset)二元组运行时从当前记录出发沿静态链向上跳curLevel - level步就到达目标记录再加上偏移就拿到地址。/* 沿静态链上溯到变量声明所在的那一层返回该层的活动记录基址 */ Frame *resolveFrame(Frame *cur, int curLevel, int targetLevel) { while (curLevel targetLevel) { cur cur-staticLink; /* 每跳一次静态链层数恰好减一 */ curLevel--; } return cur; }跳转次数等于嵌套层差通常很小。代价是每次访问都要走一遍循环于是有了 display 表一个数组display[i]直接存第i层最近一次激活的记录基址访问变 O(1)。代价是过程调用和返回时需要保存并恢复被覆盖的那一格实现复杂度上去一个台阶。对一份树遍历解释执行的实现来说这两者其实都可以省掉。解释器维护的是“环境链”——一张从变量名到值的映射表加一个指向外层环境的指针查变量时沿链上溯按名字找。这种做法慢但不需要知道活动记录的布局也不需要计算偏移非常适合把重点放在前端逻辑上的课程实现。理解这层差异之后看源码时就能分辨出哪些部分是真正被使用的哪些只是为将来生成目标代码预留的骨架。5. 常见问题与排查SNL 编译器最容易翻车的五个地方5.1 注释吞掉半个文件块注释没有独立状态现象某一行之后的所有代码都被当成注释解析器在文件末尾报“缺少 end”但错误位置指向源码中间某个完全正常的行。原因块注释的处理被塞进了主扫描循环里用一个inComment布尔量标记。一旦注释符号出现在字符串或另一个注释内部或者注释本身没有正确闭合这个布尔量就会停留在错误状态后续所有字符被无差别吞掉。解决注释应该有独立的状态机和 token 扫描互不干扰。skipBlankAndComment里用一个while循环反复处理注释起始遇到}就退出遇到 EOF 就报“注释未闭合”。关键是主循环只调用它不感知注释状态任何情况下都不允许注释状态泄漏到 token 扫描逻辑中。5.2 长表达式把栈撑爆左递归直接翻译成函数调用现象几百个加法连在一起的表达式编译时进程直接崩溃错误信息是栈溢出或段错误而三段式的短表达式一切正常。原因E - E T | T被逐行翻译成parseE开头就调用parseE形成无界递归。递归深度等于表达式里运算符的个数几百个运算符就是几百层栈帧几个大的栈帧就足以打穿默认栈。解决把左递归改写成 EBNF 的重复形式落成while循环。改写后递归深度只与括号嵌套有关。排查时可以临时在parseExpr入口加一句打印当前递归深度如果数字随着表达式的项数线性增长基本可以确认是这个问题。5.3 块结束后报“未声明变量”作用域恢复顺序错了现象一段内层过程里声明了和全局同名的变量过程结束后回到外层再次使用该名字报“标识符未声明”但外层明明声明过。原因符号表用了单表加遮蔽链的方案退出作用域时按声明顺序正向弹出而不是逆序。正向弹出会把更早压入的、属于外层的同名符号一起弹掉外层绑定凭空消失。解决如果采用单表方案退出作用域必须严格按“本层声明列表的逆序”恢复更稳妥的做法是改用分层作用域退出时整块释放本层 Scope父指针自然回退到外层。排查方法是准备一个“外层声明、内层同名遮蔽、内层结束、外层再用”的最小用例只要这个用例过了作用域逻辑基本没问题。5.4 结构相同的两个 record 判不等指针比较冒充类型比较现象两个record类型在源码里长得一模一样字段名和字段类型全都对应但把其中一个类型的变量赋给另一个类型的变量时报“类型不匹配”。原因类型等价判定退化成了指针比较。两个 record 是分别在两处类型定义里创建的内存地址不同指针比较自然判不等。这和结构等价的语义预期完全相反。解决把类型等价实现成递归的结构比较记录类型要比字段名和字段类型数组类型要比上下界和元素类型。排查时构造两组“结构相同但分别声明”的类型做赋值通过结果就能立刻判断出实现走的是哪条路线。若想进一步优化可以在类型构造阶段做哈希去重把结构比较变成指针比较。5.5 else 分支永远被执行回填的目标位置错了现象if语句无论条件真假else分支的代码都会执行而then分支的行为看起来正常。原因生成中间代码时then分支末尾应该有一条无条件跳转跳到整个if语句之后同时if条件为假时要跳到else分支开头。这两处跳转的目标地址在生成时还不存在需要事后回填。如果回填时把两条跳转的目标写反了或者then分支末尾根本没生成那条跳转就会导致else无条件落入。解决把每个待回填的四元式下标记在一个链表里等目标位置确定后统一回填。排查时把生成的四元式序列整体打印出来逐个核对跳转目标的编号是否指向预期的位置。这类错误的特征是“语法树看起来对、语义检查也没报错、就是运行结果错”只能靠对中间代码序列逐条人工核对来定位。6. 设计取舍为什么这份实现停在解释执行读完前端部分很自然会问一个问题为什么不一路做到目标代码生成。答案藏在课程级编译器最核心的约束里——它的目标是让整条流水线可见、可调试、可修改而不是产出性能。四元式加回填这套中间表示恰好卡在一个甜点位置。它比抽象语法树更扁平控制流被显式化成跳转做数据流分析的难度降了一个量级它又比目标机器的汇编更抽象不需要操心寄存器分配和指令选择。继续往下走一步就不得不引入寄存器描述符、活跃变量分析、指令调度这些与语言本身无关的工程细节整份源码的阅读焦点会从“语言怎么被理解”偏移到“机器怎么被压榨”。树遍历解释执行是同一个取舍的延续。它直接消费语法树环境用哈希表加父指针实现变量查找走名字匹配不涉及活动记录布局。慢是真慢但每次执行都能精确对应到源码里的一个节点出错时栈回溯里看到的全是有意义的函数名而不是一堆偏移量。真去写一个生成机器码的后端调试成本会翻好几倍而这份源码要展示的东西——词法状态机、FIRST 集、作用域链、类型等价——全都在前端后端加进来只是徒增噪音。验证这个取舍是否合理有个很实用的办法把解释器里某个节点的求值函数临时改成打印一行日志跑一个小程序看这行日志出现的次数和顺序是否与你的预期一致。如果整个执行轨迹和你脑子里的推导完全对得上说明这份源码的中间表示设计是自洽的如果对不上那大概率不是后端缺失的问题而是更靠前的某个环节比如作用域恢复或者类型判定悄悄出了偏差。我自己的习惯是拿到任何一份编译器源码先不急着看代码规模而是找一个小程序从main一路单步跟到输出把每个阶段的数据结构打印出来对一遍。踩过的坑里印象最深的一次是花了整整一个下午去查一个“数组下标算错”的问题最后发现根因在词法阶段某个保留字的表项少了一个字符while被识别成了标识符一路传下去在语义分析里被当成未声明变量处理报错位置完全指向别处。从那以后我每次改动扫描器都强制走一遍“保留字全表逐个打印”的验证不管改动看起来多小。希望这个习惯能帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。