先暂时放下配置文件的例子。显然,许多类似的情况下都适合使用调度表。例如,处理用户输入的命令的对话式程序可以利用调度表来分派用户命令。接下来讨论一个完全不同的例子——一个非常简单的计算器。
这个计算器的输入项是算术表达式的字符串,用逆波兰式(reverse Polish notation,简称RPN)表示。通常的算术记法会产生歧义。例如 2 + 3·4,无法准确判断应当先做加法还是先做乘法。必须指定特定规则,要求乘法必须先于加法运算,否则就只能通过增加括号的方法来消除歧义,如(2 + 3)·4。
逆波兰式用另一种方式解决了该问题。它并不是将操作符放在操作数之间,而是将它放在操作数之后。例如,2 + 3要写成2 3 +。(2 + 3)·4要写成2 3 + 4 *。+在2和3之后,表明要将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 *的抽象语法树”所示。
所有结构都直接表示出来,因此这是最常用的内部表达形式。表达式可以是个数,也可以是带有两个操作数的操作符;两个操作数也是表达式。抽象语法树可以是个数,也可以是一个操作符加上另外两个抽象语法树。有了抽象语法树,处理它的函数就呼之欲出了。例如,下面这个函数可将抽象语法树转换为字符串:
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()函数的结构直接反映了表达式的结构。
第 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,这样不需要该参数的函数只需忽略它即可。