如果你正在上编译原理这门课,那“Lex与Yacc”这六个字你大概率躲不开。翻开实验指导书,要么让你用Lex写一个词法分析器,要么让你用Yacc搭一个语法分析器,再狠一点的直接让这两个工具配合着做一个可用的计算器或小型语言前端。不少同学一上来就被这两样东西吓住,觉得从正则到文法,从DFA到LR,全是硬骨头。但我在实际做完几个实验、又在真实项目里用它们解析各种DSL之后,最大的感受是:Lex和Yacc并没有那么高深,它们不过是两个非常成熟的“生成器”,你把规则告诉它们,它们把C代码给你生成好,真正的工作在规则设计上。这篇文章就按我自己的踩坑路线,把Lex与Yacc从原理到应用完整捋一遍,顺便把实验报告里那些必须写清楚的点、面试里爱问的点,一网打尽。
1. 这门课到底在干嘛:先搞懂Lex和Yacc解决什么问题
1.1 编译原理为什么让这么多人头疼
很多同学学编译原理的第一反应是:我又不开发编译器,学它干嘛?我当年也是这么想的,直到后来做协议解析、配置文件解析、写自定义查询语言,才发现编译原理那套“字符流->词法->语法->语义”的框架,几乎无处不在。只不过在课程里,整个流程被掰开揉碎讲,加上手工构造自动机、推导规约这些理论,确实容易劝退。
但换个角度想,编译原理教的是“怎么把一段文本变成程序能理解的结构”。这门课真正硬核的部分,是把“文本处理”这件事抽象成数学问题:词法分析用正则表达式描述,语法分析用上下文无关文法描述。正则表达式的表达能力到哪里,文法能描述什么结构、不能描述什么结构,这些概念才是后续所有工具的基础。
Lex和Yacc,正是把这些理论落到实处的工具。你不需要手工去画状态转移图、不需要手动构造LR分析表,你只需要按它们规定的格式写规则文件,让工具去生成对应的C代码。所以这门课的口诀就一句话:用正则写词法,用文法写语法,然后让工具替你做苦力。
1.2 Lex和Yacc在编译前端里的位置
一个经典的编译器前端,大致是“源程序字符流 -> 词法分析 -> Token流 -> 语法分析 -> 抽象语法树”。Lex负责前半段:它把字符流切分成一个个有意义的单词符号,也就是Token,比如关键字、标识符、数字、运算符。Yacc负责后半段:它接收Lex送来的Token流,根据文法规则判断这些Token组合起来是否符合语法,并在规约的同时执行语义动作。
我习惯用一个生活化的类比:Lex就是给英文句子做分词,先把“I love programming”切成I、love、programming三个词;Yacc就是给这些词划分句子成分,判断这是一个“主谓宾”结构。分词错了,后面的语法分析一定乱套;分词对了,语法分析依然可能遇到“句子不通”的情况,也就是语法错误。两者配合,才构成一个完整的语法前端。
在代码层面,Lex生成的函数叫yylex(),每次调用把它读到的下一个Token返回;Yacc生成的函数叫yyparse(),它会不断调用yylex()取Token,然后按照文法规则进行移进和规约。两者之间靠Token编号和一个全局变量yylval传值。理解了这个调用关系,后面所有坑都好排查了。
1.3 为什么选这两个工具,而不是手写状态机
很多同学会问:我可不可以不用Lex和Yacc,手写一个词法分析器或者递归下降解析器?当然可以,而且不少编译器自己就是手写的。但课程实验选Lex和Yacc,是有原因的。
手写词法分析器,本质上就是构造一个有限自动机,再把状态转移写成嵌套的switch或if-else。代码量一大,状态一多,维护起来非常痛苦。Lex的价值在于,你只要把每个Token的正则表达式写出来,它内部会帮你构造NFA、确定化为DFA,再做最小化,最终生成一个高效的C语言状态机。你可以完全不懂这些自动机算法也能用,但懂了之后,遇到匹配优先级、最长匹配这类问题,就能快速定位。
Yacc也是一样。手写自顶向下的递归下降解析器,写起来直观,但要求文法不能有左递归,而且遇到需要回溯的情况会很麻烦。Yacc采用的是LALR(1)这种自底向上的分析法,表达能力比单纯的递归下降强很多,处理大型表达式文法很方便。它还自动处理了移进-规约冲突的默认策略,虽然这不一定是你想要的,但至少让你不用从零开始写分析表。
说白了,在课程这个阶段,工具逼着你去理解“规则驱动”的思维方式:不关心中间状态怎么流转,只关心规则怎么写才正确、没有冲突。这个思维方式,比会背分析表有用得多。
2. Lex:用正则表达式搞定词法分析
2.1 Lex文件的三段式结构
Lex源文件通常以.l结尾,结构上分成三段,用两个%%分隔。整体长这样:
%{ /* 第一段:定义区 */ #include <stdio.h> int char_count = 0; %} %% /* 第二段:规则区 */ [0-9]+ { printf("NUMBER: %s\n", yytext); } %% /* 第三段:用户子程序 */ int main(void) { yylex(); return 0; }第一段是定义区,%{ ... %}里面的内容会被原样拷贝到生成的lex.yy.c文件开头。这里通常放头文件、全局变量、以及后面规则动作里要用的函数声明。如果你想给某个正则模式起个名字,比如DIGIT [0-9],也会写在这一段,规则区里直接用{DIGIT}引用。
第二段是规则区,每一行是一条“模式 动作”对。模式部分就是正则表达式,动作部分是匹配到这个模式后要执行的C代码。每一行规则,Lex都会编译成一个DFA分支,匹配成功后执行你写的动作。
第三段是用户子程序,也就是你自己的C函数。最常见的就是main函数和yywrap函数。yywrap在输入文件读完时会被调用,返回1表示输入结束。如果你不写,链接时会报错,所以很多人直接在第三段里补一个int yywrap() { return 1; }。
三段结构看似简单,但很多初学者会搞混:头文件能放在第二段吗?规则能放在第三段吗?不行。规则区必须由%%包夹,%{ %}只出现在第一段,第三段里的代码是纯C代码,不会参与规则编译。这个结构上的约束,决定了整个.l文件的组织方式。
2.2 正则规则里最容易忽略的三个细节
第一,最长匹配原则。Lex在匹配输入时,不是碰到一个能匹配的就停,而是会一直尝试匹配最长可能的字符串。比如输入是123abc,如果你有规则[0-9]+,它会先匹配到123,然后因为下一个字符是a,不属于数字,所以返回123。但如果你还有一条规则[a-zA-Z]+,这两条规则能匹配的最大长度分别是3和0,Lex会选择更长的123。这个原则带来的坑是:如果不小心定义了太宽泛的模式,比如.匹配任意字符,那它能匹配的长度始终是1,而其他规则可能匹配更长,所以通常不会出问题,但当两条规则能匹配同样长度时,排在前面的规则优先。这个“相同长度取先出现者”的规则,经常用来处理关键字和标识符的优先级。
第二,忽略空白和注释要显式写规则。词法分析器默认不会帮你跳过空格、Tab和换行,如果你不写规则去匹配它们,它们就会被交给默认规则处理。Lex的默认行为是:把无法匹配的字符原样拷贝到yyout(通常是标准输出)。这会导致一个诡异的现象:你运行程序,什么都没写,它把你输入里的空白全给“回显”了。所以每写一个.l文件,我都会先加一条规则:
[ \t\n] ;动作是空语句,意思是匹配到空白就什么都不做,直接跳过。
第三,.不是匹配.*。在Lex的正则语法里,.匹配除换行符以外的任意单个字符,.*才匹配任意串。如果你要匹配字面意义上的小数点,必须写成\.。这个细节在识别浮点数的时候特别容易踩坑——[0-9]+\.[0-9]+和[0-9]+.[0-9]+看起来差不多,但后者中间的.会匹配任意字符,导致12x34也被当成一个数字。
2.3 一个识别整数和运算符的词法例子
拿我当年实验里最常用的一个词法规则做示范:
%{ #include <stdio.h> #include "y.tab.h" extern int yylval; %} %% [0-9]+ { yylval = atoi(yytext); return NUMBER; } [ \t\n] ; "+" { return PLUS; } "-" { return MINUS; } "*" { return TIMES; } "/" { return DIVIDE; } "(" { return LPAREN; } ")" { return RPAREN; } . { fprintf(stderr, "无法识别的字符: %s\n", yytext); } %% int yywrap() { return 1; }这里有个关键点:文件里#include "y.tab.h",这个头文件不是手写的,而是后面yacc -d生成出来的。它里面定义了NUMBER、PLUS这些Token的编号。Lex识别出模式后,对应执行return NUMBER;,把Token编号返回给yyparse(),真正的东西放在yytext里,需要转换成数字时,就用atoi把字符串转成整型,存到yylval里。
亲手写一遍这个文件,就能体会“规则驱动”是什么意思:词法分析器的核心逻辑,全在你写的正则里。你不需要考虑“当前状态是什么”这种状态机问题,Lex自动帮你完成。
2.4 词法实验必踩的坑
我当年做词法分析实验时,在yywrap上卡了整整一下午。编译lex lex.l明明成功了,gcc lex.yy.c却报undefined reference to yywrap。后来才知道,新版flex默认要求用户提供yywrap,或者用%option noyywrap告诉flex不需要这个函数。两个解决办法:一是在.l文件最后写int yywrap() { return 1; };二是在文件第一段加%option noyywrap,然后编译时用gcc lex.yy.c -lfl。我自己更常用第二种,因为少写一个无意义的函数。
还有一个很隐蔽的坑:动作里的return。在Lex规则里,return表示“这次yylex()调用到此结束,把Token编号返回给调用者”。但如果你的规则动作写的是printf而没有return,yylex()会继续往下匹配下一个Token。有时候这是故意的,比如跳过空白;但有时候是忘写了,结果解析器拿到的Token顺序完全不对。排查这类问题,最好的办法是在动作里加一条fprintf(stderr, "token: %s\n", yytext);,把每个Token打出来,一眼就能看出哪里漏了。
3. Yacc:用文法规则搞定语法分析
3.1 Yacc文件的结构和文法规则的写法
Yacc源文件以.y结尾,结构和Lex文件很像,也是三段式:声明部分、文法规则部分、用户子程序部分,同样用%%分隔。声明部分里放C代码、Token声明、优先级声明;规则部分放BNF形式的文法产生式;第三部分放main、yyerror等辅助函数。
最简单的Yacc文件长这样:
%{ #include <stdio.h> void yyerror(const char *s); %} %token NUMBER %left '+' '-' %left '*' '/' %% expr: expr '+' expr { $$ = $1 + $3; } | expr '-' expr { $$ = $1 - $3; } | expr '*' expr { $$ = $1 * $3; } | expr '/' expr { $$ = $1 / $3; } | '(' expr ')' { $$ = $2; } | NUMBER { $$ = $1; } ; %% void yyerror(const char *s) { fprintf(stderr, "语法错误: %s\n", s); } int main(void) { return yyparse(); }文法规则部分的核心是“产生式”:左边是一个非终结符,右边是终结符和非终结符的序列,用:连接,多个候选用|分隔,最后加;结束。expr: expr '+' expr表示“一个表达式加号一个表达式,整体还是一个表达式”,这就是递归定义,也是文法表达能力的核心。
每个产生式后面可以跟一段C代码,叫语义动作。$$表示规则左边非终结符的“返回值”,$1表示右边第一个符号的“返回值”,$2表示第二个,以此类推。比如expr '+' expr规约时,$1是左操作数,$2是加号,$3是右操作数,计算$$ = $1 + $3就把表达式的值算出来了。
注意一点:%token NUMBER声明了NUMBER是终结符,它的值由词法分析器放到yylval里传进来。每一个在文法里出现的终结符,理论上都该出现在%token声明里;如果你漏了,Yacc会把它默认当成非终结符处理,然后编译出一堆你根本看不懂的警告。
3.2 语义动作和属性值传递,到底怎么传值的
很多人第一次看$$和$1一脸懵。我换个方式解释:Yacc在规约一条规则时,会执行你写的语义动作。你可以把每个文法符号都想象成一个带“属性值”的节点,$1到$n就是右边节点的属性值,$$是你要赋给左边节点的属性值。
但问题来了:expr的“属性值”和NUMBER的“属性值”类型一样吗?不一定。在我们这个计算器里,NUMBER传进来的是整型,expr的属性也是整型,所以没问题。但如果你想处理浮点数、字符串、布尔值,类型就不同了。这时候需要用%union声明一个联合体,再用%token <类型名> NUMBER和%type <类型名> expr给不同符号指定不同的类型。
%union { int ival; double dval; } %token <ival> NUMBER %type <dval> expr这样声明之后,Yacc生成的yylval就是一个union,词法分析器往yylval.ival里存整型,Yacc在规约时自动根据声明取对应字段。这个机制是整个属性传递体系的核心,也是实验里“扩展功能”时几乎必改的部分。
3.3 优先级、结合性和冲突处理,面试高频考点
前面的表达式文法有个经典问题:1+2*3应该等于7还是9?如果按expr: expr '+' expr | expr '*' expr这种二义性文法,Yacc构造分析表时一定会出现冲突——读到+的时候,既可以让当前规则规约,也可以继续移进*。Yacc的默认处理是“移进优先”,所以遇到+*这种序列,它倾向于继续移进,结果就是1+2*3被解析成1+(2*3),恰好是数学上正确的优先级。
但你不能永远靠Yacc的默认策略,因为它对所有冲突都采用同一招,遇到减法和除法的结合性问题就错了。比如9-3-2,如果Yacc默认移进优先,它会把表达式解析成9-(3-2)=8,而人类期待的是(9-3)-2=4。所以必须显式声明运算符的优先级和结合性:
%left '+' '-' %left '*' '/'%left表示左结合,同一优先级的运算符从左往右计算;%left声明在后面的优先级更高,所以* /的优先级高于+ -。加上这两行,Yacc会根据优先级和结合性自动解决冲突,生成正确的分析表。
面试里经常问的一句话是:“Yacc遇到移进-规约冲突怎么办?”标准回答是:Yacc默认移进优先,但可以通过声明优先级和结合性、改写文法、或者直接查看y.output文件来分析冲突原因。这里的y.output是yacc -v生成的详细分析表文件,里面会列出每个状态下的冲突具体发生在哪个项目集上,是排查文法二义性的利器。
4. 完整实操:用Lex+Yacc写一个可用的计算器
4.1 从零开始的完整代码
理论讲了那么多,不如亲手写一个能跑的东西。我下面给出一个完整的计算器程序,它支持加减乘除、括号和负数。你可以直接把这两份代码存成calc.l和calc.y,编译运行,感受一下整个流程。
先看calc.y:
%{ #include <stdio.h> #include <stdlib.h> void yyerror(const char *s); %} %union { int ival; } %token <ival> NUMBER %left '+' '-' %left '*' '/' %nonassoc UMINUS %type <ival> expr %% input: /* 空 */ | input line ; line: '\n' | expr '\n' { printf("%d\n", $1); } | error '\n' { yyerrok; } ; expr: expr '+' expr { $$ = $1 + $3; } | expr '-' expr { $$ = $1 - $3; } | expr '*' expr { $$ = $1 * $3; } | expr '/' expr { $$ = $1 / $3; } | '(' expr ')' { $$ = $2; } | '-' expr %prec UMINUS { $$ = -$2; } | NUMBER { $$ = $1; } ; %% void yyerror(const char *s) { fprintf(stderr, "%s\n", s); } int main(void) { return yyparse(); }再写calc.l:
%{ #include <stdio.h> #include "y.tab.h" extern int yylval; %} %% [0-9]+ { yylval.ival = atoi(yytext); return NUMBER; } [ \t] ; \n { return '\n'; } "+" { return '+'; } "-" { return '-'; } "*" { return '*'; } "/" { return '/'; } "(" { return '('; } ")" { return ')'; } . { fprintf(stderr, "非法字符: %s\n", yytext); } %% int yywrap() { return 1; }注意:这里用了%union,所以yylval是一个结构体联合体,词法分析器里要写yylval.ival = atoi(yytext)而不是yylval = atoi(yytext)。这是从简单Token值到带类型Token值的关键变化。
还有一个细节:'-' expr %prec UMINUS这行,%prec的意思是“这条规则使用UMINUS声明的优先级”。因为负号的一元运算符需要比乘除更高的优先级,但文法上它又不是独立的Token,所以借助%nonassoc UMINUS先声明一个虚拟记号,再用%prec把这个规则绑定到它的优先级上。不理解这个技巧,负数表达式就会出现优先级错误。
4.2 编译连接与运行
编译过程分三步:
yacc -d calc.y lex calc.l gcc y.tab.c lex.yy.c -o calc -lfl第一行yacc -d生成两个文件:y.tab.c和y.tab.h。y.tab.h里定义了Token编号和yylval的类型,所以Lex文件必须#include "y.tab.h"。第二行lex calc.l生成lex.yy.c。第三行把两个C文件一起编译链接。如果你在calc.l里写了yywrap,那么-lfl可以去掉;如果你用了%option noyywrap,同理-lfl也可以去掉。我习惯保留-lfl,省得每次都要记得加yywrap。
运行:
echo "1+2*3" | ./calc输出:
7再试试(1+2)*3,输出9。你会发现,小小的几行规则,已经能正确处理优先级、括号和负号了。当然,除法这里我没做除零检查,1/0会直接崩掉,这是实验报告里可以提一嘴的优化方向。
4.3 关键步骤的“为什么”
我刚才那个计算器,有几个细节值得展开说说。
第一个是input和line这两个非终结符。很多人写计算器只写expr一个规则,结果程序只处理一行输入,遇到第二行就报错。input: /* 空 */ | input line实际上是在写“输入由零行或多行组成”,line表示每一行是一个完整的表达式或者一个换行符。这种“列表结构”在解析配置文件、程序代码时非常常见,先用一个“总入口”规则递归处理多行,是Yacc的固定套路。
第二个是error符号。error是Yacc内置的记号,匹配语法错误的位置。我在line规则里加了| error '\n',意思是一行内遇到语法错误,就跳过分号继续下一行。配合yyerrok这个宏,可以在输出错误信息后恢复解析,而不是整个程序直接退出。这个机制叫“错误恢复”,是Yacc非常实用的能力,很多实验题目会要求“报错但不崩溃”,靠的就是它。
第三个是优先级声明里的%nonassoc。%left表示左结合,%right表示右结合,%nonassoc表示不结合。对UMINUS这种虚拟Token用%nonassoc很合适,因为一元负号本来就不需要跟谁结合。如果你把UMINUS写成%left,在某些特殊输入下还是会出现意料之外的解析结果。这块建议自己改一改,跑几个用例对比一下,印象会非常深。
5. 常见问题与排查技巧实录
5.1 那些年我们一起踩过的坑
Lex和Yacc的报错信息,很多时候并不是直白的“你这里写错了”,而是让你去猜。下面这张表,是我这些年用得最多的排查速查表。
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
链接报undefined reference to yywrap | 缺少yywrap函数或-lfl | 在.l文件末尾加int yywrap(){return 1;},或编译时加-lfl |
lex.yy.c里到处是implicit declaration of function 'yylex' | 代码里用了yylex但没在y.tab.h里声明 | yacc -d生成头文件后,在.l文件%{ %}里#include "y.tab.h" |
| Token编号对不上,解析结果莫名其妙 | .l文件没有包含y.tab.h,或者return的Token编号和y.tab.h不一致 | 词法规则里用宏名如NUMBER,不要自己写数字 |
| 表达式计算顺序错误 | 没有声明优先级或结合性 | 增加%left、%right声明 |
| 程序一到某个输入就死循环 | 词法规则可能匹配了空串,比如{DIGIT}* | 把正则里的*改成+,确保至少匹配一个字符 |
| Yacc报大量shift/reduce conflict | 文法有歧义,或者缺少优先级声明 | 用yacc -v生成y.output,逐个状态看冲突来源 |
| 输入空白字符全部被打印出来了 | 规则区没有匹配空白的规则,Lex默认回显 | 加[ \t\n] ;规则吃掉空白 |
这里重点说下死循环的情况。词法规则如果写了[a-zA-Z]*这种“匹配空串”的正则,Lex在某个字符既不能匹配其他规则、又能匹配空串时,会陷入“匹配了一个空字符串,然后继续从相同位置开始”的循环,表现出来就是程序卡死,CPU跑满但不输出任何东西。遇到这种现象,第一反应就是把所有*换成+,或者单独写一条规则显式匹配允许的情况。
另外一个经验:yyerror里别只打印错误信息就完事,记得把当前行号也打出来。虽然简单实验不需要记录行号,但如果你扩展成一个小型语言解释器,错误定位是刚需。Yacc本身不帮你维护行号,你可以借助yylineno,或者自己在词法规则里给line_no计数。
5.2 面试题与考试题里怎么考
很多同学做实验归做实验,面对“编译原理面试题”和“编译原理选择题”的时候还是会慌。其实考点非常集中,我梳理一下。
第一类,概念的对比区别。“词法分析和语法分析的区别是什么?”标准答法:词法分析是字符流到Token流,用正则描述,学名是“模式匹配”;语法分析是Token流到语法树,用上下文无关文法描述,学名是“语法结构识别”。再深一点可以问:“正则表达式为什么不能描述括号匹配?”因为正则表达式的表达能力对应有限自动机,它没有“记忆”深度这个概念,而括号匹配需要任意深度的嵌套,属于上下文无关文法的范畴。
第二类,工具机制的原理。“Lex匹配规则时遵循什么原则?”最长匹配,其次先出现者优先。“Yacc对冲突的默认处理是什么?”移进优先再配合优先级和结合性声明,可以用-v选项生成y.output分析冲突。
第三类,文法设计的实操。“如何消除左递归?”比如E -> E + T | T可以改写为E -> T E'、E' -> + T E' | ε。“什么是二义性文法?”一个串对应两棵不同语法树,比如经典的悬挂else问题。“如何改写?”通常会引出优先级和结合性的声明处理方式,或者改写文法强制左右结合。
选择题里还经常出现这类:编译前端各阶段的功能划分、给定一个正则,判断能匹配哪个字符串、给定一个文法,判断属于LL还是LR、哪些字符串能被LR分析器接受等。我的备考建议是:别死记,把Lex和Yacc的小实验亲手做一遍,很多选择题根本不用背,靠“感觉”就能选对。因为亲自动手写过的规则、看过的冲突输出,比抽象概念牢固得多。
我觉得这里值得加一句:考试卷上的很多题目,本身就是从Lab里改来的。比如“请用Lex写出识别无符号整数的规则”“请给表达式文法添加^右结合运算符”。这类题,只要你在实验里真的扩展过功能,平时写过类似的规则,闭着眼都能答。
6. 从课程实验到真实项目:下一步怎么走
6.1 经典工具与现代替代品,怎么选
做完课程实验,不少人会想:Lex和Yacc是不是太老了?现在还有人在用吗?这里我得说句公道话。
在Unix/Linux环境下,Lex和Yacc的现代版本是Flex和Bison,它们几乎完全兼容老语法,但增加了很多实用特性,比如更灵活的类型系统、位置跟踪、纯解析器等。很多开源项目的编译器前端,到现在依然在用Flex和Bison,比如PostgreSQL的SQL解析器、GCC早期的部分解析逻辑。所以这两样东西并没有过气,它们是“考察编译器前端核心原理”最经典的工具。
但如果你是为了真实项目快速落地,尤其是用Java技术栈,可以看看JFlex和CUP,或者ANTLR。ANTLR用起来比Lex/Yacc更现代化,支持直接生成JSON、XML、树遍历器,还能生成多种语言的目标代码。当年我接手过一个Java项目,需要解析一种自定义查询语言,最后就是用ANTLR写的,开发效率确实比先移植Flex/Bison再包一层JNI要高。
我的建议是:课程实验老老实实用Lex和Yacc,把基础原理吃透;真实项目里再根据语言和场景选现代工具。两者在底层的“正则+文法+语义动作”模型是完全一致的,学过一个,另一个上手非常快。
6.2 实验之后的三个进阶方向,玩出花来
如果你做完计算器还不过瘾,我这里给三个方向的扩展建议,每一个都对理解编译原理有巨大帮助,也适合写进实验报告的“改进与展望”。
第一个方向:把计算器扩展成带变量和赋值语句的小语言。你需要加入标识符的Token匹配,用符号表存储变量名和值,文法里增加assignment: IDENT '=' expr,并且在yylval里支持存储字符串或变量索引。这个改动能让你理解符号表在编译器中的作用,很多学校的“表达式解释器”实验就是做到这一步。
第二个方向:增加类型系统和中间代码生成。比如把布尔表达式、if判断、while循环加进去,然后生成三地址码或简单的汇编指令。到这个阶段,你就真正开始触碰编译器后端的领地了。我当时做法是让Yacc在规约if语句时输出一段类似IF_FALSE goto L1、L1: ...的文本,虽然不是真正的可执行代码,但对理解“语义动作如何驱动代码生成”特别有帮助。
第三个方向:写一个真正的JSON或INI格式解析器。别小看JSON,它虽然语法简单,但涉及嵌套结构、字符串转义、数组和对象等,写起来能实际感受到递归规则的美妙。用Yacc实现JSON解析器,生成的解析树可以直接转化成C结构体或输出检查结果,实用性和趣味性都比计算器高不少。
这三个方向里,我强烈建议至少做第一个。因为它几乎覆盖了词法、语法、符号表、语义动作所有核心点,而且面试时能讲出的东西也最多。比如面试官问你“你用过Yacc吗?做过什么?”你如果说“我用Yacc做了一个支持变量声明和赋值语句的语言前端”,比“我做过计算器”有分量得多。
最后再分享一个我刚学Lex和Yacc时踩出来的体会:这两个工具真正带给你的,不只是生成C代码的能力,而是让你把“一段文本是如何被结构化地理解的”这个抽象问题落地。很多人学完编译原理依然只会背概念,但如果你亲自用正则写词法、亲自用文法描述表达式、亲自处理过冲突,你会发现无论以后写解释器、写DSL、写配置解析还是写爬虫解析,脑子里都会自动构建“Token和语法树”的视角,这个视角是非常稀缺的工程能力。保持动手的习惯,把实验题每个细节都吃透,远比多刷几道选择题值钱。