mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2443 字
7 分钟
词法分析:从字符到 Token
2025-12-11

词法分析是编译器的第一个阶段——它面对的是最原始的字符流,输出的是结构化的 Token 流。就像阅读英文时,你不是逐个字母阅读,而是将字母组合成单词一样,词法分析器将字符组合成有意义的词法单元。

下面深入拆解词法分析——正则表达式如何转化为有限自动机?如何手工编写一个高效的词法分析器?Token 如何设计才能既简洁又信息丰富?

一、词法分析的任务与定位#

1.1 词法分析在编译流水线中的位置#

flowchart LR SRC["源码字符流"] -->|词法分析| TOKEN["Token 流"] -->|语法分析| AST["AST"] AST -->|语义分析| TYPED["带类型 AST"] style SRC fill:#e3f2fd,stroke:#1565c0 style TOKEN fill:#fff3e0,stroke:#e65100 style AST fill:#e8f5e9,stroke:#2e7d32 style TYPED fill:#fce4ec,stroke:#c62828

词法分析器(Lexer/Scanner/Tokenizer)的核心任务:

  1. 分组:将字符流分组为有意义的词法单元(Token)
  2. 过滤:去除空白、注释等无关信息
  3. 分类:为每个 Token 标注类型(关键字、标识符、数字、运算符等)
  4. 定位:记录每个 Token 的位置信息(行号、列号),用于错误报告

1.2 一个具体的例子#

int x = 42 + y;

词法分析器将其分解为:

Token 类型行:列
KEYWORDint1:1
IDENTIFIERx1:5
OPERATOR=1:7
INTEGER421:9
OPERATOR+1:12
IDENTIFIERy1:14
PUNCTUATION;1:15

1.3 为什么需要单独的词法分析阶段?#

你可能会问:为什么不把词法分析和语法分析合并?有几个重要原因:

原因说明
简化语法分析语法分析器处理 Token 比处理字符简单得多——不需要关心空白、注释
效率词法分析可以用高效的 DFA 实现,比语法分析快得多
关注点分离词法规则(正则)和语法规则(上下文无关文法)属于不同层次
错误定位Token 级别的错误信息比字符级别更有意义
Note

也有编译器将词法分析和语法分析合并——例如 Haskell 的 Parsec 库允许在语法分析中直接处理字符。但对于大多数语言,分离词法分析是更好的选择,因为它简化了语法分析器的设计。

二、正则表达式与有限自动机#

词法分析的理论基础是正则表达式有限自动机。理解这个转换链是理解词法分析器的关键。

2.1 正则表达式 → NFA → DFA 的转换链#

flowchart LR RE["正则表达式<br/>[a-z][a-z0-9]*"] -->|Thompson 构造| NFA["NFA<br/>非确定有限自动机"] NFA -->|子集构造| DFA["DFA<br/>确定有限自动机"] DFA -->|最小化| MIN_DFA["最小化 DFA"] MIN_DFA -->|代码生成| LEXER["词法分析器代码"] style RE fill:#e3f2fd,stroke:#1565c0 style NFA fill:#fff3e0,stroke:#e65100 style DFA fill:#e8f5e9,stroke:#2e7d32 style MIN_DFA fill:#e0f2f1,stroke:#00695c style LEXER fill:#fce4ec,stroke:#c62828

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_]* 为例:

flowchart LR S0((S0)) -->|a-zA-Z_| S1((S1)) S1 -->|a-zA-Z0-9_| S1 S1 -->|ε| ACC((接受)) style S0 fill:#e3f2fd,stroke:#1565c0 style S1 fill:#fff3e0,stroke:#e65100 style ACC fill:#e8f5e9,stroke:#2e7d32

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 对比#

特性NFADFA
状态数少(与正则表达式成正比)可能指数级增长
转移一个输入可有多个转移每个输入只有一个转移
ε 转移允许不允许
匹配速度慢(需回溯/并行)快(O(n) 线性时间)
构造复杂度简单(Thompson 构造)可能复杂(子集构造)
实际使用正则引擎(PCRE)词法分析器(lex/flex)
Warning

NFA 的状态数可能远小于 DFA。例如,正则表达式 (a|b)*a(a|b)^(n-1) 的 NFA 有 O(n) 个状态,但等价的最小 DFA 有 2^n 个状态。这就是所谓的”状态爆炸”。不过在实际的编程语言词法规则中,这种情况很少发生。

三、手工编写词法分析器#

虽然工具(如 flex、lex)可以自动生成词法分析器,但手工编写词法分析器有以下优势:

  1. 更好的错误信息:可以精确控制错误报告
  2. 更灵活:可以处理工具难以表达的特殊情况
  3. 更高效:可以针对特定语言优化
  4. 更易调试:代码逻辑清晰可见

3.1 一个简单的词法分析器#

from dataclasses import dataclass
from 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()
@dataclass
class 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 tokens

3.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 类型的层次#

graph TB TOKEN["Token"] TOKEN --> LITERAL["字面量"] TOKEN --> KEYWORD["关键字"] TOKEN --> IDENT["标识符"] TOKEN --> OP["运算符"] TOKEN --> PUNCT["分隔符"] LITERAL --> INT["整数<br/>42, 0xFF"] LITERAL --> FLOAT["浮点数<br/>3.14, 1e-5"] LITERAL --> STR["字符串<br/>'hello'"] LITERAL --> CHAR["字符<br/>'a'"] KEYWORD --> TYPE["类型关键字<br/>int, float, void"] KEYWORD --> CTRL["控制流关键字<br/>if, else, while"] KEYWORD --> MOD["修饰符<br/>static, const"] OP --> ARITH["算术<br/>+, -, *, /"] OP --> CMP["比较<br/>, ==", =="" <br=""> OP --> LOGIC["逻辑<br/>&&, ||, !"] OP --> BIT["位运算<br/>&, |, ^, ~"] style TOKEN fill:#e3f2fd,stroke:#1565c0 style LITERAL fill:#e8f5e9,stroke:#2e7d32 style KEYWORD fill:#fff3e0,stroke:#e65100 style IDENT fill:#fce4ec,stroke:#c62828 style OP fill:#f3e5f5,stroke:#6a1b9a style PUNCT fill:#e0f2f1,stroke:#00695c

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~60INDENT/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 # 同级,无 Token
print(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, NEWLINE
class 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 ## b
PASTE(foo, bar) // → foobar
// ## 运算符在预处理阶段连接 Token
// 这在普通词法分析中是不可能的

六、词法分析器的性能优化#

6.1 性能关键点#

词法分析器通常是编译器中运行最快的部分,但它处理的数据量最大(整个源码),因此仍有优化空间:

graph LR subgraph 优化策略 A["缓冲区优化<br/>减少 I/O"] B["表驱动 DFA<br/>避免分支"] C["直接编码<br/>switch-case"] D[" SIMD 扫描<br/>并行处理"] end A --> PERF["性能提升"] B --> PERF C --> PERF D --> PERF style 优化策略 fill:#e3f2fd,stroke:#1565c0 style PERF fill:#e8f5e9,stroke:#2e7d32

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/flexCC 代码经典工具,POSIX 标准
re2cC/C++C 代码直接编码,高性能
ANTLRJava多语言词法+语法一体
ragelC/C++C 代码状态机可嵌入动作
LarkPythonPython词法+语法一体,Earley 解析

7.2 flex 使用示例#

# 安装 flex
sudo 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.l
gcc lex.yy.c -o scanner -lfl
echo "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()
Tip

词法分析器的错误恢复应该尽量简单——跳过非法字符并继续。复杂的错误恢复留给语法分析器处理,因为语法分析器有更多的上下文信息来判断如何恢复。

九、动手实践#

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 的预处理 Token
clang -dump-pp-token test.c 2>/dev/null

9.3 实验三:对比正则引擎和 DFA#

import re
import 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 流,分组、过滤、分类、定位
正则 → NFAThompson 构造,O(n) 状态数
NFA → DFA子集构造,可能状态爆炸
最大匹配总是匹配最长的可能 Token
关键字处理先匹配标识符,再查关键字表
Token 设计细粒度类型便于语法分析,位置信息便于错误报告
特殊问题Python 缩进、JS 正则/除法歧义、C 预处理 Token
性能优化双缓冲区、表驱动 vs 直接编码
错误恢复跳过非法字符,简单恢复

用了整章的篇幅拆解词法分析。下一章进入 语法分析,看看 Token 流如何被组织为抽象语法树(AST)。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

词法分析:从字符到 Token
https://blog.souloss.com/posts/compiler/lexical-analysis/
作者
Souloss
发布于
2025-12-11
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时