代码之家  ›  专栏  ›  技术社区  ›  Cole Tobin Matt

为什么我的递归下降分析器是右关联的

  •  3
  • Cole Tobin Matt  · 技术社区  · 6 年前

    我正在编写自己的编程语言,我已经完成了标记器(lexer)。但是对于解析,我在编写递归下降解析器时遇到了问题。当它应该是左的时候,它似乎是右关联的,我不知道为什么。例如,它解析 1-2-3 作为 1-(2-3) ,不正确 (1-2)-3 .

    我删掉了大部分其他代码,只剩下可重复的代码:

    using System.Collections.Generic;
    
    namespace Phi
    {
        public enum TokenType
        {
            Plus, // '+'
            Minus, // '-'
            IntegerLiteral,
        }
    
        public interface INode
        {
            // Commented out as they aren't relevant
            //NodeType GetNodeType();
            //void Print(string indent, bool last);
        }
    
        class Program
        {
            static void Main(string[] args)
            {
                List<Token> tokens = new List<Token>()
                {
                    new Token(TokenType.IntegerLiteral, "1"),
                    new Token(TokenType.Minus, ""),
                    new Token(TokenType.IntegerLiteral, "2"),
                    new Token(TokenType.Minus, ""),
                    new Token(TokenType.IntegerLiteral, "3"),
                };
    
                int consumed = ParseAdditiveExpression(tokens, out INode root);
            }
    
            private static int ParseAdditiveExpression(List<Token> block, out INode node)
            {
                // <additiveExpr> ::= <multiplicativeExpr> <additiveExprPrime>
                int consumed = ParseMultiplicataveExpression(block, out INode left);
                consumed += ParseAdditiveExpressionPrime(GetListSubset(block, consumed), out INode right);
    
                if (block[1].Type == TokenType.Plus)
                    node = (right == null) ? left : new AdditionNode(left, right);
                else
                    node = (right == null) ? left : new SubtractionNode(left, right);
                return consumed;
            }
            private static int ParseAdditiveExpressionPrime(List<Token> block, out INode node)
            {
                // <additiveExprPrime> ::= "+" <multiplicataveExpr> <additiveExprPrime>
                //                     ::= "-" <multiplicativeExpr> <additiveExprPrime>
                //                     ::= epsilon
                node = null;
                if (block.Count == 0)
                    return 0;
                if (block[0].Type != TokenType.Plus && block[0].Type != TokenType.Minus)
                    return 0;
    
                int consumed = 1 + ParseMultiplicataveExpression(GetListSubset(block, 1), out INode left);
                consumed += ParseAdditiveExpressionPrime(GetListSubset(block, consumed), out INode right);
    
                if (block[0].Type == TokenType.Plus)
                    node = (right == null) ? left : new AdditionNode(left, right);
                else
                    node = (right == null) ? left : new SubtractionNode(left, right);
                return consumed;
            }
    
            private static int ParseMultiplicataveExpression(List<Token> block, out INode node)
            {
                // <multiplicativeExpr> ::= <castExpr> <multiplicativeExprPrime>
                // unimplemented; all blocks are `Count == 1` with an integer
                node = new IntegerLiteralNode(block[0].Value);
                return 1;
            }
    
            private static List<T> GetListSubset<T>(List<T> list, int start)
            {
                return list.GetRange(start, list.Count - start);
            }
        }
    }
    

    关于 AdditionNode , SubtractionNode MultiplicationNode ,它们都是相同的,只用于语义目的。为了简洁起见 小精灵 :

    namespace Phi
    {
        public class AdditionNode : INode
        {
            public AdditionNode(INode left, INode right)
            {
                Left = left;
                Right = right;
            }
    
            public INode Left { get; }
            public INode Right { get; }
    
            // Print and GetNodeType have been removed as they aren't relevant
        }
    }
    

    至于我的问题,当我跑步的时候 Phi.Program ,如开头所述,它使用错误的关联性进行解析。这里是 root 之后 ParseAdditiveExpression 完成:

    enter image description here enter image description here enter image description here

    如您所见,它将 2 3 而不是 1 . 为什么要这么做?

    1 回复  |  直到 6 年前
        1
  •  20
  •   Eric Lippert    6 年前

    正如我在评论中指出的,问题是你 二元运算符的最右边的子项 具有 加法时间中最右边的子项 . 二进制运算符的最右边的子项是 表达 。加法时间的最右边是 加法时间 ,因此仅就“树节点类型”而言,我们必须断定您构建了错误的解析树。

    跟踪每个解析工件的“逻辑类型”是在解析器中查找错误的强大技术。另一个我喜欢的,可悲的是没有被充分利用 将程序中的每个标记都精确地赋给一个解析树节点 。如果你这样做了,你会很快意识到操作符的标记在逻辑上是 位置:在二进制运算符及其最右边的子运算符中。这也告诉我们出了问题。

    不起作用的是,用于解析的基础结构是一个传递数字和参数的糟糕的混乱。 您的解析器缺乏纪律性 . 您的解析器代码看起来像 计数令牌 是解析器所做的最重要的事情,其他的事情都是附带的。

    解析是 非常清晰的问题 ,解析器方法应该做一件事,只做一件事,并且做得完美。解析器的结构和每个方法的结构应该 直接地 反映正在分析的语法。应该有 几乎没有整数运算 在解析器中,因为解析是关于构建一个解析树,而不是关于计算令牌。

    我以构建递归下降分析器为生。让我向您展示如何构建这个解析器,如果我为了自己的目的快速构建它的话。(如果我为生产应用程序构建它,它在许多方面都会有所不同,但我们在这里会更容易理解。)


    好吧,我们开始吧。首先是: 当你困在一个问题上,解决一个更简单的问题 . 让我们用以下方式简化这个问题:

    • 假设令牌流是格式良好的程序。没有错误检测。
    • 标记是字符串。
    • 语法是: E ::= T E', E' ::= + T E' | nil T 是由单个标记组成的术语。

    好的。 首先创建表示这些内容的类型 .

    sealed class Term : ParseTree 
    {
        public string Value { get; private set; }
        public Term(string value) { this.Value = value; }
        public override string ToString() { return this.Value; }
    }
    sealed class Additive : ParseTree 
    { 
        public ParseTree Term { get; private set; }
        public ParseTree Prime { get; private set; }
        public Additive(ParseTree term, ParseTree prime) {
            this.Term = term;
            this.Prime = prime;
        }
        public override string ToString() { return "" + this.Term + this.Prime; }
    }
    sealed class AdditivePrime : ParseTree 
    { 
        public string Operator { get; private set; }
        public ParseTree Term { get; private set; }
        public ParseTree Prime { get; private set; }
        public AdditivePrime(string op, ParseTree term, ParseTree prime) {
            this.Operator = op;
            this.Term = term;
            this.Prime = prime;
        }
        public override string ToString() { return this.Operator + this.Term + this.Prime; }
    }
    sealed class Nil : ParseTree 
    {
        public override string ToString() { return ""; }
    }
    

    注意以下几点:

    • 抽象类是抽象的。
    • 混凝土类是密封的。
    • 一切都是不变的。
    • 一切都知道如何打印。
    • 没有空! 无空值 . 空值导致崩溃。你有一个名为nil的产品,所以创建一个名为 Nil 代表它。

    下一步:我们希望解析器从用户的角度看是什么样的?我们想要一个令牌序列进入,我们想要一个解析树出来。伟大的。因此,公众面应该是:

    sealed class Parser
    {
        public Parser(List<string> tokens) { ... }
        public ParseTree Parse() { ... }
    }
    

    如果我们做的一切都是对的,那么呼叫站点看起来是这样的:

    public static void Main()
    {
        var tokens = new List<string>() { "1" , "+" , "2" , "+" , "3" , "+" , "4"};
        var parser = new Parser(tokens);
        var result = parser.Parse();
        System.Console.WriteLine(result);
    }
    

    超级的.现在我们要做的就是实现它。

    解析器跟踪令牌列表和正在考虑的当前令牌。 不要把这些信息从一个方法传播到另一个方法 . 它在逻辑上是解析器的一部分,所以请将它保存在解析器中。

    public sealed class Parser
    {
        private List<string> tokens;
        private int current;    
        public Parser(List<string> tokens)
        {
            this.tokens = tokens;
            this.current = 0;
        }
    

    语言现在只包含加法表达式,因此:

        public ParseTree Parse()
        {
            return ParseAdditive();
        }
    

    很好,我们已经完成了解析器的两个成员。现在,怎么办 ParseAdditive 怎么办? 它照它在锡上说的做 . 它解析一个加法表达式,它有语法 E:: T E' 现在,这就是它所做的一切。

    private ParseTree ParseAdditive()
    {
        var term = ParseTerm();
        var prime = ParseAdditivePrime();
        return new Additive(term, prime);
    }
    

    如果解析器方法看起来不那么简单,那么您就做错了 . 整体 指向 递归下降解析器的特点是易于理解和实现。

    现在我们可以看到如何实现 ParseTerm() ;它只消耗一个标记:

    private string Consume() 
    {
      var t = this.tokens[this.current];
      this.current += 1;
      return t;
    }
    private ParseTree ParseTerm() {
      return new Term(Consume());
    }
    

    同样,我们假设令牌流是格式良好的。当然,如果这是一个错误的形式,这将崩溃,但这是一个问题的另一天。

    最后,最后一个有点难,因为有两个案例。

    private bool OutOfTokens() 
    {
      return this.current >= this.tokens.Count;
    }
    private ParseTree ParseAdditivePrime()
    {
        if (OutOfTokens())
            return new Nil();
        var op = Consume();
        var term = ParseTerm();
        var prime = ParseAdditivePrime();
        return new AdditivePrime(op, term, prime);
    }
    

    如此简单 . 再一次, 你所有的方法都应该和他们做的一模一样 .

    注意我没有写信

    private ParseTree ParseAdditivePrime()
    {
        if (this.current >= this.tokens.Count)
            return new Nil();
    

    使程序的文本读起来像它正在执行的操作一样 。我们想知道什么? 我们的代币用完了吗? 所以 . 别让读者——你自己——不得不考虑哦,我是说 > < <= 或者…别这样。解决问题 一旦 ,将解决方案放入一个名称正确的方法中,然后调用该方法。你未来的自我会感谢你过去的自我对你的照顾。

    还请注意,我没有写C 7超级光滑:

    private ParseTree ParseAdditivePrime() => 
      OutOfTokens() ? new Nil() : new AdditivePrime(Consume(), ParseTerm(), ParseAdditivePrime());
    

    那就是你 可以 将解析器方法编写为一行是设计了一个好的解析器的标志,但这并不意味着 应该 . 如果保持解析器的顺序语句形式,而不是简单的一行代码,那么它通常更容易理解和调试。做出正确的判断。


    好的,我们已经解决了更简单的问题!现在我们来解决一个 轻微地 更难的问题。我们已经解决了语法分析问题 E::=T E',E':=+T E'零 ,但是语法我们 希望 解析是 B :: T | B + T .

    注意,我并不困惑 E ,这是一个带有 B ,或者是 T 或A A + 和A T . 自从 e 是不同的,用不同的类型来表示。

    让我们为 :

    sealed class Binary : ParseTree 
    {
        public ParseTree Left { get; private set; }
        public string Operator { get; private set; }
        public ParseTree Right { get; private set; }
        public Binary(ParseTree left, string op, ParseTree right) 
        {
            this.Left = left; 
            this.Operator = op;
            this.Right = right;
        }
        public override string ToString() 
        {
            return "(" + Left + Operator + Right + ")";
        }
    }
    

    注意,我在输出中添加了括号作为视觉帮助,以帮助我们看到它是左关联的。

    现在,假设我们有一个 Additive 我们需要一个 Binary . 我们要怎么做 ?

    加法总是一个项和一个素数。所以有两个案子。要么素数是零,要么不是。

    如果素数为零,我们就结束了:a Term 在以下情况下是可以接受的 二元的 是必需的,这样我们就可以把这个词传回去了。

    如果素数不是零,那么素数就是op,term,prime。 我们总得找个 二元的 从那 。一个二进制需要三样东西。 记住,我们将每个令牌都赋给一个节点 ,所以这将帮助我们找到答案。

    • 我们有加法中的左项。
    • 我们的手术是从黄金时期开始的。
    • 我们从黄金时期就有了合适的任期。

    但这就把素数的素数抛在后面了!我们需要做点什么。我们来解释一下。同样,有两种情况:

    • 如果素数的素数是零,那么结果就是二进制数。
    • 如果素数的素数不是零,则结果是一个新的二进制数,旧的二进制数在左边,并且… 等等,这和我们刚才描述的把加法转换成二进制的算法是一样的 .

    我们刚刚发现,这个算法是递归的,一旦你意识到写这个算法很简单:

    private static ParseTree AdditiveToBinary(ParseTree left, ParseTree prime) 
    {
        if (prime is Nil) return left;
        var reallyprime = (AdditivePrime) prime;
        var binary = new Binary(left, reallyprime.Operator, reallyprime.Term);
        return AdditiveToBinary(binary, reallyprime.Prime);
    }
    

    现在我们修改了 分析添加剂 :

    private ParseTree ParseAdditive()
    {
        var term = ParseTerm();
        var prime = ParseAdditivePrime();
        return AdditiveToBinary(term, prime);       
    }     
    

    运行它:

    (((1+2)+3)+4)
    

    我们结束了。

    嗯,不完全是。 分析添加剂 别再做罐头上写的事了!它说 分析添加剂 但它返回一个 二元的 .

    事实上… 我们需要 添加剂 完全 ?我们可以从解析器中完全消除它吗?事实上我们可以;我们现在 从不创建的实例 添加剂 ,所以它可以被删除,并且 分析添加剂 可以重命名 ParseBinary .

    当使用“解决更简单的问题”的技术构造程序时,经常会发生这种情况。你最终可以丢掉你以前的工作 伟大的 . 删除的代码没有错误。

    • 运动 :将运算符表示为字符串是粗糙的。做一个 Operator 类类似于 术语 ,以及分析运算符的方法。 不断提高解析器的抽象级别,使之远离具体的字符串并进入解析器的业务领域 . 与记号类似;它们不应该是字符串。
    • 运动 :我们解决了一个稍微困难的问题,所以继续前进。你现在能加乘法了吗?
    • 运动 :您能解析一种左右结合运算符的语言吗?

    一些额外的想法:

    • 我想你这样做不是为了自己的乐趣,就是为了学校的作业。 不要把我的工作复制粘贴到你的作业中 . 那是学术欺诈。 如果你提交的作品不完全是你自己的,记得在提交时正确地将所有的作品归类。

    • 如果你做这件事是为了好玩,那么设计一门语言就很有趣了!这是个不错的爱好,如果你真的很幸运,总有一天会有人付钱给你。

    • 你在设计你自己的语言,所以你不必重复过去的错误。例如,我注意到您的评论建议您添加cast表达式。欢迎来到一个痛苦的世界,如果你像C,C++,C语言,Java等等。所有这些语言的解析器必须在 (x)+y 意思是“把一元加在y上,然后把它转换成 x ,和“添加数量” (x) y “。这是一个很大的痛苦。考虑一个更好的强制转换语法,比如 as 操作员。此外,还要检查强制转换的含义;在c强制转换意味着“生成一个表示相同值的不同类型的实例”和“我断言此对象的运行时类型与其编译时类型不同;如果我错了就抛出”。这些操作完全不同,但语法相同。所有的语言都是对以前语言的回应,所以仔细想想你喜欢什么 因为它很熟悉 VS 因为它很好 .

    推荐文章