第 2 章 调度表

目录

2.1. 处理配置文件
2.1.1. 表驱动的配置文件
2.1.2. 调度表的优点
2.1.3. 调度表的策略
2.1.4. 默认动作
2.2. 计算器
2.2.1. 重新思考HTML处理

第 1 章 递归和回调函数讨论了让函数更加灵活的方法:通过将函数行为写在其他函数中,并作为参数使用。例如,不要在hanoi()函数每次要移动盘子时,直接书写输出特定消息的处理,而是让它去调用另一个从外边传过来的函数。通过给hanoi()提供合适的函数,可以让它显示一系列指令、检查移动是否合法,或者生成图形显示,而无需去修改基本算法。类似地,可以将目录遍历的行为从total_size()函数的文件大小计算行为中提取出来,得到一个更通用、更实用的dir_walk()函数,它可以用来执行许多其他功能。

使用代码引用才能提取出hanoi()dir_walk()的抽象行为。将附加功能作为参数传递给hanoi()dir_walk(),实质上等于把这些函数当作数据使用。有了代码引用,我们才能这样使用。

现在暂时告别递归,去看看使用代码引用的另一个方面。

2.1. 处理配置文件

假设应用程序要读取配置文件,其格式如下:

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结构的各个分支之间毫无关联,除了它们都是配置项这个无关痛痒的事实之外。这种函数违反了一个重要的编程规则:相关的内容应放到一起,无关的内容应当分开。

根据这个规则,函数应当做成另一种结构:读取并解析配置文件的处理应该跟识别指令后执行的处理分开。此外,实现各种相互无关的指令的代码不应该堆积在一个函数内。

2.1.1. 表驱动的配置文件

应该把配置文件的打开、读取和解析的代码,从实现各种指令的无关代码段中分离出来。这样把代码分成两部分,可以更灵活地修改各部分代码,还可以将处理指令的代码分开。

下面是另一种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结构,但最后的这个表并没有小多少。似乎并不是什么骄人的成绩。但实际上,这个表有许多优点。

2.1.2. 调度表的优点

调度表是数据而不是代码,因此可以在运行时修改。如有需要,可以随时加入新的指令。比如表中有下面这一行:

'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中,然后改变当前目录至dirsPOPDIR恢复最后一次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 节 “默认动作”

2.1.3. 调度表的策略

前面在实现PUSHDIRPOPDIR时,动作函数使用了全局变量@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; # 成功
}
				

这样一来就可以消灭全局变量,只需将PUSHDIRPOPDIR定义成下面这种方式:

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);
				

那么PUSHDIRPOPDIR就使用@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了。

2.1.4. 默认动作

示例中的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中,供以后的程序使用。