表达式求值(二):语法分析
语法分析的目的就是在上一步的词法分析结束后,对生成的 token 表达式进行语法上的校验分析,检查是否为合法表达式。
这节就主要讲解如何正确地进行语法分析。最终要实现的就是eval(p, q)表达式:
eval(p, q):
if p > q:
error
if p == q:
return 基本值
if 被一对最外层括号包裹:
return eval(p+1, q-1)
op = 寻找主运算符(p, q)
val1 = eval(p, op-1)
val2 = eval(op+1, q)
return apply(op, val1, val2)
1. 表达式的归纳定义
表达式的归纳定义特性可以浓缩为两个关键点:
- 基础边界:存在不可再分的 “原子表达式”(数字、寄存器等),是所有复杂表达式的起点;
- 递归组合:用运算符 / 括号将已定义的表达式组合成新表达式,且组合过程可无限套用但总能拆解回基础单元。
2. 判断表达式被一对最外层括号包裹
判断的函数名为:check_parentheses
2.1 核心问题
check_parentheses 函数要处理:
- 是否是合法的
(expr)结构(布尔值) - 区分 “括号本身非法” 和 “括号匹配但非
(expr)结构” 两种错误
解决方案:
- 扩展函数接口,通过输出型参数传递错误类型 / 报错信息,而非仅返回布尔值
2.2 基于 TDD 的测试开发流程
TDD(测试驱动开发)的核心是 “先写测试用例→验证失败→实现功能→验证通过”,针对 check_parentheses 函数,我们需设计覆盖所有核心场景的表驱动测试用例,既验证 “合法 (expr) 结构” 的正确识别,也验证各类错误场景的精准报错(错误类型 + 位置)。
我们首先创建mock_check_parentheses.c文件,专门用来测试函数check_parentheses。
1. TDD 设计核心思路
针对括号检测函数,测试用例需满足:
- 全覆盖:覆盖 “合法结构 + 所有错误类型”,无遗漏核心场景;
- 可验证:每个用例明确 “输入(token 序列 + 检查范围)+ 预期输出(返回值 + 错误类型 + 错误位置)”;
- 低耦合:测试用例不依赖求值逻辑,仅针对
check_parentheses函数独立验证; - 结构化:表驱动设计,新增场景只需补充数据,无需修改测试逻辑。
2. 测试用例数据结构设计
基于 check_parentheses 的接口,定义专属测试用例结构体,封装 “输入 + 预期输出”:
// 括号检测测试用例结构体
typedef struct {
const char *name; // 测试用例名称(便于定位失败场景)
Token test_tokens[16]; // 测试用的token序列
int nr_tokens; // token序列长度
int start; // check_parentheses的start参数
int end; // check_parentheses的end参数
bool expected_ret; // 预期返回值(true/false)
ParenErrType expected_err; // 预期错误类型
} TestCase;
3. 核心测试场景与表驱动用例
覆盖 check_parentheses 的所有核心场景,每个用例对应一个明确的测试目标:
| 测试场景 | 用例名称 | 核心验证点 |
|---|---|---|
| 合法 (expr) 结构 | test_valid_single_paren | 返回 true,错误类型为 PAREN_ERR_NONE |
| 括号不匹配 - 多左括号 | test_err_mismatch_more_left | 返回 false,错误类型 PAREN_ERR_MISMATCH,位置为第一个左括号 |
| 括号不匹配 - 多右括号 | test_err_mismatch_more_right | 返回 false,错误类型 PAREN_ERR_MISMATCH,位置为多余的右括号 |
| 括号不匹配 - 顺序错误 | test_err_mismatch_order | 返回 false,错误类型 PAREN_ERR_MISMATCH,位置为非法右括号 |
| 空括号 | test_err_empty_paren | 返回 false,错误类型 PAREN_ERR_EMPTY,位置为左括号 |
| 括号匹配但非 (expr) 结构 | test_err_not_wrap_paren | 返回 false,错误类型 PAREN_ERR_NOT_WRAP,位置为减号 |
| 无效检查范围 | test_err_invalid_range | 返回 false,错误类型 PAREN_ERR_INVALID_RANGE,位置 - 1 |
4. 通用测试函数(TDD 核心:自动化验证)
编写可复用的测试函数,自动执行每个用例并验证结果,输出清晰的失败 / 通过信息:
/**
* @brief 运行单个括号检测测试用例
* @param case_: 测试用例
* @return true: 测试通过;false: 测试失败
*/
static bool run_paren_test_case(const ParenTestCase *case_) {
// 步骤1:替换全局tokens数组(模拟测试场景)
memcpy(tokens, case_->test_tokens, sizeof(Token) * case_->nr_test_tokens);
nr_token = case_->nr_test_tokens;
// 步骤2:调用check_parentheses
bool actual_ret = check_parentheses(case_->check_start, case_->check_end, &err_type, &err_pos);
// 步骤3:验证结果(TDD核心:比对预期与实际)
// 验证返回值
// 验证错误类型
// 验证错误位置
// 步骤4:输出结果
}
/**
* @brief 运行所有括号检测测试用例
*/
static void run_all_paren_tests() {
ParenTestCase all_cases[] = {case1, case2, case3, case4, case5, case6, case7};
...
}
5. TDD 执行流程(落地步骤)
- 第一步:编写测试用例(如上),此时
check_parentheses未实现或实现不完整,运行测试会全部失败; - 第二步:实现
check_parentheses核心逻辑,先聚焦 “合法结构” 和 “括号不匹配” 场景; - 第三步:运行测试,修复失败的用例(如补充空括号、非 (expr) 结构的判断);
- 第四步:迭代优化,直到所有测试用例通过,确保函数覆盖所有场景。
2.3 check_parentheses 输入输出
1. 核心目标
函数要回答两个关键问题:
- 「是 / 否」:
[start, end]范围内的 Token 序列是否是单一、完整的(expr)结构(即整个范围以(开头、)结尾,且内部是合法表达式,无多余括号 / 其他结构); - 「错在哪」:若非法,要区分是 “括号本身不匹配”“空括号”“非 (expr) 结构” 还是 “范围无效”,并定位错误位置。
为了让这个函数“干净”,明确排除这些事情:
-
不检查表达式是否合法
(1 + *) (+ 1 2) -
不检查是否能求值
(1 / 0)
2. 输入输出设计
- 输入:检查范围
[start, end](Token 数组的索引); - 输出:
- 返回值:
true(合法(expr))/false(非法); - 输出型参数
err_type:返回具体错误类型(枚举),解决 “报错原因不同” 的核心需求; - 输出型参数
err_pos:返回错误发生的 Token 位置,方便上层定位问题。
- 返回值:
2.4 设计步骤拆解
步骤 1:初始化 + 边界校验(兜底,避免无效计算)
- 过滤错误边界:
- start > end
- 是不是想成为
(expr)?- 判断表达式首尾是否为左右括号
步骤 2:括号平衡检测(解决 “括号本身是否合法”)
这是括号检测的核心基础,这里我们使用一个括号计数器:
balance = 0 // 当前左括号数量 - 右括号数量
这一步只是用来检验括号是否合法:
- 遍历中
balance < 0,说明右括号提前出现 - 遍历结束后,
balance != 0,说明左括号数量大于右括号
步骤3:判断括号是否提前闭合
判断“最外层是否真的包住整个表达式”,这一步骤需要识别非"(expr)"的表达式。类似:
"(4 + 3) * (2 - 1)"
- 最外层
'('在"(4 + 3)"就已经闭合了 - 后面还有内容 → 不算“整体被包住”
伪代码:
balance = 0
for i from 0 to expr.length - 2: // 注意:不看最后一个 ')'
if expr[i] == '(':
balance += 1
else if expr[i] == ')':
balance -= 1
// 如果在中途就回到 0
// 说明最外层括号提前闭合
if balance == 0:
return false
步骤 4:返回合法结果(所有校验通过)
若上述所有校验都通过,说明 [start, end] 是合法的 (expr) 结构,返回 true,错误类型为 “无错误”。
步骤 5:错误信息精准传递(核心需求落地)
每个错误场景都对应唯一的 err_type 和精准的 err_pos:
| 错误类型 | err_type | err_pos 定位逻辑 |
|---|---|---|
| 范围无效 | PAREN_ERR_INVALID_RANGE | -1(无具体位置) |
| 括号不匹配(多右 / 顺序错) | PAREN_ERR_MISMATCH | 错误的右括号位置 |
| 括号不匹配(多左) | PAREN_ERR_MISMATCH | 第一个未匹配的左括号位置 |
| 空括号 | PAREN_ERR_EMPTY | 左括号位置 |
| 非 (expr) 结构 | PAREN_ERR_NOT_WRAP | 非括号的起始位置或结束位置或(1+2)-(3+4) |
check_parentheses括号处理逻辑实现:📄 Code/expr/mock_check_parentheses.c
3.找到表达式的主运算符
3.1 为什么要找主运算符
"主运算符"为表达式人工求值时, 最后一步进行运行的运算符, 它指示了表达式的类型(例如当一个表达式的最后一步是减法运算时, 它本质上是一个减法表达式).
找到主运算符号才能运算表达式的结果。
3.2 获取主运算符的输入输出特性分析
根据伪代码的指示:
eval(p, q) {
if (p > q) {
/* Bad expression */
}
else if (p == q) {
/* Single token.
* For now this token should be a number.
* Return the value of the number.
*/
}
else if (check_parentheses(p, q) == true) {
/* The expression is surrounded by a matched pair of parentheses.
* If that is the case, just throw away the parentheses.
*/
return eval(p + 1, q - 1);
}
else {
op = the position of 主运算符 in the token expression;
val1 = eval(p, op - 1);
val2 = eval(op + 1, q);
switch (op_type) {
case '+': return val1 + val2;
case '-': /* ... */
case '*': /* ... */
case '/': /* ... */
default: assert(0);
}
}
}
按照“寻找优先级最低”的原则,确定寻找 二元主运算符 函数的输入输出:
1. 输入
输入 token 序列的两个索引地址参数:
- 起始位置:
p - 结束位置:
q
对应了 token 表达式数组要检测的索引区间。
2. 输出
输出的就是主运算符在 token 序列[p, q]中的索引。
需要注意的是,函数并不检测主运算符是否合法。
函数声明为:
static int find_main_operator(int start, int end)
3.3 TDD测试开发流程
1. 测试用例数据结构设计
- 输入:一个 token 数组以及起始和结束地址。
- 输出:验证函数找到的主运算符是否正确。
typedef struct {
const char *name; // 用例描述
Token tokens[16]; // 输入token序列
int nr_tokens; // token序列长度
int start; // 检测起始索引(修正原笔误strat)
int end; // 检测结束索引
int expected_op_pos; // 预期主运算符索引(-1表示无主运算符)
} MainOpTestCase;
2. 核心测试场景
-
通过(绿灯)场景:
// 不带括号的场景 1 + 2 + 3 - 4 1 + 2 - 3 / 4 1 + 2 - 3 * 4 1 + 2 - 3 * 4 + 5 1 / 2 * 3 * 4 + 5 1 * 2 / 3 * 4 * 5 // 带括号的场景 (1 + 2) + 3 - 4 (1 + 2 + 3) - 4 1 + (2 - 3) / 4 1 + 2 - (3 + 4) (1 + 2 - 3) * 4 -
异常(红灯)场景:
// 没有运算符 1 $ 2 1 (2 4) 1 0
这样经过简单 TDD 测试案例,可以较为简单地完成仅有二元运算符的寻找主运算符的方法(只看符号本身即可):
static int find_main_operator(int start, int end) {
int op_index = -1;
if (start > end) {
return op_index;
}
int balance = 0;
for (int i = start; i <= end; i++) {
// 如果 token[i] 是不在括号内的运算符
if (balance == 0 && is_operator(tokens[i].type)) {
if (op_index == -1 || has_higher_precedence(tokens[op_index].type, tokens[i].type)) {
op_index = i;
continue;
}
} else if (tokens[i].type == '(') { // 括号内的运算符均不做数
balance++;
} else if (tokens[i].type == ')') {
balance--;
}
}
return op_index;
}
3.4 单目运算符
当我们引入负数时,发现 - 既可以是减法(Binary),也可以是负号(Unary)。上面实现了双目运算符+ - * /的运算规则,那么考虑下如何实现单目运算符:
- 负号(先解决)
$:访问寄存器或内部变量*:解引用&:取地址
1. 如何区分负号和减号 🎭
首先举例减号和负号的场景,然后分析不同的特性:
// 减号
1 - 2 // 1. 减号的左边 token 为数字、标识符
(1 + 2) - 3 // 2. 减号的左边 token 为右括号
1 + 2 - 3
(1 + 2) - (3 + 4)
// 负号
1 + -2 // 1. 负号的左边是加号运算符,右边是表达式
(1 + 2) + -3
1 + 2 + -3
-1 + -2 // 2. 负号的左边没有 token,右边是表达式
--1 + -2 // 3. 负号左边是减号运算符,右边是表达式
-
减号 (Subtraction): 二元运算符 (Binary Operator),需要两个操作数(左边一个,右边一个)。
-
负号 (Negation): 一元运算符 (Unary Operator),只需要一个操作数(在右边)。
再归纳总结:
| 当前符号 - 的前一个元素 | - 的身份 | 例子 |
|---|---|---|
数字、标识符、右括号 ) |
二元运算符 (减法 ➖) | 5 - 3, x - 1, (a+b) - c |
运算符、左括号 (、或处于句首 |
一元运算符 (负号 负) | -5, 3 * -2, (-x) |
我们意识到不能只看符号本身,必须看它的上下文。
2. 单目运算符的识别时机
可以通过下面的案例来思考:
*ptr + 5
-5 + 2
| 处理顺序 | 表现 | 结论 |
|---|---|---|
| 单目优先 | 可能会错误地截获属于二元运算的一部分(如把 *ptr + 5 误判为 *(ptr+5))。 |
❌ 破坏优先级 |
| 二元优先 | 先找优先级最低的“缝隙”(主运算符)。如果找不到,说明剩下的部分可能是一个整体,再由单目逻辑处理。 | ✅ 保护优先级 |
所以我们选择 二元优先 的处理策略。
3.纯粹的“二元运算符探测器”
之前的寻找主运算符函数find_main_operator仅仅是为了服务于二元运算符的结构,寻找一个“主运算符”把表达式劈成左右两半(val1opval2)。
而实现这个简单的功能,仅需要考虑符号本身以及其对应的优先级即可。对应的函数find_main_operator的辅助函数仅有以下两个函数支持即可:
// 判断是否为运算符
bool is_operator(int type);
// 判断运算符op2的优先级是否高于op1
bool has_higher_precedence(int op1, int op2);
现在由于一元运算符的加入,同时要保持find_main_operator依旧能够识别到正确的二元运算符,就不仅仅需要支持分割二元运算符的:
- 符号本身是否为运算符
- 符号的优先级
还需要加入上下文的判断(以负号-为例):
- 如果
-前面是数字、标识符、寄存器或右括号)→ 二元减法。 - 如果
-处于句首,或者前面是左括号(或另一个运算符 → 一元负号。
通过加入上下文的判断,将find_main_operator打造成为了纯粹的“二元运算符探测器”。
通过改造后的find_main_operator,学会了“识别身份”。如果发现某个 - 或 * 处于一元运算符的位置(比如在句首),它会跳过。
需要注意的是,在修改任何代码之前,请基于 TDD 的开发模式,加入红色🍎的测试案例,让代码不通过,然后再编写代码让其变为🍏通过的测试案例。
这样预想的流程就是:
- 括号剥离:处理
(expr),消除最高优先级的边界。 - 二元主运算符探测:调用
find_main_operator。- 关键改进:它现在学会了“识别身份”。如果发现某个
-或*处于一元运算符的位置(比如在句首),它会跳过。 - 结果:它只返回真正的二元分割点。
- 关键改进:它现在学会了“识别身份”。如果发现某个
- 单目运算符剥壳:如果第 2 步没找到分割点,
eval才检查开头是不是-或*。- 如果是,剥掉符号,递归处理
eval(p + 1, q)。
- 如果是,剥掉符号,递归处理
- 原子项识别:如果以上都不是,那它就是一个纯粹的数字或寄存器。
3.5 纯粹的获取主运算符函数
贴一下增加了精确识别二元运算符find_main_operator的实现:
static int find_main_operator(int start, int end) {
int op_index = -1;
if (start > end) {
return op_index;
}
int balance = 0;
for (int i = start; i <= end; i++) {
// 排除括号内的运算符
if (tokens[i].type == '(') {
balance++;
continue;
}
if (tokens[i].type == ')') {
balance--;
continue;
}
if (balance == 0 && is_operator(tokens[i].type) && can_be_binary_prefix(i - 1, start)) {
if (op_index == -1 || has_higher_precedence(tokens[op_index].type, tokens[i].type)) {
op_index = i;
}
}
}
return op_index;
}

浙公网安备 33010602011771号