「词法分析器实现」

从正则表达式到有限自动机,系统讲解词法分析器的手写与自动生成,覆盖 Thompson 构造、子集构造、DFA 状态表、最长匹配规则、跳过分隔符与错误恢复,并给出可运行的 Python 实现与避坑经验。

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两个状态一条边
连接aba 的终态接到 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 流。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. 「错误恢复与诊断」
  2. 「运行时与内存管理」
  3. 「现代优化 Pass 管线」