C++实现编译原理语法分析实验:递归下降与AST构建全攻略

发布时间:2026/10/12 20:25:29
C++实现编译原理语法分析实验:递归下降与AST构建全攻略 简介面向编译原理课程语法分析环节的C实验方案适合正在学习递归子程序法、需要完成语法分析程序设计的本科生或自学者。资源针对给定文法在词法分析识别出单词的基础上对常量说明、变量说明、声明语句等各类语法成分逐项分析并按要求在输出文件中标记语法成分名称便于自动评测与结果校验。代码已在CG实验平台上满分通过具备较强的参考价值可作为实现思路、输出格式与程序结构的对照样板。压缩包共2个文件其中cpp源文件为完整可编译的语法分析程序doc文档为问题描述与实验要求整包大小约17KB轻量易用。已有6100余人浏览学习适合需要快速上手语法分析实验、参考满分实现或自查排错的同学。1. 编译原理-语法分析实验c版绝大多数人不是在算法上崩的是在接口上崩的语法分析实验是编译原理课程里最容易让人“写崩”的一环。语法分析器要解决的核心问题是给定一组 token来自词法分析如何按照文法规则判断它们能不能组成合法语句并在此基础上构建出后续语义分析要用的结构。它的难点不在于理解上下文无关文法也不在于背诵 FIRST 集和 FOLLOW 集的定义而在于当你用 C 动手实现递归下降或 LR 分析表时接口设计、内存管理、错误恢复和调试手段全都会挤在一起向你索债。本文面向正在做课程实验、或想补上编译基础但不想只看概念图的 C 从业者按我实际做过的方案把选型、实现、参数和排错路径完整拆开。2. 递归下降还是 LR 表驱动C 语法分析实验的选型与前置设计2.1 两条路线的取舍为什么课程实验优先选递归下降常见做法是从两条路线里挑一条手工递归下降对应 LL(1) 文法或自动表驱动对应 LR(0)/SLR(1)/LALR(1)用栈 分析表推进。我一般会建议课程实验优先做递归下降理由不是 LR 高不可攀而是递归下降把“文法规则”直接映射为函数调用关系——每个非终结符对应一个 C 成员函数读起来和文法一字不差出错时栈回溯就是函数调用栈几乎不需要额外调试工具。LR 的优点是文法表达能力更强比如左递归文法可以直接写但代价是分析表的构造规则多、状态机和 goto 表容易变成黑匣子一旦表算错你很难凭肉眼判断是 FIRST 集算歪了还是项目集闭包漏了状态。以表达式文法为例典型的右递归文法写出来是这样E - E T | T T - T * F | F F - ( E ) | id这个文法天然左递归递归下降直接照抄会死循环。所以要做两件事消除左递归得到等价文法E - T E E - T E | ε T - F T T - * F T | ε F - ( E ) | id消除左递归之后每个非终结符的函数体就是一组 case 分支。选择这条路的隐含收益是后续如果要加错误恢复、打印调用栈、观察匹配过程改的都是你自己的函数而不是去改一张表。对 C 实验来说这也是调试成本最低的路线。2.2 先把 token 接口钉死在第一个星期行号、列号和类型一个都不能少绝大多数人犯的第一个规划错误是语法分析器直接基于词法分析器给的字符串数组干活等排查“为什么括号不匹配”时才发现不知道错误发生在源码第几行。语法分析器的输入接口应当在动手写递归下降之前就定死一个 Token 结构体必须携带 type、lexeme原文、line、column 四个字段。类型用 enum class 而不是裸 int是为了让 switch 分支可读且在编译期就能发现漏判。// Token.h #pragma once #include string enum class TokenType { Identifier, Number, Plus, Star, LParen, RParen, EndOfFile, Unknown }; struct Token { TokenType type; std::string lexeme; int line; int column; std::string toString() const { return Token{type std::to_string(static_castint(type)) , lexeme lexeme , line std::to_string(line) , col std::to_string(column) }; } };这段代码里的 line 和 column 不是装饰品。语法分析实验的验收里通常有“对错误输入给出报错位置”的隐性要求很多同学在词法阶段不想保存行列号到语法分析排错时只能用“第几个 token”这种抽象坐标非常被动。toString() 是我建议从第一天就写上的调试接口——后续无论是打印 token 流还是断言 AST 结构它都能让失败信息直接可读。如果你用 VS Code 配 C 环境做实验可以额外加一条编译选项-Wall -Wextra -Werror把未使用字段和隐式转换直接拦在编译阶段。2.3 输出什么才算实验完成AST 结构才是语法分析的交付物语法分析实验的交付物通常不是“判断 accept/reject”这一个布尔结果而是一棵抽象语法树AST或至少一棵语法分析树。区别在于语法分析树保留所有推导细节包括括号、ε 产生式而 AST 只保留影响后续语义分析的关键节点。课程实验一般要求到 AST 就够因为下一步语义分析只需要知道表达式的操作符和操作数不需要知道括号出现在哪里——括号的嵌套关系已经由树的层级体现。用 C 实现 AST 节点常见做法是定义一个基类加若干派生类再配合std::variant做访问者模式。我实际推荐后者std::variant把“节点类型”和“节点内容”收敛在一个类型里不需要虚函数表调试打印和遍历都更直接。// Ast.h #pragma once #include memory #include string #include variant #include vector struct NumberExpr { double value; }; struct BinaryExpr { char op; // 或 * std::unique_ptrExpr lhs; std::unique_ptrExpr rhs; }; struct Expr { using Node std::variantNumberExpr, BinaryExpr; Node node; };这段代码故意把BinaryExpr里的op定义成char而不是std::string是为了让调试输出更紧凑。std::unique_ptr管理子树可以免去手写析构函数的困扰只要你不在多个 AST 节点之间共享同一棵子树——语法分析场景下每个节点只被持有一次这是unique_ptr的典型适用场景。如果实验要求你把 AST 序列化输出或者做多次遍历可以为这个variant类型补一个visit重载用std::visit分发到不同的处理函数。所有节点都用std::shared_ptr是常见的过度设计它会让遍历代码里到处是引用计数检查没必要。3. 用 C 把一个四则运算文法跑通从递归下落到 LL(1) 预测表3.1 最小可跑的递归下降核心表达式、项、因子三层分解代码骨架定了以后核心实现就是每个非终结符一个函数。下面这段是一份可以直接抄进实验的递归下降代码处理加减乘除和括号// Parser.h #pragma once #include deque #include Ast.h #include Token.h class Parser { public: explicit Parser(std::dequeToken tokens) : tokens_(std::move(tokens)) {} bool parse() { try { root_ parseExpression(); return peek().type TokenType::EndOfFile; } catch (const std::runtime_error e) { lastError_ e.what(); return false; } } private: std::dequeToken tokens_; std::unique_ptrExpr root_; std::string lastError_; const Token peek() const { return tokens_.front(); } Token advance() { Token t tokens_.front(); tokens_.pop_front(); return t; } bool check(TokenType type) const { return peek().type type; } // E - T E std::unique_ptrExpr parseExpression() { auto left parseTerm(); while (check(TokenType::Plus)) { advance(); auto right parseTerm(); left std::make_uniqueExpr( Expr{BinaryExpr{, std::move(left), std::move(right)}}); } return left; } // T - F T std::unique_ptrExpr parseTerm() { auto left parseFactor(); while (check(TokenType::Star)) { advance(); auto right parseFactor(); left std::make_uniqueExpr( Expr{BinaryExpr{*, std::move(left), std::move(right)}}); } return left; } // F - ( E ) | number std::unique_ptrExpr parseFactor() { if (check(TokenType::Number)) { Token t advance(); auto expr std::make_uniqueExpr( Expr{NumberExpr{std::stod(t.lexeme)}}); return expr; } if (check(TokenType::LParen)) { advance(); auto inner parseExpression(); if (!check(TokenType::RParen)) { throw std::runtime_error( line std::to_string(peek().line) : expected ) after expression); } advance(); return inner; } throw std::runtime_error( line std::to_string(peek().line) : unexpected token peek().lexeme); } };这段代码有两个关键设计说明。第一parseExpression和parseTerm用了 while 循环而不是直接递归调用本质是用迭代消化右递归的E和T避免处理 ε 产生式时多写一层空函数——这是递归下降里最常见的等价改写。第二错误抛出时携带peek().line这条信息会在你调试表达式缺失右括号的用例时节省几个小时没有行号的报错信息本质上等于没有报错。3.2 FIRST 集和 FOLLOW 集手算五分钟后用代码验证递归下降不需要显式建预测分析表但你心里必须知道自己写的每个分支对应哪些 FIRST 集。我的建议是动手写 parse 函数之前先用五分钟手算一遍文法中每个非终结符的 FIRST 集和 FOLLOW 集比如上面的文法FIRST(E)FIRST(T)FIRST(F){ id ( number }FOLLOW(E){ $ ) }。算错的地方会在你跑测试用例时以“意外 token”的形式暴露出来但那时再回头查文法定义成本远高于提前算。如果你的实验指定用 LL(1) 预测分析表驱动而不是手写递归下降那就要实现一张二维表并用栈驱动。C 里我一般用std::mapstd::pairNonTerminal, TokenType, Production来表示这张表// TableDrivenParser.cpp // 伪代码结构用 map 表示预测分析表键是 (非终结符, 终结符) using Production std::vectorSymbol; std::mapstd::pairNonTerminal, TokenType, Production table;表的构造规则是教科书上的经典两条对产生式 A - α对 FIRST(α) 中的每个终结符 a把该产生式填入table[{A, a}]如果 α 能推出 ε则对 FOLLOW(A) 中的每个终结符 b把该产生式填入table[{A, b}]。这两条规则听起来简单实际实验里翻车最频繁的就是 ε 产生式的处理——漏掉 FOLLOW 集合会导致表中出现空槽运行时直接报“无对应产生式”却不告诉你是哪个终结符漏了。填表之后建议写一段五分钟的校验代码打印所有空槽对着文法逐个确认是不是真的不合法。3.3 从分析器到 ASTmake_unique 与 move 语义的正确姿势递归下降的返回值和 AST 的构建是同步发生的。上面代码里make_unique和std::move的配合是为了避免拷贝整个子树——如果你用std::make_shared或者裸指针非常容易在某次抛异常时泄漏子树。C17 以后unique_ptr的移动语义让返回局部变量不需要额外写std::move编译器自动处理返回值优化但把left移到BinaryExpr里时必须写std::move否则left会被拷贝而unique_ptr禁止拷贝导致编译失败。我建议构建 AST 的顺序和文法推导顺序一致也就是“自底向上”先构建操作数节点再创建二元运算节点把操作数挂上去。这样每棵子树的构建路径清晰后续打印 AST 时层级关系也对得上。调试时如果发现 AST 的左右子树反了优先检查BinaryExpr的初始化列表次序这是 order of evaluation 踩坑的重灾区{BinaryExpr{op, std::move(left), std::move(right)}}里left和right的求值顺序在 C17 前是未指定的务必用两行代码先 move 到局部变量再传给构造器。4. 语法分析实验避坑指南五个能把人困住一整晚的经典翻车现场4.1 现象程序一跑就栈溢出崩溃栈指向 parseExpression 无限递归原因是左递归没有消除干净或者文法是间接左递归。比如A - B x、B - A y这种间接左递归在递归下降里同样会让函数互相调用直到栈溢出不死磕直接推导的人容易忽略。解决方法是先把整个文法做一遍系统的非终结符排序消除对每个非终结符把间接左递归展开成直接左递归再统一消除或者干脆用 LR 分析器处理左递归文法。我自己的血泪经验是动手写代码前把文法放在纸上推导三次A - ... - A ...确认不存在任何回到自身的路径再开工这十分钟能省下一晚上。4.2 现象输入12*3得到 AST 是(12)*3结合性完全错了原因是 parseExpression 里 while 循环直接进入减法/除法分支时没有在循环内部重新调用下一层优先级函数。典型的错误写法是在 parseExpression 里 parseTerm 之后直接把剩余 token 全部吃掉导致*被当成的同级操作符。解决方法是严格按优先级分层加法和减法一层乘法和除法一层因子再一层。每层循环体内部只能调用“比当前层低一级”的 parse 函数这样12*3的乘法子树必然先构建。这个 bug 隐蔽的地方在于只测同级运算如123时全部通过一混入不同优先级就翻车。测试用例必须覆盖12*3和(12)*3各一条二者 AST 形状必须不同。4.3 现象语法错误能报出来但位置永远指向文件末尾原因是错误抛出时取的是peek().line但栈回溯之后 token 已经推进到 EOF位置信息丢失。这个问题在递归下降里的标准解法是在进入每个非终结符函数时记录当前 token 的位置出错抛出时带的是进入函数时的位置而不是异常上抛到顶层时的位置。另一个常见做法是每个advance()时把currentToken_单独存一份报错时用最近一次成功消费的 token 的行号加 1 作为近似位置。我自己的习惯是在 parse 函数入口打印一条 debug 日志内容包含 token 类型和行列号DEBUG 宏关掉后不影响验收。4.4 现象语句级语法分析比如含赋值、if、while 的文法在分号处理上疯狂报错这是悬空 else 之外最容易被忽略的坑分号不是表达式的一部分而是在 statement 级别的终结符。递归下降的 expression 函数返回后调用它的 statement 函数必须显式消费分号如果 expression 内部偷偷把分号跳过了后续所有 statement 的边界判断全乱。更麻烦的是有些同学把分号放进 token 流里就忘了处理在 parseStatement 里不检查check(Semicolon)就返回导致两个语句连在一起被当成一个整体。解决方案是分号只由 statement 层消费expression 层看到分号必须报错错误恢复时以分号为同步点跳过当前 statement 剩余 token。4.5 现象C 里传 token 流用std::vectorToken传值分析器内部推进没错但调用方永远看到原样原因是 vector 传值发生拷贝分析器内部的pop_front全作用在副本上。这个坑在实验代码里出现的频率比你想象得高因为很多人习惯了把数组当参数传。解决方法是使用std::dequeToken并传引用或直接定义成成员变量初始化一次需要回溯的场合比如尝试匹配某个分支失败后回退就用额外的位置索引保存当前读头。我自己的推荐是std::deque配size_t pos_游标推进就是pos_回溯就是pos_ savedPos_比 pop_front 更灵活虽然会占一点空间但语法分析实验的 token 量级完全可以接受。提示第四章里的五条问题我都实际踩过或被学生问过。左上无限递归、右结合性错乱、报错位置漂移、分号吞并、容器拷贝这五类基本能覆盖 90% 的语法分析实验“卡死”场景。如果新的报错不属于这五类优先怀疑 FIRST 集算错。5. 验证你的语法分析器断言驱动的测试用例与 AST 打印技巧写分析器的人都会犯同一个错只拿“能通过的正确输入”测不拿“应当拒绝的输入”测。我现在的实验习惯是准备三组用例合法输入组、非法输入组、边界输入组。合法输入组覆盖运算符优先级和括号嵌套非法输入组覆盖缺失右括号、连续运算符、空表达式边界输入组覆盖单个数字、多层括号嵌套、超长表达式验证不会栈溢出。每组用例用断言判断 parse 返回值并用一个格式化函数打印 AST。AST 打印建议用缩进表示层级而不是用括号嵌套的 Lisp 风格。缩进风格能让你一眼看出结合性和优先级是否正确// AstPrinter.cpp #include iostream #include variant void printExpr(const Expr expr, int depth) { struct Visitor { int depth; void operator()(const NumberExpr n) { for (int i 0; i depth; i) std::cout ; std::cout n.value \n; } void operator()(const BinaryExpr b) { for (int i 0; i depth; i) std::cout ; std::cout b.op \n; } }; std::visit(Visitor{depth}, expr.node); }这个打印函数配合断言测试能在五分钟内发现大多数结构性问题。断言测试的写法要覆盖“树的形状”而不只是“能通过”// 断言用例示例 void testPrecedence() { auto ast parse(12*3); auto root std::getBinaryExpr(ast-node); assert(root.op ); auto right std::getBinaryExpr(*root.rhs); assert(right.op *); std::cout precedence test passed\n; }我给这条测试用例命名叫“回到未来测试”——它保证你今天改完代码明天不会把优先级改回错的。如果你把分析器接到符号表做语义分析这里还有个进阶习惯AST 打印器和断言框架早期就写好后续每加一种语句类型就跑一遍全量回归这比期末熬夜调新的实验功能高效得多。语法分析器是编译器的输油管道前端每个细微错误都会放大到后端变成让人头大的“灵异现象”把验证体系搭在前面后面才不至于靠玄学调 bug。希望这些方法和踩坑经验能帮你少熬几个夜把实验做成能拿得出手、用得起来的东西。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询