词法分析器开发(下):操作符与分隔符识别、错误处理机制、单元测试编写

好,咱们接着聊。上一节我们把标识符和关键字给拿下了,这一节要啃的,是操作符和分隔符。说实话,这部分看着简单,但坑不少。我当年第一次写词法分析器时,就在操作符的贪婪匹配上栽过跟头——<= 被拆成了 <=,结果后面语法分析直接崩了。嗯,咱们今天就把这些坑一个个填平。

操作符识别:贪婪匹配与优先级

HDL里的操作符,有单字符的,比如 +-*;也有双字符的,比如 <==>/=。你想想看,如果读到 <,你怎么知道后面有没有 =

我的做法很简单:先看两个字符,能匹配双字符操作符就认双字符,不行再退回单字符。这就是所谓的「最长匹配」原则。

def _try_match_operator(self, pos):
    # 先尝试双字符
    if pos + 1 < len(self.source):
        two_char = self.source[pos:pos+2]
        if two_char in self.operator_map:
            return self.operator_map[two_char], 2
    # 再尝试单字符
    one_char = self.source[pos]
    if one_char in self.operator_map:
        return self.operator_map[one_char], 1
    return None, 0
个人经验:我习惯把操作符定义成一个字典,key 是字符串,value 是 Token 类型。这样查起来快,也方便扩展。比如你要加一个 ** 幂运算符,加一行字典就行。

这里有个细节:-- 在 VHDL 里是注释开头,但在 Verilog 里不是。所以操作符表得根据语言来配。我见过有人把所有符号塞进一个表里,结果解析 VHDL 时把 -- 当成了两个减号……嗯,那画面太美。

分隔符识别:括号、逗号、分号

分隔符其实比操作符还简单,因为它们都是单字符。但简单归简单,容易漏。比如 ; 在 HDL 里是语句结束,你要是漏了,语法分析器会报一堆莫名其妙的错。

我一般把分隔符也放进一个字典里,跟操作符分开管理:

self.delimiter_map = {
    '(': 'LPAREN',
    ')': 'RPAREN',
    ',': 'COMMA',
    ';': 'SEMICOLON',
    ':': 'COLON',
    '.': 'DOT',
}

识别逻辑就更直接了——查表,命中就返回,没命中就报错。

注意:冒号 : 在 VHDL 里也用于信号赋值 <= 的一部分,但单独出现时是分隔符。所以你得先处理双字符操作符,再处理单字符分隔符。顺序错了,解析就乱套。

错误处理机制:别让一个错卡死整个文件

词法分析器的错误处理,说白了就是「发现错误,记录错误,继续往下走」。你不能一遇到非法字符就抛异常退出,那样用户得疯——改一个错,跑一次,再改一个,再跑一次……

我设计的错误处理流程是这样的:

  1. 遇到非法字符:记录错误信息(行号、列号、非法字符内容)
  2. 跳过该字符:继续解析下一个
  3. 收集所有错误:最后一次性返回给调用方
def _report_error(self, message, line, column):
    self.errors.append({
        'message': message,
        'line': line,
        'column': column,
    })
    # 不抛异常,继续解析

我曾经在一个项目里遇到过这种情况:用户写了一个 @ 符号,这在 Verilog 里是非法的。如果我的词法分析器直接崩溃,用户得花半小时定位问题。但有了错误收集机制,我可以一次性告诉他:「第3行第5列有个非法字符 @,第12行第8列有个未闭合的字符串」。这才叫用户体验。

核心原则:词法分析器要「宽容地读,严格地报」。能恢复就恢复,不能恢复也要给出精确的定位信息。

单元测试编写:把边界情况都测一遍

好,代码写完了,怎么保证它没问题?写单元测试。我个人的习惯是:先写测试,再写代码(TDD)。但如果你已经写完了代码,也别慌,补测试就行。

测试用例要覆盖哪些场景?我列了个清单:

测试类别 具体用例 预期结果
操作符 <==>/= 识别为双字符操作符
操作符 +-* 识别为单字符操作符
分隔符 (); 识别为对应分隔符
错误恢复 @# 记录错误,继续解析
混合输入 a <= b + c; 正确识别所有 Token

用 Python 的 unittest 框架写起来很顺手:

import unittest

class TestLexer(unittest.TestCase):
    def test_operator_two_char(self):
        lexer = Lexer("a <= b")
        tokens = lexer.tokenize()
        self.assertEqual(tokens[1].type, 'LE')
    
    def test_error_recovery(self):
        lexer = Lexer("a @ b")
        tokens = lexer.tokenize()
        self.assertGreater(len(lexer.errors), 0)
        self.assertEqual(len(tokens), 2)  # a 和 b 被正确识别

if __name__ == '__main__':
    unittest.main()
避坑指南:我曾经写过一个测试,只测了正常情况,没测边界。结果上线后,用户输入了一个空文件,词法分析器直接返回了空列表,但上层代码没处理这种情况,导致空指针异常。所以,空输入、只有注释、只有空白字符这些边界情况,一定要测。

知识体系总览

这一节的内容,我用一张 SVG 图来总结。你看一眼,就能把操作符识别、分隔符识别、错误处理、单元测试这四块的关系理清楚。

词法分析器(下) 操作符识别 贪婪匹配:先试双字符 字典映射:operator_map 分隔符识别 单字符匹配:查表即得 与操作符顺序配合 错误处理机制 记录错误,不抛异常 跳过非法字符,继续解析 单元测试编写 覆盖正常/边界/异常 unittest 框架验证 四个模块相互独立,但顺序执行:先识别操作符/分隔符,再处理错误,最后用测试验证

这张图把四个模块的关系画得很清楚。操作符和分隔符是「识别层」,错误处理是「容错层」,单元测试是「质量层」。三层叠加,就是一个靠谱的词法分析器。

好,这一节就到这儿。代码写完了,测试也补上了,接下来就可以放心地交给语法分析器去用了。你想想看,如果每个模块都这么扎实地做,整个工具链会稳得多。


公众号:蓝海资料掘金营,微信deep3321