表达式求值(一):词法分析(Lexical Analysis)

1. 问题背景:为什么需要词法分析?

表达式求值并不是一次性完成的,而是一个典型的“分阶段处理”问题。
词法分析的目标,是将原始字符串转化为结构化、可被后续算法处理的 Token 序列。

2. 表达式语言的最小定义

表达式的样式类似:

"0x80100000+   ($a0 +5)*4 - *(  $t1 + 8) + number"

2.1 支持的 Token 类型

  • 整数(十进制 / 十六进制)
  • 寄存器 $eax
  • 运算符 + - * / ==
  • 括号
  • 变量

3. Token 的设计

typedef struct token {
  int type;
  char str[32];
} Token;

解释:

  • type:token 类型
  • str:原始字符串(用于后续转换)

3.1 什么时候保存原始字符串

对于仅仅记录类型不够的 token,就需要保存其原始字符串。例如整数、寄存器以及变量等需要知道其具体数值的 token。

4. 基于正则表达式的 Token 识别策略

4.1 为什么用 regex

在 Token 的识别场景中选择正则表达式,核心是其能以简洁、可维护、精准的方式解决 “文本模式匹配” 问题,相比纯手动字符串遍历 / 字符判断,适配 Token 提取的核心需求。

1. 简洁描述复杂的 Token 格式规则

Token 识别的核心是 “按格式特征区分不同类型”(如十六进制、寄存器、数字、标识符等),正则可将复杂的格式约束浓缩为一行表达式,避免大量冗余的 if-else/ 字符遍历代码

  • 例 1:识别十六进制 0x80100000 → 正则 0[xX][0-9a-fA-F]+ 一行描述 “0x/0X 前缀 + 1 + 个十六进制字符” 的规则,无需手动判断每个字符是否为 0/x/0-9a-f
  • 例 2:识别寄存器 $a0/$t1 → 正则 \$(0|[a-zA-Z]+[0-9]*) 精准描述 “$前缀 + 仅$0 纯数字 / 其他字母开头 + 可选数字” 的约束,替代数十行字符判断逻辑;
  • 例 3:识别十进制数字 → 正则 [0-9]+ 替代 isdigit() 循环遍历。

2. 统一的规则管理,易扩展 / 维护

Token 类型会随需求扩展(如新增标识符、解引用符、比较符等),基于正则可将所有 Token 规则集中管理(如 rules 数组),新增 / 修改 Token 只需调整正则表达式,无需改动核心匹配逻辑:

// 集中管理的规则数组,新增 Token 仅需加一行
static struct rule rules[] = {
  {" +", TK_NOTYPE},          // 空格
  {"0[xX][0-9a-fA-F]+", TK_HEX}, // 十六进制
  {"[0-9]+", TK_DEC},         // 十进制
  {"\\$(0|[a-zA-Z]+[0-9]*)", TK_REG}, // 寄存器
  {"[a-zA-Z_][a-zA-Z0-9_]*", TK_IDENT}, // 标识符(如 number)
  // 新增 Token 仅需追加规则
};

相比纯手动遍历(新增 Token 需修改核心匹配函数,易引入 bug),正则规则的 “声明式” 特性大幅降低维护成本。

3. 精准区分易混淆的 Token 格式

Token 识别的关键挑战是 “避免格式重叠导致的误匹配”,正则可通过规则优先级 + 精准模式解决:

  • 优先级:将十六进制规则放在十进制前,避免 0x80100000 被拆分为 0(十进制)+ x + 80100000(十进制);
  • 精准度:通过 \$(0|[a-zA-Z]+[0-9]*) 区分合法寄存器($0/$a0)和非法格式($123/$b0),避免纯遍历的 “一刀切” 判断。

4.2 匹配顺序的重要性

POSIX 正则引擎(regcomp/regexec)遵循「按规则数组顺序遍历 + 最长左匹配 + 首次匹配即终止」的核心逻辑,错误的匹配顺序会直接导致 Token 识别错误(如拆分合法 Token、误判 Token 类型),甚至整个词法分析流程失效。匹配顺序的关键价值在:

4.2.1 先理解正则匹配的底层规则

在基于规则数组的 Token 识别中,引擎的执行逻辑是:

  1. 从文本当前位置开始,按规则数组的顺序逐个尝试正则匹配;
  2. 若某条规则匹配成功(且满足 “最长左匹配”),则将该段文本标记为对应 Token 类型,跳过已匹配文本,从下一个位置重新开始匹配;
  3. 若规则匹配失败,则继续尝试下一条规则,直到遍历完所有规则(无匹配则报错)。

简言之:先定义的规则拥有 “匹配优先权”,这是顺序重要性的核心根源。

4.2.2 核心匹配顺序原则(结合 Token 识别场景)

根据 Token 类型的格式特征,需遵循以下 4 条核心原则,确保识别精准:

原则 1:特殊 / 长格式规则 → 通用 / 短格式规则

逻辑:包含更多格式约束的 “特殊 Token”(如十六进制、多字符运算符),必须优先于 “通用 Token”(如十进制、单字符运算符),避免长格式被拆分为短格式。

  • 典型场景 1:十六进制(0x80100000)优先于十进制([0-9]+

    ✅ 正确顺序:0[xX][0-9a-fA-F]+(十六进制)→ [0-9]+(十进制)
    ❌ 错误顺序:十进制在前 → 0x80100000 会被拆分为 0(十进制)+ x(无匹配)+ 80100000(十进制),完全错误。

  • 典型场景 2:多字符运算符(==)优先于单字符运算符(=
    ✅ 正确顺序:==TK_EQ)→ =TK_ASSIGN
    ❌ 错误顺序:单字符在前 → == 会被拆分为两个 =(TK_ASSIGN),而非一个 ==(TK_EQ)。

原则 2:带前缀的规则 → 无前缀的通用标识符

逻辑:有固定前缀的 Token(如寄存器 $a0),需优先于普通标识符(如 number),避免前缀被拆分、主体被误判为标识符。

  • 典型场景:寄存器(\$(0|[a-zA-Z]+[0-9]*))优先于标识符([a-zA-Z_][a-zA-Z0-9_]*

    ✅ 正确顺序:TK_REG → TK_IDENT

    ❌ 错误顺序:标识符在前 → $a0 会被拆分为 $(无匹配)+ a0(TK_IDENT),丢失寄存器类型。

原则 3:忽略类规则(如空格)前置,但不影响核心 Token

逻辑:空格、制表符等无需作为 Token 的文本,需放在规则数组最前面 —— 匹配后标记为 TK_NOTYPE,直接跳过,避免空格被误判为其他 Token,同时不抢占核心 Token 的匹配优先级。

原则 4:运算符规则后置(无格式冲突时)

逻辑:运算符(+/-/*//)格式简单(单字符),且与数字、寄存器、标识符无格式重叠,可放在核心 Token 规则之后,避免抢占合法 Token 的匹配机会。

  • 例外:一元运算符(如 -123 的负号、*$t1 的解引用)无需调整顺序 —— 正则的 “最长左匹配” 会优先匹配数字 / 寄存器,自然区分一元 / 二元运算符。

5. regex 规则表的组织方式(核心)

回答: 为什么是“数组”,而不是一个大正则?

在 Token 识别场景中,选择「规则数组(多段小正则 + 绑定 Token 类型)」而非「单个大正则」,并非语法偏好,而是由 Token 识别核心需求(匹配文本 + 精准分类)工程化要求(可控、可扩展、可调试) 决定的。

单个大正则仅能实现 “匹配所有合法 Token”,但无法解决 “类型映射、顺序可控、维护扩展” 等核心问题,而规则数组是适配该场景的工程化最优解。

5.1 核心矛盾:单个大正则解决不了 Token 识别的核心诉求

Token 识别的目标不是 “找出所有合法文本片段”,而是 “找出片段 + 给每个片段打唯一的 Token 类型标签”。单个大正则的致命缺陷是:能匹配,但无法直接关联 Token 类型,而规则数组通过 “正则模式 + 类型标识” 的绑定,完美解决这一核心问题

5.2 深度拆解:为什么单个大正则不可行?

5.2.1 问题 1:类型映射冗余且易出错

单个大正则需通过 “分组” 包含所有 Token 模式,但匹配后需手动遍历所有分组,判断 “哪个分组匹配成功”,再映射为 Token 类型

5.2.2 问题 2:匹配顺序完全失控

单个大正则的分组顺序≠匹配优先级,正则引擎会按 “最长左匹配”“贪婪匹配” 规则自行决定匹配顺序,必然导致 Token 拆分错误。

反例:十六进制 vs 十进制的匹配失控

单个大正则分组顺序为 ([0-9]+)|(0[xX][0-9a-fA-F]+)(十进制在前),引擎会优先匹配 [0-9]+,导致 0x80100000 被拆分为 0(十进制)+ x80100000(无匹配);即使调整分组顺序为 (0[xX][0-9a-fA-F]+)|([0-9]+),部分 POSIX 引擎仍会因 “最长匹配” 逻辑误判,无法兼容所有环境。

而规则数组可严格按 “十六进制→十进制” 的顺序尝试匹配,完全规避该问题。

6. 词法分析主流程(make_token)

  1. 从字符串起始位置开始
  2. 尝试用所有 regex 规则进行匹配
  3. 找到第一个成功匹配的规则
  4. 生成 Token 并前进输入指针
  5. 重复直到字符串结束

7. 一元运算符的特殊处理

  • 什么时候是减号?
  • 什么时候是解引用?

在处理 token 的时候,会发现例如-*其实均有两层含义:

  • -:负数(一元运算符)和减号(二元运算符)
  • *:解引用(一元运算符)和乘号(二元运算符)

一元运算符(负号 -、解引用 *)是词法分析阶段的隐含难点,核心矛盾在于:单个 -/* 的字符格式无法直接区分其语义(一元 / 二元),必须结合上下文位置特征做初步分类,而最终语义需由语法分析阶段确认。

8. 错误处理与边界情况

8.1 无法匹配任何规则:词法分析的 “硬错误”

8.1.1 触发场景

当解析指针指向的字符 / 子串,遍历完所有正则规则后均无法匹配(即 make_tokeni == NR_REGEX),本质是输入中存在 “无规则可匹配的字符 / 子串”

8.1.2 识别逻辑(基于 make_token 代码)

外层循环中,内层遍历所有规则后未找到匹配项(i == NR_REGEX),判定为 “无法匹配任何规则”,触发错误处理。

8.2 Token 过长:缓冲区溢出的 “隐形风险”

Token 的字符串长度超出预设缓冲区(tokens[nr_token].str 的固定长度为32)。

8.2.2 识别逻辑

正则匹配成功(找到对应规则),但匹配到的子串长度 substr_len 超过 tokens[nr_token].str 的最大容量,或超过系统 / 业务定义的 Token 长度上限(如约定数字最长 32 位)。

8.2.3 处理策略(核心:防御溢出 + 明确报错)

Token 过长会导致缓冲区溢出(内存越界),是严重安全问题,需严格处理:

  • 严格模式:识别到长度超限后,打印 “Token 过长” 错误(标注 Token 类型、长度、上限),终止解析;。

8.3 非法字符:从 “词法规则” 理解本质

很多开发者易混淆 “非法字符” 与 “无法匹配规则”,核心需明确:非法字符的本质是 “无正则规则可匹配的字符 / 子串”,是 “无法匹配任何规则” 的子集,但需区分两类 “非法”:

8.3.1 词法层面的 “字符非法”(真正的非法字符)

指字符本身不在任何正则规则的匹配范围内,是最直接的非法场景:

  • 示例:@#%& 等特殊符号(无对应规则);
  • 识别:触发 “无法匹配任何规则” 错误;
  • 处理:直接报错终止。

这也是词法分析的核心预警功能。

8.3.2 语义层面的 “格式合法但内容非法”(非词法错误)

指字符 / 子串能匹配正则规则(格式合法),但不符合业务语义约束(如不在合法列表中),词法分析不处理此类 “非法”,需留到语义分析阶段:

  • 示例 1:$x99 能匹配寄存器正则(格式合法),但不在 regs 数组中(语义非法);
  • 示例 2:0xg80100000 中的 g 是十六进制规则外的字符(词法非法),但 0x80100000 格式合法(词法合法);
  • 边界:词法分析仅负责 “格式是否匹配规则”,语义合法性由后续阶段判断。

9. 测试驱动开发:如何验证词法分析正确性?

词法分析是编译器 / 解释器前端的基础,其正确性直接决定后续语法分析、求值的可靠性。

采用测试驱动开发(TDD) 验证词法分析,核心是 “脱离后续模块独立验证、用结构化用例覆盖场景、批量自动化校验”—— 这是工程化开发的核心思维,而非仅满足 “功能能用” 的刷题思维。

9.1 为什么要独立测试词法分析?

词法分析(make_token)的核心职责是 “将字符串转为 Token 序列”,独立测试它的价值在于:

9.1.1 不依赖后续模块(parser/eval),降低调试耦合

若等到 “词法 + 语法 + 求值” 全流程联调时才发现错误,无法快速定位是 “Token 生成错误” 还是 “语法解析错误”。独立测试词法分析:

  • 直接调用 make_token,无需依赖未实现的 expr(求值)、语法解析模块;
  • 聚焦 “Token 序列是否符合预期”,排除其他模块干扰。

9.1.2 快速定位错误,提升调试效率

词法错误是 “源头错误”,若 Token 生成错误,后续所有逻辑都会偏离预期。独立测试可:

  • 精准定位 “哪个表达式生成了错误的 Token”“Token 类型 / 字符串不匹配”;
  • 提前暴露边界场景(如超长 Token、非法字符、混合空格),避免错误扩散到后续模块。

9.1.3 覆盖全场景,保证代码健壮性

独立测试可针对性覆盖词法分析的所有核心场景(数字、寄存器、运算符、混合空格、非法字符等),而非仅靠 “手动输几个例子” 验证,符合工业级代码的可靠性要求。

9.2 Mock 测试环境设计

直接将expr.c复制到一个测试目录,重命名为mock_test.c,然后:

  1. Mock 基础环境:定义极简宏(如 Log/panic/ARRLEN),替代复杂的日志 / 断言框架,保证测试代码可独立编译运行。示例:

    // 1. Mock基础宏:极简且通用,无外部依赖
    #define ARRLEN(arr) (sizeof(arr) / sizeof((arr)[0])) // 数组长度计算
    #define Log(...) printf(__VA_ARGS__), printf("\n")   // 简易日志
    #define panic(fmt, ...) do { printf("PANIC: " fmt, ##__VA_ARGS__); assert(0); } while (0) // 错误终止
    
  2. 直接调用 make_token:跳过 expr(求值)等后续逻辑,直接传入测试表达式,触发 Token 生成;

  3. 打印 + 比对 Token 序列

    • 打印生成的 Token(类型 + 字符串),便于人工调试;
    • 自动比对 “实际 Token 序列” 与 “预期 Token 序列”,实现自动化校验。

9.3 表驱动测试用例(工程化核心)

表驱动测试的核心是 “将测试用例抽象为数据结构,用通用函数批量校验”,而非为每个用例写重复的校验代码 —— 这是工程思维与 “逐例手写测试” 的核心区别。

9.3.1 测试用例数据结构设计

封装 “输入表达式 + 预期输出” 为结构化数据,覆盖 “输入、预期 Token 数量、预期 Token 类型、预期 Token 字符串”,可灵活扩展:

typedef struct {
  const char *name;                // 测试用例名称(便于定位失败用例)
  const char *expr;                // 测试表达式(输入)
  int expected_nr_token;           // 预期Token数量
  int *expected_types;             // 预期Token类型数组
  const char **expected_strs;      // 预期Token字符串数组(仅针对需存储字符串的Token)
} TestCase;

设计价值:新增测试用例只需 “填充数据”,无需修改校验逻辑,符合 “开闭原则”。

9.3.2 通用测试函数(复用核心逻辑)

编写 run_test_case 函数,封装所有测试用例的通用校验逻辑,避免重复代码:

9.3.3 批量运行测试套件

编写 run_all_tests 函数,批量运行所有测试用例并统计结果,实现自动化校验:

9.3.4 核心测试场景覆盖(工程化思维)

测试用例需覆盖词法分析的全场景,而非仅简单案例,典型场景:

测试场景 示例表达式 验证点
基础数字 + 运算符 1+2+3 Token 数量、类型、字符串匹配
混合空格 1 + 2 + 3 空格被忽略,Token 序列与无空格一致
括号 + 多数字 (1+315)/4- 3*428 + 8 括号、运算符、数字的 Token 生成正确性
十六进制 + 十进制混合 (0x1234AfdE + 12345 + 0123) / 0x12345 - 12 十六进制 Token 识别、0 开头十进制处理
寄存器 + 标识符 + 解引用 0x80100000+ ($a0 +5)*4 - *( $t1 + 8) + number 寄存器、标识符、一元 * 的 Token 生成
非法字符 / 超长 Token 123@456/0x801000008010000080100000 错误处理逻辑(报错、终止解析)
Token数量超过数组上限 多于 tokens 数组大小的表达式 错误处理逻辑(报错、终止解析)

10. 小结:Lexical 阶段的职责边界

将线性的字符流,转换为语义明确的 Token 流,而不关心运算优先级或求值结果。

posted @ 2025-12-15 08:24  上山砍大树  阅读(4)  评论(0)    收藏  举报