使用递归下降算法求解表达式

使用递归下降算法求解表达式

本文围绕“如何使用 C 语言实现一个表达式解析器”展开,重点说明递归下降算法的原理、最小可用实现的组织方式,以及工程实践中的若干关键细节。文中的代码片段为说明算法而适度简化。

1. 引言

在软件系统中,将字符串形式的数学表达式转换为可执行计算过程,是一个非常常见的需求。例如:

3 + 4 * (2 - 1)

对于人类而言,这只是一个普通的四则运算表达式;但对于程序而言,最初接收到的仅是一串字符序列。程序并不天然知道:

  • 哪些字符组成数字
  • 哪些符号表示运算符
  • 运算符之间的优先级关系
  • 括号如何改变计算顺序

因此,表达式求值的核心问题并不在于加、减、乘、除运算本身,而在于:如何使程序正确理解表达式的语法结构。

这正是“表达式解析”所要解决的问题。

在多种可选方法中,递归下降算法(Recursive Descent Parsing) 是一种非常适合表达式解析的实现方式。其主要特点包括:

  • 文法规则与代码结构之间具有良好的对应关系
  • 运算符优先级可以通过函数层次自然表达
  • 实现过程直观,便于调试和教学
  • 对小型语言、配置表达式、计算器等场景具有较高的适用性

本文将围绕以下两个方面展开:

  1. 说明递归下降算法的基本原理,以及其为何适用于表达式解析
  2. 说明如何使用 C 语言实现一个最小可用的表达式解析器,并分析其核心代码结构

2. 递归下降算法原理

本节从语法分析的基本概念出发,说明递归下降算法在表达式解析中的工作方式。

2.1 “解析”的含义

在编译原理语境中,“解析”并不是对字符串进行简单扫描,而是识别输入序列的语法结构。

以表达式

2 + 3 * 4

为例。若仅从字符序列的角度观察,它只是若干字符的线性排列;但从语法结构的角度观察,它实际更接近于:

2 + (3 * 4)

这意味着程序必须能够识别以下信息:

  1. 哪些字符组成一个合法的数字记号
  2. 哪些字符属于运算符
  3. 运算符之间的优先级关系
  4. 哪些片段构成一个更大的子表达式

因此,解析的本质可以概括为:将线性的字符流还原为具有层次关系的语法结构。

2.2 递归下降的基本思想

递归下降是一种自顶向下(top-down)的语法分析方法。其基本思想是:

为文法中的每一条规则编写一个对应的解析函数,并通过函数之间的调用关系来实现语法规则之间的嵌套关系。

“递归下降”这一名称包含两层含义:

  • “递归”表示解析函数在需要时可以调用自身,或者调用同一组规则中的其他函数
  • “下降”表示解析过程从最外层规则开始,逐步进入更具体、更底层的规则

对于表达式解析而言,这种方法尤为自然,因为表达式本身就具有明显的层次结构。

2.3 表达式文法的分层表示

为了使程序能够正确处理运算符优先级,通常需要为表达式设计一套分层文法。一个典型的文法如下:

Expression ::= Term { ('+' | '-') Term }
Term       ::= Unary { ('*' | '/') Unary }
Unary      ::= ('+' | '-')* Primary
Primary    ::= Number | '(' Expression ')'

这组规则可以理解为:

  • Expression 负责处理加法和减法
  • Term 负责处理乘法和除法
  • Unary 负责处理一元正号和负号
  • Primary 负责处理最基本的语法单元,即数字与括号表达式

这种分层方式的核心意义在于:优先级越高的运算,被放置在越底层的规则中处理。

2.4 文法规则与函数结构的对应关系

递归下降的一个显著优点在于,文法结构几乎可以直接映射为函数结构:

文法规则           对应函数
Expression   ->   parse_expression()
Term         ->   parse_term()
Unary        ->   parse_unary()
Primary      ->   parse_primary()

因此,代码的调用层次本身就体现了文法的组织方式:

parse_expression()
    -> parse_term()
        -> parse_unary()
            -> parse_primary()

这也是递归下降非常适合教学和手工实现的重要原因之一:代码结构即语法结构。

2.5 “自顶向下”如何体现

自顶向下解析的含义是:解析器总是从最外层规则开始尝试解释输入。

对于表达式

3 + 4 * 2

解析器不会一开始就处理乘法,而是先按照 Expression 规则进行解析。随后:

  1. parse_expression() 先调用 parse_term()
  2. parse_term() 再调用 parse_unary()
  3. parse_unary() 再调用 parse_primary()

只有当底层规则返回结果之后,上层规则才会继续处理各自负责的运算符。

换言之,解析过程是从抽象规则逐步下降到具体规则,再从具体结果逐层返回到抽象层次。

2.6 递归真正发生的位置

递归下降并不意味着每个函数都需要直接调用自己。
在表达式解析中,递归主要发生在遇到嵌套结构时,需要重新启动同一套解析规则的场景中。

最典型的嵌套结构就是括号。例如:

(2 + 3) * 4

当解析器在 Primary 层读到左括号 ( 时,意味着接下来并不是一个普通数字,而是一段完整的子表达式。此时应当重新从 Expression 这一层开始解析,直到匹配到对应的右括号 )。

简化后的代码示意如下:

double parse_primary(Parser *p) {
    if (p->lexer.current.type == TOKEN_NUMBER) {
        double value = p->lexer.current.value;
        next_token(p);
        return value;
    }

    if (p->lexer.current.type == TOKEN_LPAREN) {
        next_token(p);                         /* 消费 '('        */
        double value = parse_expression(p);    /* 递归解析括号内部 */
        expect_right_paren(p);
        next_token(p);                         /* 消费 ')'        */
        return value;
    }

    syntax_error(p);
    return 0.0;
}

因此,递归的作用并非形式上的“函数自调用”,而是在语义上表达“括号内部又是一个完整表达式”这一事实。

2.7 运算符优先级为何必须分层处理

若不对表达式进行分层处理,程序很容易采用错误的“从左到右顺次计算”策略。例如:

2 + 3 * 4

若按简单线性顺序计算,可能得到:

(2 + 3) * 4 = 20

但这显然违背数学规则。正确结果应为:

2 + (3 * 4) = 14

递归下降通过将不同优先级的运算分配给不同层次的函数,使这一问题得到自然解决:

  • parse_expression() 只处理 + 和 -
  • parse_term() 只处理 * 和 /
  • parse_primary() 只处理数字和括号

因此,在处理 2 + 3 * 4 时,右侧的 3 * 4 会先在 Term 层完成计算,再将结果交给 Expression 层参与加法运算。

2.8 为什么同级运算通常使用循环而非递归

递归下降算法在处理同一优先级的连续运算符时,通常采用循环结构,而不是直接使用左递归文法。

例如,若将加法规则写成:

Expression ::= Expression '+' Term | Term

则 parse_expression() 在一开始就需要再次调用自身,从而导致无限递归。

因此,在实际实现中,通常会将其改写为:

Expression ::= Term { ('+' | '-') Term }

这意味着:

  1. 先解析一个左操作数
  2. 只要后续仍然出现同级运算符,就循环读取右操作数并更新结果

示例代码如下:

double parse_expression(Parser *p) {
    double left = parse_term(p);

    while (p->lexer.current.type == TOKEN_PLUS ||
           p->lexer.current.type == TOKEN_MINUS) {
        TokenType op = p->lexer.current.type;
        next_token(p);
        double right = parse_term(p);

        if (op == TOKEN_PLUS) {
            left = left + right;
        } else {
            left = left - right;
        }
    }

    return left;
}

这种写法还有一个重要性质:它天然实现了左结合。例如:

100 / 2 / 5

会被解析为:

(100 / 2) / 5

2.9 一元运算符为何常采用迭代处理

对于如下表达式:

---5
+-+-3

若严格采用递归方式处理一元运算符,虽然在语义上成立,但可能引入不必要的调用层次。更实用的工程化方式是:

  • 连续读取前缀 + 和 -
  • 统计负号出现次数
  • 根据负号个数的奇偶性决定是否对结果取反

这可以用如下文法表示:

Unary ::= ('+' | '-')* Primary

相较于右递归写法,这种处理方式更稳定,也更有利于控制调用深度。

2.10 预读 token 的作用

递归下降通常依赖一个重要机制,即预读(lookahead)。

解析器始终维护一个“当前 token”,并根据该 token 决定下一步应当匹配哪条规则。例如,在 parse_primary() 中:

  • 若当前 token 为 NUMBER,则按数字处理
  • 若当前 token 为 LPAREN,则按括号表达式处理
  • 否则说明输入在当前位置不符合文法要求,应报告语法错误

这种“根据一个当前 token 决定分支”的方式,本质上是一种典型的 LL(1) 风格处理方法。

2.11 递归下降的完整思路示例

以表达式

3 + 4 * (2 - 1)

为例,递归下降的处理过程可以概括如下:

  1. 从 Expression 层开始解析
  2. 先取得左侧 Term,得到数值 3
  3. 识别到 +,说明后续仍存在同级运算
  4. 继续解析右侧 Term
  5. 右侧 Term 先读取 4
  6. 随后识别到 *,说明需要继续解析更高优先级的右操作数
  7. 右操作数为括号表达式,因此进入 Primary
  8. 在 Primary 中再次递归调用 parse_expression() 解析 (2 - 1)
  9. 括号内部计算得到 1
  10. 返回上一层,完成 4 * 1 = 4
  11. 再返回最外层,完成 3 + 4 = 7

从本质上看,递归下降所做的工作可以概括为:

按照文法层次逐步分解输入,并在遇到嵌套结构时重新启动相同的解析过程。

2.12 与其他方法的简要比较

在表达式求值场景中,除了递归下降之外,还常见以下两类方法。

1. 栈方法

例如 Shunting Yard 算法,会维护:

  • 运算符栈
  • 操作数栈

再结合优先级和括号规则完成表达式求值。其优点在于效率较高,且不依赖递归;但从可读性角度来看,栈方法更偏向“数据结构驱动”,不如递归下降那样直接体现文法层次。

2. 自动机或 LR 类方法

这类方法在通用语法分析中更为强大,但通常也更复杂。它们更适用于完整编译器前端或更大规模的语言实现,而对于一个四则运算表达式求值器而言,往往超出了必要复杂度。

因此,对于表达式解析这一类问题,递归下降通常能够在可读性、可维护性与实现复杂度之间取得较好的平衡。

3. 实现一个最小可用解析器

在掌握算法原理之后,下一步便是实现一个最小可用的表达式解析器。

3.1 实现目标

对于初版实现,建议将目标控制在以下范围内:

  • 支持 + - * /
  • 支持括号 ( 和 )
  • 支持一元正号与负号,例如 -5、--3
  • 支持整数、小数及科学计数法
  • 输入一段表达式字符串,输出计算结果或错误位置

这一范围已经足以覆盖一个最小可用的四则运算解析器。
在此基础上,如需扩展变量、函数、AST 或表达式优化,可在后续阶段逐步加入。

3.2 建议的公共接口

最小实现可以通过一个统一的公共入口完成求值:

typedef enum {
    CALC_OK = 0,
    CALC_ERROR_NULL_EXPR,
    CALC_ERROR_INVALID_CHAR,
    CALC_ERROR_DIV_BY_ZERO,
    CALC_ERROR_UNEXPECTED_TOKEN,
    CALC_ERROR_MISSING_RPAREN,
    CALC_ERROR_SYNTAX
} CalcError;

CalcError evaluate(const char *expression, double *result, size_t *err_pos);

其中:

  • expression:输入表达式
  • result:成功时写入计算结果
  • err_pos:失败时返回错误发生的大致位置

3.3 建议的最小文件结构

一个简单而清晰的文件划分如下:

expr/
├── main.c
├── calculator.h
├── calculator.c
├── lexer.h
├── lexer.c
├── parser.h
└── parser.c

各文件职责如下:

  • main.c
    负责输入输出与运行入口
  • calculator.h / calculator.c
    负责公共错误码与 evaluate() 接口
  • lexer.h / lexer.c
    负责词法分析
  • parser.h / parser.c
    负责递归下降解析与求值

3.4 核心数据结构

实现递归下降解析器,至少需要定义以下三类核心结构:

1. Token 类型

typedef enum {
    TOKEN_NUMBER,
    TOKEN_PLUS,
    TOKEN_MINUS,
    TOKEN_MUL,
    TOKEN_DIV,
    TOKEN_LPAREN,
    TOKEN_RPAREN,
    TOKEN_END,
    TOKEN_ERROR
} TokenType;

2. Token 结构

typedef struct {
    TokenType type;
    double value;
    size_t start_pos;
    size_t end_pos;
} Token;

其中:

  • type 表示 token 的类别
  • value 仅在 TOKEN_NUMBER 时有效
  • start_pos 和 end_pos 用于错误定位

3. Lexer 状态

typedef struct {
    const char *input;
    size_t pos;
    Token current;
    CalcError err;
    size_t err_pos;
} Lexer;

其中:

  • input 指向原始输入字符串
  • pos 表示当前扫描位置
  • current 保存当前 token
  • err 与 err_pos 保存词法错误状态

4. Parser 状态

typedef struct {
    Lexer lexer;
    unsigned depth;
    unsigned max_depth;
} Parser;

在最小实现中,Parser 只需持有一个 Lexer,并记录递归深度与深度上限即可。

3.5 解析器的主控制流程

建议首先搭建解析器的整体骨架,再逐步填充各个解析函数。一个典型的入口实现如下:

static double parse_expression(Parser *p);
static double parse_term(Parser *p);
static double parse_unary(Parser *p);
static double parse_primary(Parser *p);

static void next_token(Parser *p) {
    lexer_next_token(&p->lexer);
}

CalcError evaluate(const char *expression, double *result, size_t *err_pos) {
    Parser p;

    if (expression == NULL || *expression == '\0' || result == NULL) {
        if (err_pos != NULL) {
            *err_pos = 0;
        }
        return CALC_ERROR_NULL_EXPR;
    }

    lexer_init(&p.lexer, expression);
    p.depth = 0;
    p.max_depth = 256;

    next_token(&p);
    *result = parse_expression(&p);

    if (p.lexer.err == CALC_OK && p.lexer.current.type != TOKEN_END) {
        p.lexer.err = CALC_ERROR_SYNTAX;
        p.lexer.err_pos = p.lexer.current.start_pos;
    }

    if (err_pos != NULL) {
        *err_pos = p.lexer.err_pos;
    }
    return p.lexer.err;
}

这一骨架已经体现了解析器的主流程:

初始化 lexer
    ↓
预读第一个 token
    ↓
调用 parse_expression()
    ↓
检查 token 是否已全部消费
    ↓
返回结果或错误

其中,“检查 token 是否全部消费”是非常关键的一步。
若省略此步骤,输入

1 + 2 abc

可能会被错误地接受,因为解析器虽然成功计算了前半部分,但后半部分实际上仍包含非法残留输入。

3.6 建议的实现顺序

从工程实践角度看,建议按以下顺序实现:

第一步:实现 lexer

先确保词法分析器能够正确处理:

  • 空白字符跳过
  • + - * / ( ) 的识别
  • 数字的读取
  • 输入结束标记
  • 非法字符错误

在此阶段,可先编写一个简单的测试程序,仅用于输出 token 序列,以确认词法分析是否正确。

第二步:实现 parse_primary()

该函数位于解析层级最底部,只处理两种情况:

  • 数字
  • 括号表达式

逻辑相对简单,适合作为递归下降实现的起点。

第三步:实现 parse_unary()

该函数负责处理一元 + / -,建议采用迭代方式统计前缀负号数量,再调用 parse_primary() 取得实际操作数。

第四步:实现 parse_term()

该函数负责 * 和 /,实现模式固定:

  1. 先取得左值
  2. 只要后续仍有 * 或 /
  3. 则继续读取右值并更新当前结果

第五步:实现 parse_expression()

最后实现加法与减法。
其控制结构与 parse_term() 类似,但负责更低优先级的运算。

这种实现顺序本质上遵循了语法层级由底向上的构建方式:
先实现最小原子,再逐层封装为更高层规则。

3.7 文字架构图

为了避免实现过程中职责混淆,可以将整体结构概括为如下流程:

输入字符串
    ↓
lexer_next_token()
    ↓
当前 token 存入 lexer.current
    ↓
parse_expression()   // 处理 + -
    ↓
parse_term()         // 处理 * /
    ↓
parse_unary()        // 处理一元 + -
    ↓
parse_primary()      // 处理数字和括号
    ↓
返回 double 结果

该架构图揭示了两个关键事实:

  • token 由 lexer 按需提供,而不是由 parser 直接扫描字符
  • parser 每一层只负责本层语义,不能跨层处理其他优先级的逻辑

3.8 初版实现建议采用“边解析边求值”

对于首次实现表达式解析器的读者而言,建议初版直接采用“边解析边求值”的方式,而不必立即引入 AST。

也就是说:

  • parse_primary() 返回一个基础数值
  • parse_unary() 在该数值上应用一元正负号
  • parse_term() 直接完成乘除运算
  • parse_expression() 再完成加减运算

此时各解析函数均直接返回 double。

这一方案的优点在于:

  • 结构清晰
  • 调试方便
  • 代码量较小
  • 能够尽快得到一个可运行结果

待这一版本稳定之后,再将函数返回值从 double 扩展为“语法树节点”,即可自然过渡到 AST 方案。

3.9 最小验证程序

在完成核心解析逻辑之后,可以先编写一个简单的 main() 进行验证:

#include <stdio.h>
#include "calculator.h"

int main(void) {
    char input[256];

    while (fgets(input, sizeof(input), stdin) != NULL) {
        double result = 0.0;
        size_t err_pos = 0;
        CalcError err = evaluate(input, &result, &err_pos);

        if (err == CALC_OK) {
            printf("result = %.10g\n", result);
        } else {
            printf("error = %d at pos %zu\n", err, err_pos);
        }
    }

    return 0;
}

通过这种方式,即使不依赖任何现成仓库,也可以独立完成一个最小可用版本。
当核心功能正确之后,再逐步补充更完善的错误信息、测试、日志与调试能力,会更加稳妥。

4. 核心代码讲解

本节结合一个典型的 C 语言实现,对递归下降解析器的关键代码结构进行分析。

4.1 Token 定义与词法分析

一个最小可用实现中的 token 类型通常可以定义如下:

typedef enum {
    TOKEN_NUMBER,
    TOKEN_PLUS,
    TOKEN_MINUS,
    TOKEN_MUL,
    TOKEN_DIV,
    TOKEN_LPAREN,
    TOKEN_RPAREN,
    TOKEN_END,
    TOKEN_ERROR
} TokenType;

同时,每个 token 还应包含数值和位置信息:

typedef struct {
    TokenType type;
    double value;
    size_t start_pos;
    size_t end_pos;
} Token;

其中,start_pos 和 end_pos 尤为重要,因为它们使错误定位成为可能。

词法分析器的主要职责可概括为:

  1. 跳过空白字符
  2. 检查是否到达输入末尾
  3. 识别单字符运算符和括号
  4. 识别数字常量
  5. 识别并报告非法字符

简化后的伪代码如下:

void lexer_next_token(Lexer *lexer) {
    skip_whitespace(lexer);

    if (end_of_input(lexer)) {
        lexer->current.type = TOKEN_END;
        return;
    }

    switch (current_char(lexer)) {
        case '+': /* TOKEN_PLUS   */ break;
        case '-': /* TOKEN_MINUS  */ break;
        case '*': /* TOKEN_MUL    */ break;
        case '/': /* TOKEN_DIV    */ break;
        case '(': /* TOKEN_LPAREN */ break;
        case ')': /* TOKEN_RPAREN */ break;
        default:
            /* 尝试解析数字,否则报错 */
            break;
    }
}

在工程实现中,数字解析通常直接依赖 strtod(),原因在于它天然支持:

  • 整数
  • 小数
  • 科学计数法

同时,还可以结合 ERANGE 与 isfinite() 等机制检测数值是否超出可表示范围。

4.2 parse_expression():处理加法与减法

parse_expression() 对应文法:

Expression ::= Term { ('+' | '-') Term }

它的职责非常明确:

  • 先读取一个 Term
  • 只要后续仍然出现 + 或 -
  • 就继续读取新的 Term,并将结果累积到左值中

简化代码如下:

double parse_expression(Parser *p) {
    double left = parse_term(p);

    while (p->lexer.current.type == TOKEN_PLUS ||
           p->lexer.current.type == TOKEN_MINUS) {
        TokenType op = p->lexer.current.type;
        next_token(p);
        double right = parse_term(p);

        if (op == TOKEN_PLUS) {
            left = left + right;
        } else {
            left = left - right;
        }
    }

    return left;
}

这一结构同时体现了两个关键性质:

  • 加减运算位于最外层,优先级最低
  • 多个同级运算符通过循环处理,从而实现左结合

4.3 parse_term():处理乘法与除法

parse_term() 对应文法:

Term ::= Unary { ('*' | '/') Unary }

它与 parse_expression() 的结构基本对称,只是处理的对象变为更高优先级的乘除运算:

double parse_term(Parser *p) {
    double left = parse_unary(p);

    while (p->lexer.current.type == TOKEN_MUL ||
           p->lexer.current.type == TOKEN_DIV) {
        TokenType op = p->lexer.current.type;
        next_token(p);
        double right = parse_unary(p);

        if (op == TOKEN_MUL) {
            left = left * right;
        } else {
            left = left / right;
        }
    }

    return left;
}

由于 parse_expression() 将 Term 作为操作数读取,因此乘除运算会天然先于加减完成。

4.4 parse_unary():处理一元正负号

parse_unary() 负责处理如下形式的表达式:

-5
--3
+-+-4

推荐实现方式如下:

double parse_unary(Parser *p) {
    int negative_count = 0;

    while (p->lexer.current.type == TOKEN_PLUS ||
           p->lexer.current.type == TOKEN_MINUS) {
        if (p->lexer.current.type == TOKEN_MINUS) {
            negative_count++;
        }
        next_token(p);
    }

    double result = parse_primary(p);

    if (negative_count % 2 == 1) {
        result = -result;
    }

    return result;
}

其优点在于:

  • 逻辑清晰
  • 不需要为连续一元符号创建深层递归链
  • 易于扩展和调试

4.5 parse_primary():处理数字与括号

parse_primary() 是解析链中的最底层函数,对应文法:

Primary ::= Number | '(' Expression ')'

它的职责如下:

  • 若当前 token 为数字,则直接返回对应值
  • 若当前 token 为左括号,则递归调用 parse_expression()
  • 若以上条件均不满足,则报告语法错误

示例代码如下:

double parse_primary(Parser *p) {
    if (p->lexer.current.type == TOKEN_NUMBER) {
        double value = p->lexer.current.value;
        next_token(p);
        return value;
    }

    if (p->lexer.current.type == TOKEN_LPAREN) {
        next_token(p);                      /* 消费 '(' */
        double value = parse_expression(p);

        if (p->lexer.current.type != TOKEN_RPAREN) {
            syntax_error(p);
            return 0.0;
        }

        next_token(p);                      /* 消费 ')' */
        return value;
    }

    syntax_error(p);
    return 0.0;
}

这一函数是括号递归的核心入口,也是整个递归下降算法中最重要的函数之一。

4.6 错误处理与递归深度控制

一个可用的表达式解析器不仅要在合法输入下返回正确结果,还必须能够在错误输入下给出明确反馈。
通常应至少覆盖以下错误类别:

  • 空表达式或空指针输入
  • 非法字符
  • 缺少右括号
  • 意外 token
  • 语法错误
  • 除零
  • 递归深度超限
  • 数值超范围

在错误处理策略上,常见做法是只记录第一个错误。
其原因在于:一旦前面的解析已经失败,后续错误通常只是连带后果,并不一定能反映真正的根因。

对于括号表达式,还建议引入递归深度上限。例如:

if (p->depth >= p->max_depth) {
    set_error(p, CALC_ERROR_RECURSION_LIMIT, current_pos(p));
    return 0.0;
}

这样可以有效避免异常输入导致调用栈过深。

4.7 “边解析边求值”与 AST 的关系

本文讨论的实现方式采用的是边解析边求值:

  • parse_primary() 返回一个基础数值
  • parse_unary() 在该数值基础上应用一元运算
  • parse_term() 直接合并乘除结果
  • parse_expression() 直接合并加减结果

这一方案的优点是:

  • 实现简洁
  • 内存开销小
  • 对四则运算计算器非常适合

其局限在于:

  • 不便于后续进行表达式重写
  • 不便于做静态分析或优化
  • 在扩展变量、函数、用户自定义运算符时灵活性不如 AST 方案

因此,从工程角度看,边解析边求值适合“直接求结果”的场景;若目标是构建更完整的解释器或编译前端,则应考虑引入 AST。

5. 执行流程示例

本节通过一个具体表达式说明递归下降解析器的执行过程。

5.1 输入表达式

3 + 4 * (2 - 1)

5.2 词法分析结果

词法分析完成后,可得到如下 token 序列:

NUMBER(3)
PLUS
NUMBER(4)
MUL
LPAREN
NUMBER(2)
MINUS
NUMBER(1)
RPAREN
END

5.3 逐步解析过程

第一步:解析最外层 Expression

解析从 parse_expression() 开始。该函数首先需要取得左侧第一个 Term。

第二步:解析左侧 Term

调用链逐层下降:

parse_expression()
    -> parse_term()
        -> parse_unary()
            -> parse_primary()

parse_primary() 读取到数字 3,返回数值 3。
因此,当前左值为:

left = 3

第三步:识别加号并继续解析右侧

当前 token 为 +,说明表达式尚未结束。
因此,parse_expression() 消费该 token,并继续读取右侧 Term。

第四步:解析右侧 Term

右侧 Term 首先读取数字 4。
但由于其后立即出现 *,说明该 Term 尚未完成,仍需继续读取更高优先级的右操作数。

第五步:进入括号表达式

乘号右侧为左括号 (,因此 parse_primary() 会触发一次递归调用:

parse_expression()

用于解析括号内部的表达式:

2 - 1

括号内部运算完成后,得到:

1

第六步:完成乘法与加法

括号内部返回后,右侧 Term 变为:

4 * 1 = 4

随后返回最外层,完成:

3 + 4 = 7

5.4 用树形结构理解

这一表达式的结构可以表示为一棵语法树:

        (+)
       /   \
     (3)   (*)
          /   \
        (4)   (-)
             /   \
           (2)   (1)

递归下降所做的工作,本质上就是通过函数调用,沿着这棵树逐层下降到叶子节点,再在返回过程中逐层合并结果。

6. 常见问题与实现陷阱

6.1 左递归问题

对于递归下降而言,类似如下的左递归文法是不可直接使用的:

E ::= E '+' T | T

因为 E 在展开时会立即再次调用 E,从而陷入无限递归。

正确做法是将其改写为:

E ::= T { ('+' | '-') T }

即“先读取一个操作数,再循环处理后续同级运算”。

6.2 运算符优先级处理不清

若将加减乘除全部写入同一个函数,优先级逻辑往往会迅速失控。
更可靠的方式是:

  • 每一层函数只负责一种优先级
  • 上层通过调用下层,取得已经完成更高优先级处理的结果

这是递归下降实现表达式解析的关键原则之一。

6.3 混淆一元负号与二元减号

下列两个 - 的语义并不相同:

-5     // 一元负号
3 - 5  // 二元减法

若不单独设置 Unary 层,极易在实现中混淆这两类语义。

6.4 错误信息不精确

若解析失败时仅返回“语法错误”,通常难以定位问题。
更好的做法是至少同时提供:

  • 错误码
  • 错误位置
  • 错误上下文

这对于 REPL、测试与调试都非常重要。

6.5 未限制递归深度

括号表达式是递归入口。若对嵌套层数完全不设限制,异常输入可能导致调用栈过深,甚至引发运行时故障。
因此,为解析器设置合理的递归深度上限,是必要的防御性设计。

7. 工程化实现中的若干细节

在将表达式解析器从“可运行”提升到“可维护”的过程中,以下细节尤为重要。

7.1 采用流式词法分析

词法分析器不必预先生成完整 token 数组。对于表达式求值器而言,通常只需维护当前 token,并在语法分析器需要时再读取下一个 token。

这种设计的优点包括:

  • 内存占用较低
  • 控制流更清晰
  • 词法与语法分析之间的职责边界更明确

7.2 只记录第一个错误

解析失败之后,后续出现的错误往往只是连锁反应。
若不断覆盖错误状态,最终暴露给用户的信息未必是问题根因。
因此,“first error only” 是一种非常实用的错误处理策略。

7.3 优先保证核心解析正确,再考虑工程包装

在实际开发中,建议遵循以下顺序:

  1. 先保证词法分析正确
  2. 再保证递归下降解析正确
  3. 最后再补充 REPL、日志、调试输出、命令系统和测试框架

这种方式可以有效降低问题定位成本。

7.4 文档与实现应保持一致

若项目进入工程化阶段,还应注意文档与代码的一致性。例如:

  • 公共头文件中的函数声明应与实际实现保持一致
  • 构建脚本中的语言标准应与文档说明一致
  • 控制台帮助信息应与实际支持的命令保持一致

这些问题不会影响解析器的核心算法,但会直接影响项目的可维护性与用户体验。

8. 总结

递归下降之所以适用于表达式解析,并不在于其形式上的“递归”,而在于它能够以极为自然的方式表达表达式的层次结构。

本文的核心结论可以概括如下:

  1. 文法规则可以直接映射为解析函数
  2. 运算符优先级可以通过函数层次自然表达
  3. 同级运算符通常使用循环处理,以避免左递归问题
  4. 括号表达式通过递归调用实现嵌套解析
  5. 在工程实践中,边解析边求值是一种非常适合计算器场景的实现方式

对于希望理解表达式解析机制的读者而言,亲手实现一个递归下降解析器,是将编译原理知识转化为工程能力的有效途径。
一旦完成 parse_expression()、parse_term()、parse_unary() 与 parse_primary() 这几层核心逻辑,许多原本抽象的概念便会迅速具体化,例如:

  • 文法如何驱动程序结构
  • 运算符优先级如何在代码中落地
  • 递归调用如何表达嵌套语法
  • 语法分析如何直接服务于求值过程

从这一意义上说,递归下降不仅是一种语法分析技术,也是一种结构清晰、可验证、易于工程实现的程序设计方法。

9. 引用

10. 文章声明

内容准确性: 我会尽力确保所分享信息的准确性和可靠性,但由于个人知识有限,难免会有疏漏或错误。如果您在阅读过程中发现任何问题,请不吝赐教,我将及时更正。

posted on 2026-04-01 21:24  wubing7755  阅读(95)  评论(0)    收藏  举报