为编程语言编写解析器:Output

Writing a Parser for a programming language: Output

本文关键字:Output 编程语言      更新时间:2023-10-16

我正在尝试用C++编写一种简单的解释编程语言。我读到很多人使用Lex/Flex Bison这样的工具来避免"重新发明轮子",但由于我的目标是了解这些小野兽是如何提高我的知识的,我决定从头开始编写Lexer和Parser。目前我正在开发解析器(lexer已经完成),我在问自己它的输出应该是什么。一棵树?带有"depth"或"shift"参数的语句的线性向量?我应该如何管理循环和if语句?我应该用不可见的goto语句替换它们吗?

解析器几乎应该总是输出AST。从最广泛的意义上讲,AST只是程序语法结构的树表示。Function变为包含函数体AST的AST节点。CCD_ 1成为包含条件和主体的AST的AST节点。运算符的使用将成为包含每个操作数的AST的AST节点。整型文字、变量名等将成为叶AST节点。运算符优先级等隐含在节点关系中:1 * 2 + 3(1 * 2) + 3都表示为Add(Mul(Int(1), Int(2)), Int(3))

AST中的许多细节(显然)取决于您的语言以及您想对树做什么。如果你想分析和转换程序(即在最后分割出修改后的源代码),你可以保留注释。如果您想要详细的错误消息,可以添加源位置(如中所示,此整数文本位于第5行第12列)。

编译器将继续将AST转换为不同的格式(例如,具有gotos的线性IR或数据流图)。浏览AST仍然是一个好主意,因为设计良好的AST在面向语法和只存储对理解程序重要的内容之间有很好的平衡。解析器可以专注于解析,而后面的转换则受到保护,不受无关细节的影响,如空白量和运算符优先级。请注意,这样的"编译器"也可能输出稍后解释的字节码(Python的引用实现会这样做)。

一个相对纯粹的解释器可能会解释AST。关于这一点,已经写了很多文章;这是执行解析器输出的最简单方法。这种策略从AST中获益的方式与编译器非常相似;特别是,大多数解释只是AST的自上而下遍历。

正式且最正确的答案是,您应该返回一个抽象语法树。但这只是冰山一角,根本没有答案。

AST只是描述解析的节点结构;解析通过令牌/状态机的路径的可视化。

每个节点表示一个路径或描述。例如,您将有表示语言语句的节点、表示编译器指令的节点和表示数据的节点。

考虑一个描述变量的节点,假设您的语言支持int和string变量以及"const"的概念。您可以选择将该类型设置为Variable节点结构/类的直接属性,但通常在AST中,您会将constness等属性设置为"mutator",它本身就是链接到Variable节点的某种形式的节点。

您可以通过将局部作用域变量作为BlockStatement节点的突变来实现C++的"范围"概念;"循环"节点的约束(for、do、while等)作为变元器。

当您将解析器/标记化器与语言实现紧密联系在一起时,即使进行很小的更改也可能成为一场噩梦。

虽然这是真的,但如果你真的想了解这些东西是如何工作的,那么至少有一个第一次实现是值得的,在那里你开始实现你的运行时系统(vm、解释器等),并让你的解析器直接针对它。(另一种选择是,例如,买一本《龙书》,阅读它应该如何完成,但听起来你实际上想从自己解决问题中获得充分的理解)。

告诉返回AST的问题是AST实际上需要一种解析形式。

struct Node
{
    enum class Type {
        Variable,
        Condition,
        Statement,
        Mutator,
    };
    Node*  m_parent;
    Node*  m_next;
    Node*  m_child;
    Type   m_type;
    string m_file;
    size_t m_lineNo;
};
struct VariableMutatorNode : public Node
{
    enum class Mutation {
        Const
    };
    Mutation m_mutation;
    // ...
};
struct VariableNode
{
    VariableMutatorNode* m_mutators;
    // ...
};
Node* ast;  // Top level node in the AST.

对于一个独立于运行时的编译器来说,这种AST可能是可以的,但对于一种复杂的、对性能敏感的语言来说,你需要把它收紧很多(此时"AST"中的"a"更少)。

你走这棵树的方式是从"ast"的第一个节点开始,并根据它采取行动。如果你用C++编写,你可以通过将行为附加到每个节点类型来做到这一点。但再说一遍,这并不那么"抽象",是吗?

或者,你必须写一些能穿过树的东西。

switch (node->m_type) {
    case Node::Type::Variable:
        declareVariable(node);
        break;
    case Node::Type::Condition:
        evaluate(node);
        break;
    case Node::Type::Statement:
        execute(node);
        break;
}

当你写这篇文章的时候,你会发现自己在想"等等,为什么解析器没有为我做这件事?"因为处理AST通常感觉就像你在实现AST方面做得很糟糕:)

有时你可以跳过AST,直接进入某种形式的最终表示,有时(罕见)这是可取的;有时,可以直接进入某种形式的最终表示,但现在你必须更改语言,这个决定将花费你大量的重新实现和头痛。

这通常也是构建编译器的核心——lexer和解析器通常是这种不足的部分。使用抽象/后解析表示是工作中更重要的部分。

这就是为什么人们经常直接去flex/bison或antlr或诸如此类的地方。

如果这就是你想要做的,那么看看.NET或LLVM/Clang可能是一个不错的选择,但你也可以很容易地用这样的东西引导自己:http://gnuu.org/2009/09/18/writing-your-own-toy-compiler/4/

祝你好运:)

我会构建一个语句树。在那之后,是的,goto语句就是它的大部分工作方式(跳转和调用)。你是在翻译成一个低级的程序集吗?

解析器的输出应该是一个抽象语法树,除非你对编写编译器以直接生成字节码有足够的了解,如果这是你的目标语言的话。这可以一蹴而就,但你需要知道自己在做什么。AST直接表达循环和if:您还不关心翻译它们。这属于代码生成。

人们使用lex/yacc不是为了避免重新发明轮子,而是为了更快、更轻松地构建更健壮的编译器原型,并专注于语言,避免陷入其他细节。根据我在几个VM项目、编译器和汇编程序方面的个人经验,我建议如果你想学习如何构建一种语言,就这样做吧——专注于构建语言(首先)。

不要分心:

  1. 编写自己的VM或运行时
  2. 编写自己的解析器生成器
  3. 编写自己的中间语言或汇编程序

你可以稍后再做。

当一个聪明的年轻计算机科学家第一次染上"语言热"(这是一件好事)时,我看到了这一点,但你需要小心,把精力集中在你想做好的一件事上,并利用其他强大、成熟的技术,如解析器生成器、lexer和运行时平台。当你先杀死编译器龙的时候,你总是可以稍后再回来。

只要花你的精力学习LALR语法是如何工作的,用Bison或Yacc++编写你的语言语法,如果你仍然能找到它,不要被那些说你应该使用ANTLR或其他任何东西的人分散注意力,这不是早期的目标。早期,你需要专注于打造你的语言,消除歧义,创建一个合适的AST(也许是最重要的技能集),语义检查、符号解析、类型解析、类型推理、隐式转换、树重写,当然还有最终程序生成。制作一门合适的语言已经足够了,你不需要学习其他多个研究领域,有些人花了整个职业生涯来掌握这些领域。

我建议您针对CLR(.NET)这样的现有运行时。它是制作业余语言的最佳运行时之一。使用到IL的文本输出启动您的项目,并使用ilasm进行组装。假设你花了一些时间学习ilasm,它相对容易调试。一旦你启动了原型,你就可以开始考虑其他事情,比如为自己的解释器提供替代输出,以防你的语言功能对CLR来说过于动态(然后看看DLR)。这里的要点是,CLR提供了一个很好的中间表示来输出。不要听任何人告诉你应该直接输出字节码。文本是早期学习的王者,可以让你使用不同的语言/工具。作者约翰·高夫写了一本好书,书名为《编译》。NET公共语言运行时(CLR),他将带您了解Gardens Point Pascal编译器的实现,但这不是一本关于Pascal的书,而是一本关于如何在CLR上构建真正编译器的书。它将回答您关于实现循环和其他高级构造的许多问题。

与此相关,一个很好的学习工具是使用Visual Studio和ildasm(反汇编程序)和。NET反射器。全部免费。您可以编写小代码示例,编译它们,然后对它们进行反汇编,以查看它们如何映射到基于堆栈的IL

如果您出于任何原因对CLR不感兴趣,还有其他选择。您可能会在搜索中遇到llvm、Mono、NekoVM和Parrot(所有这些都是值得学习的好东西)。我是一个最初的Parrot VM/Perl 6开发人员,编写了Perl中间表示语言和imcc编译器(我可能会添加一段相当糟糕的代码)以及第一个原型Perl6编译器。我建议你远离鹦鹉,坚持做一些更容易的事情,比如。NET CLR,你会得到更多。然而,如果您想构建一种真正的动态语言,并想使用Parrot来实现其延续和其他动态功能,请参阅O’Reilly BooksPerl和Parrot Essentials(有几个版本),关于PIR/IMCC的章节是关于我的东西的,非常有用。如果你的语言不是动态的,那就远离鹦鹉。

如果你想编写自己的虚拟机,我建议你用Perl、Python或Ruby制作虚拟机的原型。我已经成功地做过几次了。它允许您避免过早地进行过多的实现,直到您的语言开始成熟。Perl+Regex很容易调整。Perl或Python中的中间语言汇编程序需要几天的编写时间。稍后,如果你仍然喜欢,你可以用C++重写第二个版本。

我可以总结所有这些:避免过早的优化,避免尝试一次完成所有事情。

首先你需要一本好书。因此,在我的另一个答案中,我请您参阅John Gough的书,但要强调,首先要专注于学习为单个现有平台实现AST。它将帮助您了解AST的实现。

如何实现循环

在WHILE语句的reduce步骤中,语言解析器应该返回一个树节点。您可以将AST类命名为WhileStatement,WhileStatements具有ConditionExpression和BlockStatement以及几个标签(也是可继承的,但为了清晰起见,我添加了内联)作为成员。

下面的语法伪代码显示了reduce如何从典型的shift-reduce语法分析器reduce中创建WhileStatement的新对象。

shift reduce解析器是如何工作的

WHILE ( ConditionExpression )
    BlockStatement
    {
       $$ = new WhileStatement($3, $5);
       statementList.Add($$); // this is your statement list (AST nodes), not the parse stack
    }
 ;

当您的解析器看到"WHILE"时,它会移动堆栈上的令牌。等等

parseStack.push(WHILE);
parseStack.push('(');
parseStack.push(ConditionalExpression);
parseStack.push(')');
parseStack.push(BlockStatement);

WhileStatement的实例是线性语句列表中的一个节点。因此,在幕后,"$$="表示一个解析reduce(尽管如果你想变得迂腐,$$=…是用户代码,解析器正在隐式地进行自己的reduce)。reduce可以被认为是弹出生产右侧的令牌,并替换为左侧的单个令牌,从而减少堆栈:

// shift-reduce
parseStack.pop_n(5);         // pop off the top 5 tokens ($1 = WHILE, $2 = (, $3 = ConditionExpression, etc.)
parseStack.push(currToken);  // replace with the current $$ token

您仍然需要添加自己的代码来将语句添加到链表中,比如"statements.add(whileStatement)",这样您以后就可以遍历它了。解析器没有这样的数据结构,它的堆栈只是暂时的。

在解析过程中,合成一个WhileStatement实例及其相应的成员。在后一阶段,实现访问者模式来访问每个语句,解析符号并生成代码。因此,while循环可以用以下ASTC++类来实现:

class WhileStatement : public CompoundStatement {
    public:
        ConditionExpression * condExpression;  // this is the conditional check
        Label               * startLabel;      // Label can simply be a Symbol
        Label               * redoLabel;       // Label can simply be a Symbol
        Label               * endLabel;        // Label can simply be a Symbol
        BlockStatement      * loopStatement;  // this is the loop code
        bool ResolveSymbolsAndTypes();
        bool SemanticCheck();
        bool Emit();         // emit code
}

代码生成器需要有一个为汇编程序生成顺序标签的函数。一个简单的实现是一个函数,它返回一个带有递增的静态int的字符串,并返回LBL1、LBL2、LBL3等。标签可以是符号,也可以是Label类,并使用新标签的构造函数:

class Label : public Symbol {
   public Label() {
      this.name = newLabel();  // incrementing LBL1, LBL2, LBL3
   }
}

循环是通过生成condExpression的代码来实现的,然后是redoLabel,然后是blockStatement,在blockStatements的末尾,转到redoLabel。

我的一个编译器中的一个示例,用于生成CLR代码。

 // Generate code for .NET CLR for While statement
 //
 void WhileStatement::clr_emit(AST *ctx)
 {
    redoLabel = compiler->mkLabelSym();
    startLabel = compiler->mkLabelSym();
    endLabel = compiler->mkLabelSym();
    // Emit the redo label which is the beginning of each loop
    compiler->out("%s:n", redoLabel->getName());
    if(condExpr) {
       condExpr->clr_emit_handle();
       condExpr->clr_emit_fetch(this, t_bool);
       // Test the condition, if false, branch to endLabel, else fall through
       compiler->out("brfalse %sn", endLabel->getName());
    }
    // The body of the loop
    compiler->out("%s:n", startLabel->getName());   // start label only for clarity
    loopStmt->clr_emit(this);                        // generate code for the block
    // End label, jump out of loop
    compiler->out("br %sn", redoLabel->getName());   // goto redoLabel
    compiler->out("%s:n", endLabel->getName());      // endLabel for goto out of loop
 }