词法分析是编译器的第一个阶段——它面对的是最原始的字符流,输出的是结构化的 Token 流。就像阅读英文时,你不是逐个字母阅读,而是将字母组合成单词一样,词法分析器将字符组合成有意义的词法单元。
下面深入拆解词法分析——正则表达式如何转化为有限自动机?如何手工编写一个高效的词法分析器?Token 如何设计才能既简洁又信息丰富?
一、词法分析的任务与定位
1.1 词法分析在编译流水线中的位置
词法分析器(Lexer/Scanner/Tokenizer)的核心任务:
- 分组:将字符流分组为有意义的词法单元(Token)
- 过滤:去除空白、注释等无关信息
- 分类:为每个 Token 标注类型(关键字、标识符、数字、运算符等)
- 定位:记录每个 Token 的位置信息(行号、列号),用于错误报告
1.2 一个具体的例子
int x = 42 + y;词法分析器将其分解为:
| Token 类型 | 值 | 行:列 |
|---|---|---|
| KEYWORD | int | 1:1 |
| IDENTIFIER | x | 1:5 |
| OPERATOR | = | 1:7 |
| INTEGER | 42 | 1:9 |
| OPERATOR | + | 1:12 |
| IDENTIFIER | y | 1:14 |
| PUNCTUATION | ; | 1:15 |
1.3 为什么需要单独的词法分析阶段?
你可能会问:为什么不把词法分析和语法分析合并?有几个重要原因:
| 原因 | 说明 |
|---|---|
| 简化语法分析 | 语法分析器处理 Token 比处理字符简单得多——不需要关心空白、注释 |
| 效率 | 词法分析可以用高效的 DFA 实现,比语法分析快得多 |
| 关注点分离 | 词法规则(正则)和语法规则(上下文无关文法)属于不同层次 |
| 错误定位 | Token 级别的错误信息比字符级别更有意义 |
也有编译器将词法分析和语法分析合并——例如 Haskell 的 Parsec 库允许在语法分析中直接处理字符。但对于大多数语言,分离词法分析是更好的选择,因为它简化了语法分析器的设计。
二、正则表达式与有限自动机
词法分析的理论基础是正则表达式和有限自动机。理解这个转换链是理解词法分析器的关键。
2.1 正则表达式 → NFA → DFA 的转换链
2.2 Thompson 构造:正则表达式 → NFA
Thompson 构造算法将每个正则表达式转换为一个等价的 NFA。核心规则:
# Thompson 构造的 Python 实现(简化版)
class State: """NFA 状态""" def __init__(self): self.transitions = {} # char -> [State]
class NFA: """非确定有限自动机""" def __init__(self, start, accept): self.start = start # 起始状态 self.accept = accept # 接受状态
def thompson_concat(nfa1: NFA, nfa2: NFA) -> NFA: """连接:AB → nfa1 的接受状态连接 nfa2 的起始状态""" nfa1.accept.transitions.setdefault('ε', []).append(nfa2.start) return NFA(nfa1.start, nfa2.accept)
def thompson_union(nfa1: NFA, nfa2: NFA) -> NFA: """选择:A|B → 新起始状态通过 ε 转移到两个 NFA""" new_start = State() new_start.transitions['ε'] = [nfa1.start, nfa2.start] new_accept = State() nfa1.accept.transitions['ε'] = [new_accept] nfa2.accept.transitions['ε'] = [new_accept] return NFA(new_start, new_accept)
def thompson_star(nfa: NFA) -> NFA: """闭包:A* → 新起始状态通过 ε 转移到原起始和接受状态""" new_start = State() new_accept = State() new_start.transitions['ε'] = [nfa.start, new_accept] nfa.accept.transitions['ε'] = [nfa.start, new_accept] return NFA(new_start, new_accept)以标识符的正则 [a-zA-Z_][a-zA-Z0-9_]* 为例:
2.3 子集构造:NFA → DFA
NFA 的问题是非确定性——一个状态在同一个输入上可以转移到多个状态。DFA 则是确定性的——每个状态在每个输入上只有一个转移。
子集构造算法的核心思想:DFA 的每个状态对应 NFA 的一组状态。
def epsilon_closure(states: set[State]) -> set[State]: """计算 ε-闭包:从给定状态集沿 ε 转移可达的所有状态""" stack = list(states) closure = set(states) while stack: state = stack.pop() for next_state in state.transitions.get('ε', []): if next_state not in closure: closure.add(next_state) stack.append(next_state) return closure
def subset_construction(nfa: NFA, alphabet: set[str]) -> 'DFA': """子集构造:NFA → DFA""" dfa_start = epsilon_closure({nfa.start}) dfa_states = {frozenset(dfa_start): 0} # NFA状态集 → DFA状态编号 dfa_transitions = {} queue = [dfa_start]
while queue: current = queue.pop(0) current_key = frozenset(current)
for char in alphabet: # 对每个字符,计算转移后的状态集 next_states = set() for state in current: for next_state in state.transitions.get(char, []): next_states.add(next_state)
if next_states: next_closure = epsilon_closure(next_states) next_key = frozenset(next_closure)
if next_key not in dfa_states: dfa_states[next_key] = len(dfa_states) queue.append(next_closure)
dfa_transitions[(current_key, char)] = next_key
return DFA(dfa_states, dfa_transitions)2.4 NFA vs DFA 对比
| 特性 | NFA | DFA |
|---|---|---|
| 状态数 | 少(与正则表达式成正比) | 可能指数级增长 |
| 转移 | 一个输入可有多个转移 | 每个输入只有一个转移 |
| ε 转移 | 允许 | 不允许 |
| 匹配速度 | 慢(需回溯/并行) | 快(O(n) 线性时间) |
| 构造复杂度 | 简单(Thompson 构造) | 可能复杂(子集构造) |
| 实际使用 | 正则引擎(PCRE) | 词法分析器(lex/flex) |
NFA 的状态数可能远小于 DFA。例如,正则表达式 (a|b)*a(a|b)^(n-1) 的 NFA 有 O(n) 个状态,但等价的最小 DFA 有 2^n 个状态。这就是所谓的”状态爆炸”。不过在实际的编程语言词法规则中,这种情况很少发生。
三、手工编写词法分析器
虽然工具(如 flex、lex)可以自动生成词法分析器,但手工编写词法分析器有以下优势:
- 更好的错误信息:可以精确控制错误报告
- 更灵活:可以处理工具难以表达的特殊情况
- 更高效:可以针对特定语言优化
- 更易调试:代码逻辑清晰可见
3.1 一个简单的词法分析器
from dataclasses import dataclassfrom enum import Enum, auto
class TokenType(Enum): # 关键字 INT = auto() RETURN = auto() IF = auto() ELSE = auto() WHILE = auto() # 字面量 INTEGER = auto() STRING = auto() # 标识符 IDENTIFIER = auto() # 运算符 PLUS = auto() MINUS = auto() STAR = auto() SLASH = auto() ASSIGN = auto() EQ = auto() # == NEQ = auto() # != LT = auto() # < GT = auto() # > # 分隔符 LPAREN = auto() RPAREN = auto() LBRACE = auto() RBRACE = auto() SEMI = auto() COMMA = auto() # 特殊 EOF = auto()
@dataclassclass Token: type: TokenType value: str line: int col: int
class Lexer: KEYWORDS = { 'int': TokenType.INT, 'return': TokenType.RETURN, 'if': TokenType.IF, 'else': TokenType.ELSE, 'while': TokenType.WHILE, }
def __init__(self, source: str): self.source = source self.pos = 0 self.line = 1 self.col = 1
def _peek(self) -> str: if self.pos < len(self.source): return self.source[self.pos] return '\0'
def _advance(self) -> str: ch = self.source[self.pos] self.pos += 1 if ch == '\n': self.line += 1 self.col = 1 else: self.col += 1 return ch
def _skip_whitespace_and_comments(self): while self.pos < len(self.source): ch = self._peek() if ch in ' \t\r\n': self._advance() elif ch == '/' and self.pos + 1 < len(self.source): if self.source[self.pos + 1] == '/': # 单行注释 while self.pos < len(self.source) and self._peek() != '\n': self._advance() elif self.source[self.pos + 1] == '*': # 多行注释 self._advance() # / self._advance() # * while self.pos < len(self.source): if self._peek() == '*' and self.pos + 1 < len(self.source) and self.source[self.pos + 1] == '/': self._advance() # * self._advance() # / break self._advance() else: break else: break
def _read_number(self) -> Token: start_line, start_col = self.line, self.col num = '' while self.pos < len(self.source) and self._peek().isdigit(): num += self._advance() return Token(TokenType.INTEGER, num, start_line, start_col)
def _read_identifier(self) -> Token: start_line, start_col = self.line, self.col ident = '' while self.pos < len(self.source) and (self._peek().isalnum() or self._peek() == '_'): ident += self._advance() token_type = self.KEYWORDS.get(ident, TokenType.IDENTIFIER) return Token(token_type, ident, start_line, start_col)
def _read_string(self) -> Token: start_line, start_col = self.line, self.col self._advance() # 开头引号 s = '' while self.pos < len(self.source) and self._peek() != '"': if self._peek() == '\\': self._advance() # 反斜杠 ch = self._advance() escape_map = {'n': '\n', 't': '\t', '\\': '\\', '"': '"'} s += escape_map.get(ch, ch) else: s += self._advance() self._advance() # 结尾引号 return Token(TokenType.STRING, s, start_line, start_col)
def next_token(self) -> Token: self._skip_whitespace_and_comments()
if self.pos >= len(self.source): return Token(TokenType.EOF, '', self.line, self.col)
ch = self._peek() start_line, start_col = self.line, self.col
# 数字 if ch.isdigit(): return self._read_number()
# 标识符/关键字 if ch.isalpha() or ch == '_': return self._read_identifier()
# 字符串 if ch == '"': return self._read_string()
# 双字符运算符 if ch == '=' and self.pos + 1 < len(self.source) and self.source[self.pos + 1] == '=': self._advance(); self._advance() return Token(TokenType.EQ, '==', start_line, start_col) if ch == '!' and self.pos + 1 < len(self.source) and self.source[self.pos + 1] == '=': self._advance(); self._advance() return Token(TokenType.NEQ, '!=', start_line, start_col)
# 单字符运算符和分隔符 single_char_tokens = { '+': TokenType.PLUS, '-': TokenType.MINUS, '*': TokenType.STAR, '/': TokenType.SLASH, '=': TokenType.ASSIGN, '<': TokenType.LT, '>': TokenType.GT, '(': TokenType.LPAREN, ')': TokenType.RPAREN, '{': TokenType.LBRACE, '}': TokenType.RBRACE, ';': TokenType.SEMI, ',': TokenType.COMMA, } if ch in single_char_tokens: self._advance() return Token(single_char_tokens[ch], ch, start_line, start_col)
raise SyntaxError(f"Unexpected character '{ch}' at {start_line}:{start_col}")
def tokenize(self) -> list[Token]: tokens = [] while True: token = self.next_token() tokens.append(token) if token.type == TokenType.EOF: break return tokens3.2 最大匹配原则(Maximal Munch)
词法分析器遵循最大匹配原则:总是匹配最长的可能 Token。
# 输入:">>="# 正确:">>="(一个右移赋值运算符)# 错误:">>" + "="(两个 Token)
# 输入:"int32"# 正确:"int32"(一个标识符)# 错误:"int" + "32"(关键字 + 数字)3.3 关键字 vs 标识符的歧义
关键字和标识符共享同一个正则模式 [a-zA-Z_][a-zA-Z0-9_]*。处理方式:
| 策略 | 说明 | 优缺点 |
|---|---|---|
| 关键字表查找 | 先匹配为标识符,再查关键字表 | 简单、灵活、最常用 |
| 优先级规则 | 关键字模式优先于标识符模式 | flex 默认策略 |
| 前缀树 | 用 Trie 同时匹配关键字和标识符 | 高效但复杂 |
四、Token 设计原则
4.1 Token 类型的层次
4.2 Token 设计的权衡
| 设计选择 | 方案 A | 方案 B | 推荐 |
|---|---|---|---|
| 运算符粒度 | 每个运算符一个类型 | 统一 OPERATOR 类型 + value | 方案 A(语法分析更方便) |
| 数字类型 | 区分 INT/FLOAT/HEX | 统一 NUMBER 类型 + value | 方案 A(类型信息在前端就有用) |
| 关键字 | 每个关键字一个类型 | 统一 KEYWORD 类型 + value | 方案 A(语法分析可直接 switch) |
| 位置信息 | 只记录起始位置 | 记录起始和结束位置 | 方案 B(更好的错误报告) |
4.3 不同语言的 Token 设计对比
| 语言 | Token 类型数 | 特殊设计 |
|---|---|---|
| C | ~50 | 预处理器 Token(#include 等) |
| Python | ~60 | INDENT/DEDENT Token(缩进敏感) |
| JavaScript | ~70 | 模板字符串 Token、正则 Token |
| Rust | ~80 | 生命周期 Token(‘a)、原始字符串 |
| Go | ~50 | 少量关键字,运算符简洁 |
五、特殊词法问题
5.1 Python 的缩进敏感词法
Python 使用缩进表示代码块,词法分析器需要生成 INDENT/DEDENT Token:
# Python 源码if True: x = 1 # INDENT Token y = 2 # 同级,无 Tokenprint(x) # DEDENT Token
# Token 流# IF, TRUE, COLON, NEWLINE# INDENT, IDENT(x), ASSIGN, INT(1), NEWLINE# IDENT(y), ASSIGN, INT(2), NEWLINE# DEDENT, IDENT(print), LPAREN, IDENT(x), RPAREN, NEWLINEclass PythonLexer(Lexer): def __init__(self, source: str): super().__init__(source) self.indent_stack = [0] # 缩进级别栈
def _handle_indentation(self, tokens: list[Token]): """处理行首缩进,生成 INDENT/DEDENT Token""" # 计算当前行缩进 indent = 0 while self.pos < len(self.source) and self._peek() == ' ': self._advance() indent += 1
if indent > self.indent_stack[-1]: self.indent_stack.append(indent) tokens.append(Token(TokenType.INDENT, '', self.line, 1)) elif indent < self.indent_stack[-1]: while self.indent_stack[-1] > indent: self.indent_stack.pop() tokens.append(Token(TokenType.DEDENT, '', self.line, 1))5.2 JavaScript 的正则/除法歧义
在 JavaScript 中,/ 可以是除法运算符,也可以是正则表达式的开始:
a / b / c // 两个除法运算符/a/ // 正则表达式解决方法:根据上下文判断——如果前一个 Token 是标识符、数字或 ),则 / 是除法;否则是正则的开始。
5.3 C 的预处理器 Token
C 语言的预处理器在词法分析之前运行,但预处理器 Token 和普通 Token 有微妙区别:
// 预处理器看到的是"预处理 Token"#define PASTE(a, b) a ## bPASTE(foo, bar) // → foobar
// ## 运算符在预处理阶段连接 Token// 这在普通词法分析中是不可能的六、词法分析器的性能优化
6.1 性能关键点
词法分析器通常是编译器中运行最快的部分,但它处理的数据量最大(整个源码),因此仍有优化空间:
6.2 缓冲区策略
// 双缓冲区策略:避免频繁 I/O#define BUF_SIZE 4096
typedef struct { char buffer[2][BUF_SIZE]; // 两个缓冲区交替使用 int current; // 当前使用的缓冲区 char *cursor; // 当前读取位置 char *limit; // 缓冲区有效数据末尾} LexerBuffer;
char next_char(LexerBuffer *buf) { if (buf->cursor >= buf->limit) { // 当前缓冲区已读完,切换到另一个 buf->current = 1 - buf->current; int n = read(fd, buf->buffer[buf->current], BUF_SIZE); buf->limit = buf->buffer[buf->current] + n; buf->cursor = buf->buffer[buf->current]; if (n == 0) return '\0'; // EOF } return *buf->cursor++;}6.3 表驱动 vs 直接编码
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 表驱动 | DFA 转移表存储在数组中 | 代码简洁、易生成 | 间接寻址、缓存不友好 |
| 直接编码 | 每个状态用 switch-case | 缓存友好、可内联 | 代码冗长、难维护 |
| 混合 | 常见路径直接编码,冷路径表驱动 | 兼顾两者 | 实现复杂 |
七、词法分析工具对比
7.1 主流词法分析工具
| 工具 | 语言 | 输出 | 特点 |
|---|---|---|---|
| lex/flex | C | C 代码 | 经典工具,POSIX 标准 |
| re2c | C/C++ | C 代码 | 直接编码,高性能 |
| ANTLR | Java | 多语言 | 词法+语法一体 |
| ragel | C/C++ | C 代码 | 状态机可嵌入动作 |
| Lark | Python | Python | 词法+语法一体,Earley 解析 |
7.2 flex 使用示例
# 安装 flexsudo apt install flex
# 创建词法规则文件cat > scanner.l << 'EOF'%{#include <stdio.h>%}
%%[0-9]+ { printf("INTEGER: %s\n", yytext); }[a-zA-Z_][a-zA-Z0-9_]* { printf("IDENT: %s\n", yytext); }"+" { printf("PLUS\n"); }"-" { printf("MINUS\n"); }"=" { printf("ASSIGN\n"); }[ \t\n] { /* 忽略空白 */ }"//".* { /* 忽略单行注释 */ }. { printf("UNKNOWN: %s\n", yytext); }%%
int main() { yylex(); return 0;}EOF
# 生成并编译flex scanner.lgcc lex.yy.c -o scanner -lflecho "int x = 42 + y;" | ./scanner八、错误处理与恢复
8.1 词法错误类型
| 错误类型 | 示例 | 处理策略 |
|---|---|---|
| 非法字符 | int x = 0xFF; 中的 § | 报告错误,跳过该字符 |
| 未终止字符串 | "hello | 报告错误,假设在行尾终止 |
| 未终止注释 | /* comment | 报告错误,假设在文件尾终止 |
| 数字格式错误 | 123.45.67 | 报告错误,尝试恢复 |
8.2 错误恢复策略
def next_token_with_recovery(self) -> Token: """带错误恢复的词法分析""" try: return self.next_token() except SyntaxError as e: # 策略 1:跳过非法字符,继续分析 self._advance() self.errors.append(e) return self.next_token_with_recovery()词法分析器的错误恢复应该尽量简单——跳过非法字符并继续。复杂的错误恢复留给语法分析器处理,因为语法分析器有更多的上下文信息来判断如何恢复。
九、动手实践
9.1 实验一:手写词法分析器
# 使用上面的 Lexer 类source = """int main() { int x = 42 + y; return x;}"""
lexer = Lexer(source)tokens = lexer.tokenize()for token in tokens: print(f"{token.type.name:12s} '{token.value}' @ {token.line}:{token.col}")9.2 实验二:用 Clang 查看 Token
# 创建测试文件cat > test.c << 'EOF'int add(int a, int b) { return a + b;}EOF
# 查看 Clang 的 Token 输出clang -dump-tokens test.c 2>/dev/null
# 查看 Clang 的预处理 Tokenclang -dump-pp-token test.c 2>/dev/null9.3 实验三:对比正则引擎和 DFA
import reimport time
# 测试字符串test_str = "abc123xyz" * 10000
# Python re 模块(NFA 回溯引擎)pattern = re.compile(r'[a-z]+[0-9]+[a-z]+')start = time.time()for _ in range(1000): pattern.findall(test_str)nfa_time = time.time() - start
# 简单 DFA 匹配(模拟)def dfa_match(s): state = 0 # 0:字母 1:数字 2:字母 results = [] current = '' for ch in s: if state == 0 and ch.isalpha(): current += ch elif state == 0 and ch.isdigit(): state = 1 current += ch elif state == 1 and ch.isdigit(): current += ch elif state == 1 and ch.isalpha(): state = 2 current += ch elif state == 2 and ch.isalpha(): current += ch else: if state == 2 and current: results.append(current) current = '' state = 0 if ch.isalpha(): current = ch return results
start = time.time()for _ in range(1000): dfa_match(test_str)dfa_time = time.time() - start
print(f"NFA (Python re): {nfa_time:.4f}s")print(f"DFA (手写): {dfa_time:.4f}s")print(f"DFA 加速比: {nfa_time/dfa_time:.2f}x")十、本章小结
在上一章中,我们俯瞰了编译器的整体架构——源码经过前端、中端、后端三段式流水线最终变成机器码。流水线的第一步就是将字符流分解为有意义的词法单元,那么字符究竟是如何被识别为 Token 的?这正是词法分析要回答的问题。
| 概念 | 要点 |
|---|---|
| 词法分析任务 | 字符流 → Token 流,分组、过滤、分类、定位 |
| 正则 → NFA | Thompson 构造,O(n) 状态数 |
| NFA → DFA | 子集构造,可能状态爆炸 |
| 最大匹配 | 总是匹配最长的可能 Token |
| 关键字处理 | 先匹配标识符,再查关键字表 |
| Token 设计 | 细粒度类型便于语法分析,位置信息便于错误报告 |
| 特殊问题 | Python 缩进、JS 正则/除法歧义、C 预处理 Token |
| 性能优化 | 双缓冲区、表驱动 vs 直接编码 |
| 错误恢复 | 跳过非法字符,简单恢复 |
用了整章的篇幅拆解词法分析。下一章进入 语法分析,看看 Token 流如何被组织为抽象语法树(AST)。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






