随笔- 58  文章- 0  评论- 1103 

上回我们说到语法分析使用的上下文无关语言,以及描述上下文无关文法的产生式、产生式推导和语法分析树等概念。今天我们就来讨论实际编写语法分析器的方法。今天介绍的这种方法叫做递归下降(recursive descent)法,这是一种适合手写语法编译器的方法,且非常简单。递归下降法对语言所用的文法有一些限制,但递归下降是现阶段主流的语法分析方法,因为它可以由开发人员高度控制,在提供错误信息方面也很有优势。就连微软C#官方的编译器也是手写而成的递归下降语法分析器。

 

使用递归下降法编写语法分析器无需任何类库,编写简单的分析器时甚至连前面学习的词法分析库都无需使用。我们来看一个例子:现在有一种表示二叉树的字符串表达式,它的文法是:

N → a ( N, N )
N → ε

其中终结符a表示任意一个英文字母,ε表示空。这个文法的含义是,二叉树的节点要么是空,要么是一个字母开头,并带有一对括号,括号中逗号左边是这个节点的左儿子,逗号右边是这个节点的右儿子。例如字符串 A(B(,C(,)),D(,))就表示这样一棵二叉树:

bintree

注意,文法规定节点即使没有儿子(儿子是空),括号和逗号也是不可省略的,所以只有一个节点的话也要写成A(,)。现在我们要写一个解析器,输入这种字符串,然后在内存中建立起这棵二叉树。其中内存中的二叉树是用下面这样的类来表示的:

class Node
{
    public Node LeftChild { get; private set; }
    public Node RightChild { get; private set; }
    public char Label { get; private set; }

    public Node(char label, Node left, Node right)
    {
        Label = label;
        LeftChild = left;
        RightChild = right;
    }
}

这是一道微软面试题,曾经难倒了不少参加面试的候选人。不知在座各位是否对写出这段程序有信心呢?不少参选者想到了要用栈,或者用递归,去寻找逗号的位置将字符串拆解开来等等方法。但是若是使用递归下降法,这个程序写起来非常容易。我们来看看编写递归下降语法分析器的一般步骤:

  1. 使用一个索引来记录当前扫描的位置。通常将它做成一个整数字段。
  2. 为每个非终结符编写一个方法。
  3. 如果一个非终结符有超过一个的产生式,则在这个方法中对采用哪个产生式进行分支预测
  4. 处理单一产生式时,遇到正确终结符则将第一步创建的扫描索引位置向前移动;如遇到非终结符则调用第二步中创建的相应方法。
  5. 如果需要产生解析的结果(比如本例中的二叉树),在方法返回之前将它构造出来。

我们马上来试验一下。首先建立一个类,然后存放一个索引变量来保存当前扫描位置。然后要为每一个非终结符创建一个方法,我们的文法中只有一个非终结符N,所以只需创建一个方法:

class BinaryTreeParser
{
    private string m_inputString;
    private int m_index;

    //初始化输入字符串和索引的构造函数,略

    Node ParseNode()
    {
        
    }
}

回到刚才的产生式,我们看到非终结符N有两个产生式,所以在ParseNode方法的一开始我们必须做出分支预测。分支预测的方法是超前查看(look ahead)。就是说我们先“偷窥”当前位置前方的字符,然后判断应该用哪个产生式继续分析。非终结符N的两个产生式其中一个会产生a(N, N)这个的结构,而另一个则直接产生空字符串。那现在知道,起码有一种可能就是会遇到一个字母,这时候应该采用N → a(N, N)这个产生式继续分析。那么什么时候应该采用N → ε进行分析呢?我们观察产生式右侧所有出现N的地方,倘若N是空字符串,那么N后面的字符就会直接出现,也就是逗号和右括号。于是这就是我们的分支预测:

  1. 如果超前查看遇到英文字母,预测分支N → a(N, N)
  2. 如果超前查看遇到逗号、右括号预测分支N → ε

转化成代码就是这样:

Node ParseNode()
{
    int lookAheadIndex = m_index;

    char lookAheadChar = m_inputString[lookAheadIndex];

    if (Char.IsLetter(lookAheadChar))
    {
        //采用N → a(N, N)继续分析
    }
    else if (lookAheadChar == ',' || lookAheadChar == ')' )
    {
        //采用N → ε继续分析
    }
    else
    {
        throw new Exception("语法错误");
    }
}

接下来我们分别来看两个分支怎么处理。先来看N → ε,这种情况下非终结符是个空字符串,所以我们不需要移动当前索引,直接返回null表示空节点。再来看N → a(N, N) 分支,倘若输入的字符串没有任何语法错误,那就应该依次遇到字母、左括号、N、逗号、N右括号。根据上面的规则,凡是遇到终结符,就移动当前索引,直接向前扫描;而要是遇到非终结符,就递归调用相应节点的方法。所以(不考虑语法错误)的完整方法代码如下:

Node ParseNode()
{
    int lookAheadIndex = m_index;

    char lookAheadChar = m_inputString[lookAheadIndex];

    if (Char.IsLetter(lookAheadChar))
    {
        //采用N → a(N, N)继续分析
        char label = m_inputString[m_index++]; //解析字母
        m_index++; //解析左括号,因为不需要使用它的值,所以直接跳过

        Node left = ParseNode(); //非终结符N,递归调用

        m_index++; //解析逗号,跳过

        Node right = ParseNode(); //非终结符N,递归调用

        m_index++; //解析右括号,跳过

        return new Node(label, left, right);
    }
    else if (lookAheadChar == ',' || lookAheadChar == ')')
    {
        //采用N → ε继续分析
        //无需消耗输入字符,直接返回null
        return null;
    }
    else
    {
        throw new Exception("语法错误");
    }
}

因为存在语法约束,所以一旦我们完成了分支预测,就能清楚地知道下一个字符或非终结符一定是什么,无需再进行任何判断(除非要进行语法错误检查)。因此根本就不需要寻找逗号在什么位置,我们解析到逗号时,逗号一定就在那,这种感觉是不是很棒?只需要寥寥几行代码就已经写出了一个完整的Parser。大家感兴趣可以继续补全一些辅助代码,然后用真正的字符串输入试验一下,是否工作正常。前面假设输入字符串的语法是正确的,但真实世界的程序总会写错,所以编译器需要能够帮助检查语法错误。在上述程序中加入语法错误检查非常容易,只要验证每个位置的字符,是否真的等于产生式中规定的终结符就可以了。这就留给大家做个练习吧。

 

上面我们采用的分支预测法是“人肉观察法”,编译原理书里一般都有一些计算FIRST集合或FOLLOW集合的算法,可以算出一个产生式可能开头的字符,这样就可以用自动的方法写出分支预测,从而实现递归下降语法分析器的自动化生成。ANTLR就是用这种原理实现的一个著名工具。有兴趣的同学可以去看编译原理书。其实我觉得“人肉观察法”在实践中并不困难,因为编程语言的文法都特别有规律,而且我们天天用编程语言写代码,都很有经验了。

 

下面我们要研究一下递归下降法对文法有什么限制。首先,我们必须要通过超前查看进行分支预测。支持递归下降的文法,必须能通过从左往右超前查看k个字符决定采用哪一个产生式。我们把这样的文法称作LL(k)文法。这个名字中第一个L表示从左往右扫描字符串,这一点可以从我们的index变量从0开始递增的特性看出来;而第二个L表示最左推导,想必大家还记得上一篇介绍的最左推导的例子。大家可以用调试器跟踪一遍递归下降语法分析器的分析过程,就能很容易地感受到它的确是最左推导的(总是先展开当前句型最左边的非终结符)。最后括号中的k表示需要超前查看k个字符。如果在每个非终结符的解析方法开头超前查看k个字符不能决定采用哪个产生式,那这个文法就不能用递归下降的方法来解析。比如下面的文法:

F → id
F → ( E )
E → F * F
E → F / F

当我们编写非终结符E的解析方法时,需要在两个E产生式中进行分支预测。然而两个E产生式都以F开头,而且F本身又可能是任意长的表达式,无论超前查看多少字符,都无法判定到底应该用乘号的产生式还是除号的产生式。遇到这种情况,我们可以用提取左公因式的方法,将它转化为LL(k)的文法:

F → id
F → ( E )
G → * F
G → / F
E → FG

我们将一个左公因式F提取出来,然后将剩下的部分做成一个新的产生式G。在解析G的时候,很容易进行分支预测。而解析E的时候则无需再进行分支预测了。在实践中,提取左公因式不仅可以将文法转化为LL(k)型,还能有助于减少重复的解析,提高性能。

 

下面我们来看LL(k)文法的第二个重要的限制——不支持左递归。所谓左递归,就是产生式产生的第一个符号有可能是该产生式本身的非终结符。下面的文法是一个直截了当的左递归例子:

F → id
E → E + F
E → F

这个表达式类似于我们上篇末尾得到的无歧义二元运算符的文法。但这个文法存在左递归:E产生的第一个符号就是E本身。我们想像一下,如果在编写E的递归下降解析函数时,直接在函数的开头递归调用自己,输入字符串完全没有消耗,这种递归调用就会变成一种死循环。所以,左递归是必须要消除的文法结构。解决的方法通常是将左递归转化为等价的右递归形式:

F → id
E → FG
G → + FG
G → ε

大家应该牢牢记住这个例子,这不仅仅是个例子,更是解除大部分左递归的万能公式!我们将要在编写miniSharp语法分析器的时候一次又一次地用到这种变换。

 

由于LL(k)文法不能带有左递归和左公因式,很多常见的文法转化成LL(k)之后显得不是那么优雅。有许多程序员更喜欢使用LR(k)文法的语法分析器。LR代表从左到右扫描和最右推导。LR型的文法允许左递归和左公因式,但是并不能用于递归下降的语法分析器,而是要用移进-归约型的语法分析器,或者叫自底向上的语法分析器来分析。我个人认为LR型语法分析器的原理非常优雅和精妙,但是限于本篇的定位我不准备介绍它。我想任何一本编译原理书里都有详细介绍。当然如果未来我的VBF库支持了LR型语法分析器,我也许会追加一些特别篇,谁知道呢?

 

希望大家看了今天这篇文章之后,都能用递归下降法写出一些LL(k)文法的语法分析器来。下一篇我将介绍使用C#和VB中神奇的Linq语法来“组合”出语法分析器来,敬请期待!

希望大家继续关注我的VBF项目:https://github.com/Ninputer/VBF 和我的微博:http://weibo.com/ninputer 多谢大家支持!

 posted on 2011-06-21 00:22 装配脑袋 阅读(3592) 评论(22) 编辑 收藏

#1楼   回复 引用 查看   
 路过秋天       | 2011-06-21 01:00
太强大,不在我理解的范畴内。
当初我都不知道编绎原理是怎么考试过的,估计是老师给的题,背的答案。

#2楼   回复 引用 查看   
 panda32       | 2011-06-21 05:22
我的一點淺見。文章中提到 "解决的方法通常是将左递归转化为等价的右递归形式" 這方法是大多數编绎原理教課書都有提及的,其實這方法有一個問題,單純用递归下降法去處理右递归形式的話,生成的分析樹(parse tree)會往右邊成長,使接下來的工作(evaluation or code generation)都不好做。如果一定要用递归下降法的話,個人較喜歡用EBNF(Extended BNF)來表示文法。好處是生成的分析樹的方向不會改變。接下來的工作便更好做了。
#3楼   回复 引用 查看   
 Artech       | 2011-06-21 08:53
脑袋很强大,这些个东西我怎么也拎不清楚。
#4楼[楼主]   回复 引用 查看   
 装配脑袋       | 2011-06-21 08:53
@panda32
只需要在翻译成语法树的时候稍加技巧,就可以仍生成想要的语法树结构,我后面会演示。

#5楼   回复 引用 查看   
 xiao ming       | 2011-06-21 09:49
值得学习。。。。
#6楼   回复 引用 查看   
 近水楼台       | 2011-06-21 10:05
脑袋的写得这个系列文章比编译原理教材精彩多了,读大学的时候一直没有学明白编译原理,现在借脑袋这个系列重新学习编译原理,把落下这课补上。谢谢提供这么高质量的文章。
#7楼   回复 引用 查看   
 Junfeng Liu       | 2011-06-21 10:32
我以前写过LR语法的分析器,代码在这里:
http://sourceforge.net/projects/anotherpdflib/files/ParserGenerator/ParserGenerator1.5/
可以根据语法定义自动生成解析器的代码。

#8楼[楼主]   回复 引用 查看   
 装配脑袋       | 2011-06-21 10:59
@Junfeng Liu
好东东,收藏~

#9楼   回复 引用 查看   
 新的开始       | 2011-06-21 11:15
打算做一个验证文本输入格式和内容的模块,使用自定义脚本来控制,需要语法分析,千头万绪中~~
#10楼   回复 引用 查看   
 G yc {Son of VB.NET}       | 2011-06-21 13:20
额,居然在零点发布。。。


错误去了~

#11楼   回复 引用 查看   
 Hundre       | 2011-06-22 09:10
楼主有没有准备写代码生成部分的文章啊
#12楼[楼主]   回复 引用 查看   
 装配脑袋       | 2011-06-22 15:45
@Hundre
准备写呀,都要写,但是时间不定,呵呵

#13楼   回复 引用 查看   
 DiryBoy       | 2011-06-23 01:09
对这个挺感兴趣的,所以下载学习了一下代码,看到Parser类的Run方法签名很长,我把返回的Func自定义为
public delegate Result<T> ParserFunc<T> ( ForkableScanner s, ParserContext c );
//那么Future<K,V>就是
public delegate ParserFunc<V> Future<K,V> ( K val );

后,可以看到Parser.Run和Bind操作的签名是一样的。

然后请教个问题,为什么要这样做?或者说这样做的好处是?我看到 SelectMany 返回的 ConcatenationParser 就在用Run连接,但是怎么连接的任务却抛给了具体的Parser,也就是说每实现一个新的Parser都有可能用到他自己实现了的Bind方式,这样会不会使 ConcatenationParser 的“语义”造成一些混淆?
举个例说,
class MyParser:Parser<int>{ override Run( future ) {
  return ( scanner, context ) =>
  {
    newScanner = CreateNewScanner();
    newContext = new ParserContext(...);
    future(123)(newScanner, newContext);
    return context.StepResult(0, ()=>future(456)(scanner,newContext));
  };
}}

// 这样如果这么创建一个Parser
var newParser = from a in TokenParser
                from b in MyParser
                from c in ...
                select c;


貌似这样有副作用……代码很多,一时读不完,有点疑惑,所以向脑袋请教。

#14楼   回复 引用 查看   
呵呵,好文章啊,收了好好琢磨。

#15楼   回复 引用 查看   
 fyen       | 2011-06-25 12:41
您好,想了解一个这语句的意思。
internal override Func<HashSet<char>>[] GetCompactableCharSets()
{
return new Func<HashSet<char>>[] { () => new HashSet<char>(m_charSet) };
}


#16楼   回复 引用 查看   
 toEverybody       | 2011-06-25 20:28
在几年前我在学VB.ne的时候,装配脑袋是专注与VB.net的,常进你的博客...怎么现在又专注C#代码来说明问题了呀?? 什么时候再转来专注C++就好了..呵呵
#17楼[楼主]   回复 引用 查看   
 装配脑袋       | 2011-06-26 10:59
@DiryBoy
Parser.Run方法并不是直接Run,而是返回可以真正运行的函数。而每个Parser实现的时候,都要在Run方法内生成一个真正运行的函数,这个运行函数必须调用Run传入的Future。所以,连接的逻辑的确是在Run里完成,Run声明的Lambda表达式,将P2作为P1的future,然后将传进来的future作为P2的future,这样就把P1和P2连接起来了。这个Run名字起的不好,我最近会重构一下。
Parser实现显然是要符合一定规范的,不能随便写。

#18楼[楼主]   回复 引用 查看   
 装配脑袋       | 2011-06-26 11:00
@fyen
这个是获取该正则表达式可压缩字符集用的。

#19楼[楼主]   回复 引用 查看   
 装配脑袋       | 2011-06-26 11:05
@toEverybody
以上语言我想用哪个就可以用哪个

#20楼   回复 引用 查看   
 DiryBoy       | 2011-06-27 11:15
@装配脑袋
我的理解就是Create/GetParserFunction,但是我不明白的地方在,为什么能在SelectMany决定连接方式的情况下,仍然由Parser来决定。

#21楼[楼主]   回复 引用 查看   
 装配脑袋       | 2011-06-28 13:09
@DiryBoy
不理解你不明白什么。。

#22楼   回复 引用 查看   
 DiryBoy       | 2011-06-28 13:23
@装配脑袋
不明白你为什么没有采取你在下一篇所说的初学者原型。你在那篇中说这非常不擅长进行错误报告和恢复。好吧,我会继续关注下一篇CPS风格的。