目录

名称

perlootut - Perl 面向对象编程教程

日期

本文档创建于 2011 年 2 月,最后一次重大修订于 2013 年 2 月。

如果您将来阅读本文档,那么最先进的技术可能已经发生了变化。我们建议您从 Perl 最新稳定版本中阅读 perlootut 文档,而不是此版本。

说明

本文档介绍了 Perl 中的面向对象编程。它从面向对象设计的概念概述开始。然后,它介绍了来自 CPAN 的几个不同的面向对象系统,这些系统建立在 Perl 提供的基础之上。

默认情况下,Perl 的内置面向对象系统非常精简,让你完成大部分工作。这种极简主义在 1994 年非常有意义,但在 Perl 5.0 之后的几年里,我们看到 Perl 面向对象中出现了一些常见的模式。幸运的是,Perl 的灵活性让一个丰富的 Perl 面向对象系统生态系统蓬勃发展。

如果你想了解 Perl 面向对象在底层是如何工作的,perlobj 文档解释了细枝末节的细节。

本文档假设你已经了解 Perl 语法、变量类型、运算符和子例程调用的基础知识。如果你还不了解这些概念,请先阅读 perlintro。你还可以阅读 perlsynperlopperlsub 文档。

面向对象基础

大多数对象系统共享许多共同的概念。你可能以前听说过“类”、“对象”、“方法”和“属性”等术语。理解这些概念将使阅读和编写面向对象代码变得更容易。如果你已经熟悉这些术语,你仍然应该浏览本节,因为它根据 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。例如,我们可以有 FileWebPage 类,它们都具有 print_content() 方法。此方法可能会为每个类生成不同的输出,但它们共享一个公共接口。

虽然这两个类在很多方面可能有所不同,但在 print_content() 方法方面,它们是相同的。这意味着我们可以尝试对任一类的对象调用 print_content() 方法,而且我们不必知道对象属于哪个类!

多态性是面向对象设计的一个关键概念。

继承

继承允许你创建现有类的专门版本。继承允许新类重用另一个类的函数和属性。

例如,我们可以创建一个 File::MP3 类,它继承FileFile::MP3 更具体类型的 File。所有 mp3 文件都是文件,但并非所有文件都是 mp3 文件。

我们通常将继承关系称为父类-子类超类/子类关系。有时我们说子类与其父类具有是-a关系。

FileFile::MP3超类,而 File::MP3File子类

package File::MP3;

use parent 'File';

parent 模块是 Perl 让你定义继承关系的几种方式之一。

Perl 允许多重继承,这意味着一个类可以从多个父类继承。虽然这是可能的,但我们强烈建议不要这样做。通常,你可以使用角色来完成多重继承可以做的一切,但方式更简洁。

请注意,为给定类定义多个子类没有任何问题。这既常见又安全。例如,我们可以定义 File::MP3::FixedBitrateFile::MP3::VariableBitrate 类来区分不同类型的 mp3 文件。

重写方法和方法解析

继承允许两个类共享代码。默认情况下,父类中的每个方法在子类中也可用。子类可以明确重写父类的方法以提供自己的实现。例如,如果我们有一个 File::MP3 对象,它具有来自 Fileprint_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 中调用该方法。

如果 FileDataSource 继承,而 DataSourceThing 继承,那么 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对象。这是一个组合的完美示例。我们可以更进一步,让pathcontent访问器也返回对象。然后,File类将由几个其他对象组成

角色

角色是一个类所做的事情,而不是它是什么。角色对于 Perl 来说相对较新,但已经变得相当流行。角色应用于类。有时我们说类使用角色。

角色是提供多态性的另一种继承方式。假设我们有两个类,RadioComputer。这两者都有开/关开关。我们希望在我们的类定义中对此进行建模。

我们可以让这两个类都从一个公共父类(如Machine)继承,但并非所有机器都有开/关开关。我们可以创建一个名为HasOnOffSwitch的父类,但这非常人为。收音机和计算机并不是此父类的专业化。这个父类实际上是一个相当可笑的创造。

这就是角色发挥作用的地方。创建一个HasOnOffSwitch角色并将其应用于这两个类非常有意义。此角色将定义一个已知的 API,例如提供turn_on()turn_off()方法。

Perl 没有任何内置方式来表达角色。过去,人们只是咬紧牙关并使用多重继承。如今,CPAN 上有几个不错的选择可用于使用角色。

何时使用 OO

面向对象并不是解决每个问题的最佳方案。在Perl 最佳实践(版权 2004 年,由 O'Reilly Media, Inc. 出版)中,Damian Conway 提供了一份在决定 OO 是否适合你的问题时使用的标准列表

PERL OO 系统

正如我们之前提到的,Perl 的内置 OO 系统非常精简,但也相当灵活。多年来,许多人开发了在 Perl 内置系统之上构建的系统,以提供更多功能和便利性。

我们强烈建议你使用这些系统之一。即使是最精简的系统也能消除大量重复的样板代码。在 Perl 中从头开始编写类没有任何充分的理由。

如果你对这些系统背后的原理感兴趣,请查看 perlobj

Moose

Moose 自称是“Perl 5 的后现代对象系统”。不要害怕,“后现代”标签是对 Larry 将 Perl 描述为“第一门后现代计算机语言”的回调。

Moose 提供了一个完整、现代的 OO 系统。它最大的影响是 Common Lisp 对象系统,但它也借鉴了 Smalltalk 和其他几种语言的思想。Moose 由 Stevan Little 创建,并大量借鉴了他对 Raku OO 设计的工作。

下面是使用 MooseFile

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 并不完美。

Moose 可能会使你的代码加载速度变慢。Moose 本身并不小,并且在你定义类时会进行大量的代码生成。此代码生成意味着你的运行时代码尽可能快,但当你首次加载模块时,你需要为此付出代价。

当启动速度很重要时,例如使用命令行脚本或每次执行时都必须加载的“纯香草”CGI 脚本时,此加载时间命中可能是一个问题。

在你惊慌之前,要知道许多人确实将 Moose 用于命令行工具和其他对启动敏感的代码。我们鼓励你首先尝试 Moose,然后再担心启动速度。

Moose 还依赖于其他模块。其中大多数是小型独立模块,其中一些是从 Moose 中分离出来的。Moose 本身及其某些依赖项需要一个编译器。如果你需要在没有编译器的系统上安装软件,或者如果存在任何依赖项是一个问题,那么 Moose 可能不适合你。

Moo

如果你尝试了 Moose,发现其中一个问题阻止你使用 Moose,我们建议你接下来考虑 MooMoo 在一个更简单的包中实现了 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

Class::AccessorMoose 的极端对立面。它提供的功能非常少,也不是自托管的。

但是,它非常简单,是纯 Perl,并且没有非核心的依赖项。它还按需为其支持的功能提供“类似 Moose”的 API。

尽管它做得不多,但它仍然优于从头开始编写自己的类。

以下是使用 Class::AccessorFile

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

最后,我们有 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 的语法。

Role::Tiny

如前所述,角色提供了继承的替代方案,但 Perl 没有任何内置的角色支持。如果您选择使用 Moose,它将附带一个成熟的角色实现。但是,如果您使用我们推荐的其他 OO 模块之一,您仍然可以使用 Role::Tiny 使用角色

Role::Tiny 提供了一些与 Moose 角色系统相同的功能,但封装在一个更小的包中。最值得注意的是,它不支持任何类型的属性声明,因此您必须手动进行。尽管如此,它仍然很有用,并且可以很好地与 Class::AccessorClass::Tiny 配合使用

OO 系统摘要

以下是我们涵盖选项的简要回顾

其他 OO 系统

除了这里涵盖的模块之外,CPAN 上还有几十个其他与 OO 相关的模块,如果你使用其他人的代码,你很可能会遇到其中一个或多个模块。

此外,很多代码都是手工完成所有 OO 操作,只使用 Perl 内置的 OO 功能。如果你需要维护此类代码,你应该阅读 perlobj 以准确了解 Perl 的内置 OO 是如何工作的。

结论

正如我们之前所说,Perl 最小的 OO 系统导致了 CPAN 上 OO 系统激增。虽然你仍然可以退回到底层并手动编写类,但对于现代 Perl 来说,这样做真的没有必要。

对于小型系统,Class::TinyClass::Accessor 都提供了最小的对象系统,可以为你处理基本的样板代码。

对于较大的项目,Moose 提供了一组丰富的功能,让你可以专注于实现你的业务逻辑。当需要大量功能但需要更快的编译时间或避免 XS 时,Moo 提供了 Moose 的不错替代方案。

我们鼓励你试用并评估 MooseMooClass::AccessorClass::Tiny,以了解哪种 OO 系统适合你。