2.2. 计算器

先暂时放下配置文件的例子。显然,许多类似的情况下都适合使用调度表。例如,处理用户输入的命令的对话式程序可以利用调度表来分派用户命令。接下来讨论一个完全不同的例子——一个非常简单的计算器。

这个计算器的输入项是算术表达式的字符串,用逆波兰式reverse Polish notation,简称RPN)表示。通常的算术记法会产生歧义。例如 2 + 3·4,无法准确判断应当先做加法还是先做乘法。必须指定特定规则,要求乘法必须先于加法运算,否则就只能通过增加括号的方法来消除歧义,如(2 + 3)·4。

逆波兰式用另一种方式解决了该问题。它并不是将操作符放在操作数之间,而是将它放在操作数之后。例如,2 + 3要写成2 3 +。(2 + 3)·4要写成2 3 + 4 *+23之后,表明要将2和3相加;*表明要将前两个表达式2 3 +4相乘。而2 + (3·4)用逆波兰式则写成2 3 4 * ++的操作对象是它之前的两个参数,第一个是2,第二个是3 4 *。操作符永远在操作数之后,因此这种形式称为后缀形式postfix form)。与此相对,通常的形式中操作符位于操作数之间,称为中缀形式infix form)。

计算逆波兰式表达式的值十分简单。只需使用一个栈,从左到右依次读取表达式。遇到数字时,就将它压入栈中。遇到操作符,就将栈顶的两个元素弹出并执行相应的操作,然后将结果压回栈中。例如,要计算2 3 + 4 *,首先压入2和3,下一个符号是+,因此要将2和3弹出,然后压入结果5。接下来,将4压入栈放在5的上边,下面的*表明要将4和5弹出并将最终结果20压入。要计算2 3 4 * +,首先依次压入2、3和4。*表明要将3和4弹出并压入乘积12,后面的+说明要弹出12和2,把最终结果14压入。

下面是这个小型计算器程序,需要计算的逆波兰式通过命令行参数指定:

my $result = evaluate($ARGV[0]);
print "Result: $result\n";

sub evaluate {
  my @stack;
  my ($expr) = @_;
  my @tokens = split /\s+/, $expr;
  for my $token (@tokens) {
    if ($token ~= /^\d+$/) { # 是数字
      push @stack, $token;
    } elsif ($token eq '+') {
      push @stack, pop(@stack) + pop(@stack);
    } elsif ($token eq '-') {
      my $s = pop(@stack);
      push @stack, pop(@stack) - $s
    } elsif ($token eq '*') {
      push @stack, pop(@stack) * pop(@stack);
    } elsif ($token eq '/') {
      my $s = pop(@stack);
      push @stack, pop(@stack) / $s
    } else {
      die "Unrecognized token '$token'; aborting";
    }
  }
  return pop(@stack);
}
			

该函数按照空格将参数分解成记号token)——有意义的最小输入单位。接下俩,函数从左到右依次循环每个记号。如果记号匹配/^\d+$/,说明是个数字,于是将其压入栈中。否则它就是个操作符,就要从栈中弹出两个值进行操作,再将结果压回栈中。代码中的辅助变量$s用于执行减法,因为5 3 -应该得到2而不是-2。如果执行:

push @stack, pop(@stack) - pop(@stack);
			

那么计算5 3 -时,第一个pop首先弹出3,第二个弹出5,结果就是-2了。同样的原因,除法的处理也是类似的。而乘法和加法则对操作数的顺序没有要求。

所有记号都处理完毕之后,该函数将栈顶的值弹出,作为最终结果。这段代码没有考虑栈中可能残留多个值的情况(即参数中含有多个表达式的情况),比如10 2 * 3 4 +会在栈中留下20和7。它也没有考虑栈为空的情况,例如2 *2 3 + *都是错误的表达式,因为*需要两个参数,但实际上却只有一个。计算这些表达式时,会遇到要进行操作,但栈为空的情况。此时应当报告错误,但为了简单起见,此处没有考虑错误处理。

使用调度表代替庞大的if-else分支,可以让程序更简单、更灵活。

my @stack;
my $actions = {
  '+' => sub { push @stack, pop(@stack) + pop(@stack) },
  '*' => sub { push @stack, pop(@stack) * pop(@stack) },
  '-' => sub { my $s = pop(@stack); push @stack, pop(@stack) - $s },
  '/' => sub { my $s = pop(@stack); push @stack, pop(@stack) / $s },
  'NUMBER' => sub { push @stack, $_[0] },
  '_DEFAULT_' => sub { die "Unrecognized token '$_[0]'; aborting" }
};

my $result = evaluate($ARGV[0], $actions);
print "Result: $result\n";

sub evaluate {
  my ($expr, $actions) = @_;
  my @tokens = split /\s+/, $expr;
  for my $token (@tokens) {
    my $type;
    if ($token ~= /^\d+$/) { # 是数字
      $type = 'NUMBER';
    }

    my $action = $actions->{$type}
              || $actions->{$token}
              || $actions->{_DEFAULT_};
    $action->($token, $type, $actions);
  }
  return pop(@stack);
}
			

现在该程序的主要处理evaluate()比以前更小、更通用了。如果记号有“类型”,就根据类型选择一个动作;否则,就需要根据记号本身的值来决定动作;如果没有该动作,就使用默认动作。evaluate()函数通过对记号进行模式匹配来判断记号的类型,如果记号像数字,那么类型就是NUMBER。只需给调度表%actions添加新的数据项,即可添加新的操作符:

...
'sqrt' => sub { push @stack, sqrt(pop(@stack)) },
...
			

同样,调度表的结构使得只需给计算程序传递不同的调度表,就可以实现不同的功能。上述例子是将表达式计算出一个数字的,但只要使用下面这个调度表,就可以将表达式编译成抽象语法树abstract syntax tree,缩写为AST):

my $actions = {
  'NUMBER'    => sub { push @stack, $_[0] },
  '_DEFAULT_' => sub { my $s = pop(@stack);
                       push @stack,
                          [ $_[0], pop(@stack), $s ]
                       },
};
			

2 3 + 4 *的编译结果为[ '*', [ '+', 2, 3 ], 4 ],如图 2.1 “表达式2 3 + 4 *的抽象语法树”所示。

图 2.1. 表达式2 3 + 4 *的抽象语法树

表达式2 3 + 4 *的抽象语法树

所有结构都直接表示出来,因此这是最常用的内部表达形式。表达式可以是个数,也可以是带有两个操作数的操作符;两个操作数也是表达式。抽象语法树可以是个数,也可以是一个操作符加上另外两个抽象语法树。有了抽象语法树,处理它的函数就呼之欲出了。例如,下面这个函数可将抽象语法树转换为字符串:

sub AST_to_string {
  my ($tree) = @_;
  if (ref $tree) {
    my ($op, $a1, $a2) = @$tree;
    my ($s1, $s2) = (AST_to_string($a1),
                     AST_to_string($a2));
    "($s1 $op $s2)";
  } else {
    $tree;
  }
}
			

对于图 2.1 “表达式2 3 + 4 *的抽象语法树”AST_to_string()函数的生成结果是"((2 + 3) * 4)"。函数首先检查树是否为复杂结构,如果不是引用,就肯定是个数字,那么相应的字符串就是这个数字。否则,字符串应当由三部分组成:表示操作符的符号(保存在$op中),以及另外两个抽象语法树。函数用递归调用将这两个树转换为字符串$s1$s2,然后在$s1$s2之间加入适当的操作符符号以组成字符串,并在两端加上括号以避免歧义。这就是个能将后缀表示法转换成中缀表示法的程序:给evaluate()一个后缀表达式,就能生成抽象语法树,然后将抽象语法树传给AST_to_string()来生成中缀表达式。

表达式的递归结构决定了抽象语法树的递归性,而抽象语法树的递归性又决定了AST_to_string()函数也是递归的。AST_to_string()函数的结构直接反映了表达式的结构。

2.2.1. 重新思考HTML处理

第 1.7 节 “HTML”中讨论了递归的HTML处理程序walk_html()。HTML处理程序需要两个函数参数:用于处理不带标签的文本的$textfunc函数,和用于处理HTML元素的$elementfunc函数。但“HTML元素”这个概念很模糊,因为元素多种多样,而我们需要对每种标签执行不同的处理。

我们已经知道有几种做法。最直接的就是让使用者在$elementfunc中写一个巨大的if-else分支。如前所述,这种方法有很大缺点。使用者也可以为$elementfunc提供一个调度表。调度表的结构显而易见:表的键就是标签名,值就是处理每个标签的动作。这样就无需让$elementfunc这一个函数去处理每种可能的元素,而只需在调度表中,为每种元素准备一个动作即可,外加一个将元素分派给适当动作的通用$elementfunc函数。

$elementfunc访问调度表有几种方式。调度表可以直接写在处理元素的函数中:

sub elementfunc {
  my $table = { h1        => sub { shift; my $text = join '', @_;
                                   print $text; return $text ;
                                 },
                _DEFAULT_ => sub { shift; my $text = join '', @_;
                                   return $text ;
                                 },
              };
  my ($element) = @_;
  my $tag = $element->{_tag};
  my $action = $table->{$tag} || $table{_DEFAULT_};
  return $action->(@_);
}
				

或者,也可以直接让walk_html()支持调度表,这样就不用传递$elementfunc,而是直接把调度表传给walk_html()。此时walk_html()函数如下所示:

sub walk_html {
  my ($html, $textfunc, $elementfunc_table) = @_;
  return $textfunc->($html) unless ref $html; # 纯文本字符串的情况
  my ($item, @results);
  for $item (@{$html->{_content}}) {
    push @results, walk_html($item, $textfunc, $elementfunc_table);
  }
  my $tag = $html->{_tag};
  my $elementfunc = $elementfunc_table->{$tag}
                 || $elementfunc_table->{_DEFAULT_}
                 || die "No function defined for tag '$tag'";
  return $elementfunc->($html, @results);
}
				

另一种选择是让walk_html()$textfunc$elementfunc传递一个用户自定义参数。这样,通过自定义参数,就可以将调度表传递给$elementfunc

sub walk_html {
  my ($html, $textfunc, $elementfunc, $userparam) = @_;
  return $textfunc->($html, $userparam) unless ref $html;
  my ($item, @results);
  for $item (@{$html->{_content}}) {
    push @results, walk_html($item, $textfunc, $elementfunc, $userparam);
  }
  return $elementfunc->($html, $userparam, @results);
}
				

这样,使用者就可以自由地设计处理调度表的$elementfunc函数了。

这里有个细节很重要。自定义参数在传给$elementfunc的同时也传给了$textfunc。如果自定义参数是标签的调度表,那$textfunc就完全用不到它。那为什么还要传给它呢?因为自定义参数可能不是调度表。例如,用户可以这样调用walk_html()

walk_html($html_text,

          # $textfunc
          sub { my ($text, $aref) = @_;
                push @$aref, $text },

          # $elementfunc does nothing
          sub { },

          # user parameter
          \@text_array
         );
				

此时walk_html()就会遍历HTML树,并将所有不带标签的纯文本放进@text_array数组中。自定义参数是@text_array的引用,传给$textfunc后,该函数就将文本存进该引用指向的数组。$elementfunc则完全不使用该参数。作为walk_html()的作者,我们无法预知使用者需要哪种类型的自定义参数,因此最好将它同时传给$textfunc$elementfunc,这样不需要该参数的函数只需忽略它即可。