1. 词法分析在编译器中的位置
一句话总结: 词法分析器把字符流切分成 token 流,是编译流水线的第一站,其质量直接决定后续语法与语义分析的复杂度。
编译器的前端可以划分为词法分析、语法分析、语义分析与中间代码生成四个阶段。词法分析器(Lexer)读取源码字符流,跳过空白与注释,把连续字符切分成带类型的词法单元(token),例如关键字、标识符、数字字面量与运算符。语法分析器不再关心字符细节,只需要消费 token 序列,因此词法层做得越干净,语法层就越简单。
源码字符流:
int a = 10 + b * 2;
token 流:
<kw:int> <id:a> <op:=> <num:10> <op:+> <id:b> <op:*> <num:2> <semi>
| 阶段 | 输入 | 输出 | 代表技术 |
|---|---|---|---|
| 词法分析 | 字符流 | token 流 | 正则表达式、DFA |
| 语法分析 | token 流 | 语法树 | 递归下降、LR |
| 语义分析 | 语法树 | 带标注语法树 | 符号表、类型检查 |
| 中间代码生成 | 语义树 | IR | 三地址码、SSA |
词法分析器在工程上还承担外围职责:维护行号与列号用于错误定位、跳过注释、处理字符串与数字字面量的转义与进制、以及为预处理器保留回退位置。一个常见误区是把词法分析器的职责无限扩大,例如试图在这里解决配对括号问题,这会与语法分析器冲突,导致两边逻辑纠缠。
2. 正则表达式与有限自动机
一句话总结: 正则表达式描述词法模式,而 DFA 能以线性时间执行匹配,两者之间通过 NFA 桥接。
词法模式本质上是正则语言。正则表达式由三种基本运算构成:连接、选择与闭包。例如关键字 if 是字符 i 与 f 的连接,整数常量可写作 [0-9]+,其中 + 是闭包的一种缩写。正则语言恰好与有限自动机识别语言等价,这一结论奠定了词法分析器的理论基础。
| 正则运算 | 记法 | 对应的 NFA 片段 |
|---|---|---|
| 单个字符 | a | 两个状态一条边 |
| 连接 | ab | a 的终态接到 b 的初态 |
| 选择 | a|b | 新增起终点并联两条分支 |
| 闭包 | a* | 新增 ε 边构成环 |
| 正闭包 | a+ | 至少一次的闭包 |
# 正则表达式运算优先级: 闭包 > 连接 > 选择
# a|bc* 等价于 a | (b (c*))
# 词法规则书写时通常加括号避免歧义
TOKEN_RULES = [
("KW_IF", r"if"),
("ID", r"[a-zA-Z_][a-zA-Z0-9_]*"),
("NUM", r"[0-9]+"),
("OP_PLUS", r"\+"),
("OP_MUL", r"\*"),
("WS", r"[ \t\n]+"),
]
从正则到可执行匹配器有两条路线:一是把正则转成 NFA,再确定化为 DFA,最后查状态表;二是直接用手写分支匹配常见模式。生产级词法生成器(如 flex、re2c)走前一条路线,而手写词法分析器往往对常见 token 直接匹配、对复杂 token 用自动机构造,两条路线各有取舍。
3. 从正则到 NFA 的 Thompson 构造
一句话总结: Thompson 构造把每个正则运算映射为一组 NFA 片段,通过 ε 边拼接出整体自动机。
Thompson 构造算法按正则表达式的语法树自底向上构建 NFA。每个子表达式对应一个片段,片段只有一个入口状态和一个出口状态,这样可以方便地拼接。连接运算把前一个片段的出口接到后一个片段的入口,选择运算引入新的起终点并把两个片段并联,闭包运算则增加 ε 环。
class NFAState:
def __init__(self):
self.trans = {} # 字符 -> [目标状态]
self.eps = [] # ε 边目标列表
self.accept = False
class NFASegment:
def __init__(self, start, end):
self.start = start
self.end = end
def thompson_concat(seg1, seg2):
seg1.end.eps.append(seg2.start)
return NFASegment(seg1.start, seg2.end)
def thompson_choice(seg1, seg2):
s, e = NFAState(), NFAState()
s.eps += [seg1.start, seg2.start]
seg1.end.eps.append(e)
seg2.end.eps.append(e)
return NFASegment(s, e)
def thompson_star(seg):
s, e = NFAState(), NFAState()
s.eps += [seg.start, e]
seg.end.eps += [seg.start, e]
return NFASegment(s, e)
| 正则 | 构造后片段 | 状态数 |
|---|---|---|
| a | 两状态一直边 | 2 |
| ab | 三状态两直边 | 3 |
| a|b | 六状态含 ε 边 | 6 |
| a* | 四状态含 ε 环 | 4 |
NFA 的状态数与正则长度成正比,这个性质让 Thompson 构造保持线性复杂度。NFA 的缺点是非确定:同一状态可能有多条同字符边,且存在 ε 边,直接模拟需要维护状态集合,无法保证单路径扫描。
4. NFA 到 DFA 的子集构造
一句话总结: 子集构造把 NFA 的任意状态集合压缩成 DFA 的单个状态,消除不确定性。
DFA 的每个状态对应 NFA 的一个状态子集。算法从初态的 ε 闭包出发,反复对当前子集应用 move 运算并计算新的 ε 闭包,直到没有新子集产生。ε 闭包是能够仅通过 ε 边从当前集合到达的全部状态。这个算法称为子集构造,它把不确定的 ε 迁移一次性合并为确定迁移。
def epsilon_closure(nfa_states):
stack = list(nfa_states)
closure = set(nfa_states)
while stack:
s = stack.pop()
for t in s.eps:
if t not in closure:
closure.add(t)
stack.append(t)
return frozenset(closure)
def move(nfa_states, ch):
nxt = set()
for s in nfa_states:
nxt.update(s.trans.get(ch, []))
return nxt
def subset_construction(nfa_start, alphabet):
dfa_states = {}
start = epsilon_closure({nfa_start})
worklist = [start]
dfa_states[start] = {}
while worklist:
cur = worklist.pop()
for ch in alphabet:
t = epsilon_closure(move(cur, ch))
if not t:
continue
dfa_states[cur][ch] = t
if t not in dfa_states:
dfa_states[t] = {}
worklist.append(t)
return dfa_states
| NFA 子集 | 读 0 | 读 1 |
|---|---|---|
| {0,1} | {2,3} | {} |
| {2,3} | {} | {4,5} |
| {4,5} | {6} | {} |
子集构造的最坏情况是指数级状态数,例如语言 (a|b)*a(a|b)^n 的 DFA 有 2^n 个状态。不过词法规则通常规模较小,实际生成的 DFA 与 NFA 状态数大致相当。DFA 的好处是每个状态对每个字符至多一条迁移,扫描过程可以用单层循环完成。
5. DFA 最小化与状态表
一句话总结: 最小化合并等价状态,把 DFA 压缩成紧凑的跳转表,供词法分析器快速查表。
最小化算法的核心是把状态划分为等价类。初始把所有状态分为接受状态集合与非接受状态集合两类,然后反复按迁移目标所在等价类拆分,直到不再变化。等价状态对任意输入串表现完全相同,可以安全合并。经典实现是填表法或 Hopcroft 算法,前者直观后者渐进更优。
def minimize(dfa, accept_states):
# 初始划分: 接受态 / 非接受态
partition = [set(accept_states),
set(dfa.keys()) - set(accept_states)]
changed = True
while changed:
changed = False
new_partition = []
for group in partition:
# 按每个字符迁移目标所在的组拆分
buckets = {}
for s in group:
key = tuple(partition.index_of(dfa[s][c])
for c in ALPHABET)
buckets.setdefault(key, set()).add(s)
for bucket in buckets.values():
new_partition.append(bucket)
if len(bucket) < len(group):
changed = True
partition = new_partition
return partition
# 最小化后的词法状态表
# 行是状态, 列是字符, 数字表示迁移目标, -1 表示死状态
LEX_TABLE = [
[1, 2, 3, -1, -1], # 状态 0: 起始
[1, -1, -1, -1, -1], # 状态 1: 标识符推进
[-1, 4, -1, -1, -1], # 状态 2: 数字开头
[-1, -1, 5, -1, -1], # 状态 3: 运算符
[4, -1, -1, -1, -1], # 状态 4: 数字继续
[-1, -1, -1, -1, -1], # 状态 5: 接受
]
把状态表与接受动作表结合,词法分析器就能以「当前字符 + 当前状态」为索引做一次查表完成一步扫描。状态表在 flex 里最终被压缩为跳转数组或 DFA 位图,以空间换速度。最小化还能顺便去掉不可达状态,进一步减小表体积。
6. 最长匹配与词法分析器驱动
一句话总结: 词法分析采用最长匹配原则,多模式竞争时优先吞入最长前缀,必要时向缓冲区回退一个字符。
当多个正则模式在同一个起点都能匹配时,词法分析器必须决定选谁。原则有二:一是最长匹配优先,二是若长度相同则取规则列表中靠前的模式。例如 <= 不能拆成 < 和 =,123abc 应被识别为数字 123 加上标识符 abc,而不是把整段报错。最长匹配通常配合缓冲回退实现。
def lex(src):
pos = 0
tokens = []
while pos < len(src):
if src[pos].isspace():
pos += 1
continue
matched = None
matched_len = 0
for rule in TOKEN_RULES:
m = rule.pattern.match(src, pos)
if m and m.end() - pos > matched_len:
matched = rule
matched_len = m.end() - pos
if matched is None:
raise LexError(f"unexpected char {src[pos]!r} at {pos}")
tokens.append((matched.name,
src[pos:pos + matched_len]))
pos += matched_len
return tokens
| 冲突场景 | 输入 | 最长匹配结果 |
|---|---|---|
<= 与 < | < = | 无法匹配,报错 |
<= 与 < | <= | 单个 token <= |
| 关键字与标识符 | ifx | 标识符 ifx,不是关键字 |
| 数字与标识符 | 123x | 数字 123,标识符 x |
关键字处理有两种风格:一是为每个关键字单独建正则规则并把规则放在标识符规则之前;二是把所有关键字统一定义为标识符,识别后再查关键字字典。第二种方式状态表更小、便于扩展新关键字,是主流做法。缓冲区回退的长度受最长匹配边界限制,词法分析器需维护一个最近匹配位置。
7. 错误处理与手写实践
一句话总结: 词法错误要尽早发现并给出可读定位,手写词法分析器在简单语言上往往比生成器更可控。
词法错误包括非法字符、未闭合字符串、数字字面量越界与非法转义序列。策略上应当报告行号与列号,尽量跳过非法字符继续分析,让一次编译暴露尽量多的错误。对于未闭合注释这类错误,跳过时会消耗整个剩余缓冲区,避免产生海量虚假错误。
class LexError(Exception):
def __init__(self, msg, line, col):
self.msg = msg
self.line = line
self.col = col
super().__init__(f"{line}:{col}: {msg}")
def lex_with_recovery(src):
pos, line, col = 0, 1, 1
while pos < len(src):
ch = src[pos]
try:
tok, npos = scan_one(src, pos, line, col)
yield tok
pos = npos
except LexError as e:
print(f"warning: {e}")
pos += 1 # 跳过非法字符
col += 1
| 常见错误 | 表现 | 处理策略 |
|---|---|---|
| 非法字符 | @ 出现在 C 源码 | 报告并跳过 |
| 未闭合字符串 | "abc 到行尾 | 报错并终止字面量 |
| 数字越界 | 超出 int 表示范围 | 报告长度或溢出 |
| 非法转义 | \q | 报告转义序列非法 |
手写词法分析器的优势是代码直观、依赖少、错误信息可控,适合教育语言与脚本语言;自动生成器(flex/re2c)的优势是正则维护方便、支持 Unicode 与大状态集。实践中不少语言走混合路线:go/scanner 用表驱动,Clang 的 lexer 则大量手写特殊路径,两者都验证了正确性与性能可以兼得。避开在词法层做缩进或配对逻辑,Python 的缩进处理其实发生在词法与语法的边界,需要专门的 INDENT/DEDENT token。
8. 总结
| 环节 | 要点 |
|---|---|
| 位置职责 | 字符流转 token 流,承担行号与注释跳过 |
| 理论基础 | 正则语言等价于有限自动机识别语言 |
| NFA 构造 | Thompson 构造线性规模、ε 边拼接 |
| DFA 确定化 | 子集构造合并 ε 闭包,消除不确定性 |
| DFA 最小化 | 等价类划分压缩状态,生成查表结构 |
| 匹配原则 | 最长匹配优先,同长取先列规则 |
| 错误恢复 | 报告行列号,跳过非法字符继续分析 |
词法分析器是编译器里最容易写对也最容易被低估的部分,把正则与有限自动机的理论吃透,再配合最长匹配与错误恢复的工程细节,就能为整个编译流水线打下可靠地基。下一站将由语法分析器消费这里的 token 流。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。