TT Lab
开始
学习 学习路径 课程

编译器 — 从头到尾亲手打造一门小语言

做一个记住位置的词法分析器

在 TT Lab 中继续学习

目标

做出一个词法分析器,把 mini 语言的源码切分成词法单元列表。给每个词法单元附上种类、文本、行和列,对不认识的字符、没有闭合的注释、过大的整数,都按起始位置报告。

为什么重要

后面所有阶段都继承词法分析器给出的位置。语法分析器的“这里应该是 ;”、语义分析的“没有这个名字”、运行中的“除以 0”,全都是用这套坐标标出来的。如果列按字节数,或者从 0 开始数,含有韩文注释的那一行里所有错误都会指向莫名其妙的地方。而且词法分析器是唯一把整个输入扫描一遍的阶段,这里慢,整体就慢。

词法单元的规则

Token(kind, text, line, col)   namedtuple. line·col 은 1부터, col 은 글자(코드 포인트) 단위.
                               탭도 한 칸, \r 도 한 칸. 줄바꿈(\n)을 지나면 줄 +1, 칸 1.
공백      ' ' '\t' '\r' '\n' 은 버린다
주석      // 부터 줄 끝까지 · /* 부터 */ 까지(겹치지 않는다: /* /* */ 에서 끝난다)
정수      ASCII 숫자 하나 이상. kind "INT", text 는 쓴 그대로("007"). 2^63-1 을 넘으면 오류
이름      ASCII 글자나 _ 로 시작, 그 뒤 ASCII 글자·숫자·_. 키워드면 kind 가 그 키워드, 아니면 "IDENT"
키워드    let fn return if else while print true false
기호      두 글자 == != <= >= && || 를 먼저 보고, 한 글자 + - * / % ^ ( ) { } , ; = < > !
끝        마지막에 늘 Token("EOF", "", 줄, 칸) — 마지막 글자 바로 뒤의 위치
오류      LexError(줄, 칸, 메시지). str() 은 "줄:칸: 메시지"
          unexpected character '@'   (repr 로 감싼 그 글자, 그 글자의 위치)
          unterminated comment       (그 /* 의 위치)
          integer literal too large  (그 숫자의 첫 칸)

步骤

  1. 补全 /root/mini/lexer.py 中的 Token、LexError(line, col, message) 和 Cursor(src)。游标持有 src、i(下一个字符的位置)、line 和 col,并提供 peek(ahead=0)(越过末尾则返回空字符 '')、advance()(返回一个字符并移动行和列)以及 at_end()。
  2. 补全 skip_trivia(cur)——跳过空白和注释,在第一个不是注释的字符之前停下。没有闭合的 /* 以它的起始位置抛出 unterminated comment。
  3. 补全 read_number(cur) 和 read_word(cur)——在游标位于数字或名字的第一个字符时调用,返回词法单元,并把游标移到它的末尾。同时补全 KEYWORDS。
  4. 补全 read_operator(cur) 以及 TWO_CHAR 和 ONE_CHAR——先看两个字符的符号,遇到不认识的字符就在那个位置抛出错误。
  5. 补全 tokenize(src)——跳过空白,用第一个字符来选择读数字、名字还是符号,如此重复直到结束,最后附上 EOF。评分器会对照固定程序的完整词法单元列表。
  6. 在错误程序(/opt/fixtures/mini/errors/lex-*.mini)中,确认错误文字与参考逐字符一致。其中有夹杂韩文的注释之后的列号,还有韩文名字(不是 ASCII,所以是不认识的字符)。
  7. 评分器会把 300 份随机词法单元杂烩(混有空白、换行、\r\n 和韩文注释)与参考对照。
  8. 评分器会把输入放大到四倍来测量时间。如果词法分析器只扫描一遍,应该在 4 倍上下。

参考

数行和列的游标

游标只移动一个位置编号 i。在 advance 中,如果刚刚经过的字符是换行,就把行加一、列置为 1,否则只把列加一。Python 字符串本来就按字符计,直接使用 len 和下标,列就是按字符计数的。

跳过空白和注释

同时看当前字符和下一个字符(peek(1))。遇到 /* 时,先记下那个位置的行和列,再往前走,直到找到 */。如果走到末尾,就用记下的位置报错。只有一个 “/” 就不是注释,要停下。

整数、名字和关键字

记下起始位置,在条件成立期间一直 advance。名字要读完之后,再看它是否在 KEYWORDS 中。要排除韩文和 é,就同时使用 isascii() 和 isalnum()。整数如果 int(text) 大于 2 ** 63 - 1,就以第一列报错。

先切最长的符号

如果 peek() + peek(1) 在两个字符的符号列表中,就 advance 两次。否则查看一个字符的列表。两者都不是,就用 “unexpected character %r” 报告,把那个字符用 repr 括起来(单独一个 & 或单独一个 | 也是不认识的字符)。

用 tokenize 连起来

每次循环先调用 skip_trivia,到了末尾就附上 EOF 并返回。第一个字符是 ASCII 数字就用 read_number,是 ASCII 字母或 _ 就用 read_word,其余的用 read_operator。EOF 的位置就是 skip_trivia 停下的那个位置。

把错误标在原位

没有新代码。如果没通过,看消息里的行:列。如果在韩文注释之后列号错位,说明是按字节数的;如果接受了韩文名字,说明只检查了 isalpha。

用随机词法单元杂烩对照

没有新代码。随机输入里混有 \r\n 换行。\r 不是换行,而是占一列的空白——如果在 \r 处把行加一,行号就会翻倍。

测量是否只扫描了一遍

没有新代码。输入是 4 倍时,如果时间接近 16 倍,说明某处在每个字符上都复制了全部剩余字符(src[i:]),或者每次都从头重新数(src[:i].count('\n'))。