perlootut - Perl 面向对象编程教程
本文档创建于 2011 年 2 月,最后一次重大修订于 2013 年 2 月。
如果您将来阅读本文档,那么最先进的技术可能已经发生了变化。我们建议您从 Perl 最新稳定版本中阅读 perlootut 文档,而不是此版本。
本文档介绍了 Perl 中的面向对象编程。它从面向对象设计的概念概述开始。然后,它介绍了来自 CPAN 的几个不同的面向对象系统,这些系统建立在 Perl 提供的基础之上。
默认情况下,Perl 的内置面向对象系统非常精简,让你完成大部分工作。这种极简主义在 1994 年非常有意义,但在 Perl 5.0 之后的几年里,我们看到 Perl 面向对象中出现了一些常见的模式。幸运的是,Perl 的灵活性让一个丰富的 Perl 面向对象系统生态系统蓬勃发展。
如果你想了解 Perl 面向对象在底层是如何工作的,perlobj 文档解释了细枝末节的细节。
本文档假设你已经了解 Perl 语法、变量类型、运算符和子例程调用的基础知识。如果你还不了解这些概念,请先阅读 perlintro。你还可以阅读 perlsyn、perlop 和 perlsub 文档。
大多数对象系统共享许多共同的概念。你可能以前听说过“类”、“对象”、“方法”和“属性”等术语。理解这些概念将使阅读和编写面向对象代码变得更容易。如果你已经熟悉这些术语,你仍然应该浏览本节,因为它根据 Perl 的面向对象实现解释了每个概念。
Perl 的面向对象系统是基于类的。基于类的面向对象相当普遍。它被 Java、C++、C#、Python、Ruby 和许多其他语言使用。还有其他面向对象范例。JavaScript 是使用另一种范例的最流行的语言。JavaScript 的面向对象系统是基于原型的。
对象是一个数据结构,它将数据和对该数据进行操作的子例程捆绑在一起。对象的数据称为属性,其子例程称为方法。对象可以被认为是一个名词(一个人、一个网络服务、一台计算机)。
一个对象表示一个单独的离散事物。例如,一个对象可能表示一个文件。文件对象 的属性可能包括它的路径、内容和最后修改时间。如果我们创建一个对象来表示名为“foo.example.com”的计算机上的 /etc/hostname,该对象的路径将为“/etc/hostname”,其内容将为“foo\n”,其最后修改时间将为自纪元以来的 1304974868 秒。
与文件关联的方法可能包括 rename()
和 write()
。
在 Perl 中,大多数对象都是哈希,但我们推荐的 OO 系统让你不必担心这一点。在实践中,最好将对象的内部数据结构视为不透明的。
类定义了一类对象的的行为。类是一个类别的名称(如“文件”),类还定义了该类别中对象的的行为。
所有对象都属于一个特定的类。例如,我们的 /etc/hostname 对象属于 File
类。当我们想要创建一个特定对象时,我们从它的类开始,并构造或实例化一个对象。一个特定对象通常被称为一个类的实例。
在 Perl 中,任何包都可以是一个类。一个作为类的包和一个不是类的包之间的区别取决于包的使用方式。以下是我们为 File
类编写的“类声明”
package File;
在 Perl 中,没有用于构造对象的特殊关键字。但是,CPAN 上的大多数 OO 模块都使用名为 new()
的方法来构造一个新对象
my $hostname = File->new(
path => '/etc/hostname',
content => "foo\n",
last_mod_time => 1304974868,
);
(不用担心 ->
运算符,稍后会解释。)
正如我们之前所说,大多数 Perl 对象都是哈希,但一个对象可以是任何 Perl 数据类型(标量、数组等)的实例。将一个普通数据结构变成一个对象是通过使用 Perl 的 bless
函数祝福该数据结构来完成的。
虽然我们强烈建议你不要从头开始构建对象,但你应该知道术语祝福。一个受祝福的数据结构(又名“引用”)是一个对象。我们有时说一个对象已被“祝福进入一个类”。
一旦一个引用被祝福,来自 Scalar::Util 核心模块的 blessed
函数就可以告诉我们它的类名。当传递一个对象时,此子例程返回对象的类,否则返回 false。
use Scalar::Util 'blessed';
print blessed($hash); # undef
print blessed($hostname); # File
一个构造函数创建一个新对象。在 Perl 中,一个类的构造函数只是一个普通方法,与其他提供构造函数语法的语言不同。大多数 Perl 类使用 new
作为其构造函数的名称
my $file = File->new(...);
你已经了解到,方法是对对象进行操作的子例程。你可以将方法视为对象可以执行的操作。如果一个对象是一个名词,那么方法就是它的动词(保存、打印、打开)。
在 Perl 中,方法只是存在于类包中的子例程。方法总是被编写为将对象作为其第一个参数接收
sub print_info {
my $self = shift;
print "This file is at ", $self->path, "\n";
}
$file->print_info;
# The file is at /etc/hostname
使方法特殊的是它的调用方式。箭头运算符 (->
) 告诉 Perl 我们正在调用一个方法。
当我们进行方法调用时,Perl 会安排将方法的调用者作为第一个参数传递。调用者是箭头左侧内容的一个花哨名称。调用者可以是类名或对象。我们还可以将其他参数传递给方法
sub print_info {
my $self = shift;
my $prefix = shift // "This file is at ";
print $prefix, ", ", $self->path, "\n";
}
$file->print_info("The file is located at ");
# The file is located at /etc/hostname
每个类都可以定义其属性。当我们实例化一个对象时,我们会为这些属性分配值。例如,每个文件
对象都有一个路径。属性有时被称为属性。
Perl 没有用于属性的特殊语法。在底层,属性通常存储为对象底层哈希中的键,但不必担心这一点。
我们建议您仅通过访问器方法访问属性。这些方法可以获取或设置每个属性的值。我们在前面的print_info()
示例中看到了这一点,该示例调用$self->path
。
您还可能看到术语获取器和设置器。这两种类型的访问器。获取器获取属性的值,而设置器设置属性的值。设置器的另一个术语是变异器
属性通常被定义为只读或读写。只读属性只能在首次创建对象时设置,而读写属性可以随时更改。
属性的值本身可能是另一个对象。例如,文件
类可以返回表示该值的DateTime对象,而不是将其最后修改时间作为数字返回。
有可能有一个不公开任何可公开设置属性的类。并非每个类都有属性和方法。
多态性是一种花哨的说法,表示来自两个不同类的对象共享一个 API。例如,我们可以有 File
和 WebPage
类,它们都具有 print_content()
方法。此方法可能会为每个类生成不同的输出,但它们共享一个公共接口。
虽然这两个类在很多方面可能有所不同,但在 print_content()
方法方面,它们是相同的。这意味着我们可以尝试对任一类的对象调用 print_content()
方法,而且我们不必知道对象属于哪个类!
多态性是面向对象设计的一个关键概念。
继承允许你创建现有类的专门版本。继承允许新类重用另一个类的函数和属性。
例如,我们可以创建一个 File::MP3
类,它继承自 File
。File::MP3
是更具体类型的 File
。所有 mp3 文件都是文件,但并非所有文件都是 mp3 文件。
我们通常将继承关系称为父类-子类或 超类
/子类
关系。有时我们说子类与其父类具有是-a关系。
File
是 File::MP3
的超类,而 File::MP3
是 File
的子类。
package File::MP3;
use parent 'File';
parent 模块是 Perl 让你定义继承关系的几种方式之一。
Perl 允许多重继承,这意味着一个类可以从多个父类继承。虽然这是可能的,但我们强烈建议不要这样做。通常,你可以使用角色来完成多重继承可以做的一切,但方式更简洁。
请注意,为给定类定义多个子类没有任何问题。这既常见又安全。例如,我们可以定义 File::MP3::FixedBitrate
和 File::MP3::VariableBitrate
类来区分不同类型的 mp3 文件。
继承允许两个类共享代码。默认情况下,父类中的每个方法在子类中也可用。子类可以明确重写父类的方法以提供自己的实现。例如,如果我们有一个 File::MP3
对象,它具有来自 File
的 print_info()
方法
my $cage = File::MP3->new(
path => 'mp3s/My-Body-Is-a-Cage.mp3',
content => $mp3_data,
last_mod_time => 1304974868,
title => 'My Body Is a Cage',
);
$cage->print_info;
# The file is at mp3s/My-Body-Is-a-Cage.mp3
如果我们想在问候语中包含 mp3 的标题,我们可以重写该方法
package File::MP3;
use parent 'File';
sub print_info {
my $self = shift;
print "This file is at ", $self->path, "\n";
print "Its title is ", $self->title, "\n";
}
$cage->print_info;
# The file is at mp3s/My-Body-Is-a-Cage.mp3
# Its title is My Body Is a Cage
确定应该使用什么方法的过程称为方法解析。Perl 所做的是首先查看对象的类(在本例中为 File::MP3
)。如果该类定义了该方法,那么将调用该类的该方法版本。如果没有,Perl 将依次查看每个父类。对于 File::MP3
,其唯一的父类是 File
。如果 File::MP3
没有定义该方法,但 File
定义了该方法,那么 Perl 将在 File
中调用该方法。
如果 File
从 DataSource
继承,而 DataSource
从 Thing
继承,那么 Perl 将在必要时继续沿“链条”向上查找。
可以从子类显式调用父类方法
package File::MP3;
use parent 'File';
sub print_info {
my $self = shift;
$self->SUPER::print_info();
print "Its title is ", $self->title, "\n";
}
SUPER::
位告诉 Perl 在 File::MP3
类的继承链中查找 print_info()
。当它找到实现此方法的父类时,将调用该方法。
我们之前提到了多重继承。多重继承的主要问题是它极大地复杂化了方法解析。有关更多详细信息,请参阅 perlobj。
封装是一种认为对象是不透明的思想。当其他开发人员使用你的类时,他们不需要知道它是如何实现的,他们只需要知道它做什么。
封装很重要,原因有几个。首先,它允许你将公共 API 与私有实现分开。这意味着你可以更改该实现,而不会破坏 API。
其次,当类封装得很好时,它们就更容易进行子类化。理想情况下,子类使用与父类相同的 API 来访问对象数据。实际上,子类化有时涉及违反封装,但一个好的 API 可以最大程度地减少这样做的必要性。
我们之前提到,大多数 Perl 对象在底层都作为哈希实现。封装的原则告诉我们,我们不应该依赖于此。相反,我们应该使用访问器方法来访问该哈希中的数据。我们下面推荐的对象系统都自动生成访问器方法。如果你使用其中之一,你永远不必直接将对象作为哈希访问。
在面向对象代码中,我们经常发现一个对象引用另一个对象。这称为组合,或has-a关系。
之前,我们提到过File
类的last_mod_time
访问器可以返回DateTime对象。这是一个组合的完美示例。我们可以更进一步,让path
和content
访问器也返回对象。然后,File
类将由几个其他对象组成。
角色是一个类所做的事情,而不是它是什么。角色对于 Perl 来说相对较新,但已经变得相当流行。角色应用于类。有时我们说类使用角色。
角色是提供多态性的另一种继承方式。假设我们有两个类,Radio
和Computer
。这两者都有开/关开关。我们希望在我们的类定义中对此进行建模。
我们可以让这两个类都从一个公共父类(如Machine
)继承,但并非所有机器都有开/关开关。我们可以创建一个名为HasOnOffSwitch
的父类,但这非常人为。收音机和计算机并不是此父类的专业化。这个父类实际上是一个相当可笑的创造。
这就是角色发挥作用的地方。创建一个HasOnOffSwitch
角色并将其应用于这两个类非常有意义。此角色将定义一个已知的 API,例如提供turn_on()
和turn_off()
方法。
Perl 没有任何内置方式来表达角色。过去,人们只是咬紧牙关并使用多重继承。如今,CPAN 上有几个不错的选择可用于使用角色。
面向对象并不是解决每个问题的最佳方案。在Perl 最佳实践(版权 2004 年,由 O'Reilly Media, Inc. 出版)中,Damian Conway 提供了一份在决定 OO 是否适合你的问题时使用的标准列表
正在设计的系统很大,或者可能变得很大。
数据可以聚合到明显的结构中,尤其是在每个聚合中都有大量数据的情况下。
各种类型的数据聚合形成一个自然层次结构,便于使用继承和多态性。
你有一段数据,对其应用了许多不同的操作。
你需要对相关类型的数据执行相同的一般操作,但根据操作应用于数据的特定类型略有不同。
你很可能以后需要添加新的数据类型。
数据片段之间的典型交互最好用运算符表示。
系统各个组件的实现可能会随着时间的推移而改变。
系统设计已经是面向对象的。
大量其他程序员将使用你的代码模块。
正如我们之前提到的,Perl 的内置 OO 系统非常精简,但也相当灵活。多年来,许多人开发了在 Perl 内置系统之上构建的系统,以提供更多功能和便利性。
我们强烈建议你使用这些系统之一。即使是最精简的系统也能消除大量重复的样板代码。在 Perl 中从头开始编写类没有任何充分的理由。
如果你对这些系统背后的原理感兴趣,请查看 perlobj。
Moose 自称是“Perl 5 的后现代对象系统”。不要害怕,“后现代”标签是对 Larry 将 Perl 描述为“第一门后现代计算机语言”的回调。
Moose
提供了一个完整、现代的 OO 系统。它最大的影响是 Common Lisp 对象系统,但它也借鉴了 Smalltalk 和其他几种语言的思想。Moose
由 Stevan Little 创建,并大量借鉴了他对 Raku OO 设计的工作。
下面是使用 Moose
的 File
类
package File;
use Moose;
has path => ( is => 'ro' );
has content => ( is => 'ro' );
has last_mod_time => ( is => 'ro' );
sub print_info {
my $self = shift;
print "This file is at ", $self->path, "\n";
}
Moose
提供了许多功能
声明式语法糖
Moose
为定义类提供了一层声明式“语法糖”。该语法糖只是一组导出函数,它们使声明类的运作方式变得更简单、更易于接受。这让你可以描述类的是什么,而不是必须告诉 Perl如何实现类。
has()
子例程声明一个属性,Moose
会自动为这些属性创建访问器。它还会为你创建一个 new()
方法。此构造函数了解你声明的属性,因此你可以在创建新 File
时设置这些属性。
内置角色
Moose
让你可以像定义类一样定义角色
package HasOnOffSwitch;
use Moose::Role;
has is_on => (
is => 'rw',
isa => 'Bool',
);
sub turn_on {
my $self = shift;
$self->is_on(1);
}
sub turn_off {
my $self = shift;
$self->is_on(0);
}
微型类型系统
在上面的示例中,你可以看到我们在创建 is_on
属性时将 isa => 'Bool'
传递给了 has()
。这告诉 Moose
此属性必须是布尔值。如果我们尝试将其设置为无效值,我们的代码将抛出错误。
完全内省和操作
Perl 的内置内省功能相当精简。Moose
在其基础上构建,并为你的类创建了一个完整的内省层。这让你可以提出诸如“File 类实现了哪些方法?”之类的疑问。它还让你可以以编程方式修改类。
自托管且可扩展
Moose
使用其自身的内省 API 来描述自身。除了是一个很酷的技巧之外,这意味着你可以使用 Moose
本身来扩展 Moose
。
丰富的生态系统
在 MooseX 命名空间下,CPAN 上有一个丰富的 Moose
扩展生态系统。此外,CPAN 上的许多模块已经使用了 Moose
,为你提供了许多可以学习的示例。
更多功能
Moose
是一个非常强大的工具,我们无法在此处涵盖其所有功能。我们鼓励你通过阅读 Moose
文档来了解更多信息,从 Moose::Manual 开始。
当然,Moose
并不完美。
Moose
可能会使你的代码加载速度变慢。Moose
本身并不小,并且在你定义类时会进行大量的代码生成。此代码生成意味着你的运行时代码尽可能快,但当你首次加载模块时,你需要为此付出代价。
当启动速度很重要时,例如使用命令行脚本或每次执行时都必须加载的“纯香草”CGI 脚本时,此加载时间命中可能是一个问题。
在你惊慌之前,要知道许多人确实将 Moose
用于命令行工具和其他对启动敏感的代码。我们鼓励你首先尝试 Moose
,然后再担心启动速度。
Moose
还依赖于其他模块。其中大多数是小型独立模块,其中一些是从 Moose
中分离出来的。Moose
本身及其某些依赖项需要一个编译器。如果你需要在没有编译器的系统上安装软件,或者如果存在任何依赖项是一个问题,那么 Moose
可能不适合你。
如果你尝试了 Moose
,发现其中一个问题阻止你使用 Moose
,我们建议你接下来考虑 Moo。Moo
在一个更简单的包中实现了 Moose
功能的一个子集。对于它确实实现的大多数功能,最终用户 API 与 Moose
完全相同,这意味着你可以很容易地从 Moo
切换到 Moose
。
Moo
未实现 Moose
的大部分内省 API,因此在加载模块时通常速度更快。此外,它的依赖项都不需要 XS,因此可以在没有编译器的情况下安装在计算机上。
Moo
最引人注目的功能之一是它与 Moose
的互操作性。当有人尝试在 Moo
类或角色上使用 Moose
的内省 API 时,它会透明地扩展为 Moose
类或角色。这使得将使用 Moo
的代码合并到 Moose
代码库中,反之亦然,变得更加容易。
例如,Moose
类可以使用 extends
扩展 Moo
类,或使用 with
使用 Moo
角色。
Moose
作者希望有一天可以通过充分改进 Moose
使 Moo
过时,但目前它为 Moose
提供了一个有价值的替代方案。
Class::Accessor 是 Moose
的极端对立面。它提供的功能非常少,也不是自托管的。
但是,它非常简单,是纯 Perl,并且没有非核心的依赖项。它还按需为其支持的功能提供“类似 Moose”的 API。
尽管它做得不多,但它仍然优于从头开始编写自己的类。
以下是使用 Class::Accessor
的 File
类
package File;
use Class::Accessor 'antlers';
has path => ( is => 'ro' );
has content => ( is => 'ro' );
has last_mod_time => ( is => 'ro' );
sub print_info {
my $self = shift;
print "This file is at ", $self->path, "\n";
}
antlers
导入标志告诉 Class::Accessor
您希望使用类似 Moose
的语法定义属性。可以传递给 has
的唯一参数是 is
。如果您选择 Class::Accessor
,我们建议您使用这种类似 Moose 的语法,因为这意味着如果您以后决定迁移到 Moose
,您将拥有更平滑的升级路径。
与 Moose
一样,Class::Accessor
为您的类生成访问器方法和构造函数。
最后,我们有 Class::Tiny。此模块真正名副其实。它具有极其最小的 API,并且绝对不依赖于任何最近的 Perl。尽管如此,我们认为它比从头开始编写自己的 OO 代码更容易使用。
以下是我们的 File
类
package File;
use Class::Tiny qw( path content last_mod_time );
sub print_info {
my $self = shift;
print "This file is at ", $self->path, "\n";
}
就是这样!
使用 Class::Tiny
,所有访问器都是可读写的。它会为您生成一个构造函数,以及您定义的访问器。
您还可以使用 Class::Tiny::Antlers 来获得类似 Moose
的语法。
如前所述,角色提供了继承的替代方案,但 Perl 没有任何内置的角色支持。如果您选择使用 Moose,它将附带一个成熟的角色实现。但是,如果您使用我们推荐的其他 OO 模块之一,您仍然可以使用 Role::Tiny 使用角色
Role::Tiny
提供了一些与 Moose 角色系统相同的功能,但封装在一个更小的包中。最值得注意的是,它不支持任何类型的属性声明,因此您必须手动进行。尽管如此,它仍然很有用,并且可以很好地与 Class::Accessor
和 Class::Tiny
配合使用
以下是我们涵盖选项的简要回顾
Moose
是最大选项。它有很多功能、一个大型生态系统和一个活跃的用户群。我们还简要介绍了 Moo。Moo
是精简版的 Moose
,当 Moose 不适用于你的应用程序时,它是一个合理的替代方案。
Class::Accessor
的功能远少于 Moose
,如果你觉得 Moose
难以驾驭,它是一个不错的替代方案。它已经存在很长时间了,并且经过了充分的实战检验。它还具有一个最小的 Moose
兼容模式,这使得从 Class::Accessor
迁移到 Moose
变得容易。
Class::Tiny
是绝对最小的选项。它没有依赖项,几乎没有需要学习的语法。对于超级极简的环境以及在不必担心细节的情况下快速拼凑一些东西,它是一个不错的选择。
如果你发现自己在考虑多重继承,请将 Role::Tiny
与 Class::Accessor
或 Class::Tiny
一起使用。如果你使用 Moose
,它会附带自己的角色实现。
除了这里涵盖的模块之外,CPAN 上还有几十个其他与 OO 相关的模块,如果你使用其他人的代码,你很可能会遇到其中一个或多个模块。
此外,很多代码都是手工完成所有 OO 操作,只使用 Perl 内置的 OO 功能。如果你需要维护此类代码,你应该阅读 perlobj 以准确了解 Perl 的内置 OO 是如何工作的。
正如我们之前所说,Perl 最小的 OO 系统导致了 CPAN 上 OO 系统激增。虽然你仍然可以退回到底层并手动编写类,但对于现代 Perl 来说,这样做真的没有必要。
对于小型系统,Class::Tiny 和 Class::Accessor 都提供了最小的对象系统,可以为你处理基本的样板代码。
对于较大的项目,Moose 提供了一组丰富的功能,让你可以专注于实现你的业务逻辑。当需要大量功能但需要更快的编译时间或避免 XS 时,Moo 提供了 Moose 的不错替代方案。
我们鼓励你试用并评估 Moose、Moo、Class::Accessor 和 Class::Tiny,以了解哪种 OO 系统适合你。