前言

你有没有想过,当你写下一行代码:

result = 1 + 2 * 3

编译器是怎么知道先算 2 * 3,再算 1 + 6 的?

这就是语法解析(Syntax Parsing)要做的事情。今天,我用最简单的方式,带你理解这个过程。


一、语法解析是什么?

想象你在读一个句子:

“小明吃苹果”

你的大脑会自动分析:

  • 主语:小明
  • 谓语:吃
  • 宾语:苹果

语法解析就是让计算机也能这样"理解"代码的结构。

1.1 解析的输入和输出

输入:一串符号(Token)

result = 1 + 2 * 3
↓ 分词后
[变量: result] [符号: =] [数字: 1] [符号: +] [数字: 2] [符号: *] [数字: 3]

输出:一棵树(语法树)

        =
       / \
    result +
          / \
         1   *
            / \
           2   3

这棵树告诉我们:

  • 最顶层是赋值操作 =
  • 左边是变量 result
  • 右边是加法 +
  • 加法的右边是乘法 *(所以先算乘法)

1.2 为什么要用树?

因为树能清楚地表达优先级层次关系

  • 树的越深层的节点,越先计算
  • 乘法在加法下面,所以先算

二、两种解析方法

语法解析有两种主要方法:

  1. 自顶向下(Top-Down):从树根开始,向下生长
  2. 自底向上(Bottom-Up):从树叶开始,向上合并

让我们详细看看这两种方法。


三、自顶向下解析

3.1 核心思想

想象你在画画:

  1. 先画出树干(最外层结构)
  2. 再画大树枝(次级结构)
  3. 最后画树叶(具体内容)

这就是自顶向下:从整体到局部

3.2 递归下降解析

这是最常用的自顶向下方法。原理很简单:

  • 每个语法规则对应一个函数
  • 函数调用其他函数,就像语法规则嵌套

示例:简单的数学表达式

假设我们的语法规则是:

表达式 → 项 + 项 | 项
项 → 因子 * 因子 | 因子
因子 → 数字 | (表达式)

用代码表示:

class Parser:
    def __init__(self, tokens):
        self.tokens = tokens
        self.pos = 0
    
    def parse_expression(self):
        """解析表达式:项 + 项 | 项"""
        left = self.parse_term()  # 先解析左边的项
        
        while self.current_token() == '+':
            self.consume('+')  # 吃掉 + 号
            right = self.parse_term()  # 解析右边的项
            left = ('+', left, right)  # 构建语法树节点
        
        return left
    
    def parse_term(self):
        """解析项:因子 * 因子 | 因子"""
        left = self.parse_factor()  # 先解析左边的因子
        
        while self.current_token() == '*':
            self.consume('*')  # 吃掉 * 号
            right = self.parse_factor()  # 解析右边的因子
            left = ('*', left, right)  # 构建语法树节点
        
        return left
    
    def parse_factor(self):
        """解析因子:数字 | (表达式)"""
        token = self.current_token()
        
        if token == '(':
            self.consume('(')
            expr = self.parse_expression()  # 递归解析括号内的表达式
            self.consume(')')
            return expr
        else:
            # 这是一个数字
            self.consume(token)
            return ('number', token)
    
    def current_token(self):
        """获取当前 token"""
        if self.pos < len(self.tokens):
            return self.tokens[self.pos]
        return None
    
    def consume(self, expected):
        """消耗一个 token"""
        if self.current_token() == expected:
            self.pos += 1
        else:
            raise Exception(f"期望 {expected}, 但得到 {self.current_token()}")

# 使用示例
tokens = ['1', '+', '2', '*', '3']
parser = Parser(tokens)
tree = parser.parse_expression()
print(tree)
# 输出:('+', ('number', '1'), ('*', ('number', '2'), ('number', '3')))

3.3 解析过程图解

让我们看看 1 + 2 * 3 是如何被解析的:

graph TD
    A[开始解析表达式] --> B[解析项]
    B --> C[解析因子: 1]
    C --> D[返回 number:1]
    D --> E{当前是 + 号?}
    E -->|是| F[消耗 + 号]
    F --> G[解析项: 2*3]
    G --> H[解析因子: 2]
    H --> I[返回 number:2]
    I --> J{当前是 * 号?}
    J -->|是| K[消耗 * 号]
    K --> L[解析因子: 3]
    L --> M[返回 number:3]
    M --> N[构建 * 节点]
    N --> O[返回 * 节点]
    O --> P[构建 + 节点]
    P --> Q[返回语法树]
    
    style A fill:#e1f5ff
    style Q fill:#d4edda

3.4 生成的语法树

graph TD
    PLUS["+"] --> NUM1["number: 1"]
    PLUS["+"] --> MUL["*"]
    MUL["*"] --> NUM2["number: 2"]
    MUL["*"] --> NUM3["number: 3"]
    
    style PLUS fill:#fff4e1
    style MUL fill:#fff4e1
    style NUM1 fill:#e8f5e9
    style NUM2 fill:#e8f5e9
    style NUM3 fill:#e8f5e9

3.5 优缺点

优点 缺点
✅ 代码直观,容易理解 ❌ 不能处理左递归语法
✅ 容易手动实现 ❌ 某些情况需要回溯
✅ 错误定位准确 ❌ 对复杂语法支持有限
✅ 可以自定义错误信息 ❌ 效率相对较低

四、自底向上解析

4.1 核心思想

这次反过来,想象你在拼图:

  1. 先找到边缘的碎片(最底层的符号)
  2. 把相关的碎片拼成小区域
  3. 把小区域拼成大区域
  4. 最后完成整幅图

这就是自底向上:从局部到整体

4.2 移进-归约解析

这是最常用的自底向上方法。核心操作有两个:

  1. 移进(Shift):把输入符号压入栈
  2. 归约(Reduce):把栈顶的符号按语法规则合并

示例:解析 1 + 2 * 3

假设语法规则:

1. E → E + E
2. E → E * E
3. E → number

解析过程:

sequenceDiagram
    participant 输入
    participant 栈
    participant 动作
    
    Note over 输入: 1 + 2 * 3 $
    
    输入->>栈: 移进 1
    栈->>动作: 归约: number → E (规则3)
    Note over 栈: E
    
    输入->>栈: 移进 +
    Note over 栈: E +
    
    输入->>栈: 移进 2
    栈->>动作: 归约: number → E (规则3)
    Note over 栈: E + E
    
    输入->>栈: 移进 *
    Note over 栈: E + E *
    
    输入->>栈: 移进 3
    栈->>动作: 归约: number → E (规则3)
    Note over 栈: E + E * E
    
    栈->>动作: 归约: E * E → E (规则2)
    Note over 栈: E + E
    
    栈->>动作: 归约: E + E → E (规则1)
    Note over 栈: E
    
    Note over 动作: ✅ 解析成功!

4.3 代码实现

class ShiftReduceParser:
    def __init__(self, tokens, grammar):
        self.tokens = tokens + ['$']  # 添加结束符
        self.grammar = grammar  # 语法规则
        self.stack = ['$']  # 栈,初始有结束符
        self.pos = 0  # 输入位置
    
    def parse(self):
        """执行移进-归约解析"""
        print(f"{'步骤':<6} {'栈':<20} {'输入':<15} {'动作'}")
        print("-" * 60)
        
        step = 0
        while True:
            step += 1
            stack_str = ' '.join(self.stack)
            input_str = ' '.join(self.tokens[self.pos:])
            
            # 尝试归约
            reduced = self.try_reduce()
            if reduced:
                print(f"{step:<6} {stack_str:<20} {input_str:<15} 归约: {reduced}")
                continue
            
            # 尝试移进
            if self.pos < len(self.tokens):
                self.stack.append(self.tokens[self.pos])
                self.pos += 1
                print(f"{step:<6} {stack_str:<20} {input_str:<15} 移进")
            else:
                # 无法移进也无法归约
                if len(self.stack) == 2 and self.stack[1] == 'E':
                    print(f"{step:<6} {stack_str:<20} {input_str:<15} ✅ 接受")
                    return True
                else:
                    print(f"{step:<6} {stack_str:<20} {input_str:<15} ❌ 错误")
                    return False
    
    def try_reduce(self):
        """尝试归约栈顶"""
        # 检查每条语法规则
        for rule_name, rule in self.grammar.items():
            pattern = rule['pattern']
            # 检查栈顶是否匹配规则
            if len(self.stack) >= len(pattern):
                top = self.stack[-len(pattern):]
                if top == pattern:
                    # 归约:移除栈顶,压入结果
                    self.stack = self.stack[:-len(pattern)]
                    self.stack.append(rule['result'])
                    return f"{rule_name}: {' '.join(pattern)}{rule['result']}"
        return None

# 定义语法规则
grammar = {
    'rule1': {'pattern': ['E', '+', 'E'], 'result': 'E'},
    'rule2': {'pattern': ['E', '*', 'E'], 'result': 'E'},
    'rule3': {'pattern': ['number'], 'result': 'E'},
}

# 预处理:把数字替换为 number
tokens = ['number', '+', 'number', '*', 'number']

parser = ShiftReduceParser(tokens, grammar)
parser.parse()

4.4 输出示例

步骤   栈                   输入            动作
------------------------------------------------------------
1      $                    number + number * number $ 移进
2      $ number             + number * number $ 归约: rule3: number → E
3      $ E                  + number * number $ 移进
4      $ E +                number * number $ 移进
5      $ E + number         * number $ 归约: rule3: number → E
6      $ E + E              * number $ 移进
7      $ E + E *            number $ 移进
8      $ E + E * number     $ 归约: rule3: number → E
9      $ E + E * E          $ 归约: rule2: E * E → E
10     $ E + E              $ 归约: rule1: E + E → E
11     $ E                  $ ✅ 接受

4.5 优缺点

优点 缺点
✅ 可以处理左递归 ❌ 实现复杂
✅ 效率高(LR解析器) ❌ 难以手动编写
✅ 功能强大 ❌ 错误信息不够友好
✅ 支持复杂的语法 ❌ 调试困难

五、两种方法对比

5.1 图解对比

自顶向下

graph TD
    A[表达式] --> B[项]
    A --> C[+]
    A --> D[项]
    B --> E[因子: 1]
    D --> F[项]
    D --> G[*]
    D --> H[因子: 3]
    F --> I[因子: 2]
    
    style A fill:#e1f5ff
    style B fill:#fff4e1
    style D fill:#fff4e1

方向:从根节点向下展开

自底向上

graph TD
    E1["number: 1"] --> E2["E"]
    E3["number: 2"] --> E4["E"]
    E5["number: 3"] --> E6["E"]
    E4 --> E7["E"]
    E6 --> E7
    PLUS["+"] --> E7
    E2 --> E8["E (最终)"]
    E7 --> E8
    MUL["*"] --> E8
    
    style E8 fill:#d4edda

方向:从叶子节点向上合并

5.2 适用场景

场景 推荐方法 原因
手写解析器 自顶向下 代码直观,容易实现
编译器课程作业 自顶向下 便于理解原理
工业级编译器 自底向上(LR) 功能强大,效率高
解释器 自顶向下 错误信息友好
IDE 语法检查 自顶向下 可以实时反馈

5.3 常见的解析器

自顶向下

  • LL(1):简单,常用于教学
  • LL(k):看 k 个符号决定下一步
  • 递归下降:最常见,如 GCC 的 C++ 解析器

自底向上

  • LR(0):最基础,功能有限
  • SLR(1):简单 LR,有改进
  • LR(1):功能强大,被广泛使用
  • LALR(1):LR(1) 的优化版本,如 Yacc、Bison

六、实战示例:解析简单的算术表达式

让我们写一个完整的解析器,计算算术表达式:

import re

class ArithmeticParser:
    """算术表达式解析器 - 使用递归下降"""
    
    def __init__(self, text):
        self.tokens = self.tokenize(text)
        self.pos = 0
    
    def tokenize(self, text):
        """分词:把字符串转换成 token 列表"""
        tokens = []
        for match in re.finditer(r'\d+|\+|\-|\*|\/|\(|\)', text):
            token = match.group()
            # 如果是数字,转换成整数
            if token.isdigit():
                tokens.append(('number', int(token)))
            else:
                tokens.append(('op', token))
        return tokens
    
    def current_token(self):
        """获取当前 token"""
        if self.pos < len(self.tokens):
            return self.tokens[self.pos]
        return None
    
    def consume(self, expected_type=None, expected_value=None):
        """消耗一个 token"""
        token = self.current_token()
        if token is None:
            raise Exception("意外的输入结束")
        
        if expected_type and token[0] != expected_type:
            raise Exception(f"期望类型 {expected_type}, 但得到 {token[0]}")
        
        if expected_value and token[1] != expected_value:
            raise Exception(f"期望 {expected_value}, 但得到 {token[1]}")
        
        self.pos += 1
        return token
    
    def parse(self):
        """开始解析"""
        result = self.parse_expression()
        if self.current_token() is not None:
            raise Exception("还有未解析的内容")
        return result
    
    def parse_expression(self):
        """解析表达式:处理 + 和 -"""
        left = self.parse_term()
        
        while self.current_token() and self.current_token()[1] in ['+', '-']:
            op = self.consume('op')[1]
            right = self.parse_term()
            
            if op == '+':
                left = left + right
            else:
                left = left - right
        
        return left
    
    def parse_term(self):
        """解析项:处理 * 和 /"""
        left = self.parse_factor()
        
        while self.current_token() and self.current_token()[1] in ['*', '/']:
            op = self.consume('op')[1]
            right = self.parse_factor()
            
            if op == '*':
                left = left * right
            else:
                left = left / right
        
        return left
    
    def parse_factor(self):
        """解析因子:数字或括号表达式"""
        token = self.current_token()
        
        if token[0] == 'number':
            return self.consume()[1]
        elif token[1] == '(':
            self.consume('op', '(')
            result = self.parse_expression()
            self.consume('op', ')')
            return result
        else:
            raise Exception(f"意外的 token: {token}")

# 测试
expressions = [
    "1 + 2",
    "1 + 2 * 3",
    "(1 + 2) * 3",
    "10 - 3 * 2",
    "20 / 4 + 1",
]

for expr in expressions:
    parser = ArithmeticParser(expr)
    result = parser.parse()
    print(f"{expr} = {result}")

6.1 输出结果

1 + 2 = 3
1 + 2 * 3 = 7
(1 + 2) * 3 = 9
10 - 3 * 2 = 4
20 / 4 + 1 = 6.0

6.2 解析过程分析

让我们看看 (1 + 2) * 3 的解析过程:

graph TD
    A[parse_expression] --> B[parse_term]
    B --> C[parse_factor]
    C --> D["遇到 ( , 进入括号"]
    D --> E[parse_expression]
    E --> F[parse_term]
    F --> G[parse_factor: 1]
    G --> H["返回 1"]
    H --> I["遇到 + , 继续"]
    I --> J[parse_term]
    J --> K[parse_factor: 2]
    K --> L["返回 2"]
    L --> M["计算 1 + 2 = 3"]
    M --> N["遇到 ) , 退出括号"]
    N --> O["返回 3"]
    O --> P["遇到 * , 继续"]
    P --> Q[parse_factor: 3]
    Q --> R["返回 3"]
    R --> S["计算 3 * 3 = 9"]
    S --> T["返回 9"]
    
    style A fill:#e1f5ff
    style T fill:#d4edda

七、常见问题

Q1:为什么 1 + 2 * 3 先算乘法?

因为我们的语法规则定义了优先级

表达式 → 项 + 项      (加法在外层)
项 → 因子 * 因子      (乘法在内层)

越在内层的规则,越先被解析,所以乘法优先级更高。

Q2:什么是左递归?为什么自顶向下不能处理?

左递归:语法规则直接或间接地以自己开头

E → E + E  ❌ 这是左递归!

问题

def parse_E():
    parse_E()  # 无限递归!
    ...

解决方案

E → E + T | T      改为:E → T + E'
E' → + T E' | ε    (ε 表示空)

Q3:如何选择解析方法?

flowchart TD
    A[需要实现解析器] --> B{语法是否复杂?}
    B -->|简单| C[自顶向下<br/>递归下降]
    B -->|复杂| D{需要高性能吗?}
    D -->|是| E[自底向上<br/>LR/LALR]
    D -->|否| F[自顶向下<br/>使用解析器生成器]
    
    C --> G[手写代码]
    E --> H[使用 Yacc/Bison]
    F --> I[使用 ANTLR]
    
    style C fill:#d4edda
    style E fill:#d4edda
    style F fill:#d4edda

八、总结

核心概念

  1. 语法解析:把代码转换成语法树
  2. 自顶向下:从树根开始,递归下降
  3. 自底向上:从树叶开始,移进归约

选择建议

  • 学习/教学:用自顶向下(递归下降)
  • 手写解析器:用自顶向下
  • 工业级应用:用自底向上(LR/LALR)
  • 快速开发:用解析器生成器(ANTLR、Yacc)

进阶学习

想深入学习编译器,推荐:

  • 《编译原理》(龙书)
  • 《Engineering a Compiler》
  • Stanford 编译器课程(Coursera)

扩展阅读


希望这篇文章能帮你理解语法解析的基本原理。编译器虽然复杂,但核心思想其实很简单!