(基于Java)编写编译器和解释器-第6章:解释执行表达式和赋值语句(连载)

时间:2022-08-28 17:09:33

在上一章,你已为复合语句、赋值语句和表达式开发出分析树格式的中间码。在这章,你将为执行这些语句和表达式在后端编写代码去解释生成的分析树。

==>> 本章中文版源代码下载:svn co http://wci.googlecode.com/svn/branches/ch6/ 源代码使用了UTF-8编码,下载到本地请修改!

方法和目标

本章从上一章结束的地方开始,它有一个主要目的:

  • 后端的语言无关的执行器(Executor)将会解释中间码以便执行表达式、复合语句以及赋值语句。

上一章中,你开发了前端的Pascal解析器子类StatementParser和孙子类CompoundStatementParser、AssignmentStatementParser以及ExpressionParser,用来解析Pascal语句和表达式。本章的解释器后端开发执行器子类将会采用同样模式。然而这些执行器之类将会是语言无关的(只与分析树有关)。本章末,为验证你的开发工作,有个简单的解释器实用程序将读取和执行Pascal赋值语句和表达式。

运行时错误处理

frontend.pascal包中的Pascal相关的解析程序实用了错误处理类PascalErrorHandler和枚举类型PascalErrorCode。类似地,backend.interpreter包中的语言无关解释器使用运行时错误处理类RuntimeErrorHandler和枚举类型RuntimeErrorCode。

清单6-1 展示了包backend.interpreter中RuntimeErrorHandler类的flag方法。详细参见本章源代码,这里不再显示。任何时候解释器检测到一个运行时错误就把它发给后端的所有监听器。我们约定此错误消息格式为:

  • errorCode.toString():运行时错误消息
  • node.getAttribute(LINE):源代码行位置

方法flag()创建这些消息(错误消息)。它顺着传递的当前分析子树节点往上搜寻最近的带"LINE"(行)属性节点。这个行将会是造成此次运行时错误的源语句。

清单6-2 展示了枚举类型RuntimeErrorCode。枚举值表示解释器能够检测到的运行时错误。详细参见本章源代码,这里不再显示。

执行赋值语句和表达式

在第五章已解析过Pascal表达式、赋值语句和复合语句并生成语言无关的分析树格式的中间码。为执行这些语句和表达式,马上你将会编写后端语言无关类实现用于解释这些分析树。

语句执行器子类

图6-1 展示了backend.interpreter.executors包中执行器的UML类图。你在第二章就见过的类Executor,现在是类StatementExecutor的基类,而StatementExecutor又是CompoundExecutor和ExpressionExecutor的基类。比较一下这幅图和图5-7

(基于Java)编写编译器和解释器-第6章:解释执行表达式和赋值语句(连载)

设计笔记

设计良好的软件时常显示出结构对称。第二章的图2-3展示了解析器和后端框架类对于中间码和符号表等组件的对称性。图5-7和图6-1 展示了PascalParserTD及其它的子类和类Executor及其子类的对称性。

类StatementExecutor和每个子类都有一个execute()方法专门用来以解释某种具体语言结构分析树的方式执行此语言相关结构。除了类ExpressionExecutor的返回值为表达式的计算值,它总是返回null值。因为中间码和符号表都与具体语言无关,自然StatementExecutor及其子类也是语言无关的。(因为在本书中你将只解释Pascal程序,你的Executor子类具有Pascal倾向性。然而后端要达到的其余目标是能解释不只一门源语言)

清单6-3 展示了Executor类的新构造函数和新版本的process()方法。

public class Executor extends Backend
{
//执行的语句数
protected static int executionCount = 0;
//错误处理类
protected static RuntimeErrorHandler errorHandler = new RuntimeErrorHandler();
//本章的parent继承主要是共享此Executor的两个static域,真无聊的对称!
public Executor(Executor parent){
super();
}
public void process(ICode iCode, SymTabStack stack)
throws Exception
{
this.iCode = iCode;
this.symTabStack = stack;
long startTime = System.currentTimeMillis();
ICodeNode root_node = this.iCode.getRoot();
StatementExecutor root_executor = new StatementExecutor(this);
root_executor.execute(root_node);
float elapsedTime = (System.currentTimeMillis() - startTime)/1000f;
int runtimeErrors = errorHandler.getErrorCount();
// 发送解释摘要消息省略...
}
}

 

 

所有Executor的子类实例通过新构造函数。process()方法取得前端解析器生成的中间码的根节点并调用statementExecutor.execute()执行(相当于从根节点往下遍历,跟第5A章的Antlr一样)。

执行语句

清单6-4 展示了执行器子类StatementExecutor的构造函数和execute()方法。一同展示的还有它的私有sendSourceLineMessage()方法。详细参见本章源代码,这里不再显示。

execute()方法首先调用sendSourceLineMessage()发送一条SOURCE_LINE消息给语句执行器的监听器。我们约定SOURCE_LINE消息的格式如下:

  • node.getAtrribute(LINE):源代码行位置

此消息指明将被执行语句的源代码行位置,此位置对于调试器实现跟踪可能非常有用。目前前端将忽略这消息。

消息发送后,execute()方法视传入的节点是否COMPOUND,ASSIGN或NO_OP类型,分别调用compoundExecutor.execute(),assignmentExecutor.execute()或什么也不干,如果节点是其它类型,方法标记一个UNIMPLEMENTED_FAILURE运行时错误。后面章节在你要执行更多其它类型语句的时候,此方法将会被修改。

  执行复合语句

清单6-5 展示了子类CompoundExecutor的execute()方法。此方法遍历COMPOUND节点的每一个子节点并调用相应的statementExecutor.execute()。

   1: public Object execute(ICodeNode node)
   2: {
   3:     // 遍历每个子节点并执行之
   4:     StatementExecutor statementExecutor = new StatementExecutor(this);
   5:     List<ICodeNode> children = node.getChildren();
   6:     for (ICodeNode child : children) {
   7:         statementExecutor.execute(child);
   8:     }
   9:     return null;
  10: }

  执行赋值语句

清单6-6 展示了执行器子类AssignmentExecutor的execute()和sendMessage()方法。详细参见本章源代码,这里不再显示。execute()方法取得ASSIGN节点的两个子节点,第一个是赋值语句的目标变量VARIABLE节点,第二个是表达式子树的根节点,它调用方法expressionExecutor.execute()取得表达式的计算值,并把这个值设置为目标变量符号表项的DATA_VALUE属性值。execute()方法调用sendMessage()发送赋值消息给执行器的监听器。

我们约定的赋值消息格式如下:

  • node.getAttribute(LINE):源行位置
  • variableName:目标变量名
  • value:表达式的值

此消息对于调试和跟踪很有用。

  执行表达式

清单6-7 展示了执行器子类ExepressionExecutor的execute()方法。

   1: //计算表达式的值
   2: public Object execute(ICodeNode node)
   3: {
   4:     ICodeNodeTypeImpl nodeType = (ICodeNodeTypeImpl) node.getType();
   5:     switch (nodeType) {
   6:         case VARIABLE: {
   7:             SymTabEntry entry = (SymTabEntry) node.getAttribute(ID);
   8:             return entry.getAttribute(DATA_VALUE);
   9:         }
  10:         //三中常量节点,直接返回值
  11:         case INTEGER_CONSTANT: {
  12:             return (Integer) node.getAttribute(VALUE);
  13:         }
  14:         case REAL_CONSTANT: {
  15:             return (Float) node.getAttribute(VALUE);
  16:         }
  17:         case STRING_CONSTANT: {
  18:             return (String) node.getAttribute(VALUE);
  19:         }
  20:         case NEGATE: {//“负”节点只有一个子节点且其值为数值
  21:             List<ICodeNode> children = node.getChildren();
  22:             ICodeNode expressionNode = children.get(0);
  23:             Object value = execute(expressionNode);
  24:             if (value instanceof Integer) {
  25:                 return -((Integer) value);
  26:             }
  27:             else {
  28:                 return -((Float) value);
  29:             }
  30:         }
  31:         case NOT: {//“非"节点也只有一个节点且其值为布尔值
  32:             List<ICodeNode> children = node.getChildren();
  33:             ICodeNode expressionNode = children.get(0);
  34:             boolean value = (Boolean) execute(expressionNode);
  35:             return !value;
  36:         }
  37:         //二元操作符比如加+减-乘*除/之类
  38:         default: return executeBinaryOperator(node, nodeType);
  39:     }
  40: }

  操作符优先级

语法图5-2包含了Pascal的操作符优先级规则。前端类ExpressionParser构建表达式子树反映这些语法图示(我们看语法图,上面的规则对应的是分析树的深度较小的节点,下面的规则对应的深度较大的节点),因此如果表达式执行器以合适的顺序计算树节点的值,那么程序执行过程中的优先级规则就水到渠成了。(比如按照中序/后序遍历,总是会先遍历深度较大的节点,也就是下面一点的规则,即优先级高的规则,那么遍历下来,自然是从优先级高到优先级低

一个表达式子树是一个二叉树,表达式执行器必须后序遍历(你还记得前序/中序/后序遍历二叉树么?不记得找度娘)这些节点去计算它们。如果子树的根是一个操作符节点(比如+/-之类),执行器首先计算左节点的值作为第一个操作数;如果此节点有一个右孩子,执行器计算右子节点的值作为第二个操作数。最后执行器分析操作符节点并针对一个或两个操作数做具体的运算。如果任一子节点是某一子树的根节点,执行器递归的对这个子树做一个后续遍历。

图6-2 展示了如下表达式的分析树。节点上的数字是遍历的顺序。

alpha + 3/(beta - gamma) + 5
 
图6-2:带节点计算顺序的表达式分析树展示
(基于Java)编写编译器和解释器-第6章:解释执行表达式和赋值语句(连载) 操作数的值
 
如果传入的节点是一个VARIABLE(变量)节点,execute()方法查找节点对应的符号表项,将表项的VALUE属性值作为变量的值返回;如果节点是一个INTEGER_CONSTANT节点或REAL_CONSTANT节点(整数或浮点数),此方法从节点的VALUE属性取得对应的整数或浮点数值。如果节点是STRING_CONSTANT类型,方法直接返回节点的字符串值。
 
如果节点是一个NEGATE节点(取反),execute()方法取得此节点的表达式子节点子树,递归调用自己计算式的值并返回此值的相反数。依次类推,如果节点是NOT类型,方法取得节点的表达式子节点子树,调用自己去计算表达式的值并将值的非值 (true->false, false->true) 返回。

如果节点是一个二元操作符节点,execute()方法调用executeBinaryOperator()方法并返回计算后的值。参见清单6-8。executeBinaryOperator()方法使用静态的枚举集合ARITH_OPS,此集合包含表示算术运算符的所有节点类型。这个方法首先递归的调用execute(),计算此节点的两个孩子节点并将值作为第一个和第二个操作数。它设置局部变量integerMode为true当且仅当两个操作数都是整数。
 
 
 
 
 
 
 
 
 
清单6-8:类ExpressionExecutor的executeBinaryOperator()方法。 详细参见本章源代码,这里不再显示。 这里将二元运算分为整数运算、浮点数运算、布尔运算和关系运算。

  整数算术运算

如果传入的节点是一个算术运算符节点并且局部变量integerMode值为true,那么方法executeBinaryOperator执行整数计算,但对于FLOAT_DIVIDE操作符节点来说它执行浮点运算。
对于ADD(加),SUBSTRACT(减),MULTIPLY(乘)操作符节点来说,此方法简单针对两个整数操作数做相应计算并返回整数结果;对于FLOAT_DIVIDE、INTEGER_DIVIDE和MOD 操作符节点来说,这个方法得检查第二个操作数的值不为零才能执行除运算,否则标记DIVISON_BY_ERROR运行时错误。如果是FLOAT_DIVIDE节点,此方法为执行浮点除法,必须将整数操作数转化成浮点数。

  浮点算术运算

如果传入的节点是一个算术操作符,但是局部变量integerMode值为false,那么executeBinaryOperator()必须执行浮点运算。它先将任何整数操作数转化能成为浮点数。
对于ADD(加),SUBSTRACT(减),MULTIPLY(乘)操作符节点来说,此方法简单针对两个浮点操作数做相应计算并返回浮点结果;对于FLOAT_DIVIDE操作符节点,这个方法检查第二个操作数的值不为零才能执行除运算,否则标记DIVISON_BY_ERROR运行时错误。

  与和或运算

如果方法executeBinaryOperator()接收到AND(与)或者OR(或)操作符节点,那么方法针对两个布尔操作数执行相应布尔计算并返回结果。
关系运算
如果方法executeBinaryOperator()接收到关系操作符节点,它视局部变量integerMode的值为true/false的情况来比较两个整数操作数还是浮点数操作数,如果是后一种情况,方法得先把任一整数操作数转成浮点数。最后方法返回的是布尔结果。

设计笔记

类ExpressionExecutor不做语言相关的类型检测。它依赖前端语言相关的解析器做类型检查并构建一个有效的分析树。这个类同样不必关心语言相关的操作符优先级规则。还有,它依赖前端构建合适的分析树,通过后序遍历树的方式执行。

程序6:简单解释器 I

你想必已经准备好为你在本章和上一章开发的新代码做一个端对端的测试了。这个测试将会解析在清单5-12中看到的文件assignments.txt,生成解析树并解释之以便执行文件中语句,最后打印出结果。
第一步要修改主程序Pascal类,允许它监听新的运行时消息ASSIGN和RUNTIME_ERROR。清单6-9 展示了内部类BackendMessageListener两种新的情况。
   1: case ASSIGN: {
   2:     if (firstOutputMessage) {
   3:         System.out.println("\n===== 运行时输出 =====\n");
   4:         firstOutputMessage = false;
   5:     }
   6:  
   7:     Object body[] = (Object[]) message.getBody();
   8:     int lineNumber = (Integer) body[0];
   9:     String variableName = (String) body[1];
  10:     Object value = body[2];
  11:  
  12:     System.out.printf(ASSIGN_FORMAT,
  13:                       lineNumber, variableName, value);
  14:     break;
  15: }
  16:  
  17: case RUNTIME_ERROR: {
  18:     Object body[] = (Object []) message.getBody();
  19:     String errorMessage = (String) body[0];
  20:     Integer lineNumber = (Integer) body[1];
  21:  
  22:     System.out.print("*** 运行时错误");
  23:     if (lineNumber != null) {
  24:         System.out.printf(" 在第  %03d行", lineNumber);
  25:     }
  26:     System.out.println(": " + errorMessage);
  27:     break;
  28: }
  29: se INTERPRETER_SUMMARY: {
  30:   Number body[] = (Number[]) message.getBody();
  31:   //省略...
对于ASSIGN消息的消息使用了格式字串:
private static final String ASSIGN_FORMAT = ">>> 行 %03d处: %s = %s\n";
执行Eclipse中的命令"execute assignments.txt" 将解析并解释assignments.txt文件中的“程序”( 这个程序不正规,为本章需要)。清单6-10 展示了运行输出。解释器捕捉并标记了一个除零的运行时错误。解释器来自后端的摘要消息现在包含了实际点的执行语句条数和总的执行时间。
清单6-10:解析解释输出
----------代码解析统计信息--------------
源文件共有 27行。
有 0个语法错误.
解析共耗费 0.02秒.

===== 运行时输出 =====

>>> 行 003处: five = 5
>>> 行 004处: ratio = 0.5555556
>>> 行 006处: fahrenheit = 72
>>> 行 007处: centigrade = 22.222223
>>> 行 009处: centigrade = 25
>>> 行 010处: fahrenheit = 77.0
>>> 行 012处: centigrade = 25
>>> 行 013处: fahrenheit = 77.0
*** 运行时错误 在第 017行: 除零操作
>>> 行 017处: dze = 0.0
>>> 行 020处: number = 2
>>> 行 021处: root = 2
>>> 行 022处: root = 1.5
>>> 行 025处: ch = x
>>> 行 026处: str = hello, world

----------解释统计信息------------
共执行 14 条语句。
运行中发生了 1 个错误。
执行共耗费 0.01 秒。

设计笔记

第6章的小伎俩(Hacks)

这章依赖几个临时的“伎俩”(hacks),使得你的初级解释器运行的起来:

  1. 所有变量都是标量(scalar,没有记录record或数组array)实则没有声明的类型(declared type)。在第9章前你不会解析类型申明。
  2. 解析器解析到赋值语句时,如果它的目标变量(即左边的变量)是首次碰到,就认定变量是“申明”过的,并把这个变量放入符号表。第9章你将修正这个问题。(修正为变量使用之前必须要做声明
  3. 符号表堆栈中还是只有一个符号表。在第9章和第11章你会碰到需要多个符号表的情况。
  4. 后端执行器假定前端已经做过类型检查了,中间码肯定是OK的。但因为你还没有解析过变量声明,所以没有类型检查。第10章你将实现类型检查。
  5. 在运行过程中,每个赋值语句将表达式值存到目标变量的符号表项中。这对于像Pascal之类支持递归过程和函数调用的语言来说不可行。在第12章中你将实现运行时内存管理。

随着开发的深入,你将逐渐用实现取代这些小伎俩。

你已成功为两阶段(two-pass)解释器的编写开了个头。第一阶段,前端解析源文件生成中间码;第二阶段,后端解释中间码,执行源程序的语句和表达式。
下一章中,你将解析Pascal控制语句并生成对应的中间码。