第 1 章 递归和回调函数讨论了让函数更加灵活的方法:通过将函数行为写在其他函数中,并作为参数使用。例如,不要在hanoi()函数每次要移动盘子时,直接书写输出特定消息的处理,而是让它去调用另一个从外边传过来的函数。通过给hanoi()提供合适的函数,可以让它显示一系列指令、检查移动是否合法,或者生成图形显示,而无需去修改基本算法。类似地,可以将目录遍历的行为从total_size()函数的文件大小计算行为中提取出来,得到一个更通用、更实用的dir_walk()函数,它可以用来执行许多其他功能。
使用代码引用才能提取出hanoi()和dir_walk()的抽象行为。将附加功能作为参数传递给hanoi()和dir_walk(),实质上等于把这些函数当作数据使用。有了代码引用,我们才能这样使用。
现在暂时告别递归,去看看使用代码引用的另一个方面。
假设应用程序要读取配置文件,其格式如下:
VERBOSITY 8 CHDIR /usr/local/app LOGFILE log ... ...
现在需要读取该配置文件,并针对每条指令执行适当的动作。例如,对于VERBOSITY执行,应当设置一个全局变量。但遇到LOGFILE指令,则应当立刻告诉程序将诊断信息输出到指定的文件中。至于CHDIR,应该让程序执行chdir,将当前目录改变到指定目录中,这样后续的文件操作即可相对于新的目录来执行。因此,前面例子中的LOGFILE应该是/usr/local/app/log,而不是该程序执行时用户所在的目录下的log文件。
许多程序员看到这个问题,就会立刻想到使用大量的if-else分支来解决,大概是这个样子:
sub read_config {
my ($filename) = @_;
open my($CF), $filename or return; # Failure
while (<$CF>) {
chomp;
my ($directive, $rest) = split /\s+/, $_, 2;
if ($directive eq 'CHDIR') {
chdir($rest) or die "Couldn’t chdir to '$rest': $!; aborting";
} elsif ($directive eq 'LOGFILE') {
open STDERR, ">>", $rest
or die "Couldn’t open log file '$rest': $!; aborting";
} elsif ($directive eq 'VERBOSITY') {
$VERBOSITY = $rest;
} elsif ($directive eq ...) {
...
} ...
} else {
die "Unrecognized directive $directive on line $. of $filename; aborting";
}
}
return 1; # Success
}
该函数有两部分。第一部分打开文件并逐行读入。它将每一行分成$directive(第一个单词)和$rest(其余的内容)。$rest是指令的参数,比如使用LOGFILE指令时,该参数指定日志文件的名称。函数的第二部分是个巨大的if-else结构,检查$directive是什么指令,如果无法识别,就不执行任何动作。
由于if-else中可能有许许多多分支,因此该函数会变得硕大无比。要添加新的指令,就得修改函数,加入另一个elsif分支。if-else结构的各个分支之间毫无关联,除了它们都是配置项这个无关痛痒的事实之外。这种函数违反了一个重要的编程规则:相关的内容应放到一起,无关的内容应当分开。
根据这个规则,函数应当做成另一种结构:读取并解析配置文件的处理应该跟识别指令后执行的处理分开。此外,实现各种相互无关的指令的代码不应该堆积在一个函数内。
应该把配置文件的打开、读取和解析的代码,从实现各种指令的无关代码段中分离出来。这样把代码分成两部分,可以更灵活地修改各部分代码,还可以将处理指令的代码分开。
下面是另一种read_config()的写法:
sub read_config {
my ($filename, $actions) = @_;
open my($CF), $filename or return; # 失败
while (<$CF>) {
chomp;
my ($directive, $rest) = split /\s+/, $_, 2;
if (exists $actions->{$directive}) {
$actions->{$directive}->($rest);
} else {
die "Unrecognized directive $directive on line $. of $filename; aborting";
}
}
return 1; # 成功
}
配置文件的打开、读取和解析的方法跟前面完全相同,但是去掉了巨大的if-else分支。取而代之的是该函数接收的另一个参数$actions,它是个记载了动作的表格,每次read_config()读取一条配置指令时,都会执行表格中的某个动作。该表格称为调度表(dispatch table),因为read_config()读取文件后,会把控制权调度到表中的函数上。$rest变量与前面的意思相同,不过现在要将它作为参数传给适当的动作函数。
典型的调度表如下所示:
$dispatch_table =
{ CHDIR => \&change_dir,
LOGFILE => \&open_log_file,
VERBOSITY => \&set_verbosity,
... => ...,
};
这个调度表是个散列,键(通常称为标签(tag)),是指令名,值为动作(action)它是识别指令后要执行的子程序的引用。动作函数应该接收$rest变量。典型的动作函数如下所示:
sub change_dir {
my ($dir) = @_;
chdir($dir)
or die "Couldn’t chdir to '$dir': $!; aborting";
}
sub open_log_file {
open STDERR, ">>", $_[0]
or die "Couldn’t open log file '$_[0]': $!; aborting";
}
sub set_verbosity {
$VERBOSITY = shift
}
如果动作很小,也可以直接写在调度表中:
$dispatch_table =
{ CHDIR => sub { my ($dir) = @_;
chdir($dir) or
die "Couldn’t chdir to '$dir': $!; aborting";
},
LOGFILE => sub { open STDERR, ">>", $_[0] or
die "Couldn’t open log file '$_[0]': $!; aborting";
},
VERBOSITY => sub { $VERBOSITY = shift },
... => ...,
};
使用调度表,就可以消灭巨大的if-else结构,但最后的这个表并没有小多少。似乎并不是什么骄人的成绩。但实际上,这个表有许多优点。
调度表是数据而不是代码,因此可以在运行时修改。如有需要,可以随时加入新的指令。比如表中有下面这一行:
'DEFINE' => \&define_config_directive,
其中,define_config_directive()为:
sub define_config_directive {
my $rest = shift;
$rest =~ s/^\s+//;
my ($new_directive, $def_txt) = split /\s+/, $rest, 2;
if (exists $CONFIG_DIRECTIVE_TABLE{$new_directive}) {
warn "$new_directive already defined; skipping.\n";
return;
}
my $def = eval "sub { $def_txt }";
if (not defined $def) {
warn "Could not compile definition for '$new_directive': $@; skipping.\n";
return;
}
$CONFIG_DIRECTIVE_TABLE{$new_directive} = $def;
}
这样配置程序就可以接受下面这种指令:
DEFINE HOME chdir('/usr/local/app');
define_config_directive()将HOME放在$new_directive中,把chdir('/usr/local/app');放进$def_txt中。它通过eval将程序文本编译成子程序,然后将新的子程序放到主配置表%CONFIG_DIRECTIVE_TABLE中,用HOME作为键。如果%CONFIG_DIRECTIVE_TABLE恰好是传给read_config()的调度表,那么read_config()就可以使用新的定义,此后在输入文件中遇到HOME指令时,就可以执行与HOME关联的动作。现在配置文件可以写成:
DEFINE HOME chdir('/usr/local/app');
CHDIR /some/directory
...
HOME
其中...部分的指令在/some/directory目录中执行。处理程序遇到HOME后,就回到主目录。而下面这种定义方式实现了同样的功能,但更为健壮:
DEFINE PUSHDIR use Cwd; push @dirs, cwd(); chdir($_[0]) DEFINE POPDIR chdir(pop @dirs)
PUSHDIRdirs使用标准Cwd模块提供的cwd()函数,来获取当前目录的名称。之后将当前目录的名称保存在变量@dirs中,然后改变当前目录至dirs。POPDIR恢复最后一次PUSHDIR的效果:
PUSHDIR /tmp A PUSHDIR /usr/local/app B POPDIR C POPDIR
程序首先进入/tmp,然后执行指令A。接下来进入/usr/local/app并执行指令B。之后的POPDIR返回/tmp,然后执行指令C;最后,第二个POPDIR回到最初所在的目录。
为了使DEFINE能顺利修改配置表,必须将配置表定义为全局变量。也许,将这个表直接传给define_config_directive更好些。这样就需要对read_config做些小小的修改:
sub read_config {
my ($filename, $actions) = @_;
open my($CF), $filename or return; # 失败
while (<$CF>) {
chomp;
my ($directive, $rest) = split /\s+/, $_, 2;
if (exists $actions->{$directive}) {
$actions->{$directive}->($rest, $actions);
} else {
die "Unrecognized directive $directive on line $. of $filename; aborting";
}
}
return 1; # 成功
}
现在define_config_directive可以这样写:
sub define_config_directive {
my ($rest, $dispatch_table) = @_;
$rest =~ s/^\s+//;
my ($new_directive, $def_txt) = split /\s+/, $rest, 2;
if (exists $dispatch_table->{$new_directive}) {
warn "$new_directive already defined; skipping.\n";
return;
}
my $def = eval "sub { $def_txt }";
if (not defined $def) {
warn "Could not compile definition for '$new_directive': $@; skipping.\n";
return;
}
$dispatch_table->{$new_directive} = $def;
}
这样改动后,就可以添加一个真正实用的配置指令:
DEFINE INCLUDE read_config(@_);
它将在调度表中添加这样一项:
INCLUDE => sub { read_config(@_) }
现在,配置文件中就可以这样写:
INCLUDE extra.conf
主read_config()函数调用该动作并传给它两个参数。第一个参数是配置文件中的$rest部分,本例中为文件名extra.conf。动作的第二个参数为调度表本身。该动作递归调用read_config,并传递上述两个参数。read_config读取extra.conf,读取结束后,控制权交还给主read_config,从上次中断的地方开始继续处理主配置文件的剩余部分。
为使得递归调用能正常运行,read_config()必须是可重入的。导致函数不可充入的罪魁祸首就是全局变量,如使用全局文件句柄,而不是之前用过的词法文件句柄。如果用到了全局文件句柄,那么递归调用read_config()就会使用跟主函数调用相同的文件句柄打开extra.conf,从而关闭主配置文件。递归调用返回后,由于文件句柄已经关闭,read_config()就无法读取主文件的其余部分。
INCLUDE定义相当简单,也相当实用。但它也过于奇巧,以至于我们在书写read_config时根本意识不到这种用法。可能会想“哦,read_config不需要可重入。”但要是真的把read_config写成了不可重入的函数,那么实用的INCLUDE定义就无法正常运行了。此处要记住的重点:默认情况下函数应该写成可重入的,因为函数有时会以某种意想不到的方式被递归调用。
与不可重入函数相比,可重入函数的行为更具有可预测性。由于可被递归调用,它们显得更灵活。INCLUDE的例子说明,我们无法预测有人想要递归调用函数的原因。只要可能,把一切都做成可重入的,无疑是万全之策。
与直接在read_config()中写代码相比,调度表的另一个优点是,同一个read_config()可以用来处理两个完全无关、指令也完全不同的文件,只需给read_config()传递不同的调度表即可。给read_config()传递一个精简后的调度表就能把程序变成“入门版”,或者给read_config()传递另一套指令的调度表,让它处理基本语法相同的另一种配置文件。这种用法的例子请参见第 2.1.4 节 “默认动作”。
前面在实现PUSHDIR和POPDIR时,动作函数使用了全局变量@dir来维护目录栈。这种做法并不妥当。换个方法,让read_config()支持用户自定义的参数,可以让系统更加灵活。这个自定义参数由read_config()的调用者提供,并原封不动地传给动作函数:
sub read_config {
my ($filename, $actions, $user_param) = @_;
open my($CF), $filename or return; # 失败
while (<$CF>) {
my ($directive, $rest) = split /\s+/, $_, 2;
if (exists $actions->{$directive}) {
$actions->{$directive}->($rest, $user_param, $actions);
} else {
die "Unrecognized directive $directive on line $. of $filename; aborting";
}
}
return 1; # 成功
}
这样一来就可以消灭全局变量,只需将PUSHDIR和POPDIR定义成下面这种方式:
DEFINE PUSHDIR use Cwd; push @{$_[1]}, cwd(); chdir($_[0])
DEFINE POPDIR chdir(pop @{$_[1])
$_[1]参数指向传给read_config()的用户自定义参数。如果这样调用read_config():
read_config($filename, $dispatch_table, \@dirs);
那么PUSHDIR和POPDIR就使用@dir作为栈。如果这样调用:
read_config($filename, $dispatch_table, []);
就使用全新的匿名数组作为栈。
将被调用的动作的标签名称传给动作回调函数通常很有用。可以这样修改read_config():
sub read_config {
my ($filename, $actions, $user_param) = @_;
open my($CF), $filename or return; # 失败
while (<$CF>) {
my ($directive, $rest) = split /
\s+/, $_, 2;
if (exists $actions->{$directive}) {
$actions->{$directive}->($directive, $rest, $actions, $user_param);
} else {
die "Unrecognized directive $directive on line $. of $filename; aborting";
}
}
return 1; # 成功
}
为什么说有用呢?比如像这样定义VERBOSITY指令:
VERBOSITY => sub { $VERBOSITY = shift },
不难想象,其他配置指令也可能是这种形式:
VERBOSITY => sub { $VERBOSITY = shift },
TABLESIZE => sub { $TABLESIZE = shift },
PERLPATH => sub { $PERLPATH = shift },
... etc ...
最好是将这三个相似的动作合并成一个函数,完成所有处理。这样,函数就需要知道指令名称,以便设置恰当的全局变量:
VERBOSITY => \&set_var,
TABLESIZE => \&set_var,
PERLPATH => \&set_var,
... etc ...
sub set_var {
my ($var, $val) = @_;
$$var = $val;
}
如果不喜欢定义一大堆全局变量,可以将配置信息保存在散列中,并将散列的引用作为用户自定义参数:
sub set_var {
my ($var, $val, undef, $config_hash) = @_;
$config_hash->{$var} = $val;
}
本例中并没有节约多少代码,因为动作太简单了。但可能会出现多个配置指令需要共享同一个复杂函数的情况。下面是个稍微复杂些的例子:
sub open_input_file {
my ($handle, $filename) = @_;
unless (open $handle, $filename) {
warn "Couldn't open $handle file '$filename': $!; ignoring.\n";
}
}
许多配置指令都可以共用这个open_input_file()函数。例如,假设程序有三个输入源:历史文件、临时文件和模式文件(pattern file)。我们希望三个文件的位置都可以在配置文件中指定,这就需要在调度表中加入三条指令。但三条指令都可以共享同一个open_input_file()函数:
... HISTORY => \&open_input_file, TEMPLATE => \&open_input_file, PATTERN => \&open_input_file, ...
假设配置文件如下:
HISTORY /usr/local/app/history TEMPLATE /usr/local/app/templates/main.tmpl PATTERN /home/bill/app/patterns/default.pat
read_config()首先处理第一行,将其分派到open_input_file()函数,参数为('HISTORY', '/usr/local/app/history')。open_input_file()就会将参数HISTORY作为文件句柄名使用,用HISTORY打开/usr/local/app/history文件。第二行,read_config()仍然会分派给open_input_file(),传递参数('TEMPLATE', '/usr/local/app/templates/main.tmpl')。这次,open_input_file()就会打开TEMPLATE文件句柄,而不再是HISTORY了。
示例中的read_config()在遇到不认识的动作时就会异常终止。这个动作是硬编码的。如果调度表能够自己处理无法识别的指令就好了。添加这个功能很容易:
sub read_config {
my ($filename, $actions, $userparam) = @_;
open my($CF), $filename or return; # 失败
while (<$CF>) {
chomp;
my ($directive, $rest) = split /\s+/, $_, 2;
my $action = $actions->{$directive} || $actions->{_DEFAULT_};
if ($action) {
$action->($directive, $rest, $actions, $userparam);
} else {
die "Unrecognized directive $directive on line $. of $filename; aborting";
}
}
return 1; # 成功
}
这里,函数在动作表中查找特定指令,如果不存在,就查找_DEFAULT_动作。如果调度表中连默认动作都不存在,就出错。典型的_DEFAULT_动作如下:
sub no_such_directive {
my ($directive) = @_;
warn "Unrecognized directive $directive at line $.; ignoring.\n";
}
由于传给动作函数的第一个参数就是指令名称,因此默认动作能判断出无法识别的指令是什么。而no_such_directive()函数也能获取到整个调度表,所以可以取出正确的指令名,通过一些模式匹配来猜测无法识别的指令可能是什么。下面的no_such_directive()调用score_match()函数(该函数我们不再详细说明)来判断哪个动作与无法识别的指令最合适:
sub no_such_directive {
my ($bad, $rest, $table) = @_;
my ($best_match, $best_score);
for my $good (keys %$table) {
my $score = score_match($bad, $good);
if ($score > $best_score) {
$best_score = $score;
$best_match = $good;
}
}
warn "Unrecognized directive $bad at line $.;\n";
warn "\t(perhaps you meant $best_match?)\n";
}
这个系统的代码并不多,但它极其灵活。假设另一个程序要读取一个用户ID和电子邮件地址的一览表,格式如下:
fred fred@example.com bill bvoehno@plover.com warez warez-admin@plover.com ... ...
只需给read_config()提供适当的调度表,就可以重用它来读取并分析该文件:
$address_actions =
{ _DEFAULT_ => sub { my ($id, $addr, $act, $aref) = @_;
push @$aref, [$id, $addr];
},
};
read_config($ADDRESS_FILE, $address_actions, \@address_array);
传给read_config()的调度表很小,只有一个_DEFAULT_项。read_config()每次读取地址簿文件中的一行,都会调用一次默认动作,将“指令名”(实际上是用户ID)和地址($rest值)传给该动作。默认动作将这个信息存储到@address_array中,供以后的程序使用。