如何手写语法分析器

从网上无意间看到这个系列的文章,作者非常有想法,转下来慢慢研究,好好学习。   祝大家学习愉快,做自己的爱好 ^_^ !


如何手写语法分析器

陈梓瀚

华南理工大学软件05级本科

 

在写可配置的语法分析器之前,我觉得还是先说说如何手写语法分析器好。因为对于大部分人来说,开发一个可配置的语法分析器并没有什么作用,反而针对某种特定的语法开发特定的语法分析器是特别有必要的。典型的有表达式计算器、某种格式化的文件(HTMLXML等)或者是其他的复杂而且符合树型结构的字符串。根据目前论坛的反应来看,有一些朋友们对如何开发一套自己的脚本引擎比较感兴趣。等基础的文章都写完以后我会考虑撰写一个系列的文章介绍如何开发自己的脚本引擎。

 

这篇文章会附带一些必要的代码以便帮助读者们理解。为了方便,代码使用DevC++开发。

 

一、定义语法

 

在开发语法分析器之前,有必要讲一下语法的定义。这篇文章给出的是一个比较机械化的方法,掌握了这个方法之后手写语法分析器会变成一件没什么挑战性但是很麻烦的工作。因为设计起来太简单,但是代码很多。有些人为了连麻烦的工作也不要会去开发可配置的语法分析器。不过这里先不管这么多,首先给出一个比较使用的语法。

 

我们考虑一个经常从书上或者经常见到的例子:LISP语言。这门语言的表达式相当奇怪,操作符基本上当成函数处理,而且强迫用户给出优先级。因为LISP的操作符是没有优先级的。譬如(1+2)*(3+4)LISP会被写成(* (+ 1 2) (+ 3 4) )

 

让我们看一下这种语法的结构。括号内可以写很多个值,第一个被约定为是函数,之后的是参数。参数的个数是不确定的。一个函数调用的结果仍然是值,这就允许表达式进行嵌套。一个复杂一点的例子:2sinxcosxLISP内被写成(* 2 (sin x) (cos x))。我们注意到最外层的乘法有3个参数,因此代表连乘。其次,(1)1的结果是一样的。

 

于是我们如何规定这种表达式的语法呢?我们可以给出若干条。为了方便我们去掉LISP语言允许的curry属性,也就是说(+ 1 2)等价于( ( (+) 1) 2)

1、  数字可以为值

2、  一个值可以构成参数列表,参数列表后紧接着一个值仍然是参数列表

3、  表达式可以为值,或者是括号内包含操作符或函数名外加可选的参数列表

 

于是我们可以使用一种形式化的方法来写出这个表达式。首先我们可以为表达式命名,譬如表达式我们使用expression或者exp等。其次name=rule代表复杂的rule将会使用一个名字name来代替。最后,a b代表a之后紧接着b

 

这样的话,我们就可以使用一种比较简洁的方法来表示上面提到的简化后的LISP表达式语法:

Operator=”+”

Operator=”-“

Operator=”*”

Operator=”/”

Expression=<数字>

Expression= “(” Operator Expression Expression “)”

Expression=“(”Expression “)”

 

这样写的话觉得很烦,我们可以追加多两种定义语法的语法:

1A | B代表A或者B都可以,并且如果字符串被A匹配成功的话将不会考虑B

2[ A ]代表A是可以存在或者不存在的,但是尽量使其存在

 

于是我们可以把上面的语法改写成如下形式:

1)       Operator=”+” | “-” | “*” | “/”

2)       Expression=<数字> | “(“ Expression “)” | “(“ Operator Expression Expression “)”

 

第一条语法规则说的是Operator,也就是操作符,可以是加号、减号、乘号或者除号。第二条语法规则说的是一条表达式可以只由数字构成、一个加了括号的表达式或者一个加上了括号的操作符和两个参数。

 

二、根据语法写代码

 

到了这里,我们可以考虑一下如何通过语法组织我们的代码了。上面的语法并没有包含如何去除空格的语法,这个事情语法表达只会徒增烦恼,因此我们自己解决可能会更好一点。在语法分析的时候,我们都是一点一点读入字符串的,因此我们的函数的的形式大概如下:

·读入字符串,返回结果或者错误信息

·如果没有错误的话,则将字符指针偏移到尚未读取的位置

·如果有错误的话,保持字符指针不变

 

好了,现在我们来看第一条语法。我们需要一个方法来检查输入是否由我们需要的字符串开头,当然这里仍然需要考虑空格的问题。我们可以写一个函数,输入字符指针和一个字符串。这个函数先过滤掉空格然后检查剩下的地方是不是由指定的字符串开始的。正确的话返回true并将输入的字符指针往后诺到尚未读取的地方:

/*

检查Stream的前缀是否Text

    是返回true并将Stream偏移strlen(Text)个字符

    否则返回false

此函数会过滤Stream开头的空格

*/

bool Is(char*& Stream , const char* Text)

{

    size_t len=strlen(Text);

    /*保存参数*/

    char* Read=Stream;

    /*过滤空格*/

    while(*Read==' ')Read++;

    if(strncmp(Read,Text,len)==0)

    {

        Stream=Read+len;

        return true;

    }

    else

    {

        return false;

    }          

}

代码很短我就不解释了。当然,有了这个函数之后我们可以很轻松地写出一个判断字符串是否由操作符开头的函数:

/*

检查Stream是否操作符

    是的话返回操作符的字符并将Stream偏移至操作符之后

    否则返回

*/

char IsOperator(char*& Stream)

{

    /*A||B操作符的特性是如果A==true则不对B求值

    所以表达式会在一个检查成功后停下来

    */

    if(Is(Stream,"+") || Is(Stream,"-") || Is(Stream,"*") || Is(Stream,"/"))

    {

        /*此时操作符已经被越过,所以返回Read[-1]*/

        return Stream[-1];

    }

    else

    {

        return 0;

    }       

}

第一条语法到了这里就结束了。然后我们考虑第二条语法。这条语法判断一个字符串是否表达式,首先判断一个字符串是否数字,失败的话再检查是否由括号打头。因此我们需要一个判断字符串是否由数字开头。这里我们先引进一个struct

/*表达式分析结果*/

struct Expression

{

    int Result;     /*表达式结果*/

    char* Error;    /*错误信息,没有错误则为*/

    char* Start;    /*错误的位置*/

}; 

这个Expression结构用于表达字符串的分析结果。Result是表达式的计算结果,Error如果非0则保存了错误信息,此时Start保存了错误信息在字符串的什么地方被引发。有了这个Expression之后我们就可以写出如下判断字符串是否由数字开头的函数了。为了方便,这个函数只判断非负整数。

/*

检查Stream是否数字,是的话则将Stream偏移到数字之后

*/

Expression GetNumber(char*& Stream)

{

    /*初始化结果*/

    Expression Result;

    Result.Result=0;

    Result.Error=0;

    Result.Start=0;

    bool GotNumber=false;

    /*保存参数*/

    char* Read=Stream;

    /*过滤空格*/

    while(*Read==' ')Read++;

    while(true)

    {

        /*读入一个字符并将Read偏移一个字符*/

        char c=*Read;

        /*检查字符是否为数字*/

        if('0'<=c && c<='9')

        {

            /*把结果添加进Result,进行进位*/

            Result.Result=Result.Result*10+(c-'0');

            GotNumber=true;

            Read++;

        }

        else

        {

            break;

        }   

    }

    if(GotNumber)

    {

        Stream=Read;

    }

    else   

    {

        Result.Error="这里需要数字";

        Result.Start=Read;

    }   

    return Result;

}

这个函数仍然会过滤掉字符串开头的空格。如果成功的话,也就是Result.Error==0的时候,参数Stream会被偏移到已经分析的数字后面。

 

让我们看一看第二条语法接下来的部分:“(“ Expression “)” | “(“ Operator Expression Expression “)”。我们注意到,这两个部分都是使用括号开始和结束的,因此在写代码的时候可以把它们写在一起,只把中间的部分分开。这种方法在课本中通常被称为合并前缀。于是我们可以写一个GetExpression函数。这个函数首先判断字符串是不是由数字开头,否则的话看一看是否由括号开头。如果是括号开头的话,那么检查接下来的是Operator还是一个Expression。如果是Expression则到此结束,如果是Operator的话还要再输入两个Expression。然后判断一下是不是由右括号结束字符串:

/*检查Stream是否表达式,是的话则将Stream偏移至表达式之后*/

Expression GetExpression(char*& Stream)

{

    /*保存参数*/

    char* Read=Stream;

    /*检查是否数字*/

    Expression Result=GetNumber(Read);

    if(Result.Error)

    {

        if(Is(Read,"("))

        {

            /*不是数字而是左括号,则将ResultError*/

            Result.Error=0;

            char Operator=0;

            /*检查是否操作符*/

            if(Operator=IsOperator(Read))

            {

                /*获得左参数。如果参数获取失败会直接返回*/

                Expression Left=GetExpression(Read);

                if(Left.Error) return Left;

                /*保存当前的Read变量,以便在右参数出错的情况下正确指出错误的地点*/

                char* RightRead=Read;

                /*获得右参数。如果参数获取失败会直接返回*/

                Expression Right=GetExpression(Read);

                if(Right.Error) return Right;

                /*根据操作进行计算*/

                switch(Operator)

                {

                    case '+':

                        Result.Result=Left.Result+Right.Result;

                        break;

                    case '-':

                        Result.Result=Left.Result-Right.Result;

                        break;

                    case '*':

                        Result.Result=Left.Result*Right.Result;

                        break;

                    case '/':

                        if(Right.Result==0)

                        {

                            Result.Error="除错";

                            Result.Start=RightRead;

                        }

                        else

                        {   

                            Result.Result=Left.Result/Right.Result;

                        }   

                        break;

                    default:

                        Result.Error="未知操作符";/*不可能发生,执行到这里则证明其他部分有bug*/

                        Result.Start=Read;

                        return Result;

                }   

            }

            else

            {

                /*不是操作符则尝试获得表达式*/

                Result=GetExpression(Read);

                /*获取失败则直接返回*/

                if(Result.Error) return Result;

            }

            /*检查是否有配对的右括号*/

            if(!Is(Read,")"))

            {

                Result.Error="此处缺少右括号";

                Result.Start=Read;

            }  

        }   

    }

    /*如果没有出错则更新Stream的位置*/

    if(Result.Error==0)

    {

        Stream=Read;

    }   

    return Result;          

}

到了这里表达式的分析就完成了,我们得到了一个工具:GetExpression。我们可以将一个字符串输入GetExpression,然后看看返回了什么。当然,有可能返回计算结果,也有可能返回错误信息以及错误位置。为了解释如何使用GetExpression,我也写了一个main函数:

int main(int argc, char *argv[])

{

    /*声明一个长度的字符串缓冲区,可能有溢出的危险,此处不考虑*/

    char Buffer[1000];

    cout<<"输入一个表达式:"<<ends;

    gets(Buffer);

    {

        char* Stream=Buffer;

        Expression Result=GetExpression(Stream);

        if(Result.Error)

        {

            cout<<"发生错误"<<endl;

            cout<<"位置:"<<Result.Start<<endl;

            cout<<"信息:"<<Result.Error<<endl;

        }

        else

        {

            cout<<"结果:"<<Result.Result<<endl;

        }       

    }   

    system("PAUSE");  

    return 0;

}

这个函数输入一个字符串,然后计算结果或者输出错误信息。当然,错误的检查时不完全的,因为GetExpression只负责检查前缀,至于剩下的部分是什么是不管的。因此实际上还要检查一下剩下的字符是不是全都是空格,不是的话就要自己报错了。完整的代码见附带的文件夹Code_1_LISP

 

三、处理左递归

 

上面的方法其实还是不完全的。我们有时候会遇到一些自己产生自己的语法。譬如我们在表达一个使用逗号隔开的数字列表的时候,有如下两种写法:

1)  List=<数字> [“,” List]

2)  List=[List “,”]<数字>

这两种写法所产生的效果是一致的,但是我们如果按照第二种方法直接写出代码的话就会陷入无限循环。这种自己导出自己的特征就叫做左递归了。像这种情况左递归还是能避免的,但并不是所有的最递归都能直接避免的。虽然不能避免,但是仍然有一个通用的办法来解决,只不过或破坏一点点美感。

 

分析了LISP的表达式之后,我们进入下一个例子:分析四则运算式子。我们的四则运算式子由加减乘除、括号和数字构成。为了方便不考虑正负。使用语法规则是可以表达出操作符的优先级的。下面就让我们来思考如何构造四则运算式子的语法。

 

我们将一个表达式定义为Expression。首先,数字可以成为Expression,其次,加了括号的Expression仍然是Expression

Expression=<数字> | “(“ Expression “)”

但是这里有一个问题,操作符号的优先级并不能当纯通过写Expression=Expression “+” Expression来完成。因此我们进入进一步的思考。

 

我们考虑一下乘除先于加减背后的本质是什么。看一下一条比较长的表达式:

1*2*3+4*5*6+7*8*9

我们在计算的时候会把他们分成三个部分:1*2*34*5*67*8*9,分别计算出结果,然后相加。如果我们可以把仅仅由乘除组成的表达式的语法写出来,那么写出四则运算式子的语法也就有希望了。事实是可以的。于是我们要对之前的结果做一下调整。无论是数字或者是括号包含的表达式都不可能因为在旁边添加其他操作符而对优先级有所影响,因此我们抽象出一个类型叫Term

Term=<数字> | “(“ Expr “)”

然后我们就可以写一条只用乘除构成的表达式的语法了:

Factor=Term | Factor “*” Term | Factor “/” Term

最后,我们可以写出一条只用加减和Factor构成的表达式的语法:

Exp=Factor | Exp “+” Factor | Exp “-“ Factor

到了这里表达式的语法就大功告成了。上面的三条语法中的Exp就是四则运算的语法了。

 

我们注意到ExpFactor都是左递归的。在这里我介绍一种消除左递归的方法。我们考察一下语法Factor=Term | Factor “*” Term这一条。为了形象的表达出什么是Factor,我们反过来可以考察一下Factor究竟可以产生出什么样的东西来。

 

一个Factor可以产生出一个Term。然后,一个Factor可以变成Factor “*” Term。如果我们把Factor “*” Term中的Factor替换成已知的结果的话,那么我们可以得到一个结论:一个Factor可以产生出Term “*” Term。同理,我们又可以知道一个Factor可以产生出Term “*” Term “*” Term,为Factor可以产生出Term “*” Term。于是我们大概可以猜出解决左递归的方法:

 

假设存在如下表达式:

A=B1

A=Bn

A=A C1

A=A Cn

我们可以将这个语法修改为如下形式:

A’=C1 | C2 | … | Cn [A’]

A=(B1 | B2 | … | Bn) [A’]

我们可以看到现在的A没有发生变化,但是新的语法已经不存在左递归了。我们为了简化表达,可以引进一种新的语法:我们让X*代表XXX等等只由A组成的字符串或者空字符串,那么上面这个语法就可以被修改成A=(B1 | B2 | … | Bn) (C1 | C2 | … | Cn)*了。

 

于是,我们重新写一下四则运算式子的语法:

1)       Term=<数字> | “(“ Exp “)”

2)       Factor = Term ( ( “*” | “/” ) Term) *

3)       Exp = Factor ( ( “+” | “-“ ) Factor) *

 

我在这里仍然要写出四则运算分析的代码。但是这一次我不求值了,这个新的程序将把四则运算式子转换成等价的LISP表达式然后输出。

 

代码的结构是这样的。首先,仍然会存在上文中的函数Is。其次,表达式Expression的结构将被我替换成一个递归的二叉树,异常信息使用C++的异常处理机制实现。

 

在这里贴出GetTermGetFactor的代码,GetExpGetFactor结构相似。

 

 

Expression* GetTerm(char*& Stream);

Expression* GetFactor(char*& Stream);

Expression* GetExp(char*& Stream);

 

/*

检查Stream是否一个Term

*/

Expression* GetTerm(char*& Stream)

{

    try

    {

        return GetNumber(Stream);

    }   

    catch(Exception& e)

    {

        char* Read=Stream;

        /*检查左括号*/

        if(Is(Read,"("))

        {

            /*检查表达式*/

            Expression* Result=GetExp(Read);

            if(Is(Read,")"))

            {

                /*如果使用右括号结束则返回结果*/

                Stream=Read;

                return Result;

            }

            else

            {

                /*否则抛出异常*/

                delete Result;

                throw Exception(Stream,"此处需要右括号");

            }       

        }   

        else

        {

            throw e;

        }   

    }   

}   

 

/*

检查Stream是否一个Factor

*/

Expression* GetFactor(char*& Stream)

{

    /*获得一个Term*/

    char* Read=Stream;

    Expression* Result=GetTerm(Read);

    while(true)

    {

        /*检查接下来是否乘除号*/

        char Operator=0;

        if(Is(Read,"*"))

            Operator='*';

        else if(Is(Read,"/"))

            Operator='/';

        else

            break;

        if(Operator)

        {

            /*如果是乘除号则获得下一个Term*/

            try

            {

                Result=new Expression(Operator,Result,GetTerm(Read));

            }

            catch(Exception& e)

            {

                /*发生异常的时候,首先删除Result,其次转发异常*/

                delete Result;

                throw e;

            }       

        }   

    }

    Stream=Read;

    return Result;   

} 

完整的代码见文件夹Code_2_EXP2LISP

 

这份代码跟分析LISP表达式代码不同的是这里展示了给出树形结构而不仅仅是计算出结果的代码。这两种方法的区别仅仅是获得了数据之后如何处理的问题,但是代表了两种经常需要处理的任务。

 

四、尾声

 

这篇文章相比起以前的两篇正则表达式来的确是短了不少。递归下降法是一种适合人脑使用而不是电脑使用的方法。这种方法非常好用,所以大部分编译原理的教科书都会专门使用一个章节来说明递归下降的实现、局限性以及遇到的问题的解决方法。这篇文章不是理论文章,所以有一些本文没阐述到的问题可以通过人的智商来解决。

 

在语法处理过程中遇到的一个问题是出现异常的时候如何组织错误信息。在写编译器的时候我们并不能通过异常处理来向外传播异常信息,因为编译器需要输出许多异常。不过大部分分析工作还是仅仅需要第一个异常信息的。

 

第二个常见的问题是如何在发生异常的时候处理分析结果。在本文的第二个例子里面,在抛出异常之前总是会手动delete掉已经产生的指针。其实这样做是很容易漏掉一些处理从而造成内存泄漏的,如果读者使用C++的话,那么我推荐使用STLauto_ptr或者Boostsmart_ptr,或者干脆自己写吧。树型结构的文档通常不会有循环引用的问题,所以在这种情况下无论如何产生文档或者产生异常,使用auto_ptr或者smart_ptr都是没有问题的。

 

第三个问题是写不出语法。这个问题没有什么好的办法,只有通过练习来解决了。或者干脆做一个YACC出来,经过一次非常深入的思考也能获得很多经验。就像写出一手好的正则表达式的人,要么就是练习了很多次,要么就是写过正则表达式引擎一样。不过这种方法比较耗时间,不是非常有兴趣的读者们还是不要这么做的好。

 

最后说明一下,本文使用四则运算式子作为例子仅仅是为了方便。实际上分析四则运算狮子已经有各种各样的好方法了。但是读者们将来却很难遇到分析四则运算的工作,而是分析各种各样复杂字符串的工作。这个时候递归下降法起得作用是在代码还没开始写之前,就已经把思考不慎密导致的bug都消除了大半了。因为设计语法的过程很容易让人深入的思考问题。递归下降法能够用最快的速度从语法产生出代码,但是还是要根据实际情况调整细节。

 

本文作为《构造正则表达式引擎》一文的补充而出现,因为有一些朋友们反映在析正则表达式的结构以及合法性遇到了一些困难。因为正则表达式的语法跟四则运算很像,因此参考一下本文对这些朋友们来说可能会有帮助。


                                   参考:《词法分析》《正则表达式》

你可能感兴趣的:(分析)