ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

词法分析器实现:状态转换图与Token设计的核心要点

词法分析器实现:状态转换图与Token设计的核心要点 1. 词法分析这一关到底在“考”什么先说一个可能让不少同学感到意外的事实在整个编译原理课程里词法分析往往不是最难的但却是“失分最冤”的一关。代码写得长、写得复杂不一定得分高真正决定分数差距的是你有没有把正规式、状态转换图、Token设计这三件事想清楚。头歌平台第1关的“词法分析”本质上不是让你从零实现一个生产级编译器前端而是通过一个精心裁剪过的任务考察你对词法分析几个核心概念的掌握程度怎么把源程序字符流拆成有意义的词素lexeme、怎么给不同类别的单词分类并编号、怎么处理多字符运算符和关键字识别、以及遇到非法字符时怎么报错。我见过很多同学在这关的误区是一上来就闷头写if-else嵌套试图用“穷举所有可能输入”的方式硬刚。写出来的代码又臭又长样例过了几个一换测试用例立刻崩。问题的根源在于你跳过了设计环节直接进入了编码。而词法分析器恰恰是一个“设计比编码更重要”的程序。在编译原理的理论框架里词法分析处于前端的最前端它的输入是源代码字符串输出是Token流。Token流再交给语法分析器继续处理。如果你在词法层就把单词切错了、分类错了后面语法分析再怎么写都是空中楼阁。所以这一关的实验虽然看起来只是“写一个读字符串、切单词的小程序”实际上是在帮你建立“形式化描述 有穷自动机实现”的工程思维。另一个常见误解是词法分析必须手写状态转换图、必须用自动机工具生成。实际上很多商业编译器比如早期的C编译器的词法分析器就是手写的。手写状态转换图的代码和用工具生成的结果在核心逻辑上完全等价只是工具帮你自动生成了状态跳转表。对于头歌这一关的训练目标来说手写反而能让你把状态转换图吃得更透。这关实验的典型任务描述通常是给定一段高级语言源代码一般会简化去掉预处理指令、注释等要求程序识别出其中的关键字、标识符、整数常量、实数常量、运算符、分界符等并输出它们的类别编码与属性值。不同学校、不同关卡的题目描述会有差异但骨架基本一致。你只需要把握住一个核心思路先分类设计再状态转换最后写代码。2. 状态转换图从“看图说话”到“动手设计”很多同学拿到状态转换图就晕觉得这是某种玄学。其实它不过是把“当前读到了什么字符、接下来该往哪走”画出来而已。可以把它理解成一张地铁线路图每个圆圈是一个站点状态每条箭头是一条线路字符输入双圈站点是终点站终态/接受态。2.1 一张标准的状态转换图长什么样以识别标识符和关键字为例规则是“以字母或下划线开头后跟字母、数字或下划线”。状态转换图如下状态0初态还没读到任何字符。读到字母或下划线进入状态1读到其他字符进入其他处理流程或报错。状态1已经读到了一个合法首字符。后续读到字母、数字或下划线仍然停留在状态1读到其他字符说明标识符结束状态1是终态。这里有一个极其关键的细节什么时候才能确定一个单词已经结束答案是当你读到一个不属于当前单词的字符时才知道前一个字符组成的序列已经完整了。但这个“触发结束”的字符本身是你要“回头看”的——它是下一个单词的开头。这个动作在编译原理里叫回退retract在代码实现里通常体现为 ungetc() 或者指针自减。让我用一个具体例子说明。输入字符串是abc123 xyz。程序从状态0出发读到a进入状态1读到b继续状态1读到c继续状态1读到1继续状态1读到2继续状态1读到3继续状态1读到空格发现空格既不是字母也不是数字于是判断abc123是一个完整的标识符输出Token。同时要把空格“还回去”因为空格可能是单词之间的分隔符不需要被当前Token吞掉——但如果不还回去下一次循环就从空格之后开始读那就把xyz前面的空格吃掉了。虽然很多简单实现会直接丢弃空白符但从原理上讲你并不知道当前字符对下一个Token是否有意义安全做法是回退。如果状态1读到空格后直接返回Token而不回退下一次读取会从空格的下一个字符开始这在“空白符不重要”的任务里碰巧没问题。一旦你的词法规则里空格有特殊含义比如Python的缩进、某些语言的行号标记不回退就会酿成大错。所以好的实现一律回退。2.2 数字识别的状态转换图包含隐式回退的经典案例数字的识别比标识符要复杂一档因为涉及整数、小数、指数等形态。一个简化版本状态0读到数字0-9进入状态1。状态1继续读数字0-9停留在状态1读到小数点进入状态2读到e或E进入状态4读到其他字符状态1为终态回退。状态2读到数字0-9进入状态3读到其他字符状态2不是终态报错。状态3继续读数字0-9停留在状态3读到e或E进入状态4读到其他字符状态3为终态回退。状态4指数部分读到正负号进入状态5读到数字0-9进入状态6。状态5读到数字0-9进入状态6读到其他字符报错。状态6读数字0-9停留在状态6读到其他字符状态6为终态回退。这个图覆盖了整数、小数、科学计数法三种形态。但你注意一个细节从状态1出发走到状态2时如果输入是123.后面没有数字程序会停在状态2而状态2不是终态这就意味着输入不合法。同样1e停在状态5也不是终态。这种“非终态即报错”的设计是状态转换图实现里最容易被忽略、最容易丢分的地方。2.3 头歌关卡里最常见的状态转换图设计题多字符运算符如果你搜过“源程序的词法分析的状态转换图怎么画”大概率是在处理、、、:这类多字符运算符。这里有个通用设计套路读到可以先“乐观地”认为这是小于号进入一个中间状态。在中间状态读下一个字符如果是输出如果是输出如果是其他字符则回退并输出。这种“先进入中间状态再通过下一个字符决定最终Token”的思路适用于所有多字符运算符。代码实现时通常用一个getChar()方法读下一个字符再用一个ungetChar()方法回退。这两个方法是词法分析器的基础设施务必封装好。3. 代码实现的核心套路从状态转换图到可运行的词法分析器3.1 先定义Token体系再写逻辑我不建议上来就写状态跳转逻辑。先把Token的“分类体系”定下来哪些东西是关键字哪些是标识符哪些是常数哪些是运算符哪些是分界符每个类别用什么整数编号表示。编号本身没约定你完全可以自定义但有一个原则编号在同一个项目里必须是唯一的且要形成文档哪怕只是注释。下面是我用C语言风格写的一个极简但完整的参考结构换成C/Java/Python思路也完全一致#define KEYWORD 1 // 关键字 #define IDENT 2 // 标识符 #define INT_CONST 3 // 整数常量 #define FLOAT_CONST 4 // 实数常量 #define OPERATOR 5 // 运算符 #define DELIMITER 6 // 分界符 #define ERROR 7 // 非法字符如果题目要求“每个单词输出二元组类别编码属性值”属性值在关键字语境下一般是关键字本身的字符串标识符语境下是标识符的字符串内容常数语境下是常数值。你可以用一个结构体或字典来统一承载。3.2 主控流程一个循环切出一个Token整个词法分析器的骨架可以收敛为Token getNextToken() { skipWhitespace(); // 跳过空白符 char c getChar(); // 读入第一个非空白字符 if (isLetter(c) || c _) { return parseIdentifierOrKeyword(c); } if (isDigit(c)) { return parseNumber(c); } return parseOperatorOrDelimiter(c); // 运算符、分界符、报错都在这 }三个子函数对应三类扫描路径标识符/关键字、数字、运算符/分界符。这样组织的优点在于每一类字符的处理逻辑完全隔离出了问题只需要改一个子函数不需要在大段if-else里大海捞针。3.3 标识符与关键字的识别查表法识别标识符时边读字符边拼串直到碰到不属于标识符字符集的字符回退。然后拿着拼好的字符串去查一张“关键字表”。如果在表里就返回对应关键字的Token不在表里就是普通标识符。Token parseIdentifierOrKeyword(char first) { char buf[128]; int len 0; buf[len] first; char c; while (isLetter(c getChar()) || isDigit(c) || c _) { buf[len] c; } ungetChar(c); buf[len] \0; // 查关键字表 static char* keywords[] {int, float, if, else, while, return, ...}; for (int i 0; i sizeof(keywords)/sizeof(keywords[0]); i) { if (strcmp(buf, keywords[i]) 0) { Token t {KEYWORD, buf}; return t; } } Token t {IDENT, buf}; return t; }这里有一个大家常犯的错误在拼串循环里直接用while (isLetter(getChar()) || isDigit(getChar()) || getChar() _)。我见过无数同学这样写然后莫名多出好多字符。注意这种写法在同一个表达式里多次调用getChar()每次都会读入新的字符你对字符的判断和收集完全错位了。正确做法是先char c getChar();再对c判断。注意遇到这种问题别急着怀疑自动机原理先检查你代码里的字符读取是不是“一次读取、多次使用”。这是这类实验里高频出现的第一大Bug。3.4 数字的识别支持整数、小数和科学计数法数字识别严格对应数字状态转换图。可以这样实现Token parseNumber(char first) { char buf[128]; int len 0; buf[len] first; char c; // 整数部分 while (isDigit(c getChar())) { buf[len] c; } // 小数部分 if (c .) { buf[len] c; c getChar(); if (!isDigit(c)) { // 状态2非终态报错 printf(小数部分缺少数字\n); return errorToken(); } while (isDigit(c)) { buf[len] c; c getChar(); } } // 指数部分 if (c e || c E) { buf[len] c; c getChar(); if (c || c -) { buf[len] c; c getChar(); } if (!isDigit(c)) { printf(指数部分缺少数字\n); return errorToken(); } while (isDigit(c)) { buf[len] c; c getChar(); } } ungetChar(c); buf[len] \0; // 判断是整数还是实数如果buf里出现过.或e/E则类别为FLOAT_CONST Token t { (containsDotOrExp(buf) ? FLOAT_CONST : INT_CONST), buf }; return t; }这个实现里最容易出问题的地方是“指数部分只有一个正负号”这种情况比如1e。程序走到c不是数字时会报错并返回错误Token。这个分支一定要测很多测试用例专门卡这里。3.5 运算符和分界符利用查找表避免疯狂if运算符和分界符有一个特点单字符的、、、、-、*、/、(、)、;、,等很好处理多字符的、、、:、需要多看一个字符才能确定。我的建议是建立一个“运算符表”把所有运算符的字符串和编号放进去。单字符运算符直接查表多字符运算符先拼两个字符查表查不到就回退第二个字符只输出第一个字符。static struct { char* op; int code; } opTable[] { {, OP_LE}, {, OP_GE}, {, OP_NE}, {:, OP_ASSIGN}, {, OP_EQ}, {, OP_ADD}, {-, OP_SUB}, ... }; Token parseOperatorOrDelimiter(char c) { char two[3] {c, getChar(), \0}; for (int i 0; i opTableSize; i) { if (strcmp(two, opTable[i].op) 0) { Token t {OPERATOR, two}; return t; } } // 双字符不匹配回退 ungetChar(two[1]); for (int i 0; i opTableSize; i) { if (opTable[i].op[0] c strlen(opTable[i].op) 1) { char one[2] {c, \0}; Token t {OPERATOR, one}; return t; } } // 都不是报错 return errorToken(); }注意ungetChar(two[1])必须放在“双字符匹配不成功”的分支里不要一进来就回退否则双字符运算符永远匹配不上。4. 实验里最容易翻车的四个隐藏坑4.1 关键字识别成标识符或者反过来识别标识符后查关键字表这个顺序不能颠倒。有些同学会在外面套一个大的if-else判断“是不是关键字”然后单独写“普通标识符”的处理逻辑。这样做的结果往往是if、else、while这些关键字单独处理了但intSome这种以关键字开头的变量名会被错误地切成intSome。正确做法是先按“标识符”完整吃进一个单词再查表判断它是不是关键字。这两个动作的顺序绝不能反。4.2 空白符和注释的处理空白符空格、Tab、换行在绝大多数语言里只是分隔符词法分析阶段一般直接跳过。但跳过的方式有讲究。最简单的是在getNextToken()开头用一个循环跳过所有空白符。如果题目要求处理注释有的实验会把注释部分加进来需要单独设计状态转换图。//行注释遇到换行结束/* ... */块注释遇到*/结束。处理注释时特别要注意块注释没有嵌套这一说法遇到第一个*/就结束了。4.3 忘记回退导致的“丢字”Bug这是我在代码评审里见过最多的问题。假设输入是a:b对a识别标识符时读到:发现不是标识符字符回退。然后下一轮getNextToken()读取到:拼双字符:成功匹配。如果忘了回退就会读到然后输出两个错误的Token。定位这类Bug有个很实用的技巧在getChar()和ungetChar()里加调试输出观察字符流动。一旦发现“某个字符只被读了一次但逻辑上应该被读两次第一次回退、第二次重新读入”就说明回退逻辑有问题。4.4 缓冲区边界越界很多线上评测平台会把测试用例隐藏得很好其中就包括“缓冲区末尾恰好是单词边界”这种情况。如果你的读取逻辑是“先读入再判断”就可能读到缓冲区末尾之后的内存垃圾数据。解决方案很简单封装一个getChar()函数在函数内部判断是否读到文件末尾EOF。如果读到EOF返回一个特殊值比如\0并且在ungetChar()里不允许回退EOF标志。这比在每一处循环里手工判断EOF要安全得多也不容易漏判。5. 头歌平台实测从“样例通过”到“评测满分”之间的差距头歌平台这类在线实验课有个共同特点给的题目描述和样例极简但后台测试数据相当完善。第1关通常会给1-2个示例输入输出供你自测但真正评测时可能会用几十组不同的源程序覆盖每一条状态路径。我自己测试时吃过一个亏样例里没有科学计数法我以为数字只考整数结果一提交满屏FAIL。回过头来补了指数部分的处理才勉强过全。所以我给所有做这关的同学一个建议不要只拿样例测自己多构造几组边界测试。下面是几个我强烈建议自测的用例测试输入期望结果考察点if iflyif是关键字ifly是标识符关键字与标识符边界123abc123是整数常量abc是标识符数字后的字母分割 四种不同Token多字符运算符匹配1.报错小数部分缺失1e报错指数部分缺失: ::是赋值符:单独成符双字符匹配失败回退/* comment */ x注释被跳过x为标识符注释处理int_1标识符带下划线的合法标识符把这组用例跑通再用下面的模板测试一个完整小程序int main() { int a 10; float b 1.5e2; while (a b) { a a 1; } return 0; }输出里应当能看到int关键字、main标识符、(分界符、赋值符、10整数常量、1.5e2实数常量、运算符等等类别全部正确没有遗漏。另一个容易踩的坑是输出格式。头歌平台的评测机是严格比对字符串的多一个空格、少一个换行都会判错。我建议在提交前认真核对题目要求的是(类别, 值)还是(类别, 行号, 值)是每个Token一行还是用空格分隔输出的括号是英文还是中文。这不是技术问题但丢分比技术Bug还快。6. 从状态转换图到代码的“翻译心法”把一个状态转换图“翻译”成代码有一套稳定的心法一个状态对应一个处理阶段一个判断分支对应一条转换边终态对应的分支返回Token。如果你发现自己的代码里有嵌套超过五层的if-else大概率说明状态设计出了问题——不是代码乱而是状态转换图画得不够细致。一个可操作的方法是先把状态转换图完整画出来不急着写代码用纸笔模拟几组典型输入沿着状态图走几遍。比如、、这三个Token走同一条起始路径走到某个分叉点根据第二个字符分开。你在纸上走一遍代码逻辑就自然清楚了。用一个简单类比解释为什么要先画图这就像去一个陌生的城市自驾手里有导航和没导航完全两种体验。状态转换图就是你的导航。虽然有的老司机能靠头脑记忆机智地直接写代码但遇上复杂的多字符运算符、嵌套注释、字符串常量没有图纸很容易走错路。7. 字符串常量和字符常量如果你不想在这关丢分建议看一眼有的实验题不要求处理字符串但很多变体题里会出现双引号括起来的字符串或单引号括起来的字符。如果题目没有明确排除一定要考虑进去否则一个hello会被拆成、hello、三个垃圾Token。字符串处理的状态转换图不复杂状态0读到双引号进入字符串状态在该状态下持续读入字符直到再遇到双引号结束如果题目允许转义字符还要处理\等转义序列。字符串中间遇到换行通常是语法错误需要报错处理。注意这部分的难度不在于状态切换而在于和运算符状态、标识符状态如何互斥。简单做法是在getNextToken()的主控里加入isQuote(c)分支单独调用一个parseString()函数。保证这个分支和标识符、数字分支在逻辑上是平行的而不是交叉的。8. 一个可用的小型完整示例把前面所有套路串联起来说了这么多不贴一个能跑的小例子说不过去。我用C语言写了一个简化版词法分析器的核心部分适合做头歌第1关的参考骨架。注意这不是标准答案头歌的题目描述千差万别Token编号也不同而是一个可运行、可扩展的框架。#include stdio.h #include string.h #include ctype.h #include stdlib.h #define MAX_LEN 128 typedef struct { int category; char value[MAX_LEN]; } Token; int lookahead -1; char getChar() { if (lookahead ! -1) { char c (char)lookahead; lookahead -1; return c; } return (char)getchar(); } void ungetChar(char c) { lookahead (unsigned char)c; } void skipWhitespace() { char c getChar(); while (c || c \t || c \n || c \r) { c getChar(); } ungetChar(c); } int isKeyword(const char* s) { static const char* keywords[] { int, float, if, else, while, return }; for (int i 0; i 6; i) { if (strcmp(s, keywords[i]) 0) { return 1; } } return 0; } Token parseIdentifier(char first) { Token t; int len 0; t.value[len] first; char c getChar(); while (isalnum(c) || c _) { t.value[len] c; c getChar(); } ungetChar(c); t.value[len] \0; t.category isKeyword(t.value) ? 1 : 2; // 1关键字, 2标识符 return t; } Token parseNumber(char first) { Token t; int len 0; int isFloat 0; t.value[len] first; char c getChar(); while (isdigit(c)) { t.value[len] c; c getChar(); } if (c .) { isFloat 1; t.value[len] c; c getChar(); if (!isdigit(c)) { t.category -1; // 错误 strcpy(t.value, error: missing digit after decimal point); return t; } while (isdigit(c)) { t.value[len] c; c getChar(); } } if (c e || c E) { isFloat 1; t.value[len] c; c getChar(); if (c || c -) { t.value[len] c; c getChar(); } if (!isdigit(c)) { t.category -1; strcpy(t.value, error: missing digit in exponent); return t; } while (isdigit(c)) { t.value[len] c; c getChar(); } } ungetChar(c); t.value[len] \0; t.category isFloat ? 4 : 3; // 4实数, 3整数 return t; } Token parseOperatorOrDelimiter(char c) { Token t; char two[3] {c, getChar(), \0}; const char* doubleOps[] {, , , :, }; for (int i 0; i 5; i) { if (strcmp(two, doubleOps[i]) 0) { t.category 5; strcpy(t.value, two); return t; } } ungetChar(two[1]); if (strchr(-*/(){}[],;:, c)) { t.category 5; t.value[0] c; t.value[1] \0; return t; } t.category -1; t.value[0] c; t.value[1] \0; return t; } Token getNextToken() { Token t; skipWhitespace(); char c getChar(); if (c EOF || c \0) { t.category 0; // EOF strcpy(t.value, EOF); return t; } if (isalpha(c) || c _) { return parseIdentifier(c); } if (isdigit(c)) { return parseNumber(c); } return parseOperatorOrDelimiter(c); } int main() { Token t; do { t getNextToken(); if (t.category 0) break; if (t.category -1) { printf(Error: %s\n, t.value); continue; } printf((%d, %s)\n, t.category, t.value); } while (1); return 0; }这个例子里类别编号是我自定义的0表示EOF1表示关键字2表示标识符3表示整数4表示实数5表示运算符/分界符-1表示错误。如果你的题目给了具体的编号表替换这些数字即可。试想如下输入int x 1.5e3 2;这个程序会输出(1, int) (2, x) (5, ) (4, 1.5e3) (5, ) (3, 2) (5, ;)如果你能把1.5e3完整识别为一个实数而不是拆成1.5、e、3说明数字状态转换图的思路已经建立了。9. 调试经验在线评测不通过时按优先级排查头歌平台评测失败时不会告诉你具体是哪组数据失败了这很让人抓狂。我的经验是按下面这个顺序排查效率最高第一先跑自己构造的边界测试。上面那张表格里的用例全部跑一遍很多时候问题就在某一行“你觉得不可能出错”的代码里。第二检查输出格式。把输出重定向到文件用十六进制查看器看末尾是不是多了一个换行、中间是不是多了空格。在线判题对格式的挑剔程度超乎想象。第三检查缓冲区处理。在getChar()函数里对EOF的处理以及ungetChar()对EOF负值的处理。这个问题在本地测试时通常暴露不了因为输入文件短不会触发缓冲区边界但评测机上的大数据文件很可能触发。第四检查所有错误分支的返回路径。每一条报错分支都不能“卡死”都要返回一个Token给上层。有的同学报错后直接exit(0)导致整个程序停止评测机得到“无输出”或“缺少Token”的结果。第五检查重复率高的代码片段。如果你发现某段代码在文件里出现了两三遍比如“读字符直到非某个字符”的逻辑想一想能不能统一封装成一个函数。重复代码往往意味着你在某个分支改了逻辑、忘了在另一个分支改这类Bug非常隐蔽。在测试用例不充分的情况下拿不到满分是几乎所有在线实验课的常态。不要灰心词法分析这个关卡恰好是锻炼你“构造测试用例”能力的最佳场所——因为状态转换图本身就是一张“所有路径清单”顺着每条路径写一个用例覆盖率自然就上去了。10. 我踩过的一次大坑把“回退”变成了“预读”最后分享一个我个人印象最深的bug。当时我在写多字符运算符匹配用了“预读”的思路先读下一个字符拼成双字符去查运算符表查不到就回退。逻辑上是对的但我在回退时的写法有问题。我当时写的是ungetChar(two[1])但getChar()函数内部维护了一个全局变量lookahead。如果我在调用getChar()之前lookahead已经被某个其他分支赋过值就会出现“回退的位置不对”的情况。举个例子输入是ab。识别完a之后标识符程序读到进入运算符匹配逻辑。此时two[0]然后调用getChar()读b。如果b恰好和two拼成了b...的什么组合匹配失败后回退b。但此时我的程序在识别a时已经把光标推进到了之前如果回退逻辑里有任何一格偏移错误就会有字符丢失或重复读取。最后我是怎么定位的在getChar()和ungetChar()里加了两个打印语句把每次读入和回退的字符打印出来肉眼盯着字符流走了一遍才发现是lookahead变量在不同函数间被意外共用导致的。从那以后我定下一条铁律所有的字符读取和回退只能通过getChar()和ungetChar()完成绝不在函数内部直接操作输入流指针。这条铁律在我后来写任何词法、语法相关实验时都帮我省了大量时间。词法分析这关说难不难说简单也绝不像表面那样简单。你只要抓住“状态转换图是核心设计、Token编号是核心约定、回退是核心操作”这三板斧并且肯多构造几组边界测试拿高分是水到渠成的事。
返回列表