ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零实现C++语言解析器:手写递归下降解析器与AST构建实战

从零实现C++语言解析器:手写递归下降解析器与AST构建实战 1. 项目概述从“模糊探索”到“清晰实现”最近在社区里看到不少朋友对“自己动手实现一个语言解析器”这个话题很感兴趣但往往被“编译原理”、“抽象语法树”、“词法分析”这些大词吓退觉得这是只有编译器大神才能碰的领域。其实解析器的核心思想远比想象中要贴近我们的日常编程。这次我想以一个“模糊探索”的视角和大家聊聊如何用C一步步构建一个简易的X语言解析器。这里的“X语言”可以是你想设计的任何一门小型领域特定语言DSL比如一个配置文件解析器、一个简单的查询语言或者一个玩具脚本语言。我们不去追求像GCC或Clang那样工业级的完备性而是聚焦于理解“将文本变成结构化数据”这一核心过程并体验用C实现它的乐趣与挑战。这个过程对于深入理解你每天使用的JSON、XML解析库乃至提升对编程语言本身的认识都大有裨益。为什么选择C因为它能让我们在“足够底层”和“表达清晰”之间找到一个平衡点。我们可以精细地控制内存和性能也能利用面向对象和模板等特性来组织代码让解析器的结构一目了然。整个探索之旅我们会从最基础的字符串处理开始逐步搭建起词法分析器Lexer和语法分析器Parser并最终生成一个可用的中间表示。我会把我在实现过程中踩过的坑、做过的权衡以及那些教科书里不会写的调试技巧都分享出来。无论你是想为你的项目增加一个灵活的配置接口还是单纯对“语言是如何被计算机理解”感到好奇这篇内容都能给你提供一个切实可行的起点。2. 核心思路与架构设计2.1 解析器的基本工作流程在开始写代码之前我们必须先想清楚解析器到底要干什么。你可以把它想象成一个翻译官它的任务是把人类或程序员写的文本“源代码”翻译成计算机能够理解和执行的一种结构。这个过程通常分为几个清晰的阶段词法分析这是第一道工序。我们把源代码看作一个长长的字符串流。词法分析器也叫扫描器的任务是把这个字符串流切割成一个个有意义的“单词”在编译原理中称为“词法单元”或“Token”。例如对于语句int age 25;词法分析器会识别出int关键字、age标识符、运算符、25数字字面量、;分隔符。它不关心这些Token之间的语法关系只负责识别和分类。语法分析这是核心环节。语法分析器接收词法分析器产出的Token流然后根据预先定义好的“语法规则”通常用上下文无关文法描述检查这些Token的排列组合是否符合规则并在这个过程中构建出“抽象语法树”。AST是源代码的树形结构表示它清晰地表达了代码的嵌套层次和执行顺序。例如age 25会被表示为一个“赋值表达式”节点其左子节点是标识符age右子节点是字面量25。语义分析可选在简易解析器中常与语法分析合并在AST的基础上进行更深层次的检查比如变量在使用前是否已声明赋值语句左右两边的类型是否匹配函数调用参数个数是否正确对于我们的探索性项目我们可以将一些简单的语义检查如变量重复定义融入到语法分析过程中。我们的“X语言”为了简化可以定义一些非常基础的语法比如只包含变量声明、赋值、算术运算和打印语句。这样我们的目标就非常明确了编写一个C程序它能读取一个符合我们自定义语法的文本文件最终在内存中构建出一棵能代表其逻辑结构的AST。2.2 为什么选择“手写递归下降”解析器实现语法分析器有多种方法比如自动生成工具Yacc/Bison, ANTLR或者手写解析器递归下降、Pratt解析。对于学习探索和中小型DSL来说我强烈推荐手写递归下降解析器。它的核心思想直观得惊人为语法规则中的每一个非终结符可以理解为一个语法结构单元编写一个对应的C函数。这个函数负责从Token流中“吃掉”符合它负责的那部分规则的Token并返回构建出的AST节点。举个例子假设我们的语法有一条规则表达式 - 标识符 ‘’ 表达式。我们就会有一个函数parseAssignmentExpression()。这个函数会尝试调用parseIdentifier()获取左边的标识符节点。检查当前Token是不是如果是就“吃掉”它。递归调用parseExpression()获取右边的表达式节点。最后创建一个“赋值表达式”AST节点将左右子节点挂载上去并返回。选择它的理由易于理解和调试代码结构几乎就是语法规则的直译逻辑清晰。当解析出错时你可以很容易地通过函数调用栈定位到是哪条语法规则出了问题。灵活性强你可以完全控制错误恢复策略、错误信息生成并且很容易处理那些“上下文相关”的语法比如C中A(B)可能是类型转换也可能是函数调用取决于A是什么。学习价值高亲手实现一遍你对语法分析的理解会远超使用工具。你能真切感受到“递归”是如何优雅地处理嵌套结构的。当然它的缺点是对于非常复杂的、有左递归的语法需要手动进行文法转换。但对于我们设计的简单X语言这完全不是问题。在“模糊探索”阶段理解原理和获得成就感比处理极端情况更重要。3. 核心模块实现详解3.1 定义Token词法分析器的输出单元Token是我们整个解析过程的基石。我们需要用一个结构体来精确描述每一个“单词”。在C中我们通常使用枚举类来定义Token的类型再用一个结构体或类来封装具体值。// TokenType.h #pragma once #include string #include variant // 使用枚举类避免命名污染 enum class TokenType { // 文件结束 EndOfFile, // 标识符 (变量名 函数名等) Identifier, // 字面量 Number, String, // 关键字 Let, // 用于变量声明如 let x 5 Print, // 打印语句 If, Else, While, // 运算符 Plus, // Minus, // - Asterisk, // * Slash, // / Assign, // Equals, // NotEquals,// ! Less, // Greater, // // 分隔符 LeftParen, // ( RightParen, // ) LeftBrace, // { RightBrace, // } Semicolon, // ; Comma, // , }; // Token结构体 struct Token { TokenType type; // 使用std::variant来安全地存储不同类型的字面值 // 对于Identifier和String存储std::string // 对于Number存储double (或int64_t根据需求) // 对于其他类型可以使用std::monostate表示无值 std::variantstd::monostate, std::string, double value; int line; // 行号用于错误定位 int column; // 列号 Token(TokenType t, int l, int c) : type(t), value(std::monostate{}), line(l), column(c) {} Token(TokenType t, std::string v, int l, int c) : type(t), value(std::move(v)), line(l), column(c) {} Token(TokenType t, double v, int l, int c) : type(t), value(v), line(l), column(c) {} };注意这里我使用了std::variant来存储Token的值。这是一种现代C的类型安全联合体。对于初学者也可以用简单的std::string来存所有值在需要数字时再转换但variant能更清晰地表达意图并在编译期捕获类型错误。std::monostate表示“空”状态用于那些没有附加值的Token如,;。3.2 实现词法分析器从字符流到Token流词法分析器的工作像是一个精细的“分词器”。它逐个字符地读取源代码根据字符的特征将它们组合成Token。// Lexer.h #pragma once #include “Token.h” #include string #include unordered_map class Lexer { public: explicit Lexer(const std::string source) : source_(source), currentPos_(0), currentLine_(1), currentColumn_(1) { // 初始化关键字映射表 keywords_ { {“let”, TokenType::Let}, {“print”, TokenType::Print}, {“if”, TokenType::If}, {“else”, TokenType::Else}, {“while”, TokenType::While}, }; } Token nextToken(); private: std::string source_; size_t currentPos_; int currentLine_; int currentColumn_; std::unordered_mapstd::string, TokenType keywords_; char peek() const; char advance(); bool match(char expected); void skipWhitespaceAndComments(); Token identifierOrKeyword(); Token number(); Token stringLiteral(); };nextToken()是主方法它用一个大的switch语句来分发对不同字符的处理逻辑。核心逻辑如下// Lexer.cpp (部分) Token Lexer::nextToken() { skipWhitespaceAndComments(); // 跳过空格、制表符、换行符和注释 if (currentPos_ source_.length()) { return Token(TokenType::EndOfFile, currentLine_, currentColumn_); } char c peek(); // 根据首字符判断Token类型 if (std::isalpha(c) || c ‘_’) { return identifierOrKeyword(); } if (std::isdigit(c)) { return number(); } if (c ‘“’) { return stringLiteral(); } // 处理运算符和分隔符 switch (c) { case ‘’: advance(); if (match(‘’)) { return Token(TokenType::Equals, currentLine_, currentColumn_ - 1); } else { return Token(TokenType::Assign, currentLine_, currentColumn_ - 1); } case ‘’: advance(); return Token(TokenType::Plus, currentLine_, currentColumn_ - 1); case ‘-’: advance(); return Token(TokenType::Minus, currentLine_, currentColumn_ - 1); case ‘*’: advance(); return Token(TokenType::Asterisk, currentLine_, currentColumn_ - 1); case ‘/’: advance(); return Token(TokenType::Slash, currentLine_, currentColumn_ - 1); case ‘;’: advance(); return Token(TokenType::Semicolon, currentLine_, currentColumn_ - 1); // ... 处理其他符号 default: // 处理未知字符错误 advance(); throw std::runtime_error(“Lexer error at line “ std::to_string(currentLine_) “, column “ std::to_string(currentColumn_ - 1) “: Unexpected character ‘“ c “‘“); } }实操心得行号与列号在advance()函数中当遇到换行符\n时需要增加currentLine_并将currentColumn_重置为1。这是调试和生成友好错误信息的关键务必在最初就设计好。关键字识别在identifierOrKeyword()函数中先读取完整的标识符字符串然后去keywords_哈希表中查找。如果找到返回对应的关键字Token否则返回标识符Token。这种方法简单高效。数字解析number()函数需要处理整数和小数。一个常见的陷阱是只用一个while循环读取数字但遇到小数点后需要判断后面是否还有数字。建议先按整数部分解析遇到.后再解析小数部分最后组合成double。别忘了处理像123.这样不合法的输入。注释处理在skipWhitespaceAndComments()中如果遇到//就一直advance()直到行尾如果遇到/*则需要一直读取直到找到匹配的*/这需要状态记录避免陷入无限循环。3.3 设计抽象语法树内存中的程序结构AST节点需要用一个类层次结构来表示。我们可以定义一个基类ASTNode然后派生出各种具体的节点类型。// AST.h #pragma once #include memory #include vector #include string // 前向声明 class Visitor; // 所有AST节点的基类 class ASTNode { public: virtual ~ASTNode() default; // 访问者模式用于后续的遍历、分析和代码生成 virtual void accept(Visitor visitor) 0; }; // 具体节点类型 class NumberLiteralNode : public ASTNode { public: double value; explicit NumberLiteralNode(double val) : value(val) {} void accept(Visitor visitor) override; }; class IdentifierNode : public ASTNode { public: std::string name; explicit IdentifierNode(std::string id) : name(std::move(id)) {} void accept(Visitor visitor) override; }; class BinaryExpressionNode : public ASTNode { public: std::unique_ptrASTNode left; std::unique_ptrASTNode right; TokenType op; // 使用TokenType来代表操作符如Plus, Minus等 BinaryExpressionNode(std::unique_ptrASTNode l, TokenType o, std::unique_ptrASTNode r) : left(std::move(l)), op(o), right(std::move(r)) {} void accept(Visitor visitor) override; }; class AssignmentNode : public ASTNode { public: std::unique_ptrIdentifierNode identifier; std::unique_ptrASTNode expression; AssignmentNode(std::unique_ptrIdentifierNode id, std::unique_ptrASTNode expr) : identifier(std::move(id)), expression(std::move(expr)) {} void accept(Visitor visitor) override; }; class PrintStatementNode : public ASTNode { public: std::unique_ptrASTNode expression; explicit PrintStatementNode(std::unique_ptrASTNode expr) : expression(std::move(expr)) {} void accept(Visitor visitor) override; }; // 程序根节点包含一系列语句 class ProgramNode : public ASTNode { public: std::vectorstd::unique_ptrASTNode statements; void accept(Visitor visitor) override; };注意这里使用了std::unique_ptr来管理节点的生命周期这意味着所有权是独占且清晰的。父节点拥有子节点当父节点被销毁时所有子节点也会被自动清理。这是现代C中管理树形结构内存的推荐方式能有效防止内存泄漏。3.4 实现递归下降语法分析器这是整个项目最核心也最体现逻辑的部分。我们需要一个Parser类它持有Lexer的引用并维护当前的Token。// Parser.h #pragma once #include “Lexer.h” #include “AST.h” #include memory #include vector class Parser { public: explicit Parser(Lexer lexer) : lexer_(lexer) { currentToken_ lexer_.nextToken(); // 预读第一个Token } std::unique_ptrProgramNode parseProgram(); private: Lexer lexer_; Token currentToken_; // 辅助函数 void consume(TokenType expected); bool check(TokenType type) const; bool match(TokenType type); // 各语法规则的解析函数 std::unique_ptrASTNode parseStatement(); std::unique_ptrASTNode parseExpression(); std::unique_ptrASTNode parseAssignmentExpression(); std::unique_ptrASTNode parseAdditiveExpression(); std::unique_ptrASTNode parseMultiplicativeExpression(); std::unique_ptrASTNode parsePrimaryExpression(); };解析过程是自顶向下的。parseProgram()是入口它循环调用parseStatement()直到文件结束将所有语句节点收集起来。// Parser.cpp (部分) std::unique_ptrProgramNode Parser::parseProgram() { auto program std::make_uniqueProgramNode(); while (currentToken_.type ! TokenType::EndOfFile) { program-statements.push_back(parseStatement()); // 每条语句后期望一个分号 if (currentToken_.type TokenType::Semicolon) { consume(TokenType::Semicolon); } else if (currentToken_.type ! TokenType::EndOfFile) { // 不是文件结束也不是分号报告错误可以尝试错误恢复 throw std::runtime_error(“Expected ‘;’ after statement at line “ …); } } return program; } std::unique_ptrASTNode Parser::parseStatement() { // 根据当前Token判断语句类型 if (currentToken_.type TokenType::Let) { return parseVariableDeclaration(); // 需要实现 } else if (currentToken_.type TokenType::Print) { return parsePrintStatement(); } else if (currentToken_.type TokenType::Identifier) { // 可能是赋值语句也可能是表达式语句如函数调用 // 我们尝试按赋值语句解析如果不是再回退或报错 // 这里简化处理先尝试赋值 return parseAssignmentExpression(); } else { // 其他语句类型... // 如果都不是尝试解析为表达式语句 return parseExpression(); } } std::unique_ptrASTNode Parser::parsePrintStatement() { consume(TokenType::Print); // 吃掉‘print’关键字 consume(TokenType::LeftParen); // 吃掉‘(’ auto expr parseExpression(); // 解析括号内的表达式 consume(TokenType::RightParen); // 吃掉‘)’ return std::make_uniquePrintStatementNode(std::move(expr)); } std::unique_ptrASTNode Parser::parseExpression() { // 表达式解析的入口通常从优先级最低的运算符开始 return parseAssignmentExpression(); } std::unique_ptrASTNode Parser::parseAssignmentExpression() { // 检查是否是赋值 Identifier ‘‘ Expression // 我们预读一下如果下一个Token是‘’才按赋值解析 // 这里需要“向前看”一个Token但我们的Lexer不支持peek下一个Token。 // 一种常见做法是先解析一个高优先级的表达式比如加法表达式 // 然后检查后面是不是‘’。如果是就构成赋值。 // 但为了清晰我们采用另一种方法在parseStatement里根据Identifier预判。 // 这里我们实现一个简化版直接解析一个加法表达式如果后面跟着‘’再处理。 auto left parseAdditiveExpression(); if (match(TokenType::Assign)) { // 确保左边是一个标识符简单的类型检查 if (auto idNode dynamic_castIdentifierNode*(left.get())) { auto identifier std::unique_ptrIdentifierNode(idNode); left.release(); // 释放原指针所有权 auto right parseAssignmentExpression(); // 递归解析右边的表达式 return std::make_uniqueAssignmentNode(std::move(identifier), std::move(right)); } else { throw std::runtime_error(“Left side of assignment must be an identifier”); } } return left; // 如果不是赋值就返回解析出的表达式 } std::unique_ptrASTNode Parser::parseAdditiveExpression() { auto node parseMultiplicativeExpression(); // 先解析优先级更高的乘除表达式 while (true) { if (match(TokenType::Plus)) { auto right parseMultiplicativeExpression(); node std::make_uniqueBinaryExpressionNode(std::move(node), TokenType::Plus, std::move(right)); } else if (match(TokenType::Minus)) { auto right parseMultiplicativeExpression(); node std::make_uniqueBinaryExpressionNode(std::move(node), TokenType::Minus, std::move(right)); } else { break; } } return node; } std::unique_ptrASTNode Parser::parsePrimaryExpression() { Token token currentToken_; switch (token.type) { case TokenType::Number: { consume(TokenType::Number); double val std::getdouble(token.value); return std::make_uniqueNumberLiteralNode(val); } case TokenType::Identifier: { consume(TokenType::Identifier); std::string name std::getstd::string(token.value); return std::make_uniqueIdentifierNode(name); } case TokenType::LeftParen: { consume(TokenType::LeftParen); auto expr parseExpression(); consume(TokenType::RightParen); return expr; } default: throw std::runtime_error(“Unexpected token in primary expression: “ …); } }关键点解析consume和matchconsume(TokenType)期望当前Token是指定类型如果是就“吃掉”它并获取下一个Token否则报错。match(TokenType)检查当前Token是否是指定类型如果是就“吃掉”它并返回true否则返回false且不消耗Token。这两个函数是构建解析器的基石。优先级处理运算符优先级是通过函数调用层次来体现的。parseExpression()调用parseAssignmentExpression()后者调用parseAdditiveExpression()再调用parseMultiplicativeExpression()最后调用parsePrimaryExpression()。优先级低的运算符如赋值在外层函数处理优先级高的如乘除*/在内层函数处理最高的是基础单元数字、标识符、括号表达式。左结合性像a b c这样的表达式应该被解析为((a b) c)。parseAdditiveExpression中的while循环正是实现了左结合性它先解析左边的节点然后循环匹配连续的或-每次都将当前节点作为新二元表达式的左子节点。4. 从解析到执行构建简单的解释器有了AST我们就可以遍历它来执行程序了。这里我们实现一个最简单的树遍历解释器。我们使用访问者模式为每种AST节点定义相应的“访问”行为。// Visitor.h #pragma once class NumberLiteralNode; class IdentifierNode; // … 其他节点类的前向声明 class Visitor { public: virtual ~Visitor() default; virtual void visit(NumberLiteralNode node) 0; virtual void visit(IdentifierNode node) 0; virtual void visit(BinaryExpressionNode node) 0; virtual void visit(AssignmentNode node) 0; virtual void visit(PrintStatementNode node) 0; virtual void visit(ProgramNode node) 0; };然后在每个AST节点的accept方法中调用访问者的对应方法。// AST.cpp void NumberLiteralNode::accept(Visitor visitor) { visitor.visit(*this); } void IdentifierNode::accept(Visitor visitor) { visitor.visit(*this); } // … 其他节点的accept实现现在我们实现一个具体的访问者——Interpreter它负责执行程序。// Interpreter.h #pragma once #include “Visitor.h” #include “AST.h” #include unordered_map #include any // 用于存储各种类型的值 #include iostream class Interpreter : public Visitor { public: void interpret(ProgramNode program) { program.accept(*this); } // 实现各个visit方法 void visit(NumberLiteralNode node) override { // 将数字字面量的值压入“结果栈”或存储在某个上下文中 // 这里我们简化用一个成员变量存储最后一次计算的结果 lastValue_ node.value; } void visit(IdentifierNode node) override { // 从变量表中查找值 auto it variables_.find(node.name); if (it ! variables_.end()) { lastValue_ it-second; } else { throw std::runtime_error(“Undefined variable: “ node.name); } } void visit(BinaryExpressionNode node) override { // 递归计算左右子树 node.left-accept(*this); auto leftVal std::any_castdouble(lastValue_); node.right-accept(*this); auto rightVal std::any_castdouble(lastValue_); switch (node.op) { case TokenType::Plus: lastValue_ leftVal rightVal; break; case TokenType::Minus: lastValue_ leftVal - rightVal; break; case TokenType::Asterisk: lastValue_ leftVal * rightVal; break; case TokenType::Slash: if (rightVal 0) throw std::runtime_error(“Division by zero”); lastValue_ leftVal / rightVal; break; // … 处理其他运算符 default: throw std::runtime_error(“Unsupported binary operator”); } } void visit(AssignmentNode node) override { // 计算右侧表达式的值 node.expression-accept(*this); double value std::any_castdouble(lastValue_); // 存储到变量表 variables_[node.identifier-name] value; // 赋值表达式本身的值就是被赋予的值 lastValue_ value; } void visit(PrintStatementNode node) override { node.expression-accept(*this); std::cout std::any_castdouble(lastValue_) std::endl; } void visit(ProgramNode node) override { for (auto stmt : node.statements) { stmt-accept(*this); } } private: std::unordered_mapstd::string, double variables_; std::any lastValue_; // 存储最近一次表达式计算的结果 };使用方式int main() { std::string sourceCode “let x 10; let y x 2 * 3; print(y);”; Lexer lexer(sourceCode); Parser parser(lexer); auto program parser.parseProgram(); Interpreter interpreter; interpreter.interpret(*program); // 预期输出16 return 0; }5. 开发中的常见陷阱与调试技巧5.1 词法分析器的边界条件问题忘记处理文件结束符EOF导致在nextToken()中无限循环或访问越界。排查在nextToken()开头首先检查currentPos_ source_.length()如果是则返回TokenType::EndOfFile。问题数字解析时123.这样的输入会被错误地解析为123.然后遇到非数字字符停止导致小数点后的数字被当作下一个Token。技巧在number()函数中读取到小数点后必须确保后面至少跟着一个数字。可以这样处理if (peek() ‘.’ std::isdigit(peekNext())) { … }其中peekNext()是查看下一个字符的函数。5.2 语法分析中的“超前查看”困境问题在解析a b c时parseAssignmentExpression需要判断当前是不是一个赋值语句。如果只看到标识符a无法确定后面是还是其他运算符比如a b。这就是“一个Token的向前看”不够。解决方案有两种主流策略。预读Peek修改Lexer增加一个peekToken()方法它返回下一个Token但不消耗当前Token。这样Parser可以“偷看”一眼再决定走哪条解析路径。这是最清晰的方法。试探性解析Backtracking先按一种可能性比如赋值解析下去如果中途发现不符合比如没找到就回退到尝试点再按另一种可能性比如普通表达式解析。这种方法实现复杂效率较低不推荐在手写解析器中大量使用。我的选择对于我们的简单语言我采用了在parseStatement层面根据第一个Token来决策的策略。对于赋值表达式我在parseAssignmentExpression中先解析一个完整的加法表达式到left然后检查后面是不是。如果是并且left能安全地转换为IdentifierNode我才把它当作赋值语句来构建。这实际上是一种“解析后再确认”的变通方法避免了复杂的预读逻辑但要求语法设计上不能有歧义。5.3 AST节点所有权与内存管理问题使用原始指针构建AST容易造成内存泄漏或重复释放。最佳实践如示例所示始终使用std::unique_ptrASTNode。当需要转移节点所有权时比如将子节点交给父节点使用std::move。这几乎完全消除了手动内存管理的负担。如果确实需要共享所有权某些高级场景可以考虑std::shared_ptr但解析器AST中这种情况很少。陷阱dynamic_cast的使用。在parseAssignmentExpression中我们使用dynamic_cast来检查解析出的left是否是一个IdentifierNode。dynamic_cast在向下转型失败时会返回nullptr。这要求你的基类ASTNode至少有一个虚函数我们的accept就是。同时要确保你的编译器中启用了RTTI运行时类型信息。5.4 错误处理与恢复一个健壮的解析器不能一遇到错误就崩溃。我们需要友好的错误信息和一定的错误恢复能力。错误信息务必在Token和AST节点中保存行号、列号。在Parser的consume函数中如果类型不匹配抛出的异常信息应包含期望的类型、实际得到的类型以及位置信息。错误恢复简单的恢复策略是“恐慌模式”。当解析器在某个规则中遇到错误时它不再尝试继续解析该规则而是不断地丢弃Token直到遇到一个“同步点”比如分号、右大括号、行尾等然后重置状态尝试解析下一条语句。这能防止一个错误导致后面所有代码都无法被解析。在我们的示例中可以在parseStatement外围用try-catch包裹捕获错误后打印信息然后调用一个sync()函数跳过一些Token再继续循环。5.5 测试策略不要等到全部写完再测试。应该分模块、分阶段测试。单元测试Lexer给定一段源代码字符串手动列出你期望得到的Token序列包括类型和值然后运行Lexer::nextToken()并对比输出。单元测试Parser为每一个语法规则如parseAdditiveExpression编写测试。给定一个Token序列可以手动构造或由Lexer产生测试其输出的AST结构是否正确。可以编写一个简单的AST打印器另一个Visitor将AST以可读格式如LISP风格的S表达式打印出来便于比对。集成测试编写完整的X语言小程序从源代码字符串开始经过Lexer、Parser、Interpreter检查最终输出是否符合预期。整个“模糊探索”的过程其实就是将脑海中模糊的语言设计概念通过词法、语法、AST、解释器这些清晰的步骤一步步固化为可运行的代码。这个过程充满了挑战但每当你的解析器成功读懂并执行你自己设计的一行代码时那种成就感是无与伦比的。它让你不再是一个语言的使用者而是成为了语言的创造者哪怕只是一个微小的世界。希望这篇详细的探索笔记能为你点亮自己动手实现解析器的第一盏灯。
返回列表