上回我们说到用正则表达式来表示词法分析中的单词规则。正则表达式的规则很容易理解,但是正则表达式并不能直接用来解析字符串,我们还要引入一种适合转化为计算机程序的模型。今天我们引入的这种模型就叫做有穷自动机(finite automation,FA),有时也叫有穷状态机(finite state machine)。有穷自动机首先包含一个有限状态的集合,还包含了从一个状态到另外一个状态的转换。有穷自动机看上去就像是一个有向图,其中状态是图的节点,而状态转换则是图的边。此外这些状态中还必须有一个初始状态和至少一个接受状态。下面的图展示了一个有穷自动机,有根从外边来的箭头指向的状态表示初始状态,有个黑圈的状态是接受状态:
现在我们来看看有穷自动机怎么处理输入的字符串:
- 一开始,自动机处于初始状态
- 输入字符串的第一个字符,这时自动机会查询当前状态上与输入字符相匹配的边,并沿这条边转换到下一个状态。
- 继续输入下一个字符,重复第二步,查询当前状态上的边并进行状态转换
- 当字符串全部输入后,如果自动机正好处于接受状态上,就说该自动机接受了这一字符串。
刚才我们画的自动机,假如输入的字符串是"hello"(带引号)。一开始状态机处于状态1,输入引号以后就沿引号的边转换到了状态2;接下来输入hello都会沿着a-z这条边回到状态2,最后输入引号,转换到了状态3。由于状态3是接受状态,那么这个自动机就会接受这个字符串。而如果字符串是"abc(不带后面的引号),那么当字符串输入完毕之后自动机会处在状态2,而状态2不是接受状态,所以这个自动机就不接受"abc这个字符串。一个自动机接受的所有字符串组成的集合称作这个自动机的语言。这里语言的概念和上一回我们介绍正则表达式的语言概念是一样,都表示一个有限字符集上的字符串集合。
上面我们画的自动机是一个确定性有穷自动机(DFA),其特点是从每一个状态只能发出一条具有某个符号的边。也就是说不能出现同一个符号出现在同一状态发出的两条边上。但是,还有一种非确定性有穷自动机(NFA),它允许从一个状态发出多条具有相同符号的边,甚至允许发出标有ε(表示空)符号的边,也就是说,NFA可以不输入任何字符就自动沿ε边转换到下一个状态。下图展示了一个非确定性有穷自动机:
非确定性有穷自动机在遇到两条边上有相同的符号,会选择哪一边呢?遇到ε边到底会转移还是不会转移呢?答案是,NFA会自动猜测应该选择哪一条边,而且每次都能猜对。比如说,上面的NFA,假如输入字符串是aa,它就会选择右边这条路径,并且接受这个字符串;假如输入字符是aaa,它就会走左边这条路径,并接受字符串。它绝不会在输入字符是aaa的时候选择右边路径然后做出不接受这一判断。由于我们的计算机并没有这种“猜测”能力,大家可能会对NFA具有这种能力感到奇怪。有些人在刚刚接触这些概念的时候可能会觉得NFA因为具有自动猜测的能力,应该要比DFA更加强大。但事实上是,DFA、NFA和正则表达式是等价的,任何NFA都存在一个与之接受同样语言的DFA,和一个定义相同语言的正则表达式;同理任何正则表达式,也存在一个接受其所定义语言的NFA和一个DFA。这三种模型虽然定义迥然不同,但却表示同样的正则语言。幸运的是,只需要很简单的规则,就能把任何正则表达式转化成NFA,而任何一个NFA又都可以转化为DFA,这样我们就能把正则表达式转化为易于编程的DFA,来真正进行词法分析的工作。(注,也有正则表达式引擎直接模拟NFA的运行来解析字符串,有兴趣的读者可以自行寻找有关的资料。)
现在我们来看怎么把正则表达式转化为NFA。我们上次学到正则表达式有两种基本要素——字符表达式和ε表达式,以及三种基本运算——并、连接和闭包。首先我们来看最基础的ε表达式,它的NFA是这样的:
接下来是字符表达式a,它的NFA是这样:
所有正则表达式都可以转化为一个有一条输入边,以及一个接受状态的的正则表达式,我们先假设一个一般的正则表达式的NFA是这样:
然后我们定义两个正则表达试的并运算,X|Y的NFA为:(实际应用中,常常可以简化掉一部分ε转换边)
两个这正则表达式的连接运算,XY的NFA为:
一个正则表达式的克林闭包运算,Y*的NFA为:
递归运用以上规则,就可以把任何正则表达式转化为NFA。我们来试试看。上次研究了标识符的正则表达式[a-z][a-z0-9]*,运用以上规则,转换成的NFA是:
词法分析时,我们要把所有的单词的正则表达式分别转换成NFA,然后用“并”的关系将所有NFA连接到一起,就成了词法分析所需的最终NFA。
下面我们来看看上述逻辑在VBF.Compilers中是如何实现的。上次我们定义了一个RegularExpression基类和它的五个子类,分别对应于正则表达式的基本要素和基本运算。考虑到将正则表达式转换为NFA是一个相对独立的操作,所以我们采用Visitor模式,定义一个抽象类作为Visitor:
public abstract class RegularExpressionConverter<T> { protected RegularExpressionConverter() { } public T Convert(RegularExpression expression) { if (expression == null) { return default(T); } return expression.Accept(this); } public abstract T ConvertAlternation(AlternationExpression exp); public abstract T ConvertSymbol(SymbolExpression exp); public abstract T ConvertEmpty(EmptyExpression exp); public abstract T ConvertConcatenation(ConcatenationExpression exp); public abstract T ConvertAlternationCharSet(AlternationCharSetExpression exp); public abstract T ConvertStringLiteral(StringLiteralExpression exp); public abstract T ConvertKleeneStar(KleeneStarExpression exp); }
然后我们给RegularExpression类加上一个Accept抽象方法,让其子类分别实现。比如,KleeneStarExpression类的Accept就可以写成这样:
internal override T Accept<T>(RegularExpressionConverter<T> converter) { return converter.ConvertKleeneStar(this); }
最后我们实现一个NFAConverter,实现抽象类RegularExpressionConverter<NFAModel>。其中NFAModel是我们自己定义的NFA对象模型,其中定义了状态节点、边等概念。下面就是NFAConverter中翻译克林闭包的规则:
public override NFAModel ConvertKleeneStar(KleeneStarExpression exp) { var innerNFA = Convert(exp.InnerExpression); var newTail = new NFAState(); var entry = new NFAEdge(newTail); innerNFA.TailState.AddEmptyEdgeTo(newTail); newTail.AddEdge(innerNFA.EntryEdge); var kleenStarNFA = new NFAModel(); kleenStarNFA.AddStates(innerNFA.States); kleenStarNFA.AddState(newTail); kleenStarNFA.EntryEdge = entry; kleenStarNFA.TailState = newTail; return kleenStarNFA; }
代码应当是相当直观的,它就是重复上面画图的逻辑,先将克林闭包内部的表达式转换成NFA,再创建一些辅助的外围状态和相应的状态转换。
有了从正则表达式转换到NFA的算法之后,我们还需要NFA到DFA的转换。这个转换算法称作“子集构造”。我们前面说过NFA遇到同一状态发出带有同一符号的不同的边时,能自动猜测转移到哪一边。而子集构造的思想就是不猜测NFA会转移到哪个状态,而是假设NFA能同时处于所有可能的状态。比如,我们重新考虑前面最开始展示的NFA。一开始,这个NFA的初始状态就包含两个ε转换,我们假设NFA能同时处于所有这种ε转换的目标状态上,也就是说它的初始状态其实是三个状态的集合:
我们称这三个状态为初始状态的ε-闭包(ε-closure)。接下来,如果输入了字符a,那么NFA就可以从当前状态的ε-闭包内任何状态开始,通过字符a的边进行状态转换。这时,我们就得到NFA的下一个状态:
接下来再次输入字符a,我们也可以从当前状态集合出发,找到下一个状态集合:
如果字符串到此为止,这时NFA的状态集合中包含了一个接受状态,因此NFA决定接受字符串“aa”。也就是说,这次没有用到猜测能力,就成功地解析了aa这个字符串。这样我们就了解到,一定存在一个DFA,它的每个状态都是NFA状态的的一个子集。下面我简单转述一下虎书中有关子集构造算法。令edge(s, c)表示从状态s沿着标有字符c的边可以达到的所有NFA状态的集合。对状态集合S,closure(S)是从S中的状态出发,无需接受任何字符,只沿ε边就可以达到的状态组成的集合,可以用迭代法来求出:
接下来我们定义输入一个字符之后的动作规则,从NFA状态集合d中的状态出发,输入符号c,所能达到的NFA的新的状态集合记作DFAedge(d, c),它定义为
最后,假设构成语言的字符集是Σ,构造出整个DFA的算法是:
以上代码在理解了子集构造的基本原理之后很容易就能够转换成代码。VBF.Compilers中的NFA->DFA转换代码比较长,我就不贴在这里了,有兴趣的可以到github上自行下载。
将正则表达式通过NFA最后转化为DFA之后,如何进行真正的字符串扫描工作就是水到渠成的工作了。我们下一篇将介绍具体的做法,以及针对Unicode字符集的处理方式。下一篇我还会介绍VBF.Compilers.Scanners类库的基本用法。如果大家不想自己实现整套算法,那么下回就可以参考我的文章,用VBF库制造出任意的词法分析器来。所以,敬请期待下一篇!
此外别忘了关注我的VBF项目:https://github.com/Ninputer/VBF 和我的微博:http://weibo.com/ninputer 多谢大家支持!