1.4. 层次数据

前面介绍的几个例子让我们初步了解了递归过程的概貌,但它们遗漏了重哟啊的一点。我曾在汉诺塔中说过,如果问题能转化为更简单的同类问题,那么它最适合使用递归来解决。不过,这种问题有时并不明显。

绝大多数递归函数都是用来处理递归的数据结构的。例如,列表、树、堆,它们都由更简单的同类数据构成,这些都是递归数据结构。广为人知的例子恐怕就是文件系统的目录结构了。每个文件都是下面两种情况之一:

文件可能是个包含许多文件的目录,这些文件中的一部分也有可能是目录,它们还会包含更多的文件,以此类推。处理这种数据结构的最有效的方式就是使用递归过程。理论上,调用这种过程就是处理一个文件。文件可能是普通文件,也可能是目录。如果是目录,过程就会递归调用自身,来处理该目录所拥有的子文件。如果子文件还是目录,过程就会再次递归调用。

下面这个示例函数,参数为目录名称,可以计算指定目录中包含的所有文件的总大小,包括子目录以及子目录的子目录,等等:

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

在循环结尾,函数关闭目录并返回总大小。

循环中,函数跳过了...这两个名称,它们分别代表当前目录和父目录。如果不这样,函数就会去计算././././././freddir/../dir/../dir/../dir/fred这种文件的总大小,而永远无法结束。

不过,这个函数有个大缺陷。实际上它根本无法运行。问题在于,目录句柄(DIR)是全局的,所以函数是不可重入的。该函数执行失败的原因与那个缺少myfactorial()一样。第一次调用毫无问题,但如果total_size()递归调用自己,那么第二次调用就会打开同一个目录句柄DIR。最终,第二个调用会到达目录末尾,关闭DIR并返回。之后,第一个调用会继续执行,发现DIR已经关闭了,就会退出while循环,实际上并没有从顶层目录中读出所有文件名。如果第二个调用也会递归调用自己,那么也会发生同样的问题。

其结果就是,这个函数只能遍历目录树的第一个枝。如果目录的层次层次结构像这样的话:

那么函数将沿着top-a-d路径检查文件jk,并报告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;