函数型编程(FP)可以是一种方法、解决方案或信仰。我更愿意它是除信仰之外的什么东西,什么东西都行,因为它应该是只是程序员工具箱中的一件工具。
如果我们进行掩饰那么它就很好正如我经常提到的一样,不应该只因为某种工具在那儿并且您可以用而使用它。锤子用来钉钉子;螺丝刀用来拧螺丝。了解您全部的工具并使用合适的工具;您的生活会因此轻松很多。
对函数型编程的一种简单看法是:函数型编程只是将函数应用到值,而过程型(传统)编程则是为函数的副作用(side effect)而使用函数。comp.lang.functional FAQ(请参阅本文稍后的 参考资料)中引用的参考资料是理解函数型编程方法和意图的最佳起点。
Perl 本质上是过程型语言,在 Perl 中,函数型编程只能是一种方法。实际解决方案一般都使用 map() 或 grep() 函数来模拟函数型解决方案。函数型编程方法之所以有价值有三个原因。首先,FP 可以使程序员以全新的角度看待问题,并可能因此想出更好的解决方案。第二,没有 map() 和 grep() 的帮助,Schwartzian 和 Guttman-Rosler 变换以及很多其它 Perl 习惯用法都难以使用和理解。第三,因为 Perl 中的函数调用消耗系统资源相当多,所以,不使用 map() 和 grep() 而实现某些算法会极大地降低算法性能。
map() 函数
map() 函数就像一个应用到列表中所有元素的橡皮图章(有关详细信息,请参阅“perldoc -f map”)。它由两部分组成:一个块或表达式以及一个列表:
清单 1. map() 如何工作# map {BLOCK GOES HERE} LIST;
map {$_++} @p; # increment every element of @p
# map EXPRESSION, LIST;
map -f,@p; # file test every element of @p
通常, foreach() 循环在性能上要优于 map() ,因此,除非测试性能,否则不要在 CPU 密集型计算中使用 map() 。而当它使您的代码更优雅和简单而又不影响代码效率时,请一定使用它。
map() 的用处很多。首先,它可以通过更改 $_ 变量在遍历数组元素时修改数组元素。在块或(不太常见)表达式内部, $_ 是列表的当前元素。您不知道您看到的是哪个元素 ― 关键在于将一个函数单独映射到所有元素。照我的经验看,在大约 80% 对数组进行的循环中,都不需要知道当前元素在数组内部的偏移。因此,通过强制程序员不考虑数组偏移而专心思考, map() 可以改进编码效率和风格。
清单 2. foreach() vs. map()# "normal" (procedural) way
foreach (sort keys %ENV)
{
print "$_ => $ENV{$_}\n";
}
# FP way
map { print "$_ => $ENV{$_}\n" } sort keys %ENV;
在清单 2 中,您可以看到 FP 方法与常规方法并没有根本上的不同,但它却设法由右至左地传播函数流。首先,获得键列表,接着对键列表进行排序,然后对已排序的键列表的每个元素应用 print() 函数。
清单 3. 用 map() 实时修改列表# these are the users
@users = ('joe', 'ted', 'larry');
# and this is an on-the-fly substitution of user names with hash references
map { $_ = { $_ => length } } @users;
# @users is now ( { 'joe' => 3 },
# { 'ted' => 3 },
# { 'larry' => 5 } )
清单 3 演示了如何完全重写传递到 map() 的列表。在该例中,数组 @users 只包含用户名,但在应用 map() 之后,该数组却包含具有键-值对(即 username => 用户名字节长度)的散列引用。而快速填充文件信息又如何呢?
清单 4. 用 map() 实时修改列表,第 2 部分use File::stat;
use Data::Dumper;
@files = ('/etc/passwd', '/etc/group', '/etc/fstab', '/etc/vfstab');
print Dumper
map { $sb = stat $_;
$_ = (defined $sb) ?
{
name => $_,
size =>$sb->size(),
mtime => $sb->mtime()
} :
undef
} @files;
在清单 4 中,我们创建了一个文件列表,然后在一条语句中创建了一个具有每个文件的 name、size 和 mtime(修改时间)项的散列表。而且,不存在的文件会得到一个“undef”引用,而不是一个散列引用。最后,使用 Data::Dumper 打印一份便于查阅的完整产品清单。
显然,对于象清单 4 这样的代码,我们应该详细地编制文档以便于他人理解。也不要试图在一行中写下所有这些语句。如果没有适当的格式化处理和注释,所谓的优雅代码只是一只丑小鸭。
grep() 函数
对于任何用过 UNIX 的人来说,学习和使用 grep() 函数都很简单。它的作用就像 grep 实用程序一样 ― 满足测试条件的元素可以通过,而其它元素则被丢弃。
grep() 的语法就像 map() 一样。在 grep() 中可以传递块或表达式,但 $_ 成为当前正在检查的元素的别名。不要修改传递到 grep() 的列表中的元素。那是 map() 做的事。请使用 grep() 来进行字符串匹配查找,使用 map() 来进行映射。这条规则的唯一例外是您在排序的同时必须创建临时散列字段或数组项,但是要确保在事后除去它们。
清单 5. grep() 如何工作# grep {BLOCK GOES HERE} LIST;
grep {$_ > 1} @p; # only accept numbers more than 1
grep {$_++} @p; # please don't do this
# grep EXPRESSION, LIST;
grep /hi/,@p; # only accept matching elements
grep !/hi/,@p; # do not accept matching elements
使用 grep() 进行快速过滤会很方便,但是请记住,在某些情况下 foreach() 循环可能要快得多。如果有疑问,请做性能测试。
清单 6. 使用 grep() 过滤出奇数my @list = (1, 2, 3, 'hi');
my @results;
# the procedural approach
foreach (@list)
{
push @results, $_
unless $_%2;
}
# using grep - FP approach
@results = grep { !($_ % 2) } @list;
这里还有一个示例。假设我们需要查阅某个目录并从中检索出所有文件名:
清单 7. 使用 grep() 获得目录中的所有文件名opendir(DIR, ".") || die "can't opendir: $!"; # get the directory handle
my @f = grep { /^[^\.]/ && -f } readdir(DIR); # filter only files into @f
清单 7 的第 1 行只是打开当前目录,或者退出程序并给出适当的提示信息。
第 2 行调用返回文件名列表的 readdir() 函数,然后运行 grep() ,该函数过滤掉隐藏文件(文件名不能以“.”字符开始)和非文件对象(如目录)。
我们仅仅用了两行代码就做到了 foreach() 循环要四五行才可能做到的事情。别忘了为这么紧凑的代码加注释;对于生产代码(production code)来说,这里显示的简短注释是不够的。有时, grep() 还用于标量环境中(例如,检测 Perl 解释器是否在运行[Proc::ProcessTable 模块来自 CPAN]):
清单 8. 使用 grep() 获得所有运行中的 Perl 进程use Proc::ProcessTable; # get this module from CPAN
use strict;
my $table = new Proc::ProcessTable;
my @procs;
if (@procs = grep { defined $_->cmndline &&
$_->cmndline =~ /^perl/ }
@{$table->table})
{
print $_->pid, "\n" foreach @procs;
}
else
{
print "No Perl interpreters seem to be running.\n";
}
在这个示例中,我们然后将 grep() 返回的结果赋值给 @procs 数组,同时进行测试,看它是否包含任何元素。如果没有与该模式匹配的元素,则打印一条消息指出那种效果。在 foreach() 循环中,同样的代码可能要用五六行。如果前面没有详细的说明,则应该为象清单 8 这样的代码加上详细的注释,以便别人一看到这段代码就知道它的意图和效果。编写只有自己能看懂的生产代码毫无用处。
用 map 排序:Schwartzian 和 Guttman-Rosler 变换
Perl 中的 sort() 函数“在一定程度上”是过程型的,因为它采用一个代码块或函数名并使用它来为所有元素排序。必须编写比较函数,以便它好像只比较两个元素 ― 它不知道正在比较整个列表中的哪两个元素。象 map() 和 grep() 一样, sort() 处理对正在比较的值的引用,因此修改它们将修改正在比较的值。不要这样做(有关 sort() 函数的详细信息,请参阅“perldoc -f sort”)。
Perl 的排序能力使用起来非常简单。sort 的最简单形式可能如下:
清单 9. 缺省的 sort()@new_list = sort @old_list; # sort @old_list alphabetically
缺省的 sort 对列表中的所有标量进行简单的字符串比较。如果列表包含要按照字母顺序排序的字典词语,那么这很棒,但是如果列表包含数字,就没那么好了。为什么?举个例子来说,在字符串比较中,“1”位于“010”前面。因此必须按照值、而不是字符串来比较数字。
幸运的是,这很容易做到,而且实际上是普通的 Perl 习惯用法:
清单 10. 对数字使用 sort()@old_list = (1, 2, 5, 200, '010');
@new_list = sort { $a <=> $b } @old_list; # sort @old_list numerically
我用引号将 010 括起,因为在 Perl 中,以 0 开头的数字被解释成八进制数字,因此,010 本应该是十进制中的 8。不带引号试一下,看看会发生什么。这个示例还演示了 Perl 标量是如何在必要时自动转换成数字的。
举个例子,如果对清单 10 中的 @old_list 运行清单 9 中的缺省排序,您将看到 200 位于 5 前面。那正是我们力图避免的。
要反转已排好序的列表,可以在排序之后对列表应用 reverse() 函数,或者更改排序函数。例如,清单 10 中排序的逆序的比较代码块是 { $b <=> $a } 。
有关 <=> 和 cmp 操作符的详细信息,请参阅“perldoc perlop”,它们是 Perl 中所有的排序所不可缺少的。清单 9 中的缺省搜索暗中使用了 cmp 操作符。
嗯,很好,我们可以为标量排序了。但那还不够 ― 大多数排序都是对诸如数组和散列这样的数据结构进行的。Perl 几乎支持所有类型的排序,因为它的语法很灵活。例如,假设我们需要为一串散列引用排序,其中,散列中的“name”键是排序字段。我们想得到常规的按字母排序的顺序,因此应该使用 cmp 操作符:
清单 11. 按散列成员进行的排序# create a list with two hash references
@old_list = ( { name => "joe" }, { name => "moe" } );
# sort @old_list by the value of the 'name' key
@new_list = sort { $a->{name} cmp $b->{name} } @old_list;
现在,我们要讨论有趣的东西了。如果从正在被排序的对象获得数据会消耗大量系统资源怎么办?假设我们在每次需要获得值以进行排序时都需要对字符串应用 split() 函数。那么每次需要比较值时都要花大量时间进行计算以运行 split() ,而且您的同事也会嘲笑您。您可以构建一个比较值的临时列表,但是这样做并不是很容易,而且易于引入错误。使用 Schwartzian 变换(以 Randal Schwartz 命名)可能会好一些。
Schwartzian 变换看起来类似于:
清单 12. Schwartzian 变换# create a list with some strings
@old_list = ( '5 eagles', '10 hawks', '2 bulls', '8 cows');
# sort @old_list by the first word in each string, numerically
@new_list = map($_->[1],
sort( { $a->[0] <=> $b->[0] }
map( [ (split)[0], $_ ],
@old_list)));
从右至左查看它。首先,我们对 @old_list 列表应用 map() 以创建一个新的临时列表。请记住 map() 可以变换列表。在这个示例中,我们重写了 @old_list 以包含一个数组,该数组由 @old_list 中每个字符串的 split() 的第一个值(这是比较值)和该字符串本身组成。结果是一个新的列表;没有更改 @old_list。
接下来,对比较值(@old_list 中数组元素的第一个元素)进行排序。请注意,在整个过程中实际上没有修改 @old_list。
然后,对已排序的列表执行另一个 map() ,以便通过仅仅将第二个数组元素映射成当前变量来将该列表复原成只包含字符串。 $_->[1] 表示“将 $_ 重写为存储在 $_ 引用的列表中的第二个对象中的值”。
现在,您可能因为看到 Schwartzian 变换而困惑。它第一眼看起来确实令人畏惧,但是在深入研究它之后您会发现它并没那么可怕。如果对它仍然不太明了,请参阅下面的 参考资料。
Guttman-Rosler 变换本身很吸引人,但是对它的讨论只会使您更困惑。请查看 参考资料一篇有关 GRT 的文章,它是解释 Guttman-Rosler 变换的最佳文章。这篇文章极好地在大体上介绍了排序。如果您一点也不了解有关排序算法最起码的背景知识的话,我建议您读一读这篇文章。排序背后的理论对程序员来说极其有用,并且对 O(n) 符号的理解不仅对排序有价值、而且对剖析(profiling)、调试和编写优秀的代码都很有价值。
何时使用 FP
我要再说一次: 了解您的工具
。到目前为止我们已经看到,函数型编程是一个极佳的工具。它可以简化一些非常麻烦的问题并可以使其它问题稍微简明一些。那么,应该在什么时候使用 FP 呢?
- 首先请记住,在 Perl 中,FP 只是一种方法。实际的解决方案将是过程型的,即使它模拟函数型解决方案也是如此。问题不是应该何时使用 FP,而是应该在多大程度上使用它,是“根本不使用”还是“尽可能多地使用”还是别的什么。
- 任何时候当您需要进行复杂排序时,都要看看是否可以使用 Schwartzian 变换或 Guttman-Rosler 变换。有时可以使用这些变换来代替常规排序。
- 如果您的函数经常要相互关联,则考虑使用 FP。例如,如果需要几个函数在几个步骤中修改列表,则可能可以用 FP 方法完成这一任务。
- 如果您有许多在使用之后就要丢弃的临时变量,则可以考虑使用 FP 来减少变量数目。
- 在对列表或散列应用过滤、排序和常规变换函数时都可以考虑使用 FP。
- 如果您的函数有许多副作用,并且它们有很多参数,则 FP 可能不会工作得很好。
- 递归算法用不用 FP 都可以。使用 FP 方法的优点和缺点并不明显。
- 如果性能很重要,则要避免使用 FP。请使用 Benchmark 模块来检查您的方法 ― 有时 FP 将显著提高性能(例如,Schwartzian 变换要快得多,因为它将比较值保存在高速缓存中),但有时它会导致代码性能大幅度下降。
- 喜欢把代码写在一行中的人很适于使用 FP。
- 模糊的 Perl 代码总是喜欢使用 grep() 和 map() 作为混淆代码操作的方式。除非您正在为了比赛而编写模糊的 Perl 代码,否则不要不加注释地使用 grep() 和 map() 。
- 在日常编程工作中学习、练习和使用 FP。您将了解其它代码,看到以后的新方法并使您的生活更加轻松。不要仅仅因为有了 FP 而使用 FP,而是因为它可以帮助您解决您的特定问题而使用它。
练习
- 编写一个使用 map() 将用户名称列表变换成用户标识的代码片断。
- 编写一个使用 (1) 来查询用户标识的程序。允许使用部分名称来过滤用户列表。
- 编写代码来检查是否有 root 用户拥有的进程正在运行(在 UNIX 系统上)。
- 对小型数据集分别应用 Schwartzian 变换、您自己的排序代码和 Guttman-Rosler 变换,以比较各自的性能。使用 Benchmark 模块来这样做。
- 对大型数据集执行 (4)。例如,按照大小对您系统上的所有文件名进行排序。有关设计思想,请查看 File::Find 模块。
- 在 comp.lang.functional FAQ 中阅读有关 Erlang、Scheme 和 Haskell 的内容。查看其它 FP 语言,看看它们是否有很好的主意供您在 Perl 中使用。
- 编写一个 Perl 版本的 grep 程序。立即想到 grep() 函数了吗?您不应该在这种情况下使用 grep() ,因为您可能必须处理一个大型文件,而仅仅为了对整个文件内容运行 grep() 就将它们保留在内存中毫无意义。想一种更好的解决方案。