1.5 目录遍历的应用和变化
有一个遍历目录树的函数是有用的,可以将其应用于所有情况。例如,如果想要写一个像Unix的ls -R命令一样工作的递归的文件列出命令,将需要遍历目录树。可以期望函数的行为更像Unix的du命令,列出它找到的所有子目录的总大小以及所有文件的总大小。也可以期望函数搜索悬空符号链接,即指向不存在的文件的链接。在Perl新闻组和IRC频道中经常被问的一个问题是,怎样遍历一棵目录树并为每个文件重命名或对每个文件执行一些别的操作。
可以写许多不同的函数完成这些任务,每个有一点点不同。但是每个的核心部分是递归的目录遍历,因此希望把它提取出来,那样就能把它当工具使用了。如果可以剥离遍历部分,就可以把它放到库中,那么任何需要目录遍历的人都可以使用。
上一段落中出现了一个重要的观点变化。从现在起,直到此书剩余的大部分,将持有你此前未曾见过的一个观点:不再对开发一个可以整体使用的完整程序有兴趣。相反,将尝试把代码写成另一个程序员期望在另一个程序中可以复用的有价值的代码。不再写一个程序,而是写一个将被别的程序使用的库或模块。
从此出发的一个方向将是展示怎样为total_size()函数写一个用户界面(user interface),它可以提示用户需要一个目录名,或者从命令行或者图形控件读取一个目录名,然后以某种方式显示结果。将不做这个。加入提示用户输入目录名或读取命令行参数的代码并不困难。在此书的剩余部分,将不关注用户界面;相反,将关注程序员界面(programmer interface)。此书的剩余部分将讨论“用户”,但这不是普通用户。相反,这里的用户是另一个程序员,当他写他自己的程序时想用我们的代码。我们不再要求如何使我们的完整程序对一个最终用户使用起来既简单又方便,而将关注使其他程序员可以简单方便地在他们自己的程序中使用我们的函数与库的方法。
这样做有两个充足的理由。其一是如果我们的函数经过很好的设计,旨在易于复用 ,我们自己就将可以复用它们来节省时间和避免麻烦。与其反复地写类似的代码,不如把一个常见的目录遍历函数嵌入每个需要它的程序中。当在一个程序里改进了目录遍历函数时,在所有别的程序中的它也将自动改进。随着时间积累,我们将开发一套有用的函数与库的套件,这将使我们更多产,我们也将有更多有趣的程序。
但更重要的是,如果我们的函数是为复用而良好设计的,其他程序员将能使用它们,并得到和我们一样的益处。成为对别人有用的人是我们存在的首要理由。
头脑中观念的变化明确以后,就可以继续。已经有一个函数total_size(),包含有用的功能:它递归地遍历一棵目录树。如果能干净利落地把目录遍历部分的代码与总值计算部分的代码分开,那么就可以在许多别的项目中出于许多别的用途复用这部分目录遍历代码。如何分开两部分呢?
如同汉诺塔程序,这里的关键是向我们的函数传递一个额外的参数。这个参数自身是个函数,可以告诉total_size()要做的事情。代码将如下:
### Code Library: dir-walk-simple
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
(当前目录包含三个子目录,称为a、b和c,子目录c含有一个子子目录,称为d。)
print_dir如此简单以至于不必浪费时间给它想个名字。如果可以简单地写出不必命名的函数,那将是方便的,可以写:
$weekly_pay = 40 * $hourly_pay;
而不必命名40或把它存放到一个变量中。Perl的确为此提供了语法:
dir_walk('.', sub { print $_[0], "\n" } );
sub { ... }引入一个匿名函数(anonymous function),即没有名字的函数。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节中看到这个问题的一个解决方案。
另一个缺点是它仅当合计极其简单时才工作良好,就像这里的合计。假设不是计算单个总值,而是期望建立一个文件名与大小的散列结构,如下:
{
'a' => {
'a1' => '261',
'a2' => '171'
},
'b' => {
'b1' => '348'
},
'c' => {
'c1' => '658',
'c2' => '479',
'c3' => '889',
'd' => {
'd1' => '568',
'd2' => '889'
}
}
}
这里的键是文件与目录的名字。对应一个文件名的值是该文件的大小,对应一个目录名的值是一个散列,它的键与值表示该目录的内容。如何使简单的$total合计回调产生一个像这样的复杂结构,这一点也许是不清楚的。
dir_walk函数不够普遍。需要它检查到文件时执行一些计算,如计算它们的总大小,然后把这个计算的结果返回给它的主调者。主调者可能是main程序,或者它可能是dir_walk()的另一个执行体,后者可以用它接收到的计算结果作为它为其主调者执行的计算的一部分。
dir_walk()如何知道怎么执行计算?在total_size()中,加法计算被固定进函数了。希望dir_walk()更普遍实用。
提供两个函数:一个为普通文件,一个为目录。当dir_walk()需要为普通文件计算结果时它将调用普通文件函数,当它需要为目录计算结果时它将调用目录函数,dir_walk()不会知道它自身如何做这些计算,它只知道它应该把实际的计算委派给这两个函数。
这两个函数都将接受一个文件名参数,并计算它关心的值,如以它的参数命名的文件的大小。由于目录是一列文件,目录函数将接受一列值并计算每个成员,它也需要这些值计算整个目录的值。目录函数知道怎么合计这些值并为整个目录产生一个新的值。
有了这个变化,就能进行total_size操作了。普通文件函数将只简单地返回要求它关注的文件的大小。目录函数将接受一个目录名和一列文件的大小,合计它们,并返回结果。通用的框架函数如下:
### Code Library: dir-walk-cb
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->($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 .
为使函数产生之前看到的散列结构,可以提供以下一对回调:
### Code Library: dir-walk-sizehash
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 {});
一个目录不可能是悬空的符号链接,因此目录函数是空函数(null function),它不做任何事情并立即返回。如果愿意,就可以避免这个古怪的东西和与之相关的函数调用开销,如下:
### Code Library: dir-walk-cb-def
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(),这个空列表将合并入其他同级目录的结果列表。