前面介绍的几个例子让我们初步了解了递归过程的概貌,但它们遗漏了重哟啊的一点。我曾在汉诺塔中说过,如果问题能转化为更简单的同类问题,那么它最适合使用递归来解决。不过,这种问题有时并不明显。
绝大多数递归函数都是用来处理递归的数据结构的。例如,列表、树、堆,它们都由更简单的同类数据构成,这些都是递归数据结构。广为人知的例子恐怕就是文件系统的目录结构了。每个文件都是下面两种情况之一:
文件可能是个包含许多文件的目录,这些文件中的一部分也有可能是目录,它们还会包含更多的文件,以此类推。处理这种数据结构的最有效的方式就是使用递归过程。理论上,调用这种过程就是处理一个文件。文件可能是普通文件,也可能是目录。如果是目录,过程就会递归调用自身,来处理该目录所拥有的子文件。如果子文件还是目录,过程就会再次递归调用。
下面这个示例函数,参数为目录名称,可以计算指定目录中包含的所有文件的总大小,包括子目录以及子目录的子目录,等等:
sub total_size {
my ($top) = @_;
my $total = -s $top;
第一次调用该函数时,使用的参数$top是想要统计的文件或目录的名称。函数首先使用Perl的-s操作符获得文件或目录本身的大小。该操作符获取文件大小(以字节为单位)。如果文件是个目录,它返回目录本身所占的磁盘空间,而不包括目录中包含的文件——要知道,目录实质上是个文件列表,列表本身也要占据一些空间。如果顶层文件确实是个目录,函数应当将它包含的文件的大小追加到$total中保存的总大小中。
return $total if -f $top;
unless (opendir DIR, $top) {
warn "Couldn’t open directory $top: $!; skipping.\n";
return $total;
}
-f操作符检查参数是否为普通文件,如果是,函数就可以立即返回总大小。否则,就假设顶层文件确实是目录,并尝试用opendir()打开。如果目录打不开,函数就产生一条警告信息,并返回当前的总大小,它等于目录本身的大小,不包括目录中的文件。
my $file;
while ($file = readdir DIR) {
next if $file eq '.' || $file eq '..';
$total += total_size("$top/$file");
}
下一段代码while循环是整个函数的核心。它每次从目录中读取一个文件名,然后分别递归调用自己,并将结果添加到当前的总大小中。
closedir DIR; return $total; }
在循环结尾,函数关闭目录并返回总大小。
循环中,函数跳过了.和..这两个名称,它们分别代表当前目录和父目录。如果不这样,函数就会去计算././././././fred和dir/../dir/../dir/../dir/fred这种文件的总大小,而永远无法结束。
不过,这个函数有个大缺陷。实际上它根本无法运行。问题在于,目录句柄(DIR)是全局的,所以函数是不可重入的。该函数执行失败的原因与那个缺少my的factorial()一样。第一次调用毫无问题,但如果total_size()递归调用自己,那么第二次调用就会打开同一个目录句柄DIR。最终,第二个调用会到达目录末尾,关闭DIR并返回。之后,第一个调用会继续执行,发现DIR已经关闭了,就会退出while循环,实际上并没有从顶层目录中读出所有文件名。如果第二个调用也会递归调用自己,那么也会发生同样的问题。
其结果就是,这个函数只能遍历目录树的第一个枝。如果目录的层次层次结构像这样的话:

那么函数将沿着top-a-d路径检查文件j和k,并报告top + a + d + j + k的总大小,而忽略了b, c, e, f, g, h, i, l。
要改正这个问题,必须把目录句柄DIR变成$top和$total那样的私有变量。因此,不应当使用opendir DIR, $top,而是要写成opendir $dir, $top,其中$dir是个私有变量。当opendir的第一个参数是个未定义的变量时,opendir就会创建新的匿名句柄,并保存到$dir中。
[2]
因此,代码不能写成这样:
opendir DIR, $somedir; print (readdir DIR); closedir DIR;
而是要写成这样,即可获得同样的结果:
my $dir; opendir $dir, $somedir; print (readdir $dir); closedir $dir;
两者之间最大的区别就是,DIR是全局目录句柄,可以被程序的任何部分读取或关闭;而$dir中的目录句柄是私有的,只有创建它的函数、被明确地传递了$dir的函数才能读取或关闭它。
下面使用这种技术重写total_size(),使其正常运行:
sub total_size {
my ($top) = @_;
my $total = -s $top;
my $DIR;
return $total if -f $top;
unless (opendir $DIR, $top) {
warn "Couldn’t open directory $top: $!; skipping.\n";
return $total;
}
my $file;
while ($file = readdir $DIR) {
next if $file eq '.' || $file eq '..';
$total += total_size("$top/$file");
}
closedir $DIR;
return $total;
}
实际上,这里无需调用closedir,因为用这种方式创建的目录句柄,在离开变量作用域时会自动关闭。在total_size()返回时,包括$dir在内的私有变量都会被销毁,而$dir中正好包含了目录句柄对象的唯一的引用。于是,Perl就会销毁目录句柄对象,在此过程中关闭目录句柄。以后我们不再明确写出closedir。
这个函数还有些问题:它还不能正确处理符号链接;如果一个文件具有两个文件名并且位于同一目录下,它就会被计算两次。此外,在Unix系统上,文件在磁盘上占用的实际空间通常与-s的结果并不一致,因为磁盘空间每次按照1024字节的块来分配。但这个函数已经很有用了,还可以在其他任务中使用它。如果有必要改正这些问题,那么只需修改该函数即可,而无需去50个应用程序中修改50个稍有不同的目录遍历函数中的同样问题。
[2]
该功能是在Perl 5.6.0中引入的。Perl早期版本的用户必须通过IO::Handle模块明确地创建目录句柄:my $dir = IO::Handle->new; opendir $dir, $top;。