如何手写语法分析器
陈梓瀚
华南理工大学软件05级本科
vczh@163.com
http://www.cppblog.com/vczh/
在写可配置的语法分析器之前,我觉得还是先说说如何手写语法分析器好。因为对于大部分人来说,开发一个可配置的语法分析器并没有什么作用,反而针对某种特定的语法开发特定的语法分析器是特别有必要的。典型的有表达式计算器、某种格式化的文件(HTML、XML等)或者是其他的复杂而且符合树型结构的字符串。根据目前论坛的反应来看,有一些朋友们对如何开发一套自己的脚本引擎比较感兴趣。等基础的文章都写完以后我会考虑撰写一个系列的文章介绍如何开发自己的脚本引擎。
这篇文章会附带一些必要的代码以便帮助读者们理解。为了方便,代码使用DevC++开发。
一、定义语法
在开发语法分析器之前,有必要讲一下语法的定义。这篇文章给出的是一个比较机械化的方法,掌握了这个方法之后手写语法分析器会变成一件没什么挑战性但是很麻烦的工作。因为设计起来太简单,但是代码很多。有些人为了连麻烦的工作也不要会去开发可配置的语法分析器。不过这里先不管这么多,首先给出一个比较使用的语法。
我们考虑一个经常从书上或者经常见到的例子:LISP语言。这门语言的表达式相当奇怪,操作符基本上当成函数处理,而且强迫用户给出优先级。因为LISP的操作符是没有优先级的。譬如(1+2)*(3+4)在LISP会被写成(* (+ 1 2) (+ 3 4) )。
让我们看一下这种语法的结构。括号内可以写很多个值,第一个被约定为是函数,之后的是参数。参数的个数是不确定的。一个函数调用的结果仍然是值,这就允许表达式进行嵌套。一个复杂一点的例子:2sinxcosx在LISP内被写成(* 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 “)”
这样写的话觉得很烦,我们可以追加多两种定义语法的语法:
1、A | 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,"("))
{
/*不是数字而是左括号,则将Result的Error清*/
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,我也写了一个