Perl 设计模式
简介
1995年,设计模式(Design Patterns)一书出版,在这之后的几年里,它对许多开发人员编写软件的方式产生了巨大影响。在这篇文章系列中,我分享了自己对《设计模式》一书(所谓的四人帮书籍,简称GoF)及其哲学如何适用于Perl的看法。虽然Perl是一种面向对象的编程语言——你可以直接在Perl中使用GoF中的示例代码——但GoF试图解决的许多问题在Perl中用特定于Perl的方法解决得更好,使用Java开发者或坚持只使用对象的C++开发者无法使用的技术。即使其他语言的开发者愿意考虑过程式方法,他们也不能使用Perl极其强大的内置模式支持。
虽然这些文章是独立的,但如果你对GoF书籍(或者在阅读时将其放在桌子上会更好)有所了解,你会从中获得更多。如果你没有这本书,那么尝试在互联网上搜索——许多人谈论这些模式。由于网络和书籍都有模式的对象版本图,因此我不会在这里重复,但可以指导你访问这个优秀网站。
我将向你展示如何在Perl中实现最高价值的模式,通常是通过使用Perl丰富的核心语言。我甚至包括一些对象。
对于面向对象的实现,我需要你了解Perl对象的基本知识。你可以从Tom Christiansen和Nat Torkington的《Perl CookBook》(https://www.oreilly.com/catalog/cookbook)或Damian Conway的《Objected Oriented Perl》(https://www.manning.com/books/object-oriented-perl)这样的印刷资源中学习。但了解这些基础知识的最简单方法是阅读perldoc perltoot。
为了激励我的方法,让我从一点面向对象哲学开始。以下是关于对象的两个原则:
- 当数据和方法紧密绑定在一起时,对象是好的。
- 在大多数其他情况下,对象都是过度设计的。
让我简要地解释一下这些原则。
当数据和方法紧密绑定在一起时,对象是好的。
当你为一家租车公司(正如我一样)工作,一个表示租赁协议的对象是有意义的。协议上的数据紧密绑定到你需要执行的方法。要计算应付款项,你需要将各种费率相加,等等。这是使用对象(或实际上是一系列聚合对象)的良好用途。
在大多数其他情况下,对象都是过度设计的。
考虑一些来自其他语言的例子。Java有java.lang.Math
类。它提供了正弦和余弦等功能。它只提供类方法和一些类常量。这不应该被强制放入面向对象的框架中,因为没有Math对象。相反,这些功能应该放在核心中,完全排除,或者制作成非面向对象的函数。最后一个选项甚至在Java中都不可用。
或者想想C++标准模板库。整个模板框架是必需的,以便C++与C保持向后兼容,并处理强静态类型检查。这使得面向对象的构造变得尴尬,而这些构造应该是核心语言简单部分的组成部分。具体来说,为什么语言一开始就不能有一个更好的数组类型呢?然后一些命名良好的内置操作就可以处理堆栈、队列、双端队列以及其他我们在学校学过的许多结构。
所以,特别是,我反对一个GOF(四人帮)的常见技巧:将一个想法变成一个完整的对象类。我更喜欢Perl的方式,将最重要的概念融入到语言的核心。既然我更喜欢这种Perl方式,我就不会展示如何将可以更简单地成为一个没有方法的简单散列表或没有类的简单函数的对象化。我将反转GOF的技巧:用更简单的Perl概念实现完整的模式类。
本文中的模式主要依赖于Perl的内置功能。后面的文章将讨论其他模式的组。现在我已经告诉了你们我将要做的事情,让我们开始吧。
迭代器
有很多结构需要逐个元素遍历。这包括简单的东西,比如数组,中等复杂的东西,比如散列表的键,以及复杂的东西,比如树节点。
四人帮建议用上面提到的方法解决这个问题:将一个概念变成一个对象。这里的意思是,你应该创建一个迭代器对象。任何可以合理遍历的对象类都应该有一个返回迭代器对象的方法。这个对象本身总是以统一的方式表现。例如,考虑以下代码,它在Java中使用迭代器遍历散列表的键。
for (Iterator iter = hash.keySet().iterator(); iter.hasNext();) {
Object key = iter.next();
Object value = hash.get(key);
System.out.println(key + "\t" + value);
}
HashMap对象有可以遍历的东西:它的键。你可以请求这个keySet。这个Set会返回一个迭代器,这是通过它的iterator方法请求的。迭代器在有更多东西可以遍历时对hasNext返回true值,否则返回false。它的next方法提供迭代器正在管理的任何序列中的下一个对象。有了这个键,HashMap通过get(key)响应返回下一个值。这在具有有限操作符和内置类型的完全OO框架中非常整洁。它也完美地展示了GoF迭代器模式。
在Perl中,任何可以遍历的内建或用户定义的对象都有一个返回要遍历的项的有序列表的方法。要遍历列表,只需将其放在foreach循环的括号内。所以,上面散列表键遍历的Perl版本是
foreach my $key (keys %hash) {
print "$key\t$hash{$key}\n";
}
我可以像GoF图示的那样完全实现这个模式,但Perl提供了一种更好的方法。在Perl 6中,甚至可以返回一个懒加载的列表,所以上面的版本将比现在更高效。在Perl 5中,keys列表在我调用keys时完全构建。在未来,keys列表将在需要时构建,大多数情况下节省内存,在循环提前结束的情况下节省时间。
将迭代作为核心概念包括在内,体现了Perl设计的精华。Perl不像Java和C++(通过其标准模板库)那样在非核心代码中提供笨拙的机制,而是将这种模式融入到语言的核心。正如我在引言中所暗示的,这里有一个Perl原则
如果一个模式真正有价值,那么它应该是语言核心的一部分。
上面的例子来自语言的核心。要看到foreach完全实现了迭代器模式,即使是对于用户定义的模块,可以考虑来自CPAN的例子:[XML::DOM](https://metacpan.org/pod/XML::DOM)。XML的DOM是由Java程序员指定的。你可以调用DOM Document的一个方法是getElementsByTagName。在DOM规范中,这返回一个NodeList,这是一个Java集合。因此,NodeList在Java代码上像Set一样工作。你必须请求它一个迭代器,然后遍历迭代器。
当Perl人实现DOM时,他们决定getElementsByTagName将返回一个合适的Perl列表。要遍历列表,可以说
foreach my $element ($doc->getElementsByTagName("tag")) {
# ... process the element ...
}
这与上面Java版本过于冗长的版本形成鲜明对比
NodeList elements = doc.getElementsByTagName("tag");
for (Iterator iter = elements.iterator(); iter.hasNext();) {
Element element = (Element)iter.next();
// ... process the element ...
}
Perl的美丽之一在于它能够以如此强大的方式将过程式、面向对象和核心概念相结合。虽然GoF建议使用对象实现模式,以及像Java这样的纯对象语言需要这样做,但这并不意味着Perl程序员应该忽视Perl的非面向对象特性。
Perl在很大程度上是通过出色的使用提升原则而成功的。基本模式已集成到语言的核心中。有用的事情通过模块实现。无用的事情通常缺失。
因此,GoF的迭代模式是Perl的核心部分,我们很少考虑。下一个模式可能实际上需要我们做一些工作。
装饰器
在正常操作中,一个装饰器包装一个对象,响应与被包装对象相同的API。例如,假设我为文件写入对象添加一个压缩装饰器。调用者将文件写入器传递给装饰器的构造函数,并在装饰器上调用write
。装饰器的write方法首先压缩数据,然后调用它所包装的文件写入器的write
方法。只要所有写入器都响应相同的API,任何其他类型的写入器都可以用相同的装饰器包装。其他装饰器也可以串联使用。文本可以由一个装饰器转换为ASCII,由另一个装饰器压缩。装饰器的顺序很重要。
在Perl中,我可以用对象来做这件事,但我也可以利用一些语言特性来获得我需要的绝大部分装饰,有时甚至完全依赖内置语法。
I/O是装饰最常见的使用。Perl直接提供I/O装饰。考虑上面的例子:写入时压缩。这里有两种方法来做这件事。
使用Shell及其工具
当我在Perl中打开文件进行写入时,我可以通过shell工具进行装饰。以下是上面的例子在代码中的实现
open FILE, "| gzip > output.gz"
or die "Couldn't open gzip and/or output.gz: $!\n";
现在,我写入的所有内容都会通过gzip传输到output.gz。只要(1)你愿意使用shell(有时会引发安全问题);并且(2)shell有完成所需任务的工具,这就可以很好地工作。这里也存在一个效率问题。操作系统将为gzip步骤创建一个新的进程。进程创建是操作系统在没有执行I/O的情况下能做的最慢的事情之一。
绑定
如果你需要更多控制数据发生的事情,那么你可以使用Perl的绑定机制来自行装饰它。在Perl 6中,这将更快、更容易使用、更强大,但在Perl 5中也能工作。它确实可以在Perl的OO框架内工作;有关更多信息,请参阅perltie。
假设我想在句柄的每行输出前添加时间戳。以下是一个用于此目的的绑定类。
package AddStamp;
use strict; use warnings;
sub TIEHANDLE {
my $class = shift;
my $handle = shift;
return bless \$handle, $class;
}
sub PRINT {
my $handle = shift;
my $stamp = localtime();
print $handle "$stamp ", @_;
}
sub CLOSE {
my $self = shift;
close $self;
}
1;
这个类非常简单,在现实生活中,你需要更多的代码来使装饰器更健壮和完整。例如,上面的代码没有检查句柄是否可写,也没有提供PRINTF
,因此对printf
的调用将失败。请随意填写详细信息。(再次,请参阅perldoc perltie以获取更多信息。)
以下是这些部分的作用。绑定文件句柄类的构造函数被调用为TIEHANDLE
。其名称是固定的,为大写,因为Perl会为你调用它。这是一个类方法,因此第一个参数是类名。其他参数是打开的输出句柄。构造函数只是将此句柄的引用进行祝福,并返回该引用。
PRINT
方法接收在TIEHANDLE
中构造的对象以及提供给print的所有参数。它计算时间戳,并将时间戳与原始参数一起发送到句柄,使用真正的print函数。这是典型的装饰工作。装饰对象响应print就像常规句柄一样。它做一点工作,然后调用包装对象的同一方法。
CLOSE
方法关闭句柄。我本可以从Tie::StdHandle继承以获得此方法和许多类似的方法。
一旦我把 AddTimeStamp.pm 放入我的库路径中,我就可以这样使用它
#!/usr/bin/perl
use strict; use warnings;
use AddStamp;
open LOG, ">output.tmp" or die "Couldn't write output.tmp: $!\n";
tie *STAMPED_LOG, "AddStamp", *LOG;
while (<>) {
print STAMPED_LOG;
}
close STAMPED_LOG;
在像往常一样打开文件进行写入后,我使用内置的 tie
函数将 LOG
处理器绑定到名为 STAMPED_LOG
的 AddStamp
类。之后,我专门使用 STAMPED_LOG
。
如果有其他绑定的装饰器,那么我可以将绑定的处理器传递给它们。唯一的缺点是,Perl 5 的绑定比正常操作要慢。然而,根据我的经验,磁盘和网络是我的瓶颈,因此这种内存效率不高的情况往往并不重要。即使我将脚本代码执行速度提高 90%,我也不会节省明显的时间,因为一开始就没有花多少时间。
这种技术适用于许多内置类型:标量、数组、散列以及文件处理器。《perltie》解释了如何绑定这些类型中的每一个。
绑定很棒,因为它们不需要调用者理解你在背后使用的魔法。这也适用于 GoF 装饰器,只有一个明显的例外:在 Perl 中,你可以改变内置类型的行为。
装饰列表
在 Perl 中,最常见的任务之一是以某种方式转换列表。也许你需要跳过列表中所有以下划线开头的条目。也许你需要对列表进行排序或反转。许多内置函数都是列表过滤器。它们接受一个列表,对其进行操作并返回一个结果列表。这与 Unix 过滤器类似,Unix 过滤器期望从标准输入接收数据行,并以某种方式操作它们,然后再将结果发送到标准输出。就像在 Unix 中一样,Perl 列表过滤器可以串联起来。例如,假设你想要当前目录中所有子目录的逆字母顺序列表。这里有一个可能的解决方案。
1 #!/usr/bin/perl
2 use strict; use warnings;
3
4 opendir DIR, ".",
5 or die "Can't read this directory, how did you get here?\n";
6 my @files = reverse sort map { -d $_ ? $_ : () } readdir DIR;
7 closedir DIR;
8 print "@files\n";
Perl 6 将引入更具有意义的表示法来执行这些操作,但你可以通过一点努力学会在 Perl 5 中阅读它们。第 6 行是关键。从右边开始阅读(这对于 Unix 用户来说是反方向的)。首先,它读取目录。由于 map
期望一个列表,因此 readdir
返回目录中所有文件的列表。map
生成一个包含每个文件名称的列表(如果是目录,则为 undef
,如果 -d
测试失败)。sort
将列表按 ASCII 字典顺序排序。reverse
反转它。结果存储在 @files
中,以供以后打印。
你可以轻松地创建自己的列表过滤器。假设你想要替换上面(我倾向于认为 map
总是丑陋的)的 map
使用,以下是如何做
#!/usr/bin/perl
use strict; use warnings;
sub dirs_only (@) {
my @retval;
foreach my $entry (@_) {
push @retval, $entry if (-d $entry);
}
return @retval;
}
opendir DIR, "."
or die "Can't read this directory, how did you get here?\n";
my @files = reverse sort { lc($a) cmp lc($b) } dirs_only readdir DIR;
closedir DIR;
local $" = ";";
print "@files\n";
新的 dirs_only
程序替换了上面的 map
,排除了我们不想看到的条目。
sort
现在有一个显式的比较子程序。这是为了避免它认为 dirs_only
是其比较程序。由于我必须包含这个,我选择利用这种情况,以更巧妙的方式排序:忽略大小写。
你可以随心所欲地创建这样的列表过滤器。
我已经向你展示了最重要的装饰类型。任何其他你需要实现的都可以使用传统的 GoF 方式实现。
下一个模式感觉像是在作弊,但 Perl 经常给我这种感觉。
享元
重用对象的想法是享元模式的本质。多亏了 Mark-Jason Dominus,Perl 将这一点远远超出了 GoF 的预期。此外,他一次性完成了这项工作。Larry Wall 非常喜欢这个想法,以至于他将它推广到了 Perl 6 的核心(又是那个推广概念)。
我想要的是这个
对于那些实例不重要(它们是常量或随机的)的对象,如果可能,应将请求新对象的请求者给予他们已经收到的相同的对象。
如果不同的实例很重要,这个模式就会失败得很惨。但如果它们不重要,那么它将节省时间和内存。
以下是一个如何在Perl中实现这个功能的例子。假设我想为像大富翁或骰子游戏这样的游戏提供骰子类。我的骰子类可能看起来像这样:(警告:这个例子是为了展示技术而设计的。)
package CheapDie;
use strict; use warnings;
use Memoize;
memoize('new');
sub new {
my $class = shift;
my $sides = shift;
return bless \$sides, $class;
}
sub roll {
my $sides = shift;
my $random_number = rand;
return int ($random_number * $sides) + 1;
}
1;
乍一看,这看起来和其他许多类一样。它有一个名为new的构造函数。构造函数将接收到的面数存储到子例程词法变量(也称为my变量)中,并返回一个对其祝福的引用。roll方法计算一个随机数,根据面数进行缩放,并返回结果。
这里唯一奇怪的是这两行
use Memoize;
memoize('new');
这些充分利用了Perl的魔法。memoize
函数修改了调用包的符号表,使得new
被包装。包装函数检查传入的参数(在这种情况下是面数)。如果它以前没有看到这些参数,那么它就会像用户期望的那样调用函数,将结果存储在缓存中,并将其返回给用户。这比如果没有使用该模块要花费更多的时间和内存。
节省发生在方法再次被调用的时候。当包装器注意到一个与之前使用的相同参数的调用时,它不会调用方法。相反,它发送缓存的对象。作为调用者或对象实现者,我们不必做任何特别的事情。如果你的对象很大,或者构造很慢,那么这种技术会为你节省时间和内存。在我的情况下,由于对象很小,这种方法既浪费了时间也浪费了内存。
唯一需要注意的是,并非所有方法都受益于这项技术。例如,如果我将roll
进行记忆化,那么它将每次返回相同的数字,这并不是我们期望的结果。
请注意,Memoize也可以用于非对象情况 - 事实上,它的文档似乎没有考虑将其用于对象工厂。
不仅像Java这样的语言没有核心功能用于缓存方法返回值,而且它们不允许用户巧妙地实现这些功能。Mark-Jason Dominus通过实现Memoize做了件好事,但Larry Wall做得更好,让他这样做。想象一下Java允许用户编写一个在运行时操作调用者符号表的类 - 我几乎能听到恐怖的尖叫声。当然,这些技术可能会被滥用,但禁止它们比在少数情况下拒绝较差的代码、一些不太出色的程序员不正确地调整符号表所带来的损失更大。
在Perl中,所有事情都是合法的,但有些事情最好留给有强大开发社区的模块。这允许普通用户利用魔法操作,而不必担心我们自己的魔法是否可行。Memoize
是一个例子。与其自己编写包装调用和缓存方案,不如使用Perl(以及查找具有“已缓存”特征的特性来为Perl 6中的例程执行此操作)中随带的经过良好测试的方案。
下一个模式与此有关,因此您可以使用轻量级来实现它。
单例
在轻量级模式中,我们看到有时所有人都可以共享资源。GoF将只有一个人需要共享单一资源的情况称为单例模式。也许资源是一个配置参数的哈希。每个人都应该能够查看那里,但它只应在启动时构建(并且可能在某些信号上重建)。
在大多数情况下,您只需使用Memoize
即可。这对我来说似乎是最合理的。 (参见上面的轻量级部分。)在这种情况下,所有希望访问资源的人都调用构造函数。第一个这样做的人会触发构造过程并接收对象。随后的其他人调用构造函数,但他们接收最初构建的对象。
还有许多其他方法可以实现相同的效果。例如,如果您认为调用者可能会传递给您意外的参数,那么Memoize
将为每个参数集创建多个实例。在这种情况下,使用来自CPAN的模块如Cache::FastMemoryCache
来管理单例可能更有意义。您甚至可以使用文件词法,在BEGIN
块中为其赋值。请记住,在方法中不一定需要使用bless
。您可以说
package Name;
my $singleton;
BEGIN {
$singleton = {
attribute => 'value',
another => 'something',
};
bless $singleton, "Name";
}
sub new {
my $class = shift;
return $singleton;
}
这避免了Memoize的一些开销,并更直接地展示了我在做什么。我根本没有考虑子类继承。也许我应该这样做,但模式表明单例始终属于一个类。关于单例的基本声明是
“只能有一个单例。”
总结
本文中展示的四种模式都使用了内置功能或标准模块。迭代器是用foreach
实现的。装饰器是用Unix管道和重定向语法或使用绑定文件句柄实现的I/O。对于列表,装饰器只是接受并返回列表的函数。所以,我可能会称装饰器为过滤器。轻量级对象可以通过Memoize模块轻松实现。单例可以作为轻量级对象或使用简单的对象技术实现。
下次当一些傲慢的OO程序员开始谈论模式时,请放心,您知道如何使用它们。实际上,它们是您语言的核心功能(至少如果您有使用Perl的头脑的话)。
下次,我将探讨依赖于代码引用或数据容器的模式。
致谢和背景
我在参加了一个使用GoF的培训课程后写了这些文章,该课程由一家知名的培训和咨询公司提供。我的写作还受到了Perl社区中许多人的启发,包括马克-贾森·多米努斯,他在2002年的YAPC上以他独特的风格展示了Perl如何处理迭代器模式。尽管这里的写作是我的,但灵感来自多米努斯和Perl社区中的许多人,尤其是拉里·沃尔,他们在过去几年中将模式融入Perl的核心。正如这些模式所显示的,Perl一次又一次地谨慎地运用了提升原则。与Java和C++在源代码模块中添加集合框架不同,Perl只有两个集合:数组和哈希。两者都是语言的核心。我认为Perl最大的优势是社区对其核心中包含的内容、与核心一起发货的内容以及要排除的内容的选择。Perl 6将使Perl在语言设计思想战争中更具竞争力。
标签
反馈
这篇文章有什么问题吗?请通过在GitHub上打开一个问题或pull request来帮助我们。