你能一眼看出表达式 a - -b 中两个减号分别表示什么吗?第一个减号是二元减法运算符,第二个减号是一元负号。在文本层面上,它们长得一模一样,都由同一个 ASCII 字符 45 表示。如果解析器不做任何区分,这个表达式要么被识别成语法错误,要么被错误地理解成a减去(-b)之外的其他含义。类似的符号混淆在编程语言中比比皆是:* 可以是乘法、指针声明、解引用;& 可以是按位与、取地址、引用声明;< 可以是小于号,也可以是模板参数列表的开始。解决这些模糊性的关键,在于把符号放在上下文和语法规则的约束下去解释。

公式符号混淆的根源:一个字符多个角色
词法分析器通常按照最长匹配原则把输入文本切成 token 流。比如表达式 x = y - -z 会得到标识符 x、赋值号、标识符 y、减号、减号、标识符 z。此时两个减号已经被切成了两个独立的 token,但它们的语义类型还没有确定。词法分析阶段无法判断第一个减号是二元运算还是前缀运算,因为 token 本身不携带足够的结构信息。如果强行在词法阶段做出决定,往往会落入“假装聪明”的陷阱。例如 C 语言中的 ++ 和 + +:前者是一个自增运算符,后者是两个加号 token。如果在词法分析时看到两个连续的加号就合并成一个自增 token,那么 a+++b 会被切成 a、++、+、b,这恰好是 C 语言标准要求的解析方式;但如果表达式是 a + ++b,就需要在第一个加号后面保留空格或让解析器回溯才能正确拆开。这说明纯词法规则无法应对所有符号重叠的场景。
再举一个数学公式的例子。sin(x)^2 中的 ^ 通常表示幂运算,但在某些编程语言里 ^ 是异或运算符。如果解析器同时支持两种语境(比如在 LaTeX 公式环境中和 C 代码环境中),就必须根据语境切换符号映射表。这种切换就是一种上下文信息。更隐蔽的是 ac 这样的文本:在 HTML 中它是标签嵌套,在 C++ 模板中可能是两个模板参数的比较表达式 a < b > c。同一个字符序列,不同语法产生式会给出完全不同的树结构。这说明符号混淆不是符号本身的问题,而是缺少语法约束时产生的歧义。
很多人一遇到符号歧义就想到用更长的前瞻、更多的特判来解决,比如看见减号后面跟数字就认为是一元负号。这种做法在小规模场景下可行,但一旦语法规则复杂起来,特判就会互相冲突,最终变成一堆难以维护的补丁。真正可靠的解决思路,是把歧义交给语法分析阶段,通过上下文无关文法为每个符号指定合法的出现位置和结合方式。
上下文无关语法如何消除符号歧义
上下文无关语法(CFG)用产生式集合描述语言的结构。以四则运算表达式为例,一个常见的文法如下:
expr := term (('+' | '-') term)*
term := factor (('*' | '/') factor)*
factor := NUMBER | '-' factor | '(' expr ')'
在这个文法中,减号出现在两个不同的产生式位置:expr 产生式里的 '-' term 表示二元减法,而 factor 产生式里的 '-' factor 表示一元负号。语法分析器在从 expr 开始向下推导时,会先尝试把 a - -b 中的第一个减号与 expr 产生式的 '-' 匹配,于是左侧是 a 对应的 term,右侧是一个新的 term;而第二个减号则在解析右侧 term 时,遇到 factor 的产生式分支 '-' factor,成功吐出负号并继续解析内部 factor。这个过程没有在词法阶段做任何符号合并或拆分,而是完全依赖语法规则的层叠关系。换句话说,是语法树的层级结构赋予了同样的字符不同的语义角色。
上下文无关语法的另一个重要能力是处理运算符优先级和结合性。上面的文法通过分层 expr、term、factor 天然规定乘除优先于加减,加减左结合。如果直接写 expr := expr '-' expr | expr '+' expr | ... 这样的左递归文法,虽然也能解析表达式,但会产生大量歧义推导,需要额外规定优先级。而分层文法把优先级编码进了产生式结构,解析器不需要再维护优先级表。对于减号和负号的歧义,分层同样有效:负号被放在 factor 层,意味着它绑定得比任何二元运算符都紧。表达式 -a * b 会被解析成 (-a) * b,而不是 -(a * b)。这种绑定关系是语法规则直接给出的,不需要任何特判代码。
有些语言的符号歧义更加复杂,单靠 CFG 无法完全解决,需要引入语义动作或解析后的补充分析。例如 C++ 中的 && 既是逻辑与,也可能是右值引用声明符号 &&。在 auto&& x = foo(); 中,&& 出现在声明符位置,必须解释为右值引用;而在 if (a && b) 中则是逻辑与。这里符号的区分依赖于符号出现的位置和周围的语法类别。一个可行的做法是在语法规则中区分 declaration 和 expression 两类非终结符,让 && 在两个不同产生式分支里被消费。这种方法依然属于上下文语法范畴,只不过上下文范围比单个产生式更大。
用递归下降解析器实现带负号和减号的表达式求值
递归下降解析器是手写语法分析器时最直观的方式。每个非终结符对应一个函数,函数内部根据当前 token 选择产生式分支。下面给出一个支持加、减、乘、除、括号和一元负号的表达式求值器,代码使用 Python 编写,解析后直接计算数值,但思路可以扩展到生成 AST。
class ExprParser:
def __init__(self, text):
self.tokens = self.tokenize(text)
self.pos = 0
def tokenize(self, text):
import re
pattern = re.compile(r'\s*(?:(\d+)|([+\-*/()]))')
result = []
index = 0
while index < len(text):
m = pattern.match(text, index)
if not m:
raise SyntaxError(f"无法识别的字符: {text[index]!r}")
index = m.end()
if m.group(1):
result.append(('NUMBER', int(m.group(1))))
elif m.group(2):
result.append((m.group(2), m.group(2)))
result.append(('EOF', None))
return result
def peek(self):
return self.tokens[self.pos]
def consume(self, expected=None):
token = self.tokens[self.pos]
if expected is not None and token[0] != expected:
raise SyntaxError(f"期望 {expected},实际 {token}")
self.pos += 1
return token
def parse(self):
value = self.expr()
if self.peek()[0] != 'EOF':
raise SyntaxError("表达式结束后仍有额外 token")
return value
# expr := term (('+' | '-') term)*
def expr(self):
left = self.term()
while self.peek()[0] in ('+', '-'):
op = self.consume()[0]
right = self.term()
left = left + right if op == '+' else left - right
return left
# term := factor (('*' | '/') factor)*
def term(self):
left = self.factor()
while self.peek()[0] in ('*', '/'):
op = self.consume()[0]
right = self.factor()
left = left * right if op == '*' else left / right
return left
# factor := NUMBER | '-' factor | '(' expr ')'
def factor(self):
token = self.peek()
if token[0] == 'NUMBER':
self.consume()
return token[1]
elif token[0] == '-':
self.consume()
return -self.factor()
elif token[0] == '(':
self.consume()
value = self.expr()
self.consume(')')
return value
else:
raise SyntaxError(f"意外的 token: {token}")
if __name__ == '__main__':
parser = ExprParser('a - -b'.replace('a', '12').replace('b', '5'))
# 实际测试用: ExprParser('12 - -5')
result = parser.parse()
print(result) # 输出 17
这段代码的核心在于 factor 函数里对减号的处理。当解析器在 factor 的当前位置看到 - 时,它无条件地把它当作一元负号处理,然后递归解析下一个 factor。如果 a - -b 中的第一个减号出现在 expr 的 while 循环里,则会被消费为二元减法。同一个字符 -,因为进入的解析函数不同,得到的语义就完全不同。这种结构化的处理方式没有用到任何特殊的字符标记,完全依赖语法规则的调用顺序。
对于更复杂的符号混淆,比如同时存在 * 作为乘法和指针解引用,可以将 factor 进一步拆分:乘法运算符 * 只能出现在 term 层,而解引用运算符 * 可以出现在 factor 层,但需要注意解引用通常要求右侧是一个标识符或括号表达式,而不是数字。可以在语法中增加针对指针类型的非终结符,或者引入类型信息辅助判断。递归下降解析器同样可以处理,只需在对应位置检查当前 token 及后续 token 的类型。核心思路不变:让语法位置决定符号含义。
上下文与语法是解决公式符号混淆的两大支柱。上下文提供了符号出现的周边结构信息,语法规则则把这些信息固化成可执行的推导路径。当你下次遇到一个看似模棱两可的符号序列时,不妨先画出它的语法树候选,看看哪一棵树的产生式适用。通常,正确的答案就藏在语法层级的某个角落,而不是靠更多的字符特判。