编译器词法分析全指南:从原理到实现的落地路径

词法分析到底在做什么

词法分析是编译器的第一个阶段,核心任务是把源程序的字符流拆成一个个有意义的“单词”(Token)。打个比方,就像我们读英文文章时,会自动把连续的字母分成单词——词法分析器就是编译器的“分词器”,但它的规则更严谨。

编译器词法分析全指南:从原理到实现的落地路径

要理解词法分析,得先分清三个关键概念,用表格对比更清楚:

术语 定义 例子
词素(Lexeme) 源程序中实际出现的字符序列,是Token的“原材料” int123==
模式(Pattern) 描述一类词素的规则(比如“整数”的模式是“连续的数字”),通常用正则表达式表示 整数模式:[0-9]+
Token 词素的抽象表示(包含“类型”和“值”),是编译器后续阶段(语法分析)的输入 (INT_KEYWORD, “int”)、(INT_LITERAL, “123”)

举个例子:源程序里的int a = 123;,词法分析器会输出4个Token:
1. (INT_KEYWORD, “int”)——关键字
2. (IDENTIFIER, “a”)——标识符
3. (ASSIGN, “=”)——赋值运算符
4. (INT_LITERAL, “123”)——整数字面量

词法分析的本质,就是将“字符序列”映射到“Token序列”,帮编译器屏蔽源程序的“字符级细节”,聚焦于“语法结构”。

如何定义词法规则:正则表达式+DFA

词法规则是词法分析的“法律”——所有词素都必须匹配某个规则。而描述规则的最佳工具是正则表达式,但正则表达式是“声明式”的(说“要什么”),词法分析器需要“命令式”的执行逻辑(说“怎么做”),这就需要把正则表达式转换成确定有限自动机(DFA)

步骤1:用正则表达式描述模式

首先,你需要为每种Token定义正则表达式。比如:
– 关键字(if/else):if|else确保是完整单词,避免把ifx误判为关键字)
– 标识符:[a-zA-Z_][a-zA-Z0-9_]*(字母或下划线开头,后面跟字母、数字或下划线)
– 整数:[0-9]+(连续数字)
– 比较运算符:==|!=|<|>|<=|>=(长运算符优先,比如先匹配==再匹配=

步骤2:正则→NFA→DFA

正则表达式无法直接执行,需要先转成非确定有限自动机(NFA),再转成DFA(因为DFA的状态转移是确定的,执行效率更高)。这里用一个简单例子演示流程:

假设我们要处理“整数或浮点数”的模式:[0-9]+(.[0-9]+)?(比如123123.45)。

  1. 正则→NFA:用Thompson构造法,把正则表达式拆成基本单元(单字符、连接、选择、闭包),逐步构建NFA。比如:
  2. 单个字符[0-9]对应一个NFA(状态0→状态1,输入0-9)
  3. +(至少一次)对应“循环”:状态1→状态0的ε转移(ε表示“空输入”)
  4. (.[0-9]+)?(可选的小数部分)对应“选择”:状态2→状态3(输入.)→状态4→状态5(输入0-9)→状态4的ε转移(循环),然后状态2有ε转移到终点(表示没有小数部分)。

  5. NFA→DFA:用子集构造法,把NFA的“状态集合”当作DFA的“单个状态”。比如:

  6. 初始状态:NFA初始状态的ε-闭包(所有能通过ε转移到达的状态)
  7. 对每个输入字符,计算当前状态集合的“转移后的ε-闭包”,作为DFA的下一个状态。

  8. DFA最小化:去掉冗余状态(比如两个状态对所有输入的转移都相同,且都是接受状态或都不是),减少DFA的复杂度。比如如果有两个状态S1和S2,输入0-9都转移到S3,输入.都转移到S4,且都是接受状态,那么可以合并成一个状态。

最终的DFA,就是词法分析器的“匹配引擎”——每读一个字符,就按照DFA的状态转移规则移动,直到到达“接受状态”(匹配成功)或“错误状态”(匹配失败)。

词法分析器的核心流程拆解

词法分析器的工作流程,可以拆解成4个核心步骤,每一步都有细节要注意:

1. 字符读取:处理“向前看”(Lookahead)

词法分析器需要逐个读取字符,但有时候需要“先看一眼下一个字符”再决定怎么处理——这叫“向前看”。比如识别==时:
– 先读第一个=,此时不确定是=(赋值)还是==(等于);
– 再读下一个字符,如果是=,就组成==
– 如果不是,就把第二个字符“退回去”(ungetc),只处理第一个=

几乎所有词法分析器都需要这个逻辑——比如C语言中的<=(小于等于)、++(自增),都需要向前看1个字符。

2. 词法扫描:跳过无关字符

源程序中的空格、换行、注释都是“无关字符”(不影响语法结构),词法分析器需要跳过它们。比如:
– 空格/换行:直接读取下一个字符;
– 注释://(单行)需要读到换行符为止,/* */(多行)需要读到*/为止(注意处理嵌套注释?不,C语言不允许嵌套注释,所以直接找*/就行)。

跳过无关字符的目的,是让后续阶段(语法分析)不用处理“字符级的噪音”。

3. 模式匹配:最长匹配原则

当多个模式都能匹配当前字符序列时,要选最长的那个——这叫“最长匹配原则”。比如:
– 源程序中的123a,如果有两个模式:[0-9]+(整数)和[a-zA-Z_][a-zA-Z0-9_]*(标识符),那么最长匹配是123(整数)+a(标识符),而不是1+23a

另一个例子:ifx,如果关键字if的模式是if(带单词边界),那么ifx会被匹配为标识符([a-zA-Z_][a-zA-Z0-9_]*),而不是关键字if+x——这就是为什么关键字的正则要加

4. 生成Token:记录类型和值

匹配到词素后,需要生成Token——包含“类型”(比如INT_KEYWORD、IDENTIFIER)和“值”(比如inta)。Token的类型是枚举值(方便语法分析器判断),值是词素的原始字符串(方便后续阶段获取具体内容,比如语义分析中的变量名)。

常见问题与避坑技巧

词法分析看起来简单,但实际实现中会遇到很多“坑”,这里列几个高频问题及解决方法:

问题1:关键字与标识符冲突

比如int是关键字,但用户可能写int int = 123;——这时候第一个int是关键字,第二个int是标识符?不对,因为关键字是“保留字”,不能作为标识符。解决方法:把关键字放到“优先匹配列表”——词法分析器先查关键字表,如果当前词素是关键字,就生成关键字Token,否则生成标识符Token。

比如Python中的关键字表:['if', 'else', 'for', 'while', ...],词法分析器遇到if时,先查这个表,匹配就生成(KEYWORD, ‘if’),否则生成(IDENTIFIER, ‘if’)。

问题2:转义字符的处理

字符串中的转义字符(比如
")需要特殊处理——比如源程序中的"hello
world"
,词素是"hello
world"
,但实际存储的是hello+换行+world。解决方法:在词法分析时解析转义字符,把
转换成ASCII的换行符(0x0A),"转换成双引号(0x22)。

问题3:多行注释的嵌套

比如C语言中的/* /* comment */ */,这其实是非法的(C语言不允许嵌套注释),但有些编译器会支持。解决方法:用计数器记录注释层数——遇到/*时计数器+1,遇到*/时计数器-1,直到计数器为0时结束注释。

问题4:浮点数的歧义

比如123.(末尾的点),是整数还是浮点数?根据C语言的规则,123.是浮点数(double类型),但有些词法分析器会把123.误判为整数123+点运算符。解决方法:调整正则表达式的顺序——把浮点数的正则放到整数前面,比如先匹配[0-9]+(.[0-9]+)?(浮点数),再匹配[0-9]+(整数),这样123.会先匹配浮点数模式。

用Python实现一个简单词法分析器

说了这么多理论,不如动手写一个——我们用Python实现一个处理“简单表达式”的词法分析器,支持:
– 关键字:ifelse
– 标识符:字母或下划线开头
– 整数:连续数字
– 运算符:+-*/===

步骤1:定义词法规则和Token类型

首先,我们用re模块(Python的正则表达式库)来处理模式匹配,规则列表要按“长匹配优先”排序(比如==要在=前面):

import re

# Token类型枚举(用字符串代替枚举,方便调试)
TOKEN_TYPES = {
    'KEYWORD': 'KEYWORD',
    'IDENTIFIER': 'IDENTIFIER',
    'INT_LITERAL': 'INT_LITERAL',
    'OPERATOR': 'OPERATOR',
    'WHITESPACE': 'WHITESPACE'  # 跳过
}

# 词法规则:(Token类型, 正则表达式),长规则优先
token_specs = [
    (TOKEN_TYPES['KEYWORD'], r'if|else'),
    (TOKEN_TYPES['IDENTIFIER'], r'[a-zA-Z_][a-zA-Z0-9_]*'),
    (TOKEN_TYPES['INT_LITERAL'], r'd+'),
    (TOKEN_TYPES['OPERATOR'], r'==|[-+*/=]'),
    (TOKEN_TYPES['WHITESPACE'], r's+'),
]

步骤2:编译正则表达式

re.finditer来遍历源程序中的所有匹配项,注意正则表达式的分组命名(用(?P<name>...)):

# 编译正则表达式:拼接所有规则,每个规则用命名分组
token_regex = '|'.join(f'(?P<{name}>{regex})' for name, regex in token_specs)

步骤3:实现词法分析函数

遍历匹配项,生成Token序列,跳过空格:

def lex(source_code):
    tokens = []
    # 遍历所有匹配项
    for match in re.finditer(token_regex, source_code):
        # 获取匹配的Token类型和值
        token_type = match.lastgroup
        token_value = match.group(token_type)

        # 跳过空格
        if token_type == TOKEN_TYPES['WHITESPACE']:
            continue

        # 添加Token(类型,值)
        tokens.append((token_type, token_value))

    return tokens

步骤4:测试

我们用一个简单的源程序测试:

# 测试源程序
source = '''
if x == 42:
    y = x + 10
else:
    y = 0
'''

# 生成Token
tokens = lex(source)

# 打印结果
for token in tokens:
    print(token)

输出结果(部分):

('KEYWORD', 'if')
('IDENTIFIER', 'x')
('OPERATOR', '==')
('INT_LITERAL', '42')
('OPERATOR', ':')
('IDENTIFIER', 'y')
('OPERATOR', '=')
('IDENTIFIER', 'x')
('OPERATOR', '+')
('INT_LITERAL', '10')
...

这个词法分析器虽然简单,但覆盖了词法分析的核心逻辑——你可以在此基础上扩展:比如添加浮点数、字符串字面量、注释处理,或者用DFA代替正则表达式(更高效)。

最后:词法分析的“边界”

词法分析是编译器的“第一个关卡”,但它的职责是“拆单词”,而不是“检查语法”——比如int 123 = a;,词法分析器会生成正确的Token(INT_KEYWORD、INT_LITERAL、ASSIGN、IDENTIFIER),但语法分析器会报错(因为变量名不能是整数)。

记住:词法分析器的任务是“正确拆分Token”,而不是“检查语义”——不要让词法分析器做超出职责的事,否则会增加复杂度。

原创文章,作者:,如若转载,请注明出处:https://zube.cn/archives/346

(0)

相关推荐