从词法分析到三地址码:小型编译器的链路与避坑

发布时间:2026/10/29 11:45:31
从词法分析到三地址码:小型编译器的链路与避坑

简介:面向计算机专业编译原理课程设计与实验环节的配套资料包,适合正在完成词法分析、语法分析及小型编译器实现的学生参考。内容围绕词法分析器、LL(1)文法分析简单语句(如 i+i*i)、LR(0)与SLR(1)语法分析、四元式生成和汇编代码生成等模块展开,并附课程设计报告,可帮助读者把课堂中的文法理论、分析表构造与程序实现对应起来。资料中还给出部分LL(1)文法与一个SLR(1)文法,便于对照梳理FIRST/FOLLOW集、项目集规范族及分析表构造。全包共14个文件,以C/C++源码、头文件、说明文本和文档报告为主,rar压缩后约557KB,目录紧凑,便于按实验模块查阅与复现。已有2350人学习下载;对于需要理解词法分析流程、查看四元式到汇编代码生成脉络的读者,这份资料能提供较完整的课程设计参照。

1. 词法分析为什么不是「切字符串」:一条从字符到 token 的翻译链

很多人对编译原理课程设计的第一印象,是写一堆 if-else 把字符流切成 token,再照着文法抄几个递归函数。真正动手才会发现,卡住你的从来不是「怎么切」,而是切错一个字符之后,整条流水线怎么把错误定位回源码的第几行第几列。词法分析、语法分析、小型编译器这条链路,本质上在做同一件事:把没有结构的字符流,逐层翻译成有结构、可验证、可复现的中间表示,而每一层都必须为下一层保留「出错时能回溯到哪里」的信息。搞懂这条链路的底层机制,课程设计才不会退化成对着实验报告凑输出;也才能在答辩被追问「为什么这里要回退一个字符」时,讲出个所以然。

2. 词法分析的底层机制:正则、DFA 与最长匹配的边界

词法分析器的理论模型非常干净:正则表达式定义词法规则,Thompson 构造把正则变成 NFA,子集构造把 NFA 变成 DFA,DFA 最小化去掉冗余状态,最后那张转移表就是扫描器。这条路径教科书里写得明明白白,但课程设计里真的照着生成一遍,往往会得到一个既难调试、又对不上实验报告输出格式的东西。原因不在于理论有错,而在于理论模型省略了三个工程约束:最长匹配要求的状态停留、错误恢复要求的字符回退、以及位置信息(行列号)的维护。

2.1 从正则到自动机:子集构造到底在合并什么

NFA 的关键特征是「一个状态读入一个字符后可以走到多个状态」,这让它天然适合表达a|b、a*这类选择与重复,但也让它无法用一张二维表直接驱动。子集构造做的事情,是把「当前可能处于的状态集合」整体当成 DFA 的一个状态,于是 ε 闭包把不确定性收进了状态本身。理解这一步的分水岭在于:DFA 的状态不是源码里的某个位置,而是「到目前为止我可能匹配到哪几条规则的第几个字符」。

这个视角能解释很多实际现象。比如关键字和标识符为什么通常共用一条自动机路径:if、int和if_count在扫描到第三个字符之前是完全无法区分的,DFA 只能先把整个词素吃下来,再靠一张关键字表做二次判定。真把关键字做成独立分支,反而会让状态数膨胀,还容易在intx这种输入上匹配错。常见的实现思路是用哈希表存关键字,查表 O(1),比把几十个关键字铺成一棵前缀树更省事,调试时也更容易看清楚「到底命中了哪条规则」。

子集构造还有一个副作用值得注意:状态数可能指数级膨胀。课程设计里的词法规则一般只有几十条,不会触发这个问题,但如果把注释、字符串字面量、浮点数的各种边界写法都塞进正则,生成出来的表驱动 DFA 会变得难以人工检查。所以我一般会这么理解:自动机是给你用来验证规则完备性的,不是给你用来直接跑的。规则写成正则、心里过一遍 NFA、确认没有「接受态还能继续吃字符」的歧义,这比真的跑一遍生成器更有价值。

2.2 最长匹配与回退:多读一个字符的代价

最长匹配(maximal munch)是词法分析里最容易被低估的一条约定:当多个规则都能匹配当前前缀时,选最长的那个;长度相同时,选规则里排在前面的那个。>和>=的区分就靠它——扫描到>时你不能立刻返回,必须再读一个字符看看是不是=,如果不是,就得把这个字符「还回去」。

回退这个动作在代码里只有一行,但它牵扯出两个隐蔽的坑。第一是缓冲区:如果用fgetc直接读文件,回退要靠ungetc,而ungetc的标准只保证回退一个字符;如果用手写缓冲区,回退就是把读指针减一,代价几乎为零,这也是lex这类工具采用双缓冲加哨兵字符的原因。第二是位置信息:你为了判断>=多读了一个字符,列号已经加一了,回退时如果不把列号减回去,后面所有 token 的列号都会偏一位,报错信息就全歪了。行号更麻烦,因为多读的那个字符理论上可能是换行符,跨行回退必须由调用方保证不发生。

浮点数和注释是这条规则的压力测试点。1.后面跟的是数字还是运算符,决定了它是1.0还是「整数 1 加一个点」;/后面跟*是块注释开始,否则是除法。这些判断都依赖「先多看一位,再决定收不收」。血泪经验是:把所有需要前瞻的规则集中列一张表,写清楚每条规则最多需要看几位,然后统一用同一个peek(n)接口去取,不要让每个函数自己fgetc再自己ungetc,否则回退次数一多,行列号必然对不上。

2.3 手写扫描器为什么还要维护状态机

课程设计里最常见的写法是一个大switch套while,看起来跟「状态机」没关系,其实它就是状态机的手工展开:switch的分支是状态,while的循环是转移,循环条件里的字符判断是转移条件。区别只在于状态没有被显式编号,而是隐含在代码位置里。

/* 词法扫描核心:最长匹配 + 单字符回退 + 位置维护 */ typedef enum { TK_ID, TK_NUM, TK_OP, TK_KW, TK_EOF, TK_ERR } TokKind; typedef struct { TokKind kind; int line, col; /* 报错定位的唯一起点,必须准确 */ char text[64]; /* 词素原文,超长截断由上层的长度检查兜住 */ int ival; /* 数值字面量的值 */ } Token; static int cur_line = 1, cur_col = 1; static FILE *src; /* 读一个字符并推进位置;EOF 不推进 */ static int next_ch(void) { int c = fgetc(src); if (c == '\n') { cur_line++; cur_col = 1; } else if (c != EOF) cur_col++; return c; } /* 回退一个字符:缓冲区指针回退,位置同步回退 */ static void unget_ch(int c) { if (c == EOF) return; ungetc(c, src); cur_col--; /* 只回退列号,跨行回退由调用方保证不发生 */ } Token scan(void) { Token t = {0}; int c = next_ch(); while (c == ' ' || c == '\t' || c == '\r' || c == '\n') c = next_ch(); t.line = cur_line; t.col = cur_col > 0 ? cur_col - 1 : 0; if (c == EOF) { t.kind = TK_EOF; return t; } if (isalpha(c) || c == '_') { /* 标识符与关键字共用一条路径 */ int n = 0; do { if (n < 63) t.text[n++] = (char)c; c = next_ch(); } while (isalnum(c) || c == '_'); t.text[n] = '\0'; unget_ch(c); /* 多读的那个字符必须还回去 */ t.kind = is_keyword(t.text) ? TK_KW : TK_ID; return t; } if (isdigit(c)) { /* 数字:吃满所有数字位再收 */ int v = 0; do { v = v * 10 + (c - '0'); c = next_ch(); } while (isdigit(c)); unget_ch(c); t.kind = TK_NUM; t.ival = v; return t; } t.kind = TK_OP; t.text[0] = (char)c; t.text[1] = '\0'; return t; /* 双字符运算符由 peek 在调用方补齐 */ }

这段代码里值得掰开看的是三个地方。next_ch把位置维护内聚在唯一的读入口,任何字符消费都经过它,所以行列号不会因为某条分支忘了更新而漂移。unget_ch只回退列号,这是一个刻意的约束:它假设调用方不会回退换行符,因为所有需要前瞻的场景都发生在同一行内,把这条约束写进注释比写一堆防御分支更可靠。scan里的关键字判定放在吃满整个词素之后,对应的是 2.1 里那个结论——关键字和标识符在自动机上不可分,只能在接受态做二次判定。

参数上,text的长度上限要和文法里标识符的最大长度约定一致,超长时是截断还是报错,必须在实验报告里写清楚,否则测试用例里出现超长标识符时,输出会变成玄学。ival用int存数值,意味着整数溢出不处理,这在课程设计范围内是合理简化,但要在报告里显式声明边界。至于错误 token,TK_ERR不应该直接exit,正确的做法是记录位置、跳过当前字符、继续扫描下一个 token,把「一个非法字符导致整份源码只报一条错」变成「报出所有非法字符」——这是后面语法阶段能做错误恢复的前提。

3. 语法分析:递归下降为什么能吃掉 LL(1) 文法

如果词法阶段解决的是「词素边界」,语法阶段解决的就是「结构边界」:一串 token 到底构成一个表达式、一条语句还是一整个程序。教科书从上下文无关文法讲到 FIRST/FOLLOW 集、再到 LL(1) 预测分析表,看起来是一套完整的表驱动算法。但课程设计里绝大多数小型编译器用的是递归下降,因为递归下降把「表」变成了「调用栈」,把「查表选产生式」变成了「看当前 token 决定走哪个分支」,调试时可以单步进到某个非终结符对应的函数里,比对着压缩过的分析表猜要直观得多。

3.1 FIRST 与 FOLLOW:预测分析表的真实用途

FIRST 集回答的是「这个符号串可能以哪些终结符开头」,FOLLOW 集回答的是「这个非终结符后面可能跟哪些终结符」。在表驱动 LL(1) 里,这两张集合起来决定M[非终结符, 终结符]填哪条产生式。而在递归下降里,它们的用途变了:FIRST 集决定每个解析函数的第一个if该判什么,FOLLOW 集决定错误恢复时该跳到哪个同步记号。

这个转换带来一个很实际的收益:LL(1) 冲突检测可以提前做。如果同一个非终结符的两条产生式 FIRST 集相交,那么这个文法不是 LL(1) 的,递归下降写出来就会出现「两个分支都能进,进去之后才发现走错」的回溯。课程设计里最常见的冲突来源是表达式文法的左递归和语句文法的公共前缀,比如stmt -> id = expr | id(args),两条都以id开头,不提取左公因子就没法用一次前瞻决定。

实验报告里通常要求给出 FIRST/FOLLOW 集和预测分析表,很多人把它当成形式化的作业步骤。其实它的真正价值在于:表填不出来或者某一格有两个候选,就说明文法有问题,而这个问题在代码里表现为「莫名其妙地解析错」,在没有表的情况下极难定位。先把表算出来,等于给自己留了一颗后悔药。

3.2 文法到函数的映射:递归下降的代码骨架

递归下降的映射规则简单到近乎机械:每个非终结符一个函数,函数体按产生式右部的顺序依次调用其他非终结符函数或匹配终结符。表达式文法的难点在于优先级和结合性,标准做法是分层——每一层优先级对应一个函数,层与层之间单向调用。

/* 表达式解析:三层递归下降,层数即优先级 */ static Expr *parse_expr(void); /* 加减层,左结合 */ static Expr *parse_term(void); /* 乘除层,左结合 */ static Expr *parse_factor(void); /* 原子层:num / id / (expr) */ static Expr *parse_expr(void) { Expr *left = parse_term(); while (peek()->kind == TK_OP && (peek()->text[0] == '+' || peek()->text[0] == '-')) { Token op = advance(); /* 消费运算符,peek 与 advance 成对使用 */ Expr *right = parse_term(); left = new_binary(op.text[0], left, right); } return left; /* while 循环天然实现左结合 */ } static Expr *parse_factor(void) { Token t = advance(); if (t.kind == TK_NUM) return new_num(t.ival); if (t.kind == TK_ID) return new_var(t.text); if (t.kind == TK_OP && t.text[0] == '(') { Expr *e = parse_expr(); expect(')'); /* 括号不匹配统一在 expect 里报错 */ return e; } error_at(t, "unexpected token in expression"); return new_num(0); /* 错误恢复:产出占位节点继续解析 */ }

while循环在这里承担了两件事:一是消除左递归,二是实现左结合。1-2-3在循环里会被折成(1-2)-3,如果换成递归调用parse_expr自己,就会变成右结合甚至无限递归。peek()和advance()的分工也要严格:peek只看不动,advance才推进输入,所有「看一眼再决定」的地方都用peek,避免出现「判断完了忘了消费」或者「消费了两次」这类经典错误。

parse_term的结构和parse_expr完全同构,只是运算符集合换成*/,并且它的下层调用parse_factor。这种同构不是巧合,它把「优先级」编码进了调用方向:越靠近原子层的函数,优先级越高。加一元负号或者幂运算时,只要在正确的层插入新函数,整条链路不用改。

错误恢复那一行return new_num(0)也值得说。语法错误发生后如果直接exit,后面的语义分析和代码生成全做不了,实验报告里「错误处理」这一项就拿不到分。给定一个占位节点让解析继续,后面的阶段会得到一棵部分正确的树,至少能把剩余的语法错误也报出来。

3.3 左递归与回溯:三个必然翻车的位置

第一个位置是直接左递归。E -> E + T | T写成函数是parse_E第一句就调parse_E,栈立刻溢出,现象是段错误而不是报错信息。解决办法就是 3.2 里那种迭代改写:把左递归的产生式拆成「先解析一次右部,再用 while 循环处理剩下的」。

第二个位置是间接左递归和公共前缀。A -> B x、B -> A y这种互相调用的写法在文法里看起来没问题,展开之后依然是左递归。公共前缀则表现为「两个分支都要先读同一个 token」,递归下降没有回溯机制,进去就出不来。这两类都要在文法层面改:间接左递归先做代入消元,公共前缀提取左公因子。

第三个位置是错误恢复的同步集合。语法错误发生后,如果只是简单跳过当前 token 继续,很可能在错误的上下文里连报十几个错,实验报告的输出会变得没法看。正确的做法是按 FOLLOW 集选同步记号——跳到语句结束的;或者块的}再恢复解析,这样错误报告的数量大致等于真实错误的数量。这个策略实现起来不过十几行,但对输出质量的影响非常大。

4. 语义落地:AST、符号表与三地址码是怎么串起来的

词法和语法阶段产出的东西,要么是一串 token,要么是一棵语法分析树。语法分析树里保留了所有推导细节,包括括号、分隔符、辅助非终结符,这些东西对后续阶段毫无用处,反而增加了遍历成本。所以中间表示的第一层压缩,就是把语法分析树投影成抽象语法树,只留下「值」和「结构」。

4.1 AST 的节点设计:为什么不是语法树的复制品

AST 丢掉的是括号和分隔符,保留的是运算优先级和求值顺序。(1+2)*3和1+2*3的语法分析树深度不同,但投影成 AST 后,前者的根节点是*、左子是+,后者的根节点也是*、右子是+——括号信息已经编码进树的形状里,不需要额外节点表示。

节点结构有两种主流写法:一种是统一struct加kind标签加union,另一种是每种节点一个独立struct加基类指针。课程设计里前者更常见,因为它省内存、遍历时也少一层跳转。

typedef enum { N_NUM, N_VAR, N_BIN, N_ASSIGN, N_IF, N_WHILE, N_BLOCK } NodeKind; typedef struct Node { NodeKind kind; /* 标签:决定 union 里哪个成员有效 */ int line; /* 语义阶段的报错也要能指回源码 */ union { int ival; /* N_NUM */ char name[64]; /* N_VAR / N_ASSIGN */ struct { struct Node *l, *r; char op; } bin; /* N_BIN */ struct { struct Node *cond, *then, *els; } ctl; /* N_IF / N_WHILE */ struct { struct Node *item[64]; int n; } blk; /* N_BLOCK */ } u; } Node;

line字段看起来是冗余的,因为 token 里已经有位置了。但语义阶段的错误(未声明变量、类型不匹配)是在遍历 AST 时发现的,那时候 token 早就没了,只剩节点。每个节点存一份行号,语义报错才能指回源码。这是我在课程设计里吃过亏之后加上的字段——最初没存,结果所有语义错误只能报「发生了类型错误」,完全没法定位。

union的代价是必须靠kind分发,访问错成员不会报编译错误,只会得到垃圾值。常见的做法是写一组访问器函数(as_bin(n)、as_num(n)),在里面assert(n->kind == ...),把所有不安全的访问收敛到一处。

4.2 符号表:作用域链与哈希桶的选择

符号表要回答两个问题:这个名字在当前作用域可见吗?它是什么类型、放在哪?第一个问题决定结构,第二个问题决定字段。作用域是嵌套的,块进入时开新作用域、离开时销毁,天然对应一个栈;但查找要沿着作用域链往上走,所以要给每个作用域存一个父指针。

typedef struct Sym { char name[64]; SymType type; int slot; /* 分配到的存储位置编号,代码生成时用 */ struct Sym *next_in_scope; /* 同一作用域的链表 */ } Sym; typedef struct Scope { Sym *syms; struct Scope *parent; } Scope; /* 从内向外逐层查找,实现内层遮蔽外层 */ Sym *lookup(Scope *s, const char *name) { for (; s; s = s->parent) for (Sym *p = s->syms; p; p = p->next_in_scope) if (strcmp(p->name, name) == 0) return p; return NULL; }

作用域内的符号用链表串起来,而不是哈希桶,是因为课程设计里单个作用域的符号数量通常只有个位数到几十个,链表遍历的开销可以忽略,而链表换来了「按声明顺序遍历」这个能力——代码生成阶段要按声明顺序给局部变量分配槽位,链表天然是有序的。符号数量上千时再换成哈希桶加链表,接口不用变。

lookup逐层向外查找的顺序决定了遮蔽语义:内层声明同名变量后,外层那个在块内不可见,但离开块之后又恢复可见。这个行为靠parent链和「离开作用域时丢弃整个Scope结构」共同实现。要注意的是,如果代码生成阶段还需要知道内层变量的信息(比如生成块结束时的清理代码),就不能真的free掉Scope,得改成压栈出栈的数组结构。

4.3 三地址码生成:临时变量与标号的管理

三地址码的形态是x = y op z,每条指令最多一个运算符、最多三个地址。从 AST 生成三地址码是一个后序遍历:先递归求出子表达式的「值名」,再为当前节点生成一条指令,返回一个新临时变量名。

static int temp_cnt = 0; static int new_temp(void) { return ++temp_cnt; } /* t1, t2, t3 ... */ /* 统一输出口:所有指令都从这里打印,便于后面统一格式化 */ static void gen(const char *fmt, ...) { va_list ap; va_start(ap, fmt); vprintf(fmt, ap); va_end(ap); putchar('\n'); } /* 返回子表达式的值名:字面量返回原文,变量返回名字,运算返回新临时变量 */ void emit_expr(Node *n, char *out, size_t cap) { if (n->kind == N_NUM) { snprintf(out, cap, "%d", n->u.ival); return; } if (n->kind == N_VAR) { snprintf(out, cap, "%s", n->u.name); return; } if (n->kind == N_BIN) { char l[64], r[64]; emit_expr(n->u.bin.l, l, sizeof l); /* 各自独立的缓冲区,绝不共用 */ emit_expr(n->u.bin.r, r, sizeof r); snprintf(out, cap, "t%d", new_temp()); gen("%s = %s %c %s", out, l, n->u.bin.op, r); return; } snprintf(out, cap, "0"); }

这里最关键的设计是out由调用方提供缓冲区。早期版本我用static char buf[64]在函数内部返回指针,结果左子表达式和右子表达式拿到的是同一块内存,生成的指令变成t1 = t2 + t2——两个操作数永远相同。这个 bug 在简单用例里不一定暴露,只有嵌套表达式才会现形,属于典型的黑匣子式错误。

临时变量用全局计数器自增,好处是编号唯一、不会和作用域里的用户变量重名(前提是用户标识符不允许以t加纯数字的形态出现,这一点要在词法规则里约定)。标号同理,while和if各需要两三个标号,用L1、L2递增生成。麻烦的地方在于「回填」:生成条件跳转时,跳转目标地址还不知道,得先留空,等目标代码生成完再补上。课程设计里最常见的简化做法是先把整个结构走一遍、分配好标号,第二遍再真正生成指令——多一遍遍历,换来代码逻辑简单一个量级。

5. 排查清单:课程设计里最容易翻车的四类问题

编译器的输出是一长串结构化文本,出错时的现象往往和原因隔了好几层,靠打印中间结果硬猜效率极低。下面这几条按「现象 → 原因 → 解决」整理,基本都是我在写小型编译器时真实撞过的。

5.1 词法与语法阶段:报错位置全歪、栈直接溢出

现象一:a >= b被解析成a > (=b),或者>=直接报「非法字符」。原因是双字符运算符没有做前瞻,读到>就立刻返回,=被当成独立的运算符。解决方法是把所有多字符运算符集中在一张前缀表里,读到第一个字符后统一peek一次,命中组合就advance两个字符,否则只消费一个。别在switch的每个分支里各写一遍前瞻逻辑,那样漏掉一个组合很难发现。

现象二:报错信息里的行号普遍偏一行,尤其是在块注释跨行或者文件末尾有换行时。原因是unget_ch回退了字符但没回退位置计数器,或者注释处理函数自己用fgetc读字符、绕过了next_ch。解决方法是规定所有字符读写必须走同一对next_ch/unget_ch,注释处理也走这条路径,并且在文件末尾统计一次总行数做交叉验证。

现象三:解析稍长的表达式时直接段错误,没有报错信息。原因是文法里存在直接左递归或间接左递归,递归下降写出来就是无限调用自身。解决方法是先用一个小脚本把文法展开一遍,检查是否存在某个非终结符能推导出以自己开头的串,有的话先做消除左递归和提取左公因子,再动手写函数。

5.2 语义与输出比对:更隐蔽的两类错误

现象四:三地址码里t1 = t2 + t2,两个操作数莫名其妙相同,但语法树打印出来是对的。原因是求值函数内部用了static缓冲区返回字符串,两次递归调用拿到的是同一块内存。解决方法是让调用方传缓冲区进来,或者返回一个由调用方负责释放的堆字符串;更彻底的做法是给每个临时值一个 ID,输出阶段再统一格式化。

现象五:本地跑出来的结果和实验报告要求完全一致,一提交就 diff 全红。原因是行尾空格、末尾多余换行、临时变量编号从 0 还是从 1 开始这类格式细节没有统一。解决办法是把输出格式抽成一组常量,并且自己写一个比对脚本,把空白规范化之后再 diff,而不是靠肉眼比。格式这种东西一旦靠肉眼,翻车是迟早的。

6. 设计取舍:一个「不完整」的小型编译器为什么反而更值得读

课程设计里的小型编译器通常不做优化、不做完整的类型系统、错误恢复只做到「不崩」。很多人觉得这是能力不足的妥协,但从设计角度看,这些省略恰恰是让整条链路可理解的关键。

最典型的取舍是遍数。真实的编译器是多遍的:前端产出 IR、中端做优化、后端做指令选择与寄存器分配。每一遍之间用文件或内存结构解耦,好处是每一遍都能独立测试,坏处是任何一次改动都要跨越多个数据结构。课程设计的小型编译器一般是两遍——先建 AST,再直接生成三地址码——甚至可以在遍历 AST 的同时就打印指令。这种做法的代价是不能做需要全局信息的事情(比如常量传播、死代码消除),收益是数据流只在一个方向流动,任何一条输出都能立刻追溯到某个节点。

第二个取舍是错误恢复的深度。完整的编译器要保证「任何输入都不崩溃、并且报出所有能报的错」,这需要为每个非终结符定义同步集合、在语义阶段做错误节点的传播抑制。课程设计里通常只需要保证「合法输入正确、常见非法输入有提示」,于是同步集合只覆盖语句边界,语义错误也只报第一个。这不是偷懒,而是把精力放在可验证的部分上:实验报告里能给出测试用例、能说明每条用例覆盖了哪条产生式或哪条语义规则,比堆一堆半成品的恢复逻辑更有说服力。

第三个取舍是中间表示的层数。从 token 到 AST 到三地址码,每一层都是一次信息压缩,每一次压缩都丢掉一些东西。AST 丢掉了括号,三地址码丢掉了嵌套结构。压缩得越早,后续阶段越简单,但调试时能看到的细节也越少。我一般会保留一个「打印 AST」的开关,出问题时先把树打出来,确认结构和预期一致,再去怀疑代码生成。这个开关本身只有几十行,却能省掉大量猜谜时间。

如果要把这套东西读透,建议的顺序是反过来的:先看三地址码的输出格式,反推每个临时变量是由哪个 AST 节点产生的,再看那个节点在语法分析里由哪条产生式构造,最后看这条产生式依赖哪些 token。从输出倒推回词法规则,整条链路的设计动机就串起来了——每一层都在为下一层准备「恰好够用」的信息,多一分是浪费,少一分就断链。从那以后我每次写任何解析器,都强制先手画一遍从输入到输出的数据流,确认每个阶段手里握着什么、缺什么,再开始写第一行代码。希望帮到你。

本文还有配套的精品资源,点击获取