1.5. 应用程序和目录遍历的各种形式

写一个遍历目录树的函数非常实用,可以将它用在各种目的上。例如,如果要写个类似于Unix的ls -R命令那样的递归文件一览查看程序,就需要遍历目录树。可以让函数像Unix的du命令那样,显示出每个子目录以及其下的所有文件的总大小。还可以让函数去查找损坏的符号链接(dangling symbolic link),即指向不存在的文件的链接。Perl新闻组和IRC频道中经常有人问到的问题,就是如何遍历目录树并修改每个文件的文件名,或对每个文件执行某种操作。

我们可以写出许多大同小异的函数去处理这些任务。但每个函数的核心部分都是递归目录遍历器,所以应当将它抽象出来,作为工具来使用。如果能将遍历器分离出来,就可以将它放到函数库中,这样其他需要目录遍历器的人就可以直接使用了。

上面这段话中有个重要的观念转变。从现在开始,本书其余的绝大部分中,我们将采取一种你可能从未见过的观点:我们不再关注于开发完整的程序,而是要写出对其他程序员有用的代码,使他能在别的程序中重用。我们不再书写程序,而是书写被其他程序使用的函数库或模块。

我们可以选择的方向之一,就是演示如何为total_size()函数撰写一个用户界面,它会提示用户输入目录名,或者从命令行或图形控件中读取目录名,然后以某种方式显示结果。但我们不会这样做。添加提示用户输入目录名或者读取命令行参数的代码并不困难。本书的其余部分中,我们不会涉及到用户界面,而是要讨论程序员接口。本书其余部分讨论的“用户”并不是普通用户,而是其他希望借助我们的代码去完成他们自己的代码的程序员。因此,我们不讨论如何让整个程序对最终用户简单易用,而是讨论如何让其他程序员能够简单、方便地将函数和函数库应用到他们自己的程序中。

这样做有两点好处。其一,如果函数设计良好、易于重用,那么我们自己就能重复利用它来节约时间、减少麻烦。无需再反复书写相似的代码,只要将熟悉的目录遍历函数嵌入到每个需要的它的程序中即可。在一个程序中改善目录遍历函数,就能自动地改善其他程序的功能。日久天长,我们就会开发出一个工具包,包含了诸多实用函数和函数库,能大大提高生产性,也能增加编程的乐趣。

但更重要的是,如果函数设计良好、易于重用,其他程序员就能使用它们,并和我们一样从中得益。况且,有益于他人,也是人生的首要目的。 [3]

带着这个观点,我们继续讨论。我们已写出了一个函数total_size(),它有个实用的功能:递归遍历目录树。如果能干净地将代码中的目录遍历部分从总大小计算部分中分离出来,也许就能在许多其他项目中,将这个目录遍历功能重用于其他的目的。如何分离这两个功能呢?

正如汉诺塔程序,此处的关键是给函数传递一个额外的参数。参数本身是个函数,告诉total_size()应该做什么。代码如下所示:

sub dir_walk {
  my ($top, $code) = @_;
  my $DIR;

  $code->($top);

  if (-d $top) {
    my $file;
    unless (opendir $DIR, $top) {
      warn "Couldn’t open directory $top: $!; skipping.\n";
      return;
    }
    while ($file = readdir $DIR) {
      next if $file eq '.' || $file eq '..';
      dir_walk("$top/$file", $code);
    }
  }
}
			

这个函数我改名为dir_walk(),来表述它的通用性。它带有两个参数。第一个$top跟以前一样,是搜索开始处的文件或目录。第二个$code是新添加的参数,是个代码引用,告诉dir_walk我们想要针对目录树中发现的各个文件或目录做什么。每次dir_walk()发现一个新文件或目录时,都会调用这段代码,并将文件名作为参数传递。

这样,如果其他程序员问到,“怎样对目录树中的每个文件进行X操作?”我们就可以回答,“调用dir_walk()函数,把X放到函数里然后将函数引用传给它就行了。”此时$code参数是个回调函数。

例如,要写一个显示当前目录下所有文件和目录的程序,可以这样用:

sub print_dir {
  print $_[0], "\n";
}

dir_walk('.', \&print_dir );
			

它的输出结果是:

.
./a
./a/a1
./a/a2
./b
./b/b1
./c
./c/c1
./c/c2
./c/c3
./c/d
./c/d/d1
./c/d/d2
			

(当前目录包含三个子目录,名为abc。子目录c又包含名为d的子目录。)

print_dir这个函数太简单了,给它起个名字都会觉得是浪费时间。如果能不起名字,直接写出函数来,那就更方便了,就像这个表达式中,

$weekly_pay = 40 * $hourly_pay;
			

我们无需将40保存在变量中一样。Perl确实有这种语法:

dir_walk('.', sub { print $_[0], "\n" } );
			

sub { ... }引入了一个匿名函数,就是没有名字的函数。sub { ... }结构的值是个函数引用,可以用来调用函数。可以将函数引用保存在标量变量中,也可以像其他引用一样,作为参数传给函数。这一行与前面带有print_dir函数的冗长代码功能相同。

如果想让函数显示出文件大小和文件名,只需对函数引用参数做一点小小的修改:

dir_walk('.', sub { printf "%6d %s\n", -s $_[0], $_[0] } );

  4096 .
  4096 ./a
  261 ./a/a1
  171 ./a/a2
  4096 ./b
  348 ./b/b1
  4096 ./c
  658 ./c/c1
  479 ./c/c2
  889 ./c/c3
  4096 ./c/d
  568 ./c/d/d1
  889 ./c/d/d2
			

想让函数查找损坏的符号链接,也十分容易:

dir_walk('.', sub { print $_[0], "\n" if -l $_[0] && ! -e $_[0] });
			

-l测试当前文件是是否为符号链接,-e测试链接指向的文件是否存在。

不过,这与我的希望还有一点差距。没有简单的方法可以让dir_walk()累计所有发现的文件的大小。$code只对每个文件调用一次,所以永远没有机会去累计。如果累计很简单,可以在回调函数之外定义一个变量来实现:

my $TOTAL = 0;
dir_walk('.', sub { $TOTAL += -s $_[0] });
print "Total size is $TOTAL.\n";
			

这个方法有两个缺点。首先,回调函数依赖于$TOTAL变量的作用域,必须是使用$TOTAL的代码。通常,这种情况下这并不是问题,但如果回调函数是其他函数库中的复杂函数,实现起来就相当有难度了。我们将在2.1节[TODO]中讨论该问题的一种解决方案。

另一个缺点是,只有累计极其简单时(如本例),这种方法才有效。假如不是计算总大小,而是要建立文件名和文件大小的散列结构,如下:

{
  'a' => {
           'a1' => '261',
           'a2' => '171'
         },
  'b' => {
           'b1' => '348'
         },
  'c' => {
           'c1' => '658',
           'c2' => '479',
           'c3' => '889',
           'd' => {
                    'd1' => '568',
                    'd2' => '889'
                  }
         }
}
			

这里,散列的键是文件名和目录名。文件名的值是文件大小,目录的值是另一个散列,表示目录的内容。此时,怎样用简单的$TOTAL累计的回调函数来产生如此复杂的结构,恐怕就不那么明显了。

这个dir_walk函数还不够通用。它还应当根据文件进行一些计算,如计算总大小,并将计算结果返回给调用者。调用者可以是主程序,也可以是另一个dir_walk的调用,这样另一个dir_walk就能在它自己的计算中使用得到的值。

怎样让dir_walk()知道如何计算?total_size()中的计算是直接写在函数中的。我们希望dir_walk()能更加通用、更加实用。

我们只需要提供两个函数:一个用于普通文件,一个用于目录。dir_walk()在需要计算普通文件时就调用普通文件的函数,需要计算目录时就调用目录的函数。dir_walk()自己无需知道计算的任何细节,它只要知道,将实际计算工作交给这两个函数就够了。

这两个函数都接受文件名参数,并针对参数指定的文件,计算它们感兴趣的值(如文件大小)。由于目录就是一系列的文件,所以处理目录的函数还应当接收目录中的文件所计算出的一系列的值,使用这些值,就可以计算整个目录的值。处理目录的函数知道如何将这些值累计起来,从而算出整个目录的值。

作了这些改变之后,就可以进行total_size的操作了。处理普通文件的函数只需简单地返回文件的大小即可。处理目录的函数能取得目录名称,和其中包含的每个文件的大小,只需将它们全部相加,再返回结果。通用的函数框架如下所示:

sub dir_walk {
  my ($top, $filefunc, $dirfunc) = @_;
  my $DIR;

  if (-d $top) {
    my $file;
    unless (opendir $DIR, $top) {
      warn "Couldn't open directory $code: $!; skipping.\n";
      return;
    }

    my @results;
    while ($file = readdir $DIR) {
      next if $file eq '.' || $file eq '..';
      push @results, dir_walk("$top/$file", $filefunc, $dirfunc);
    }
    return $dirfunc->($top, @results);
  } else {
    return $filefunc->($top);
  }
}
			

计算当前目录的总大小,可以这样使用:

sub file_size { -s $_[0] }

sub dir_size {
  my $dir = shift;
  my $total = -s $dir;
  my $n;
  for $n (@_) { $total += $n }
  return $total;
}

$total_size = dir_walk('.', \&file_size, \&dir_size);
			

file_size()函数描述了给定文件名后,如何计算普通文件的大小;dir_size()函数描述了,在给定目录名和目录中内容的大小后,如何计算目录的大小。

如果希望像du那样,显示出每个子目录的大小,只需添加一行:

sub file_size { -s $_[0] }

sub dir_size {
  my $dir = shift;
  my $total = -s $dir;
  my $n;
  for $n (@_) { $total += $n }
  printf "%6d %s\n", $total, $dir;
  return $total;
}

$total_size = dir_walk('.', \&file_size, \&dir_size);
			

它的输出结果如下:

 4528 ./a
 4444 ./b
 5553 ./c/d
11675 ./c
24743 .
			

想让函数生成前面提到的散列结构,可以把下面这对回调函数传给它:

sub file {
  my $file = shift;
  [short($file), -s $file];
}

sub short {
  my $path = shift;
  $path =~ s{.*/}{};
  $path;
}
			

处理文件的回调函数返回一个数组,包含简略的文件名称(即不包含路径)和文件大小。与前面类似,累计是在处理目录的回调函数中进行的:

sub dir {
  my ($dir, @subdirs) = @_;
  my %new_hash;
  for (@subdirs) {
    my ($subdir_name, $subdir_structure) = @$_;
    $new_hash{$subdir_name} = $subdir_structure;
  }
  return [short($dir), \%new_hash];
}
			

目录回调函数获取当前目录的名称,还有对应于子文件和子目录的一个名称-值对列表。然后将这些名称-值对合并到散列中,并返回一对新的值,表示当前目录的简略名称,以及刚刚为当前目录创建的散列。

前面写过的较为简单的函数依然很简单。下面是递归文件一览查看程序。只需给文件和目录提供同样的函数:

sub print_filename { print $_[0], "\n" }
dir_walk('.', \&print_filename, \&print_filename);
			

下面是损坏符号链接检测程序:

sub dangles {
	my $file = shift;
	print "$file\n" if -l $file && ! -e $file;
}
dir_walk('.', \&dangles, sub {});
			

我们知道,目录不可能是损坏的链接,所以目录函数是个空函数,不做任何动作而直接返回。如果有必要,可以避免这种奇怪的函数,并减少调用该函数造成的额外开销,如下:

sub dir_walk {
  my ($top, $filefunc, $dirfunc) = @_;
  my $DIR;
  if (-d $top) {
    my $file;
    unless (opendir $DIR, $top) {
      warn "Couldn’t open directory $top: $!; skipping.\n";
      return;
    }

    my @results;
    while ($file = readdir $DIR) {
      next if $file eq '.' || $file eq '..';
      push@results,dir_walk("$top/$file", $filefunc, $dirfunc);
    }

    return $dirfunc ? $dirfunc->($top, @results) : () ;
  } else {
    return $filefunc ? $filefunc->($top): () ;
  }
}
			

这样只需书写dir_walk('.', \&dangles) 即可,无需再写成dir_walk('.', \&dangles, sub {})

再举最后一个例子。这个例子调用dir_walk()创建文件树中的所有普通文件的一览,但不显示任何信息:

@all_plain_files =
  dir_walk('.', sub { $_[0] }, sub { shift; return @_ });
			

文件回调函数返回它所处理的文件名。目录函数抛弃目录名,并返回目录包含的文件名一览。如果目录不含任何文件会怎样?那它就会给dir_walk()返回空列表,然后这个空列表被合并到其他同级目录的结果中。



[3] 有人觉得这完全没有说服力,但我想说的是,如果我们能有益于别人,别人就会尊敬、爱戴我们,也许会给我们带来更多好处。