ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于QT构建词法语法分析器:从Token到语法树的可视化实现

基于QT构建词法语法分析器:从Token到语法树的可视化实现 简介基于Qt框架实现的词法语法分析器是一款类似编译器前端的教学练习项目面向正在学习编译原理或词法语法分析技术的学生与开发者。它能够将源代码拆解为一个个独立的标记再根据文法规则组合成抽象语法树等结构清晰地演示词法分析与语法分析相互衔接的完整过程帮助使用者快速理解编译器前端的核心机制。资源压缩包共包含18个文件除C源文件与头文件外还有Qt界面布局文件、工程构建文件以及用户配置和说明文档整体大小仅24KB结构精简并带有注释便于结合实际代码对照学习。目前该资源已有153人学习浏览。项目中主窗口负责交互控制表格模块可直观展示分析结果另有关于页面与README提供必要说明通过研读这些代码可以学习状态机、正则表达式在词法分析中的具体应用也能了解递归下降等语法解析算法的实现思路同时熟悉Qt框架开发跨平台图形界面程序的一般方法。对于希望从零动手搭建简易编译器、或借助界面观察分析过程的学习者来说这是一份贴近实战且非常轻量的参考素材。1. 基于QT实现词法语法分析器到底在做什么把“基于QT实现词法语法分析器”拆开看它不是一个完整的编译器而是编译器前端的两条腿词法分析把源代码字符串切成 token语法分析把 token 流按文法规则归约成语法树。QT 在这里充当的是可视化交互层而不是分析算法本身——这也是很多人一开始的误区以为要拿 QT 去写状态机实际上 QT 只是壳核心是那些与界面无关的、纯粹用 C 实现的分析模块。这个项目能带你走通一条很典型的路径先用 C 手工写一个词法器和一个 LL(1) 或 LALR(1) 语法分析器再用 QT 的 QPlainTextEdit、QTreeView 和 QGraphicsScene 把 token 流、分析栈和语法树画出来。适合已经在学编译原理但被算法绕晕、想用界面把过程“看明白”的人也适合要把课程设计做成可演示工具的学生。它能解决的核心问题是让词法规则、预测分析表和归约过程不再停留在纸上而是变成你能在界面上一步步观察、断点、回放的真实程序。2. 词法分析器的设计与 QT 侧数据结构2.1 先定 token 的 C 表示再考虑界面词法分析器的输出不是简单的字符串而是一组带类型、值和位置信息的 token。常见做法是定义一个枚举类型 TokenType再加上一个 Token 结构体enum class TokenType { KEYWORD, IDENTIFIER, INTEGER_LITERAL, FLOAT_LITERAL, OPERATOR, DELIMITER, END_OF_FILE, UNKNOWN }; struct Token { TokenType type; QString lexeme; // 原始字符串 int line; int column; double value; // 仅当是数字字面量时有意义 QString toString() const { return QString(%1 \%2\ %3:%4) .arg(static_castint(type)) .arg(lexeme) .arg(line) .arg(column); } };这里的关键决策是token 里不要只存字符串一定要带行列号。QT 的 QTextBlock 和 QTextCursor 都支持按行列定位后面做“点击 token 高亮源码”时行列号是唯一能高效映射到 QPlainTextEdit 位置的桥梁。QString 在 QT 里是隐式共享的token 的 lexeme 用 QString 存跨线程传递或放进 QList 时拷贝开销都远小于 std::string。2.2 手工构造词法分析器的两种状态逐字符扫描与正则库在 QT 项目里实现词法器有两种主流选型一种是手写状态机用 switch 或表驱动另一种是直接用 QRegularExpression 按 token 类型依次匹配最长子串。手写状态机适合做纯 C 模块不依赖 QtCore 之外的东西也便于后续把分析过程可视化。推荐用这种结构class Lexer { public: explicit Lexer(const QString source); QVectorToken tokenize(); private: QString src; int pos 0; int line 1; int col 1; char current() const { return pos src.size() ? src[pos].toLatin1() : \0; } void advance() { if (current() \n) { line; col 1; } else { col; } pos; } Token makeToken(TokenType type, const QString lexeme, int startLine, int startCol); };扫描函数里需要处理的关键细节是空白与注释跳过。空白字符空格、换行、Tab在词法分析阶段就应该被丢弃但换行必须要更新行号。注释则分为单行注释//和块注释/* */块注释需要跨行这也是行号更新最容易漏的地方。我一般会专门抽一个skipWhitespaceAndComments()方法避免主循环里被 if 嵌套搞乱。主循环的写法要遵循“最长匹配”原则。比如遇到字符不能直接判断为运算符得先看下一个字符是不是如果是应该返回。同理遇到数字先累积整数部分再判断小数点后是否还有数字从而区分整数常量和浮点常量。这个逻辑写出来大概是Token Lexer::nextToken() { skipWhitespaceAndComments(); int startLine line, startCol col; if (pos src.size()) return makeToken(TokenType::END_OF_FILE, EOF, startLine, startCol); QChar c src[pos]; if (c.isLetter() || c _) { QString lexeme; while (pos src.size() (src[pos].isLetterOrNumber() || src[pos] _)) { lexeme src[pos]; advance(); } TokenType type keywords.contains(lexeme) ? TokenType::KEYWORD : TokenType::IDENTIFIER; return makeToken(type, lexeme, startLine, startCol); } if (c.isDigit()) { QString lexeme; bool isFloat false; while (pos src.size() src[pos].isDigit()) { lexeme src[pos]; advance(); } if (pos src.size() src[pos] .) { if (pos 1 src.size() src[pos 1].isDigit()) { isFloat true; lexeme src[pos]; advance(); while (pos src.size() src[pos].isDigit()) { lexeme src[pos]; advance(); } } } double v lexeme.toDouble(); return makeToken(isFloat ? TokenType::FLOAT_LITERAL : TokenType::INTEGER_LITERAL, lexeme, startLine, startCol); } // 运算符、分隔符处理略 }这个实现里有一个容易被忽略的坑当读到1.时如果小数点后不是数字不应该吃掉小数点因为1.后面可能跟着运算符比如1..2在有些文法里会解析成两个点。这里的关键动作是“先看后进”用条件判断来决定是否推进 pos而不是盲目地先吃掉再回退。2.3 用 QStandardItemModel 承载 token 表格词法分析的输出要在 QT 界面上展示直接往 QTableWidget 里逐行塞 QTableWidgetItem 完全可行但数据量大或需要排序过滤时性能不好。更工程化的做法是使用 QStandardItemModel 配合 QTableView这样 token 列表和语法树视图共用同一个 model/view 架构内存占用也更可控auto* model new QStandardItemManager(this); model-setHorizontalHeaderLabels({类型, 词素, 行, 列, 值}); for (const Token tok : tokens) { QListQStandardItem* row; row new QStandardItem(tokenTypeToString(tok.type)) new QStandardItem(tok.lexeme) new QStandardItem(QString::number(tok.line)) new QStandardItem(QString::number(tok.column)) new QStandardItem(tok.value ! 0 ? QString::number(tok.value) : ); model-appendRow(row); } ui-tableView-setModel(model);设置表头后记得把verticalHeader()-setVisible(false)否则会出现毫无意义的行号列。再开启setSelectionBehavior(QAbstractItemView::SelectRows)让用户点击一行时选中整个 token。后面做“点击 token 行跳转到源码中的对应位置”时根本不需要自定义委托只要在 selectionChanged 信号里拿到当前行的 line 和 column 数据再移动 QTextCursor 即可。3. 语法分析器从预测分析表到 QT 中的分析栈可视化3.1 为什么选择 LL(1) 而不是 LALR(1)在 QT 里做语法分析可视化LL(1) 表驱动法是性价比最高的选择。LALR(1) 需要构造状态集、ACTION/GOTO 表表格本身就够复杂可视化时能把人看晕而且手工构造文法时冲突检查较难。LL(1) 只需要 FIRST、FOLLOW 集合和预测分析表表格规模小每一行对应一个非终结符和一个终结符可以直接映射到 QTableWidget 上非常直观。LL(1) 文法的要求是对于每个非终结符的每个候选产生式它们的 FIRST 集合两两不相交。用这种文法分析像表达式、赋值语句、if/while 控制流这类经典结构完全够用。如果遇到左递归文法比如expr - expr term必须先改写为右递归形式expr - term expr。这一步至关重要在可视化界面里如果直接套用左递归文法分析栈永远走不到终态用户看到的就是“栈底一直压着同一个非终结符”。实操中我会把 FIRST 和 FOLLOW 集合作为QMapQString, QSetQString存起来QMapQString, QSetQString firstSets; QMapQString, QSetQString followSets; QMapQString, QStringList productions; // E - { T E, ε} QMapQPairQString, QString, int parseTable; // key: (非终结符, 终结符) - 产生式下标预测分析表的规则是把每个产生式A - α的 α 的 FIRST 集合中的每个终结符 a填入parseTable[(A, a)]。如果 α 能推导出 ε则把 A 的 FOLLOW 集合里的所有终结符 b 也填入parseTable[(A, b)]。这个填充逻辑就是表驱动的核心可以直接在 QT 的表格里展示。3.2 分析栈快照的记录与回放经典的 LL(1) 分析器使用栈存储符号分析过程是状态递进的。要在 QT 里把过程画出来不能只做分析要做“带快照的分析”。定义一个步骤结构体struct ParseStep { QStringList stack; // 栈底到栈顶从栈底到栈顶排列 QString input; // 剩余输入 QString action; // 匹配 或 展开 expr int productionIndex; // 若展开则记录产生式下标 };分析循环每走一步就 push 一个快照。这样做的收益是分析完成后可以拖动 QSlider 逐步查看每一步的栈变化而不用真的做一遍回溯。关键是快照里栈要保存“从栈底到栈顶”的完整列表而不是只记录压入和弹出操作否则 UI 层无法重绘正确的栈状态。分析循环的伪码stack.push($); stack.push(startSymbol); while (!stack.empty()) { QString top stack.back(); if (top tokens[pos].lexeme || top $) { if (top tokens[pos].lexeme) { action 匹配 top; pos; stack.pop(); } else break; } else if (isTerminal(top)) { action 错误期望 top 得到 tokens[pos].lexeme; break; } else if (parseTable.contains({top, tokens[pos].lexeme})) { int idx parseTable[{top, tokens[pos].lexeme}]; stack.pop(); if (productions[top][idx] ! ε) { QStringList rhs productions[top][idx].split( ); for (int i rhs.size() - 1; i 0; --i) stack.push(rhs[i]); } action 展开 top - productions[top][idx]; } else { action 错误无移进规则; break; } steps.append({stackAsStringList(stack), remainingInput(tokens, pos), action, idx}); }这里的细节是stack.back()是栈顶但 C 的QList没有 back 的 push_back 对称操作实际实现时一般用QStringList作为栈末尾是栈顶压栈用append弹栈用removeLast。可读性比用QStack好因为QStack继承自QVector时 top 语义容易混淆。每次快照记录前把整个栈转成字符串列表存好UI 层用QListWidget或自定义paintEvent就能直接画。3.3 用 QGraphicsScene 画一棵可展开的语法树语法树的可视化是项目看起来“像编译器”的关键。不需要引入第三方图形库QT 的QGraphicsScene配上QGraphicsEllipseItem和QGraphicsTextItem就能做到。树的节点应该保存实际内容而不是只保存字符串这样点击节点才能回到 token。建议创建TreeNode结构体同时存语法信息和 token 引用struct ParseTreeNode { QString symbol; const Token* token; // 终结符对应的 token非终结符为 nullptr QVectorParseTreeNode* children; };分析过程的展开步骤本身就可以直接构建树每步展开A - α时为 A 创建节点并挂上 α 中每个符号对应的子节点匹配终结符时把 token 绑到对应的叶子节点上。绘制过程需要先计算树的宽度再布局。递归计算每棵子树占据的宽度然后根节点位于总宽度中央子节点按顺序排列。这个逻辑在 QT 里可以如下写void SyntaxTreeView::layoutTree(ParseTreeNode* node, double x, double y) { const double vGap 60; const double hGap 20; double nodeWidth 40; double subtreeWidth 0; ... if (node-children.isEmpty()) { auto* item addText(node-symbol); item-setPos(x, y); x hGap item-boundingRect().width(); } else { double childX x; for (auto* child : node-children) { layoutTree(child, childX, y vGap); } double totalWidth childX - x; auto* item addText(node-symbol); item-setPos(x (totalWidth - item-boundingRect().width()) / 2, y); x qMax(x 30, childX); } }这个递归布局有一个显著特点子节点区域的宽度取决于其最深叶子节点的位置。如果遇到深层节点导致整体宽度超出视口界面可以通过ui-graphicsView-setSceneRect(scene-itemsBoundingRect())自动缩放或者让用户用滚轮缩放。更稳妥的做法是关闭滚动条启用QPixmap缓存再按需重绘。缩放可以绑定QGraphicsView::setTransform但要注意监听 wheelEvent 而不是用setDragMode混淆。4. QT 工程集成与三类典型交互实现4.1 分析模块与界面模块的目录划分工程结构不要一上来就往 mainwindow 里堆代码。按“core 与 qt 分离”的原则组织CompilerFrontEnd/ ├── core/ │ ├── Lexer.h / Lexer.cpp │ ├── Token.h │ ├── Parser.h / Parser.cpp │ ├── Grammar.h / Grammar.cpp │ └── tests/ ├── ui/ │ ├── MainWindow.h / MainWindow.cpp │ ├── TokenTableModel.h / TokenTableModel.cpp │ ├── ParseTreeView.h / ParseTreeView.cpp │ └── StackWidget.h / StackWidget.cpp ├── resources/ └── CMakeLists.txtcore 目录下的代码只依赖QString和QVector不出现任何 QWidget。测试时可以直接写一个控制台测试程序不启动 QT 窗口这对后续加自动化回归很有帮助。在 QT 工程里CMake 的find_package(Qt6 COMPONENTS Widgets)或 Qt5 的find_package(Qt5 COMPONENTS Widgets)需要先声明CMAKE_AUTOMOC ON否则 Q_OBJECT 类的信号槽无法生成。mock 代码set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) add_executable(compiler_front_end main.cpp core/Lexer.cpp core/Parser.cpp ui/MainWindow.cpp ui/ParseTreeView.cpp ) target_link_libraries(compiler_front_end PRIVATE Qt6::Widgets)新版 QT 默认用 Qt6老项目中遇到的QT_QPA_PLATFORM_PLUGIN_PATH报错大多是因为windeployqt部署时插件目录没复制全或者 QApplication 构造前setLibraryPaths被设置成了错误路径。这个问题在 Windows 上最常见的诱因是环境变量里残留了另一个 QT 版本的路径强制工程内部用绝对路径加载插件或清理环境变量都能解决。4.2 点击 token 表格行源码编辑器同步跳转并高亮这是整个项目里交互体验提升最明显的功能。信号链路是这样的QTableView 的 currentRowChanged 信号触发 MainWindow 中的一个槽槽里从 QStandardItemModel 的 data 中取出行列号创建 QTextCursor设置位置并确保可见。void MainWindow::onTokenRowChanged(const QModelIndex current) { if (!current.isValid()) return; int line model-item(current.row(), 2)-text().toInt(); int col model-item(current.row(), 3)-text().toInt(); QTextBlock block ui-sourceEdit-document()-findBlockByNumber(line - 1); QTextCursor cursor(ui-sourceEdit-document()); cursor.setPosition(block.position() col - 1); cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, tokenLength); ui-sourceEdit-setTextCursor(cursor); ui-sourceEdit-setExtraSelections({...}); }findBlockByNumber的参数是自 0 开始的行号而 token 的行号是从 1 开始的所以一定要line - 1。列号同理QTextCursor 的 position 是字符索引如果 token 是中文一个汉字会被算作一个 QChar直接用 col 偏移没问题。但如果有 Tab 字符列号基于字节或字符的算法就要统一否则定位偏差很隐晦。setExtraSelections可以做高亮这是 QPlainTextEdit 自带的功能比用额外 QLabel 挡住源码区要干净得多。高亮颜色用QColor(255, 255, 200)之类的浅色再设置QTextCharFormat::setFullWidthSelection(true)可以只高亮当前行或精确到选中文本。4.3 逐步回放与词法器、语法器联动语法分析的逐步回放需要一个 QTimer 或 QSlider。一个简单的做法QSlider 的范围是0..steps.size()-1当值变化时从 QList 中取对应快照将栈列表刷新到左侧的 QListWidget把输入框刷新到右侧的 QPlainTextEdit。void MainWindow::onParseStepChanged(int idx) { const ParseStep step parseSteps.at(idx); ui-stackListWidget-clear(); for (const QString sym : step.stack) ui-stackListWidget-addItem(sym); ui-inputEdit-setPlainText(step.input); ui-actionLabel-setText(step.action); // 高亮当前输入 token 在源码中的位置 int tokenIndex remainingStartPositions[idx]; Token t tokens[tokenIndex]; highlightSourceToken(t); }这个回放过程实际上不需要重新解析直接读取快照即可性能上毫无压力。为了联动ParseStep里除了栈和输入还存了“当前输入位置”对应的 token 下标否则无法在源码里高亮当前正在分析的 token。这个 token 下标推荐存 int而不是存迭代器因为迭代器在快照跨列表保存时容易失效。4.4 常见 QT 配置坑编译器、路径与 Qt Designer在中文互联网上与该标题相关的搜索里出现大量“QT_QPA_PLATFORM_PLUGIN_PATH”和“msvc 编译器”关键词说明多数人使用的是 Qt 5.15.2 MSVC2019 组合。这个组合下有两个高频坑一是 Qt 的 MSVC 版本必须与编译器匹配否则编译器报“无法解析的外部符号”二是项目通过 Qt Creator 启动时能找到插件exe 单独发布时缺失 platforms/qwindows.dll。逐一排查时先检查系统 PATH 里有没有多个 Qt 路径再用windeployqt做发布目录补齐插件。如果使用 MinGW 的 Qt 包则需要在 PATH 里加入 MinGW 的 bin 目录否则运行时报缺libgcc_s_seh-1.dll这类运行时这个报错与 Qt 本身无关。Qt Designer 可以用来设计带菜单和停靠窗格的 UI但词法分析器的主界面最好用代码动态布局因为表格和树视图的交互逻辑用 designer 模板改起来很别扭。5. 语法错误的恢复与定位5.1 错误记录的数据结构编译器前端一定不可能只分析正确代码。词法或语法错误时的表现是区分“玩具”和“能用”的重要分界线。错误信息至少要包含错误行、列、期望符号、实际符号。定义 ErrorItemstruct CompileError { int line; int col; QString message; ErrorKind kind; // LEX_ERROR / SYNTAX_ERROR };词法错误常见的有未闭合的字符串字面量、非法字符、数字格式错误比如0xFF如果文法不支持则提示。语法错误常见的有栈顶非终结符与输入不匹配、预测表查不到对应规则。5.2 同步错误恢复跳过 token 直到同步标记LL(1) 分析器的错误恢复最简单的策略是“恐慌模式”。当查表失败时不断弹出栈顶的非终结符或者不断丢弃输入 token直到找到一个 FOLLOW 集合中的同步 token。具体操作时if (!parseTable.contains({top, lookahead})) { // 同步集合 FOLLOW(top) 中的终结符 分号等语句终止符 while (!tokens[pos].isSyncPoint()) { errors CompileError{tokens[pos].line, tokens[pos].col, 语法错误无法匹配 top, SYNTAX_ERROR}; pos; } stack.pop_back(); // 丢弃当前非终结符 continue; }在可视化中出现错误时要允许用户手动选择“继续忽略错误”或“中止分析”。QT 的 QMessageBox 适合做中止确认但更友好的是直接让分析器继续并记录所有错误在主窗口底部用 QListWidget 列出错误列表点击错误条目自动跳转到对应源码行。这个交互与 token 跳转逻辑非常相似只是一个基于 token 行列一个基于错误行列复用同一段光标定位方法即可。6. 给你的 QT 词法语法分析器加一个“单步断点”和“符号表”验证功能做词法器、语法分析器最怕“看起来对了实际文法是错的”。验证语法树的正确性有个很笨但很可靠的方法在界面上加入“选中语法树节点高亮对应源码片段”的联动。这个功能比整个语法树更实用因为它能直观告诉你这个非终结符到底覆盖了几行代码、其中哪些 token 被归约到了同一棵子树下。具体实现时让 ParseTreeNode 保存起始 token 和结束 token 的下标struct ParseTreeNode { QString symbol; const Token* token; int startTokenIdx; int endTokenIdx; QVectorParseTreeNode* children; };在初始化节点时终结符的 startIdx 和 endIdx 都等于它自己的索引非终结符的 startIdx 取第一个子节点的 startIdxendIdx 取最后一个子节点的 endIdx。这样在 QGraphicsView 里点击某个节点时可以直接拿到源码起止位置高亮该片段。这个功能对验证错误恢复也很有用错误恢复后丢失的 token 不进入语法树树上会明显出现一段空白一眼就能确认问题出在词法还是语法阶段。另一个值得加进去的小功能是符号表。词法分析之后将所有 IDENTIFIER token 收集去重统计出现次数用 QTableView 显示标识符名与初次声明行列。这能帮你验证语法树中 id 节点的引用关系。如果分析的是一个类 C 语法可以进一步对变量声明语句解析类型信息在符号表里加入作用域列。不过这个需要 vtable 或额外的语义动作不是所有课程设计都要求建议作为扩展项放在主界面的侧边停靠栏里用 QTabWidget 把“Token、语法树、符号表、分析栈”四个视图并列展示。最后提一个你调试时一定会用到的细节为了证明你的分析器确实没有偷懒建议在分析过程中记录每个 token 被归约到哪个非终结符下的完整路径。这样当你在界面上选中一个expr节点时可以看到id num * id这五棵叶子是怎么逐步被归约上来的。这个路径可以在 ParseTreeNode 里加一个QString reductionChain字段每次归约时拼接当前产生式。整个功能不需要额外数据结构只需要在构建步骤快照时顺手积累。验证这个功能你可以输入int a 1 2 * 3;从根节点一路点击到int关键字观察右侧面板是否把词法串、产生式链和源码片段三者对齐。对齐成功说明这个小编译器前端在理论上和工程实现上都站得住。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进