词法分析器为什么要记住位置
一句话总结
词法分析器把字符序列变成词法单元(token,带有种类的单词)序列,同时滤掉空白和注释,并为每个词法单元记下它来自哪里(行、列)。有了这些,语法分析器看到的就不再是字符,而是有意义的片段,后面所有错误信息也都继承这个位置。
为什么需要它
假设语法分析器直接读取字符。let x=10;、let x = 10 ; 和 let x /* 설명 */ = 10;(注释内容为韩文,意为“说明”) 是同一条语句,但语法分析器的每条规则都得各自带一段跳过空白和注释的代码。每遇到一次 <=,每条规则都要检查“下一个字符是不是 =”,而且每条规则还得小心 letter 是以关键字 let 开头这个事实。
词法分析器把这些事集中到一处。语法分析器看到的只有 let、IDENT(x)、=、INT(10)、;、EOF 六个。此外它还多做一件事——记住位置。能对用户说“第 3 行第 17 列的 @ 不认识”,而不是笼统地说“有地方错了”,是因为最前面的阶段在数字符的时候,把行和列一起数了下来。语法分析器的“这里应该是 ;”、语义分析的“未声明的名字”,乃至运行中的“除以 0”,全都使用同一套坐标系。
工作原理
词法分析器用一个游标把字符扫描一遍。每走一步,先看当前字符,决定从哪种词法单元开始,然后一直吞到这个词法单元结束。
let x1 <= 007 ; // 끝
^^^ ^^ ^^ ^^^ ^
let IDENT <= INT ; (주석과 공백은 버리고) EOF
1:2 1:7 1:10 1:13 1:17 1:23
有四条规则必须遵守。
最长的优先(maximal munch,最长匹配)。能读成 <= 的时候,就不要切成 < 和 =。先检查两个字符的符号,不行再退回到一个字符。名字也一样——letter 要把字母读完之后,再看它是不是关键字。如果先把前三个字符当作 let 切下来,就会变成 let + ter。
列按字符计数。文件是 UTF-8 字节,但人看到的列是字符。在 /* 한글 */ x(注释内是两个韩文字符)中,x 是第 10 个字符,却是第 14 个字节。按字节数,含有韩文注释的那一行里所有错误位置都会错位。跨过换行之后,列回到 1。
错误报告它开始的位置。没有闭合的 /* 要到文件末尾才会被发现,但应该报告的位置是注释开始的地方。如果报告结束位置,用户就会在文件末尾迷路。超出 64 位的整数,同样报告该数字的第一列。
把结尾也当作词法单元。最后总是放一个 EOF 词法单元。这样语法分析器不必另外检查“还有没有词法单元”,而“这里应该是 },文件却结束了”这样的错误,也能准确地标在 EOF 的位置(最后一个字符的正后方)。
只扫描一遍也很重要。如果每次都像 src = src[1:] 那样把剩余字符切下来重新生成,那么每读一个字符就要复制整个文件,文件大小翻倍,时间就会变成四倍。游标应该只是移动一个位置编号。
在现场相遇的样子
- 编译器错误的下划线。clang 和 rustc 会显示出错的那一行,并在下面用
^标记指出确切的列,靠的就是词法分析器留下的位置。位置错误的编译器,即使错误信息本身正确,用起来也很痛苦。 - 编辑器的列号事故。语言服务器协议(LSP)规定,列按 UTF-16 代码单元计数。如果服务器按字节、编辑器按 UTF-16 计数,那么在含有韩文或 emoji 的行上,红色下划线就会画到莫名其妙的地方。“列用什么来数”看似微不足道,却是真实工具之间最典型的错位点。
- 词法分析器生成器与手写词法分析器。flex 这类工具可以根据正则表达式列表生成词法分析器。虽然方便,但“最长的优先”和“先写的规则获胜”这两条规则同时起作用,用正则的顺序去调整关键字和名字的优先级时,常常把人搞糊涂。所以 GCC、Clang、rustc、Go 都手写词法分析器——为的是准确数出行和列,打磨错误信息,并亲自掌控只扫描一遍。本实验的词法分析器也是这种方式。
- Python 的 tokenize 模块。Python 也在做同样的事。运行
python3 -m tokenize 파일.py(占位符为文件名),每个词法单元的(行,列)范围都会打印出来。不同之处只是,缩进有语义的语言,连 INDENT、DEDENT 这样的词法单元也由词法分析器来生成。
下一项实验要做什么
在 lexer.py 中分五块做出 mini 语言的词法分析器——数行和列的游标、跳过空白和注释、整数与名字和关键字、按最长优先切分的符号,以及把它们连起来的 tokenize。评分器会把每个词法单元的行和列都与参考词法分析器对照,并在夹杂韩文的注释、超过 64 位的整数、没有闭合的注释这几种情况下检查连错误文字也一致;最后把输入放大到四倍,测量时间是否线性增长。