LET'S BUILD A COMPILER!(4)

来源:互联网 发布:java 释放mysql连接池 编辑:程序博客网 时间:2024/04/30 08:09
                                 LET'S BUILD A COMPILER

                                                       By

                                       Jack W. Crenshaw, Ph.D.

                                        第三部分:再论表达式

 

介绍

 

在上一章里,我们试验了分析和翻译一个一般数学表达式的各种技术。最后我们完成了一个简单的分析器,它能够处理符合以下两种条件的任意复杂的表达式:

  o表达式中无变量,只有数字组成的factor

  o数字组成的factor仅限于单个数字字符

这一章里,我们将取消上面的限制。并且还要对分析器进行扩展,使之能处理包含函数调用的情况。要记住的是,上面提到的第二条限制主要是我们自己强加上去的……这是为了处理上的方便,也为了能让我们把注意力集中在基础概念上。稍后你就会看到,这条限制也是很容易消除的,所以现在我们不必拘泥于这点上。

 

变量

在实际问题中,我们看到的绝大多数表达式都包含变量,比如

               b * b + 4 * a * c

 不能处理变量就不能算是个好的分析器。幸好,要做到这点也是很容易的。<factor> ::= <number> | (<expression>)

|’符号表示“或”的关系,也即它们中任一种形式都是合法的factor。同时,要分清表达式中出现的到底是哪种形式也并不困难的一种情况下的先行字符是左括号‘(’,另一种情况下先行字符是数字字符。

如果我们把变量看作另一种形式的factor,你应不会感到惊讶吧?于是上面的BNF产生式可扩展为:

   <factor> ::= <number> | (<expression>) | <variable>

 这次扩展不会产生任何二义性:假如先行字符是个字母字符,得到的是变量;假如先行字符是个数字字符,得到就是数字。回想前面处理数字的时候,我们将其作为立即数,生成的代码直接将其放进D0寄存器。现在我们用同样的方法处理变量,只是放到D0中的是个变量而已。

代码生成方面稍微复杂一点的地方是,绝大多数的68000操作系统,包括我用的SK*DOS,都要求目标代码写成“位置无关”的形式,大致的意思就是所有的东西都是与PC(程序计数器――译者注)寄存器相关的。所以装载变量的指令就应写成

MOVE X(PC),D0
X
代表变量名。根据上面的结果,我们改写Factor过程:

 {---------------------------------------------------------------}

{ Parse and Translate a Math Factor }

 

procedure Expression; Forward;

 

procedure Factor;

begin

   if Look = '(' then begin

      Match('(');

      Expression;

      Match(')');

      end

   else if IsAlpha(Look) then

      EmitLn('MOVE ' + GetName + '(PC),D0')

   else

      EmitLn('MOVE #' + GetNum + ',D0');

end;

{--------------------------------------------------------------}

 前面我提到过,由于我们设计的分析器其自身结构优良,对分析器的扩展是很容易的。这一节里上面的结论依然成立。这次我们只增添了两条代码。同时注意一下if-else-else结构是如何与BNF语法相匹配的。OK,编译并测试这个新版本。

 

函数

还有一种因子,是绝大数的编程语言都支持的,这就是函数调用。不过现在就想很好地处理函数问题还为时尚早,因为我们还没谈到参数传递问题。另一个原因是,在一种实际的编程语言中,标识符一般都不止代表一种类型,而其中一种就是函数。到现在为止虽然还没涉及到对函数的处理,但我仍然坚持现在就分析表达式中的函数标识符。原因有两点,一是这样做能让分析器接近其最终的形式,二是处理函数标识符将引出一个新的非常值得一提的话题。

目前我们的分析器可以称为“预言性分析器”。意思是在任何时刻,都可以通过查看先行字符准确地知道下一步该做什么。但是当我们加进对函数的处理后情况就不同了。每种语言都有一套标识符的命名机制。现在我们的命名机制是将标识符简单地规定为字母“a”到“z”。问题就在于变量名和函数名都遵从相同的规则。那么我们怎么知道一个标识符什么时候是变量,什么时候又是函数呢?一种解决办法是要求在使用变量或函数前都声明它们。Pascal就是采用的这种方法。另一种办法是我们可以要求在函数名后加一个参数列表(参数表可为空)。这是C语言使用的方法。

因为还没有规定声明变量的机制,所以现在我们使用C中的方法。又因为还没有处理参数的机制,所以只能用空参数表。因此我们的函数调用形式是这样的:

                    x()  .

由于不用处理参数表,所以只要生成一条BSR指令(意为函数调用)就可以了。

现在,Factor过程中的“If IsAlpha”分支测试就有两种可能,我们在一个单独的过程中处理这两种情况。修改Factor如下:

{---------------------------------------------------------------}

{ Parse and Translate a Math Factor }

procedure Expression; Forward;

procedure Factor;

begin

   if Look = '(' then begin

      Match('(');

      Expression;

      Match(')');

      end

   else if IsAlpha(Look) then

      Ident

   else

      EmitLn('MOVE #' + GetNum + ',D0');

end;

{--------------------------------------------------------------}

 再把下面的新过程插入到Factor过程之前。

{---------------------------------------------------------------}

{ Parse and Translate an Identifier }

procedure Ident;

var Name: char;

begin

   Name := GetName;

   if Look = '(' then begin

      Match('(');

      Match(')');

      EmitLn('BSR ' + Name);

      end

   else

      EmitLn('MOVE ' + Name + '(PC),D0')

end;

{---------------------------------------------------------------}

 OK,编译运行新版本的分析器。看它能分析出所有的合法表达式吗?能对非法表达式给出错误信息吗?

有个重要的地方需要注意,即使我们不再拥有一个预言性的分析器了,但是并没有增加递归下降法的复杂度。当Factor过程发现一个标识符(也就是一个字母)时,Facotr并不知道那是个变量名还是函数名,但它不管这些。它将标识符传递给Ident过程,由Ident指出标识符的类型。再来看看Ident过程,它将标识符先保存起来,然后读取下一输入字符以判断正在处理的是哪种标识符。

记住上述方法。它非常强大。不论什么时候,当你遇到有多种可能性,需要向前查看输入时,都可以使用。即使你不得不向前查看好几个符号,此方法仍然适用。

 

再论错误处理

当我们谈到编译器的结构时,就会引出另一个重要话题:错误处理。请注意虽然我们现在的分析器能拒绝(几乎)所有的非法表达式,并给出有意义的出错信息,其实我们并没有在这方面做很多工作。实际上,在整个编译器中,只有两处调用了错误处理过程。即使如此,它们仍然是不必要的……假如你回头看看TermExpression过程,你就会发现那些调用错误处理过程的语句其实是无法被执行到的。早些时候我把它们放在程序里只是为了保险起见,但现在不需要了。那么现在何不删掉它们呢?

如何才能得到漂亮的错误处理而不用付出额外的代价呢?那也很简单,我只是避免直接用GetChar来读取字符。取而代之,我把错误处理都放在GetNameGetNumMatch中,让它们完成所有的错误检测。聪明的读者会注意到对Match的有些调用(比如,在AddSubtract中的调用)是不必要的……当我们到达对Match的调用处时,其实已经知道了读到的是哪个字符……但是这样做能保持程序的对称性,而且使用Match也有利于保持不直接调用的GetChar的惯例。

还有个问题,我们还没有告诉分析器一行结束的标志是什么,以及表达式中嵌入的空白字符该如何处理。所以空白字符(或者任何其他不属于分析器字符集的字符)都会让分析过程中止。

看看下面的例子:

               1+2 <space> 3+4

我们的处理方法是略过空白字符,并规定表达式是以回车符为结束标志的。为处理表达式结束的问题,我们在Expression过程的结尾加上如下一行:               if Look <> CR then Expected('Newline');

最后不要忘了在常量定义中加上CR^ M

 

赋值语句

OK,目前我们的分析器已经非常不错了。我们只用了88行代码,就实现了那么多功能。

当然,如果分析完表达式后没有后继工作,那我们的分析就没有多少意义了。表达式通常(但不是任何时候)出现在赋值语句中,形式如下:

          <Ident> = <Expression>

我们离分析一个完整的赋值语句已经很接近了,下面就完成这最后一步吧。其实只需要在Expression过程后再加上下面的一个新过程:

{--------------------------------------------------------------}

{ Parse and Translate an Assignment Statement }

procedure Assignment;

var Name: char;

begin

   Name := GetName;

   Match('=');

   Expression;

   EmitLn('LEA ' + Name + '(PC),A0');

   EmitLn('MOVE D0,(A0)')

end;

{--------------------------------------------------------------}

请再次注意上面的代码与BNF产生式是匹配如何的。并且出错处理也没有用到任何额外的代码,仍旧是用的GetNameMatch过程。

由于68000处理器的规定,所以上面产生的两条汇编代码要写成与PC寄存器相关的形式。

好,现在是在Assignment过程中调用Expression过程,而不再在主程序中调用。

Son of a gun!我们已经能翻译赋值语句了。如果赋值语句是某种编程语言的唯一语句形式,那么只要把Assignment过程放到一个循环中,一个完整的编译器就算完成了!

然而事实上,赋值语句并不是唯一的语句形式,还有控制语句(如IF语句和循环语句),过程,声明等。但值得庆祝的是,我们前面完成的对数学表达式的处理已经是语言的编译中最富挑战性的工作之一了。和我们已做的工作比较,控制语句的翻译更加容易。我将在第五章讲解它们。对其他语句的处理也是相仿的,只要记住KISS原则就行了。

多字符Token

在本节之前我一直小心翼翼地局限于分析单字符token,并且保证如果要扩展到分析多字符token也不会很困难。我不知道你是否相信这点。下面我要继续使用前面多次使用的方法来完成这个扩展,因为它会继续让我们的工作简单容易。在开始之前,请用另一个文件名保存目前的这个版本。下一章中我们还会用到这个基于单字符token的版本。

大多数的编译器都将对处理输入流的处理分割成一个单独的模块,称为词法扫描。其想法是这样的,扫描器接收并分析一个字符接一个字符的输入,然后返回独立的记号单元(也就是token)。也许以后我们也需要这么做,但是现在好像还没这个必要。我们只要对GetName和GetNum过程做些小小的修改就可以完成对多字符token的处理了。

一般都把标示符定义成以字母开始,但是其后可以接字母也可以接数字字符。基于此,我们需要一个新的函数来识别标识符:

{--------------------------------------------------------------}
{ Recognize an Alphanumeric }

function IsAlNum(c: char): boolean;
begin
   IsAlNum := IsAlpha(c) or IsDigit(c);
end;
{--------------------------------------------------------------}

把它加入你的分析器中。我是把它放到IsDigit过程后面的。

现在,修改函数GetName,让它返回一个字符串而不是一个字符:
{--------------------------------------------------------------}
{ Get an Identifier }

function GetName: string;
var Token: string;
begin
   Token := '';
   if not IsAlpha(Look) then Expected('Name');
   while IsAlNum(Look) do begin
      Token := Token + UpCase(Look);
      GetChar;
   end;
   GetName := Token;
end;
{--------------------------------------------------------------}


相识地,修改GetNum过程:

{--------------------------------------------------------------}
{ Get a Number }

function GetNum: string;
var Value: string;
begin
   Value := '';
   if not IsDigit(Look) then Expected('Integer');
   while IsDigit(Look) do begin
      Value := Value + Look;
      GetChar;
   end;
   GetNum := Value;
end;
{--------------------------------------------------------------}

令人吃惊,这就是我们要做的全部改动!Ident和Assignment过程中的局部变量原先是被声明为“char”类型的,现在必须改为string[8]。(也可以把标识符的长度定义得更长,但是终究有个限度)。好,重新编译并测试分析器。现在你相信这个修改是很简单的了吗?