perlootut - Perl 物件導向程式設計教學手冊
本文檔於 2011 年 2 月建立,最後一次重大修訂於 2013 年 2 月。
如果您在未來閱讀本文檔,則技術的最新狀態可能已經改變。我們建議您從 Perl 最新穩定版本中閱讀 perlootut 文件,而不是這個版本。
本文檔提供 Perl 物件導向程式設計的簡介。它首先簡要概述物件導向設計背後的概念。然後它介紹了幾個不同的物件導向系統,這些系統建立在 Perl 所提供的基礎之上,這些系統來自 CPAN。
預設情況下,Perl 內建的 OO 系統非常精簡,讓您進行大部分的工作。這種精簡在 1994 年非常有意義,但在 Perl 5.0 之後的這些年中,我們已經看到許多常見模式出現在 Perl OO 中。幸運的是,Perl 的靈活性讓豐富的 Perl OO 系統生態系蓬勃發展。
如果您想知道 Perl OO 在底層是如何運作的,perlobj 文件說明了重要的細節。
本文檔假設您已經了解 Perl 語法、變數類型、運算子及子常式呼叫的基本知識。如果您還不了解這些概念,請先閱讀 perlintro。您還應該閱讀 perlsyn、perlop 及 perlsub 文件。
大多數物件系統共享許多共同的概念。您可能聽過「類別」、「物件」、「方法」及「屬性」等術語。了解這些概念將使讀寫物件導向程式碼變得容易許多。如果您已經熟悉這些術語,您仍然應該瀏覽本節,因為它會根據 Perl 的 OO 實作來解釋每個概念。
Perl 的 OO 系統是基於類別的。基於類別的 OO 相當常見。它被 Java、C++、C#、Python、Ruby 及許多其他語言使用。也有其他物件導向範例。JavaScript 是使用另一種範例最流行的語言。JavaScript 的 OO 系統是基於原型的。
物件是一種資料結構,它將資料和在該資料上運作的子常式組合在一起。物件的資料稱為屬性,其子常式稱為方法。物件可以被視為名詞(人、網路服務、電腦)。
物件代表單一離散事物。例如,物件可能代表檔案。檔案物件的屬性可能包括其路徑、內容及最後修改時間。如果我們建立一個物件來代表名為「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
每個類別都可以定義其屬性。當我們實例化物件時,我們會將值指定給這些屬性。例如,每個 File
物件都有路徑。屬性有時稱為特性。
Perl 沒有屬性的特殊語法。在底層,屬性通常儲存在物件底層雜湊中的金鑰中,但不用擔心這一點。
我們建議您僅透過存取器方法存取屬性。這些方法可以取得或設定每個屬性的值。我們在先前的 print_info()
範例中看過這個,它會呼叫 $self->path
。
您可能還會看到術語getter和setter。這兩種是存取器類型。getter 取得屬性的值,而 setter 則設定屬性值。setter 的另一個術語是mutator
屬性通常定義為唯讀或讀寫。唯讀屬性只能在物件第一次建立時設定,而讀寫屬性則可以在任何時候變更。
屬性的值本身可能是另一個物件。例如,File
類別可以傳回表示該值的 DateTime 物件,而不是傳回最後修改時間作為數字。
有可能有一個類別不公開任何可設定的屬性。並非每個類別都有屬性和方法。
多型是一種花俏的說法,表示來自兩個不同類別的物件共用一個 API。例如,我們可以有 File
和 WebPage
類別,它們都有一個 print_content()
方法。此方法可能會為每個類別產生不同的輸出,但它們共用一個共通介面。
雖然這兩個類別在許多方面可能有所不同,但在 print_content()
方法方面,它們是相同的。這表示我們可以嘗試對任一類別的物件呼叫 print_content()
方法,而且我們不需要知道物件屬於哪個類別!
多型是物件導向設計的主要概念之一。
繼承讓您可以建立現有類別的專門版本。繼承讓新類別可以重複使用另一個類別的方法和屬性。
例如,我們可以建立一個 繼承自 File
的 File::MP3
類別。File::MP3
是一種更具體的 File
類型。所有 mp3 檔案都是檔案,但並非所有檔案都是 mp3 檔案。
我們通常將繼承關係稱為父項-子項或超類別
/子類別
關係。有時我們會說子項與其父類別具有是-一種關係。
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 物件在底層都實作為雜湊。封裝原則告訴我們,我們不應依賴於此。相反地,我們應該使用存取器方法來存取該雜湊中的資料。我們在下面推薦的物件系統都會自動產生存取器方法。如果您使用其中一個,您就永遠不必直接將物件當成雜湊來存取。
在物件導向程式碼中,我們經常發現一個物件會參照另一個物件。這稱為組合,或擁有關係。
之前我們提到,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 提供了一份準則清單,供您在決定物件導向是否適合您的問題時使用
正在設計的系統很大,或者可能會變得很大。
資料可以彙總成明顯的結構,特別是如果每個彙總中都有大量的資料。
各種彙總資料類型形成一個自然的階層,有助於使用繼承和多型性。
您有一段資料,對其套用許多不同的運算。
您需要對相關資料類型執行相同的通用運算,但會根據運算套用至資料的特定類型而有細微差異。
您很可能會在稍後新增新的資料類型。
資料片段之間的典型互動最適合以運算子表示。
系統中個別元件的實作很可能會隨時間而改變。
系統設計已經是物件導向。
大量的其他程式設計師將會使用您的程式碼模組。
如我們之前提到的,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 上還有數十個與物件導向相關的模組,如果你使用其他人的程式碼,你可能會遇到其中一個或多個模組。
此外,許多程式碼都「手動」完成所有物件導向的動作,只使用 Perl 內建的物件導向功能。如果你需要維護此類程式碼,你應該閱讀 perlobj 以了解 Perl 內建物件導向的運作方式。
正如我們之前所說,Perl 最小的物件導向系統導致 CPAN 上出現大量的物件導向系統。雖然你仍然可以手動撰寫類別,但使用現代 Perl 真的沒有理由這麼做。
對於小型系統,Class::Tiny 和 Class::Accessor 都提供最小的物件系統,可以為你處理基本的樣板。
對於較大的專案,Moose 提供豐富的功能,讓你專注於實作你的商業邏輯。當你需要大量功能,但需要更快的編譯時間或避免 XS 時,Moo 提供了 Moose 的絕佳替代方案。
我們鼓勵你使用並評估 Moose、Moo、Class::Accessor 和 Class::Tiny,找出最適合你的物件導向系統。