简介:这份编译原理语法分析实验资源面向正在学习编译原理、需要完成递归子程序法语法分析作业的高校学生与自学者,重点解决在已有词法分析基础上识别各类语法成分、并按自动评测要求组织输出结果的问题。压缩包为zip格式,共2个文件,约17KB,包含1份doc实验说明与1份cpp实现代码,便于对照文法规则理解递归下降程序结构。目前已有6110人学习或下载,具备较好的参考价值。代码在CG实验平台满分通过,读者可据此了解如何对文法定义的全部语法成分逐一分析,掌握按单词识别顺序逐行输出词法信息、在指定语法成分结束前另起一行输出“<常量说明>”等标记的实现思路,并借鉴testfile.txt与output.txt的输入输出组织方式,快速排查递归子程序分析中的常见问题,适合课程实验提交前对照复盘。
1. 编译原理课设里的语法分析:为什么手写 parser 反而更快出结果
编译原理的语法分析实验,不少人第一反应是找个 LR 分析表生成器,结果先被状态机构造和冲突处理绕晕,实验报告写了三页还没跑通一个int a = 1 + 2 * 3;。这份 C++ 版的实现走的是另一条路:手写词法扫描加递归下降,把一段 mini-C 源码直接解析成能打印的抽象语法树,出错时给出行列号和可读的诊断。它适合三类人:课程实验要交可运行代码的学生、想把一个轻量 parser 塞进自己小工具的开发者、以及只想知道「编译原理落到代码里长什么样」的自学者。整包不依赖第三方库,一份 CMakeLists 加十几个源文件就能编出来,改起来也没有黑匣子。
2. 骨架与选型:词法、语法、AST 三层怎么切,递归下降还是表驱动
拿到源码包先不要急着敲cmake,先花十分钟看目录怎么切分。切分方式决定了后面加语法特性时疼不疼:如果词法、语法、AST 节点全揉在main.cpp里,加一条for语句要改五个地方;如果按职责分好层,加for只是多一个parseFor()和一个节点类型。这个项目的目录属于后者,切得比较克制,没有多余的抽象层,也没有为了「工程化」硬塞设计模式。
2.1 目录结构:词法、语法、AST、诊断各自独立
syntax-lab/ ├── CMakeLists.txt ├── include/ │ ├── token.h # TokenType 枚举与 Token 结构 │ ├── scanner.h # 字符流 -> Token 流 │ ├── ast.h # AST 节点与 dump │ ├── parser.h # 递归下降的每个非终结符一个方法 │ └── diag.h # 错误收集:行、列、级别、消息 ├── src/ │ ├── scanner.cpp │ ├── ast.cpp │ ├── parser.cpp │ ├── diag.cpp │ └── main.cpp # 命令行入口:读文件、跑分析、按开关输出 └── tests/ ├── ok_basic.c # 正常用例 ├── ok_nested.c # 嵌套块与 if-else 悬挂 ├── err_missing_semi.c ├── err_bad_expr.c └── run_tests.sh| 文件 | 职责 | 二次开发时改动频率 |
|---|---|---|
token.h | 定义所有记号的类型 | 低,加关键字时动一次 |
scanner.cpp | 字符到记号的扫描、行列号维护 | 中,加运算符或字面量时改 |
parser.cpp | 语法规则的具体落地 | 高,加语句类型必改 |
ast.h / ast.cpp | 节点结构与树打印 | 中,加节点种类时改 |
diag.cpp | 错误汇总与格式化 | 低 |
这个切法有一个隐含好处:scanner只依赖token.h,parser只依赖scanner和ast,谁都不认识main。想单独测词法,写个十行的小程序喂字符串进去就行,不用把整个工程拖进来。
2.2 为什么手写递归下降而不是查表驱动
LL(1) 表驱动和 LR 表驱动都更「像编译原理课本」,但在这个场景里不一定划算。表驱动需要额外写一个构造 FIRST/FOLLOW 集的程序,再写一个分析表驱动引擎,调 bug 时面对的是「状态 17 遇到;该走哪条产生式」这种问题,断点进去也看不出业务语义。递归下降的每个函数名就是文法里的非终结符,parseIf()里出问题,你一眼就知道是 if 语句的哪一段错了。
| 维度 | 递归下降 | 表驱动 LL(1)/LR |
|---|---|---|
| 上手成本 | 低,照着文法抄函数 | 高,先要把表算对 |
| 调试体验 | 断点停在具体规则上 | 断在状态转移,需要对照表看 |
| 文法限制 | 不能有左递归,需要提取左公因子 | LL(1) 同理,LR 宽松些 |
| 扩展新语句 | 加一个函数加一个分支 | 改文法、重算表、可能引入冲突 |
| 教学价值 | 规则与代码一一对应 | 更能体现自动机思想 |
我的做法通常是:先用递归下降把整条链路跑通,让 AST 能正确打出来,再考虑要不要为了实验报告补一个 FIRST/FOLLOW 计算器做对照。两套结果一致,报告里的表也就有了依据。
2.3 文法先改再写:消除左递归是动手前的第一件事
原始表达式文法里最常见的是这种写法:
Expr -> Expr '+' Term | Expr '-' Term | Term Term -> Term '*' Factor | Term '/' Factor | Factor Factor-> NUMBER | IDENT | '(' Expr ')'照抄成代码就是parseExpr()第一行调parseExpr(),跑起来直接栈溢出。正确做法是改写成右递归或者直接用循环,项目里采用的是循环版本,文法整理如下:
Program -> StmtList END StmtList -> Stmt StmtList | ε Stmt -> Decl | Assign | If | While | Block Decl -> 'int' IDENT ('=' Expr)? ';' Assign -> IDENT '=' Expr ';' If -> 'if' '(' Expr ')' Stmt ('else' Stmt)? While -> 'while' '(' Expr ')' Stmt Block -> '{' StmtList '}' Expr -> Rel Rel -> Add (('<'|'<='|'>'|'>='|'=='|'!=') Add)? Add -> Mul (('+'|'-') Mul)* Mul -> Unary (('*'|'/') Unary)* Unary -> '-' Unary | Primary Primary -> NUMBER | IDENT | '(' Expr ')'配套的 FIRST 集在动手前算一遍,能省掉后面大量「明明文法写对了却解析错」的怀疑:
| 非终结符 | FIRST 集 |
|---|---|
Program | intifwhile{IDENTEND |
Stmt | intifwhile{IDENT |
Expr/Rel/Add/Mul | -NUMBERIDENT( |
Primary | NUMBERIDENT( |
注意Stmt的 FIRST 集里IDENT会同时出现在声明和赋值两个候选式里吗?不会,声明一定以int开头,赋值一定以IDENT开头,两者不冲突,所以这里不需要提取左公因子。真正需要多看一眼的是悬挂 else:if (a) if (b) x = 1; else x = 2;里 else 归谁,递归下降天然是「就近匹配」,跟 C 语言一致,这点在写parseIf()时不用特意处理,但要在测试用例里固定下来。
提示:文法的 FIRST 集不必手工算完整,先算
Stmt和Expr这两层就够用了,大部分歧义都出在这两处。
3. 核心链路拆解:一个 1+2*3 从字符流走到 AST 的全过程
这条链路上有三个关键节点:Token 的切分、表达式优先级的落地、语法树的构造与错误恢复。三段代码加起来不到三百行,但每一段都有一两个容易写错的地方。下面按数据流动的顺序拆。
3.1 Token 与 Scanner:最长匹配是唯一准则
Token 结构本身没什么花头,关键是它要带上行列号,否则后面报错只能告诉用户「第 N 个 token 有问题」,这在几十行的测试文件里还能数,换成几百行就没法用了。
// include/token.h #pragma once #include <string> enum class TokenType { KW_INT, KW_IF, KW_ELSE, KW_WHILE, // 关键字 IDENT, NUMBER, // 标识符与数字字面量 PLUS, MINUS, STAR, SLASH, ASSIGN, EQ, NE, LT, LE, GT, GE, LPAREN, RPAREN, LBRACE, RBRACE, SEMI, COMMA, END, ERROR }; struct Token { TokenType type; std::string lexeme; int line = 1; int col = 1; };扫描器的主循环只做一件事:跳过空白和注释,然后按首字符分派。
// src/scanner.cpp Token Scanner::next() { skipTrivia(); // 空格、换行、// 与 /* */ 全在这里吃掉 if (atEnd()) return Token{TokenType::END, "<eof>", line_, col_}; const int startLine = line_, startCol = col_; char c = peek(); if (std::isdigit(static_cast<unsigned char>(c))) return scanNumber(startLine, startCol); if (std::isalpha(static_cast<unsigned char>(c)) || c == '_') return scanIdentOrKeyword(startLine, startCol); advance(); // 先消费当前字符,再判断双字符运算符 switch (c) { case '+': return Token{TokenType::PLUS, "+", startLine, startCol}; case '-': return Token{TokenType::MINUS, "-", startLine, startCol}; case '*': return Token{TokenType::STAR, "*", startLine, startCol}; case '/': return Token{TokenType::SLASH, "/", startLine, startCol}; case '(': return Token{TokenType::LPAREN,"(", startLine, startCol}; case ')': return Token{TokenType::RPAREN,")", startLine, startCol}; case '{': return Token{TokenType::LBRACE,"{", startLine, startCol}; case '}': return Token{TokenType::RBRACE,"}", startLine, startCol}; case ';': return Token{TokenType::SEMI, ";", startLine, startCol}; case ',': return Token{TokenType::COMMA, ",", startLine, startCol}; case '=': if (peek() == '=') { advance(); return Token{TokenType::EQ, "==", startLine, startCol}; } return Token{TokenType::ASSIGN, "=", startLine, startCol}; case '<': if (peek() == '=') { advance(); return Token{TokenType::LE, "<=", startLine, startCol}; } return Token{TokenType::LT, "<", startLine, startCol}; case '>': if (peek() == '=') { advance(); return Token{TokenType::GE, ">=", startLine, startCol}; } return Token{TokenType::GT, ">", startLine, startCol}; case '!': if (peek() == '=') { advance(); return Token{TokenType::NE, "!=", startLine, startCol}; } return Token{TokenType::ERROR, "!", startLine, startCol}; default: return Token{TokenType::ERROR, std::string(1, c), startLine, startCol}; } }逻辑上有两点必须讲清楚。第一,advance()只负责推进指针并维护行列号,不做类型判断,这样所有位置信息只有一个更新点,不会出现「某些字符跳过了行号统计」的问题。第二,双字符运算符必须先看第二个字符再决定,=和==、<和<=分属不同分支,写反了会让a == b被切成两个赋值。
标识符和关键字的处理靠最长匹配,先一次性扫完整个词,再查关键字表:
Token Scanner::scanIdentOrKeyword(int l, int c) { std::string s; while (!atEnd() && (std::isalnum(static_cast<unsigned char>(peek())) || peek() == '_')) s.push_back(advance()); // 一次扫到底,中途不判断关键字 static const std::unordered_map<std::string, TokenType> kKeywords = { {"int", TokenType::KW_INT}, {"if", TokenType::KW_IF}, {"else", TokenType::KW_ELSE}, {"while", TokenType::KW_WHILE} }; auto it = kKeywords.find(s); return Token{it == kKeywords.end() ? TokenType::IDENT : it->second, s, l, c}; }这个顺序不能反。如果边扫边判断「当前前缀是不是关键字」,intx会被切成一个int加一个x,后面语法分析就会莫名其妙地报「期望标识符」。同理,scanNumber()扫完数字后要再检查下一个字符是不是字母或下划线,是的话最好直接报一个ERROR,而不是默默切成123和abc。
3.2 递归下降主体:parseExpr 怎么吃掉 1+2*3
表达式是整份代码里最容易写错的部分,因为它涉及优先级和结合性。项目采用分层下降:从优先级最低的关系运算开始,一层层往下调,每层内部用while循环吃掉同级的连续运算。
// src/parser.cpp —— 优先级从低到高逐层下降 std::unique_ptr<Node> Parser::parseExpr() { return parseRel(); } std::unique_ptr<Node> Parser::parseRel() { auto lhs = parseAdd(); while (check(TokenType::LT) || check(TokenType::LE) || check(TokenType::GT) || check(TokenType::GE) || check(TokenType::EQ) || check(TokenType::NE)) { Token op = advance(); auto rhs = parseAdd(); lhs = makeBinary(op.lexeme, std::move(lhs), std::move(rhs), op.line); } return lhs; } std::unique_ptr<Node> Parser::parseAdd() { auto lhs = parseMul(); while (check(TokenType::PLUS) || check(TokenType::MINUS)) { Token op = advance(); auto rhs = parseMul(); // 右侧仍然从更高优先级开始 lhs = makeBinary(op.lexeme, std::move(lhs), std::move(rhs), op.line); } return lhs; } std::unique_ptr<Node> Parser::parseMul() { auto lhs = parseUnary(); while (check(TokenType::STAR) || check(TokenType::SLASH)) { Token op = advance(); auto rhs = parseUnary(); lhs = makeBinary(op.lexeme, std::move(lhs), std::move(rhs), op.line); } return lhs; } std::unique_ptr<Node> Parser::parseUnary() { if (check(TokenType::MINUS)) { // 一元负号,右结合 Token op = advance(); return makeNode("Unary", op.lexeme, op.line, parseUnary()); } return parsePrimary(); } std::unique_ptr<Node> Parser::parsePrimary() { if (check(TokenType::NUMBER) || check(TokenType::IDENT)) { Token t = advance(); return makeNode(t.type == TokenType::NUMBER ? "Number" : "Ident", t.lexeme, t.line); } if (check(TokenType::LPAREN)) { advance(); auto inner = parseExpr(); expect(TokenType::RPAREN, "缺少右括号 ')'"); return inner; } return errorExpr("期望一个表达式"); }这段代码能正确处理1 + 2 * 3:parseAdd先拿到左操作数1,看到+就把右边交给parseMul,parseMul会一路把2 * 3收成一个子树再返回,最终结构是+(1, *(2, 3))而不是*(+(1, 2), 3)。左结合也是靠while循环自然得到的——每轮把已解析的lhs作为新的左子树包进去,a - b - c就会变成-( -(a, b), c ),跟数学约定一致。
语句层同样是一个分派函数加一堆具体规则:
std::unique_ptr<Node> Parser::parseStmt() { switch (peek().type) { case TokenType::KW_INT: return parseDecl(); case TokenType::KW_IF: return parseIf(); case TokenType::KW_WHILE: return parseWhile(); case TokenType::LBRACE: return parseBlock(); case TokenType::IDENT: return parseAssign(); // 变量名开头只可能是赋值 case TokenType::SEMI: advance(); return makeNode("Empty", "", prev().line); default: return errorStmt("期望一条语句"); } }parseIf()里有一处值得留意:解析完 then 分支后要先看有没有else,有就继续解析,没有就直接返回。悬挂 else 的归属由调用顺序决定,if (a) if (b) x = 1; else x = 2;中内层parseIf()会先看到else并把它吃掉,得到的语义是「就近匹配」,与常见语言一致。
3.3 AST 节点设计与错误恢复:别让一个分号毁掉整趟分析
节点结构用unique_ptr组合子节点,避免手写析构,也不用担心深拷贝:
// include/ast.h #pragma once #include <memory> #include <string> #include <vector> struct Node { std::string kind; // "Program" "VarDecl" "Binary" "If" ... std::string value; // 变量名、字面量、运算符 int line = 0; std::vector<std::unique_ptr<Node>> kids; Node(std::string k, std::string v = {}, int ln = 0) : kind(std::move(k)), value(std::move(v)), line(ln) {} }; std::unique_ptr<Node> makeNode(std::string kind, std::string value = {}, int line = 0); std::unique_ptr<Node> makeBinary(std::string op, std::unique_ptr<Node> lhs, std::unique_ptr<Node> rhs, int line); void dump(const Node* n, std::ostream& os, const std::string& prefix = "", bool last = true);kind用英文短名是有意为之,一是打印对齐好看,二是后面接 JSON 或 DOT 时不用做转义,三是避免不同终端编码下出现乱码。
错误恢复采用 panic mode:出错后不抛异常,而是丢 token 直到看见语句边界,然后继续往下解析,这样一次运行能报出多个错误。
// src/parser.cpp void Parser::synchronize() { while (!check(TokenType::END)) { if (prev().type == TokenType::SEMI) return; // 上一句刚好收尾,可以直接续上 switch (peek().type) { case TokenType::KW_INT: case TokenType::KW_IF: case TokenType::KW_WHILE: case TokenType::RBRACE: return; // 遇到新语句或块结束,重新对齐 default: advance(); } } } std::unique_ptr<Node> Parser::errorStmt(const std::string& msg) { diag_.error(peek().line, peek().col, msg + ",实际读到 '" + peek().lexeme + "'"); synchronize(); return makeNode("ErrorStmt", msg, peek().line); // 占位节点,保证上层拿到非空指针 }返回占位节点这一步不能省。如果出错时直接return nullptr,上面parseBlock()往kids里push_back一个空指针,打印 AST 时就是段错误,排查起来比语法错误本身还费劲。
注意:
synchronize()里先判断prev()再判断peek(),顺序反了会导致x = 1; @这种输入在分号处多吞一个 token。
4. 跑起来:CMake 构建、命令行开关与回归用例
代码结构清楚之后,跑通只需要三步:配构建、给输入、看输出。这份工程的构建脚本很薄,没有引入任何第三方依赖,所以不管是在 Linux 还是 Windows 上,问题基本都出在编译器标准和路径上,不在依赖上。
4.1 两条构建路径:CMake 与单文件 g++
cmake_minimum_required(VERSION 3.16) project(syntax_lab CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(syntax-lab src/main.cpp src/scanner.cpp src/parser.cpp src/ast.cpp src/diag.cpp ) target_include_directories(syntax-lab PRIVATE include) if (MSVC) target_compile_options(syntax-lab PRIVATE /W4) else() target_compile_options(syntax-lab PRIVATE -Wall -Wextra) endif()构建命令就两行,第二行跑完能在build/下拿到可执行文件:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build -j如果只是想快速验证某个函数,不想走 CMake,直接一条命令也能编:
g++ -std=c++17 -Wall -Wextra -Iinclude src/*.cpp -o syntax-lab用-std=c++17是因为代码里用了std::unique_ptr、enum class和结构化绑定,用-std=c++11会在auto [a, b]这类地方报错。Debug 类型建议保留,递归下降出错时栈帧信息很有用。
4.2 命令行开关与一次完整运行
main.cpp的入口很朴素:读文件、建 Scanner、建 Parser、按开关输出。
./build/syntax-lab tests/ok_basic.c --tokens --dump-ast--tokens打印词法结果,--dump-ast打印语法树,两个都不给时只做分析并输出错误数。写一个最小用例:
int a = 1 + 2 * 3; int b; b = a - 4; if (b > 0) { b = b * 2; } else { b = 0; }期望的树形输出大致是这样,缩进层级直接反映嵌套关系:
Program ├─ VarDecl a │ └─ Binary(+) │ ├─ Number 1 │ └─ Binary(*) │ ├─ Number 2 │ └─ Number 3 ├─ VarDecl b ├─ Assign b │ └─ Binary(-) │ ├─ Ident a │ └─ Number 4 └─ If ├─ Binary(>) │ ├─ Ident b │ └─ Number 0 ├─ Block │ └─ Assign b │ └─ Binary(*) │ ├─ Ident b │ └─ Number 2 └─ Block └─ Assign b └─ Number 0看到Binary(*)在Binary(+)的右子树里,就说明优先级处理对了;看到Assign的两个孩子分别是Ident b和表达式,就说明语句层没把赋值当成表达式。
4.3 回归用例:把「看着对」变成「比对得出来」
手工看树形输出只能覆盖几条用例,改成脚本比对才靠得住。tests/run_tests.sh的思路很简单:正常用例必须退出码为 0,错误用例必须退出码非 0。
#!/usr/bin/env bash # tests/run_tests.sh —— 正常用例要过,错误用例必须报错 set -u BIN=./build/syntax-lab fail=0 for f in tests/ok_*.c; do if ! $BIN "$f" --dump-ast > /dev/null 2>&1; then echo "FAIL(should pass): $f"; fail=1 fi done for f in tests/err_*.c; do if $BIN "$f" --dump-ast > /dev/null 2>&1; then echo "FAIL(should fail): $f"; fail=1 fi done [ $fail -eq 0 ] && echo "all cases passed" exit $fail这套脚本的价值在于锁住错误恢复行为。加了新语句之后,很容易出现「错误用例不再报错」的退化,人眼看树是看不出来的,脚本一跑就露馅。想让比对更严格,可以把--dump-ast的输出重定向到文件,跟tests/expected/下的基线做diff,第一次跑的时候人工确认基线,之后就不用再看了。
5. 避坑与排查:五类把实验拖到凌晨的问题
下面这几条基本覆盖了语法分析实验里 80% 的翻车现场,按「现象 → 原因 → 解决」写,遇到对得上的直接照方抓药。
5.1 递归与文法相关的坑
现象:程序编译通过,一跑就段错误,或者在a+a+a+...这种长表达式上直接崩。原因:把课本上的左递归文法原样翻译成代码,parseExpr()第一句调parseExpr(),无限递归直到栈耗尽。解决:把E -> E + T | T改写成E -> T (+ T)*,代码里就是先解析左操作数,再while循环吃运算符。改完检查一遍所有解析函数,确认没有哪个函数的第一句是调自己。
现象:if (a) if (b) x=1; else x=2;解析出来的树里,else挂在了外层。原因:parseIf()写成先无条件找else,或者parseBlock()把内层 if 之后的 token 提前吃掉了。解决:parseIf()里只在 then 分支解析完之后检查一次check(KW_ELSE),不要在parseBlock()里处理else;测试用例里固定一条悬挂 else,把期望的树形写进基线。
5.2 词法与位置信息的坑
现象:源码里写intx = 1;,报错却提示「第 1 行第 1 列附近有问题」,或者干脆把intx当成关键字int加标识符x。原因:标识符扫描时边扫边判断关键字,没有走最长匹配。解决:先把整个标识符扫完,再查关键字表,查不到才归为IDENT;数字同理,扫完后检查下一字符不是字母或下划线。
现象:报错的行号总是比实际少一行,尤其是文件开头有注释时。原因:skipTrivia()里自己处理了\n,但没同步更新line_,而advance()里的更新又没被走到。解决:所有字符消费统一走advance(),行号和列号只在一个地方更新;注释跳过函数只负责判断边界,不负责改状态。
现象:1 + 2能过,1+2也过了,但a<=b报「期望表达式」。原因:双字符运算符只判断了=,<后面没看第二个字符,直接把<返回。解决:所有可能组成双字符的运算符,在switch分支里先peek()一次,匹配上就advance()再返回长 token,不匹配就返回短的。
5.3 构建与编码的坑
现象:Windows 上 AST 打印是乱码,Linux 上正常。原因:节点里存了中文运算符名或诊断信息,控制台默认代码页跟源文件编码不一致。解决:AST 的kind和value一律用 ASCII 短名,中文只出现在诊断消息里;确需中文输出时,在main开头把控制台输出代码页切到 UTF-8,或者干脆把diag输出重定向到文件。
现象:error: 'make_unique' is not a member of 'std'。原因:编译时没加-std=c++17,或者 CMake 里CMAKE_CXX_STANDARD被后面的target_compile_features覆盖了。解决:显式指定标准,并且在 CMake 里加set(CMAKE_CXX_EXTENSIONS OFF),别依赖编译器的默认值;命令行编译时把-std=c++17写死在脚本里。
6. 二次开发:从算术表达式扩到带函数调用的 mini-C
想在这份代码上加东西,顺序比内容重要。我的习惯是先把扩展点按「词法 → AST → 语法 → 测试」排好,一次只改一层,每层跑一次用例,这样出错时能立刻定位到是哪一层引入的。
| 扩展点 | 改动位置 | 工作量 |
|---|---|---|
加for循环 | token.h加关键字、scanner加表项、parseFor()、Node复用While结构 | 半天 |
| 加函数定义与调用 | parseFuncDecl()、parseCall()放进parsePrimary()、新增Call节点 | 一天 |
| 加符号表与简单类型检查 | 新建sema.cpp,遍历 AST 记录声明 | 一天 |
| AST 导出 JSON | 加一个 visitor,不动 parser | 两小时 |
函数调用最值得先做,因为它是检验「表达式和语句边界是否切干净」的试金石。具体做法是在parsePrimary()的IDENT分支里多看一眼后面是不是(:
std::unique_ptr<Node> Parser::parsePrimary() { if (check(TokenType::NUMBER)) { Token t = advance(); return makeNode("Number", t.lexeme, t.line); } if (check(TokenType::IDENT)) { Token t = advance(); if (!check(TokenType::LPAREN)) // 普通标识符 return makeNode("Ident", t.lexeme, t.line); advance(); // 吃掉 '(' auto call = makeNode("Call", t.lexeme, t.line); if (!check(TokenType::RPAREN)) { call->kids.push_back(parseExpr()); // 实参先用表达式兜住 while (check(TokenType::COMMA)) { advance(); call->kids.push_back(parseExpr()); } } expect(TokenType::RPAREN, "函数调用的实参列表缺少 ')'"); return call; } if (check(TokenType::LPAREN)) { /* 括号表达式,同前 */ } return errorExpr("期望一个表达式"); }改完这一段,parsePrimary()就同时承担了变量、字面量、括号表达式和函数调用四种情况,注意check(LPAREN)一定要在advance()之后判断,否则会把普通变量误判成调用。验证方法是给三条用例:f();、f(1, 2);、f(g(x), 1 + 2);,三条的树都对了,再回头补parseFuncDecl()。
如果不想动 parser,AST 导出 JSON 是性价比最高的改造,加一个二十行的 visitor 就够,后面前端可视化或者用脚本比对基线都能直接吃:
// src/jsonvisitor.cpp —— 不侵入 parser,只遍历已有 AST struct JsonVisitor { std::ostream& os; void visit(const Node* n) { os << "{\"kind\":\"" << n->kind << "\""; if (!n->value.empty()) os << ",\"value\":\"" << n->value << "\""; os << ",\"line\":" << n->line; if (!n->kids.empty()) { os << ",\"kids\":["; for (size_t i = 0; i < n->kids.size(); ++i) { if (i) os << ","; visit(n->kids[i].get()); } os << "]"; } os << "}"; } };从那以后我每次动 parser 之前,都强制先跑一遍tests/run_tests.sh并把--dump-ast的输出存成基线,改完再跑一遍做 diff——语法分析这种东西,靠肉眼看树形输出判断对错太不可靠了,血泪经验。希望帮到你。
本文还有配套的精品资源,点击获取