1.4 层次化数据
已经介绍的例子显示了递归过程的风采,但是它们遗漏了重要的一点。在介绍汉诺塔问题时,我说过当你想解决的问题可以简化成同样问题的更简形式时,递归是很有用的。但是这样的问题是普遍的这一点可能不清楚。
多数递归的函数是为处理递归的数据结构的建立。一个递归的数据结构就像一个列表、一棵树或者一个堆,它们根据相同数据结构的更小的实例定义而成。最常见的例子可能是文件系统目录结构。文件可以是:
文件可以是一个目录,含有一列文件,其中的一些可以是目录,再含有另一些文件,以此类推。处理这样的结构最有效的办法是递归过程。从概念上,每次调用这样一个过程就处理一个单一文件。这个文件可以是普通文件,也可以是目录,程序通过递归调用自身处理这个目录所含有的子文件。如果子文件也是目录,程序将继续递归调用。
下面的例子是一个函数,以目录名作为参数计算它包含的所有文件的总大小,以及它的子目录、子目录的子目录等。
### Code Library: total-size-broken
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。
这个函数有个大bug,事实上它根本不能工作。问题在目录句柄,即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。
与其这样做:
opendir DIR, $somedir;
print (readdir DIR);
closedir DIR;
不如这样做:
my $dir;
opendir $dir, $somedir;
print (readdir $dir);
closedir $dir;
达到同样的效果。这有很大的区别:DIR是个全局目录句柄,能被程序的任何其他部分读取或关闭;$dir中的目录句柄是私有的,仅能被生成它的函数读取或关闭,或者被一些明确地传递了$dir的值的函数读取或关闭。
有了这个新的技巧,就可以重写total_size()函数以使其正确运行:
### Code Library: 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,它包含了打开的目录句柄对象的最后一个引用。Perl接着销毁目录句柄对象,在此过程中,关闭目录句柄。未来将省略明确的closedir。
这个函数仍然有些问题:它没有正确处理符号链接,如果一个文件在同一目录中有两个名字,它会统计两次。此外,在Unix系统里,一个文件在磁盘上实际所占的空间通常与-s报告的长度不同,因为磁盘空间是每次按1024字节的块分配的。但是函数足够有用了,因此可以期望它很好地应用到一些别的任务中。如果要修正这些问题,将只需在这一处修正它们,而不用在50个不同的应用中50个略微不同的目录遍历函数中修正同样的问题。