在 PHP 編程早期,PHP 代碼在本質上是限於面向過程的。過程代碼 的特徵在於使用過程構建應用程序塊。過程通過允許過程之間的調用提供某種程度的重用。
但是,沒有面向對象的語言構造,程序員仍然可以把 OO 特性引入到 PHP 代碼中。這樣做有點困難並且會使代碼難於閱讀,因爲它是混合範例(含有僞 OO 設計的過程語言)。使用 PHP 代碼中的 OO 構造 — 例如能夠定義和使用類、能夠構建使用繼承的類之間的關係以及能夠定義接口 — 可以更輕鬆地構建符合優秀 OO 實踐的代碼。
雖然沒有過多模塊化的純過程設計運行得很好,但是 OO 設計的優點表現在維護上。由於典型應用程序的大部分生命週期都花費在維護上,因此代碼維護是應用程序生命週期的重要部分。並且在開發過程中代碼維護很容易被遺忘。如果在應用程序開發和部署方面存在競爭,那麼長期可維護性可能被放在比較次要的地位。
模塊化 — 優秀 OO 設計的主要特性之一 — 可以幫助完成這樣的維護。模塊化將幫助封裝更改,這樣可以隨着時間的推移更輕鬆地擴展和修改應用程序。
總的來說,雖然構建 OO 軟件的習慣不止 7 個,但是遵循這裏的 7 個習慣可以使代碼符合基本 OO 設計標準。它們將爲您提供更牢固的基礎,在此基礎之上建立更多 OO 習慣並構建可輕鬆維護與擴展的軟件。這些習慣針對模塊化的幾個主要特性。有關獨立於語言的 OO 設計優點的更多信息。
7 個優秀 PHP OO 習慣包括:
- 保持謙虛。
- 做個好鄰居。
- 避免看到美杜莎。
- 利用最弱的鏈接。
- 您是橡皮;我是膠水。
- 限制傳播。
- 考慮使用模式。
保持謙虛指避免在類實現和函數實現中暴露自己。隱藏您的信息是一項基本習慣。如果不能養成隱藏實現細節的習慣,那麼將很難養成任何其他習慣。信息隱藏也稱爲封裝。
直接公開公共字段是一個壞習慣的原因有很多,最重要的原因是讓您在實現更改中沒有應有的選擇。使用 OO 概念隔離更改,而封裝在確保所作更改在本質上不是病毒性(viral)更改方面扮演不可或缺的角色。病毒性 更改是開始時很小的更改 — 如將保存三個元素的數組更改爲一個只包含兩個元素的數組。突然,您發現需要更改越來越多的代碼以適應本應十分微不足道的更改。
開始隱藏信息的一種簡單方法是保持字段私有並且用公共訪問方法公開這些字段,就像家中的窗戶一樣。並沒有讓整面牆都朝外部開放,而只打開一兩扇窗戶。
除了允許您的實現隱藏在更改之後外,使用公共訪問方法而非直接公開字段將允許您在基本實現的基礎上進行構建,方法爲覆蓋訪問方法的實現以執行略微不同於父方法的行爲。它還允許您構建一個抽象實現,從而使實際實現委託給覆蓋基本實現的類。
在清單 1 的壞代碼示例中,Person
對象的字段被直接公開爲公共字段而非使用訪問方法。雖然此行爲十分誘人,尤其對於輕量級數據對象來說更是如此,但是它將對您提出限制。
<?php class Person { public $prefix; public $givenName; public $familyName; public $suffix; } $person = new Person(); $person->prefix = "Mr."; $person->givenName = "John"; echo($person->prefix); echo($person->givenName); ?> |
如果對象有任何更改,則使用該對象的所有代碼也都需要更改。例如,如果某人的教名、姓氏和其他名字被封裝到 PersonName
對象中,則需要修改所有代碼以適應更改。
通過使用優秀的 OO 習慣(參見清單 2),同一個對象現在擁有私有字段而非公共字段,並且通過稱爲訪問方法 的 get
和
set
公共方法謹慎地向外界公開私有字段。這些訪問方法現在提供了一種從 PHP 類中獲取信息的公共方法,這樣在實現發生更改時,更改使用類的所有代碼的需求很可能變小。
<?php class Person { private $prefix; private $givenName; private $familyName; private $suffix; public function setPrefix($prefix) { $this->prefix = $prefix; } public function getPrefix() { return $this->prefix; } public function setGivenName($gn) { $this->givenName = $gn; } public function getGivenName() { return $this->givenName; } public function setFamilyName($fn) { $this->familyName = $fn; } public function getFamilyName() { return $this->familyName; } public function setSuffix($suffix) { $this->suffix = $suffix; } public function getSuffix() { return $suffix; } } $person = new Person(); $person->setPrefix("Mr."); $person->setGivenName("John"); echo($person->getPrefix()); echo($person->getGivenName()); ?> |
乍看之下,這段代碼可能會完成大量工作,並且實際上可能更多是在前端的工作。但是,通常,使用優秀的 OO 習慣從長遠來看十分划算,因爲將極大地鞏固未來更改。
在清單 3 中所示的代碼版本中,我已經更改了內部實現以使用名稱部件的關聯數組。比較理想的情況是,我希望擁有錯誤處理並且更仔細地檢查元素是否存在,但是本例的目的在於展示使用我的類的代碼無需更改的程度 — 代碼並沒有察覺到類發生更改。記住採用 OO 習慣的原因是要謹慎封裝更改,這樣代碼將更具有可擴展性並且更容易維護。
<?php class Person { private $personName = array(); public function setPrefix($prefix) { $this->personName['prefix'] = $prefix; } public function getPrefix() { return $this->personName['prefix']; } public function setGivenName($gn) { $this->personName['givenName'] = $gn; } public function getGivenName() { return $this->personName['givenName']; } /* etc... */ } /* * Even though the internal implementation changed, the code here stays exactly * the same. The change has been encapsulated only to the Person class. */ $person = new Person(); $person->setPrefix("Mr."); $person->setGivenName("John"); echo($person->getPrefix()); echo($person->getGivenName()); ?> |
在構建類時,它應當正確地處理自己的錯誤。如果該類不知道如何處理錯誤,則應當以其調用者理解的格式封裝這些錯誤。此外,避免返回空對象或者狀態無效的對象。許多時候,只需通過檢驗參數並拋出特定異常說明提供參數無效的原因就可以實現這一點。在您養成這個習慣時,它可以幫您 — 和維護代碼或使用對象的人員 — 節省很多時間。
考慮清單 4 中所示的示例,該示例將接受一些參數並返回填充了一些值的 Person
對象。但是,在 parsePersonName()
方法中,沒有驗證提供的
$val
變量是否爲空、是否是零長度字符串或者字符串是否使用無法解析的格式。parsePersonName()
方法不返回
Person
對象,但是返回 null。使用這種方法的管理員或程序員可能會覺得很麻煩 — 至少他們現在需要開始設置斷點並調試 PHP 腳本。
class PersonUtils { public static function parsePersonName($format, $val) { if (strpos(",", $val) > 0) { $person = new Person(); $parts = split(",", $val); // Assume the value is last, first $person->setGivenName($parts[1]); $person->setFamilyName($parts[0]); } return $person; } } |
清單 4 中的 parsePersonName()
方法可以修改爲在 if
條件外部初始化
Person
對象,確保總是獲得有效的 Person
對象。但是,您得到的是沒有 set 屬性的 Person
,這仍然沒有很好地改善您的困境。
不要讓調用方憑空猜測,而是對參數進行預先驗證。如果未設置的變量無法生成有效的結果,請檢查變量並拋出 InvalidArgumentException
。如果字符串不能爲空或者必須爲特定格式,請檢查格式並拋出異常。清單 5 解釋瞭如何在演示一些基本驗證的
parsePerson()
方法中創建異常以及一些新條件。
<?php class InvalidPersonNameFormatException extends LogicException {} class PersonUtils { public static function parsePersonName($format, $val) { if (! $format) { throw new InvalidPersonNameFormatException("Invalid PersonName format."); } if ((! isset($val)) || strlen($val) == 0) { throw new InvalidArgumentException("Must supply a non-null value to parse."); } } } ?> |
最終目的是希望人們能夠使用您的類,而不必瞭解其中的工作原理。如果他們使用的方法不正確或者不是按照期望的方法使用,也不需要猜測不能工作的原因。作爲一個好鄰居,您需要知道對您的類進行重用的人並沒有特異功能,因此您需要解決猜測的問題。
在我最初瞭解 OO 概念時,我十分懷疑接口是否真正有幫助。我的同事給我打了個比方,說不使用接口就好像看到美杜莎的頭。在希臘神話中,美杜莎是長着蛇發的女怪。凡是看了她一眼的人都會變成石頭。殺死美杜莎的珀爾休斯通過在盾上觀察她的影子,避免了變成石頭而得以與她對抗。
接口就是對付美杜莎的鏡子。當您使用一個特定的具體實現時,代碼也必須隨着實現代碼的更改而更改。直接使用實現將限制您的選擇,因爲您已經在本質上把類變成了 “石頭”。
清單 6 顯示了從數據庫中裝入 Person
對象的示例。它將獲取人員的姓名並返回數據庫中匹配的 Person
對象。
<?php class DBPersonProvider { public function getPerson($givenName, $familyName) { /* go to the database, get the person... */ $person = new Person(); $person->setPrefix("Mr."); $person->setGivenName("John"); return $person; } } /* I need to get person data... */ $provider = new DBPersonProvider(); $person = $provider->getPerson("John", "Doe"); echo($person->getPrefix()); echo($person->getGivenName()); ?> |
在環境發生更改之前,從數據庫中裝入 Person
的代碼都可以正常運行。例如,從數據庫裝入 Person
可能適用於第一個版本的應用程序,但是對於第二個版本,可能需要添加從 Web 服務裝入人員的功能。其實,該類已經變成 “石頭”,因爲它在直接使用實現類並且現在能做的更改十分有限。
清單 7 顯示了一個代碼示例,在實現了加載用戶的新方法後並沒有進行更改。該示例顯示了一個名爲 PersonProvider
的接口,該接口將聲明單個方法。如果任何代碼使用
PersonProvider
,代碼都禁止直接使用實現類。相反,它就像是一個實際對象一樣使用 PersonProvider
。
<?php interface PersonProvider { public function getPerson($givenName, $familyName); } class DBPersonProvider implements PersonProvider { public function getPerson($givenName, $familyName) { /* pretend to go to the database, get the person... */ $person = new Person(); $person->setPrefix("Mr."); $person->setGivenName("John"); return $person; } } class PersonProviderFactory { public static function createProvider($type) { if ($type == 'database') { return new DBPersonProvider(); } else { return new NullProvider(); } } } $config = 'database'; /* I need to get person data... */ $provider = PersonProviderFactory::createProvider($config); $person = $provider->getPerson("John", "Doe"); echo($person->getPrefix()); echo($person->getGivenName()); ?> |
在使用接口時,嘗試避免直接引用實現類。相反,使用對象外部的內容可以提供正確的實現。如果您的類將裝入基於某些邏輯的實現,它仍然需要獲取所有實現類的定義,並且那樣做也無法取得任何效果。
您可以使用 Factory 模式來創建實現接口的實現類的實例。根據約定,factory
方法將以 create
爲開頭並返回接口。它可以爲您的
factory
獲取必要的參數以計算出應當返回哪個實現類。
在清單 7 中,createProvider()
方法只是獲取 $type
。如果 $type
被設爲
database
,工廠將返回 DBPersonProvider
的實例。從數據庫中裝入人員的任何新實現都不要求在使用工廠和接口的類中進行任何更改。DBPersonProvider
將實現
PersonProvider
接口並且擁有 getPerson()
方法的實際實現。
將模塊鬆散耦合 在一起是件好事情;它是允許您封裝更改的屬性之一。另外兩個習慣 — “保持謹慎” 和 “避免看到美杜莎” — 可幫助您構建鬆散耦合的模塊。要實現鬆散耦合的類,可通過養成降低類依賴關係的習慣實現。
在清單 8 中,降低依賴關係並不是必須降低使用對象的客戶機的依賴關係。相反,該示例將演示如何降低與正確類的依賴關係並最小化這種依賴關係。
<?php require_once "./AddressFormatters.php"; class Address { private $addressLine1; private $addressLine2; private $city; private $state; // or province... private $postalCode; private $country; public function setAddressLine1($line1) { $this->addressLine1 = $line1; } /* accessors, etc... */ public function getCountry() { return $this->country; } public function format($type) { if ($type == "inline") { $formatter = new InlineAddressFormatter(); } else if ($type == "multiline") { $formatter = new MultilineAddressFormatter(); } else { $formatter = new NullAddressFormatter(); } return $formatter->format($this->getAddressLine1(), $this->getAddressLine2(), $this->getCity(), $this->getState(), $this->getPostalCode(), $this->getCountry()); } } $addr = new Address(); $addr->setAddressLine1("123 Any St."); $addr->setAddressLine2("Ste 200"); $addr->setCity("Anytown"); $addr->setState("AY"); $addr->setPostalCode("55555-0000"); $addr->setCountry("US"); echo($addr->format("multiline")); echo("/n"); echo($addr->format("inline")); echo("/n"); ?> |
在 Address
對象上調用 format()
方法的代碼可能看上去很棒 — 這段代碼所做的是使用
Address
類,調用 format()
並完成。相反,Address
類就沒那麼幸運。它需要了解用於正確格式化的各種格式化方法,這可能使
Address
對象無法被其他人很好地重用,尤其是在其他人沒有興趣在 format()
方法中使用格式化方法類的情況下。雖然使用
Address
的代碼沒有許多依賴關係,但是 Address
類卻有大量代碼,而它可能只是一個簡單的數據對象。
Address
類與知道如何格式化 Address
對象的實現類緊密耦合。
在構建優秀的 OO 設計時,必須考慮稱爲關注點分離(Separation of Concerns,SoC)的概念。SoC 指嘗試通過真正關注的內容分離對象,從而降低耦合度。在最初的
Address
類中,它必須關注如何進行格式化。這可能不是優秀的設計。然而,Address
類應當考慮
Address
的各部分,而某種格式化方法應當關注如何正確格式化地址。
在清單 9 中,格式化地址的代碼被移到接口、實現類和工廠中 — 養成 “使用接口” 的習慣。現在,AddressFormatUtils
類負責創建格式化方法並格式化
Address
。任何其他對象現在都可以使用 Address
而不必擔心要求獲得格式化方法的定義。
<?php interface AddressFormatter { public function format($addressLine1, $addressLine2, $city, $state, $postalCode, $country); } class MultiLineAddressFormatter implements AddressFormatter { public function format($addressLine1, $addressLine2, $city, $state, $postalCode, $country) { return sprintf("%s/n%s/n%s, %s %s/n%s", $addressLine1, $addressLine2, $city, $state, $postalCode, $country); } } class InlineAddressFormatter implements AddressFormatter { public function format($addressLine1, $addressLine2, $city, $state, $postalCode, $country) { return sprintf("%s %s, %s, %s %s %s", $addressLine1, $addressLine2, $city, $state, $postalCode, $country); } } class AddressFormatUtils { public static function formatAddress($type, $address) { $formatter = AddressFormatUtils::createAddressFormatter($type); return $formatter->format($address->getAddressLine1(), $address->getAddressLine2(), $address->getCity(), $address->getState(), $address->getPostalCode(), $address->getCountry()); } private static function createAddressFormatter($type) { if ($type == "inline") { $formatter = new InlineAddressFormatter(); } else if ($type == "multiline") { $formatter = new MultilineAddressFormatter(); } else { $formatter = new NullAddressFormatter(); } return $formatter; } } $addr = new Address(); $addr->setAddressLine1("123 Any St."); $addr->setAddressLine2("Ste 200"); $addr->setCity("Anytown"); $addr->setState("AY"); $addr->setPostalCode("55555-0000"); $addr->setCountry("US"); echo(AddressFormatUtils::formatAddress("multiline", $addr)); echo("/n"); echo(AddressFormatUtils::formatAddress("inline", $addr)); echo("/n"); ?> |
當然,缺點是隻要使用模式,通常就意味着工件(類、文件)的數量會增加。但是,通過減少每個類中的維護可以彌補這個缺點,甚至在獲得正確的可重用性時反而可以減少工件量。
具有高度內聚力的 OO 設計被集中並組織到相關模塊中。瞭解 “關注點” 對於決定如何緊密地聯繫函數和類十分重要。
當設計的內聚力較低 時,它就不能良好地組織類和方法。意大利麪條式代碼(spaghetti code)一詞通常用於描述捆綁在一起並且具有低內聚力的類和方法。清單 10 提供了意大利麪條式代碼的示例。相對通用的
Utils
類將使用許多不同對象並且有許多依賴關係。它執行很多操作,因而很難實現重用。
<?php class Utils { public static function formatAddress($formatType, $address1, $address2, $city, $state) { return "some address string"; } public static function formatPersonName($formatType, $givenName, $familyName) { return "some person name"; } public static function parseAddress($formatType, $val) { // real implementation would set values, etc... return new Address(); } public static function parseTelephoneNumber($formatType, $val) { // real implementation would set values, etc... return new TelephoneNumber(); } } ?> |
高內聚力 指將相互關聯的類和方法分組在一起。如果方法和類都具有高度的內聚力,則可以輕鬆地分解整個組而不影響設計。具有高內聚力的設計將提供降低耦合的機會。清單 11 顯示了被較好組織到類中的兩個方法。AddressUtils
類將包含用於處理
Address
類的方法,顯示了與地址相關的方法之間的高度內聚力。同樣地,PersonUtils
將包含專門處理
Person
對象的方法。這兩個擁有高度內聚力方法的新類的耦合性都很低,因爲可以完全獨立地使用。
<?php class AddressUtils { public static function formatAddress($formatType, $address1, $address2, $city, $state) { return "some address string"; } public static function parseAddress($formatType, $val) { // real implementation would set values, etc... return new Address(); } } class PersonUtils { public static function formatPersonName($formatType, $givenName, $familyName) { return "some person name"; } public static function parsePersonName($formatType, $val) { // real implementation would set values, etc... return new PersonName(); } } ?> |
我經常對我所在的軟件團隊(我在其中擔任技術主管或架構師)的成員提起,OO 語言最大的敵人是複製和粘貼操作。當在缺少預先 OO 設計的情況下使用時,沒有任何操作會像在類之間複製代碼那樣具有破壞性。無論何時,如果想將代碼從一個類複製到下一個類中,請停下來並考慮如何使用類層次結構利用類似功能或相同功能。在大多數情況下,使用優秀設計後,您將會發現完全沒有必要複製代碼。
清單 12 顯示了部分類的簡單示例。它們從重複的字段和方法開始 — 從長遠來看,不利於應用程序作出更改。如果 Person
類中有缺陷,則
Employee
類中也很可能有一個缺陷,因爲看上去似乎實現是在兩個類之間複製的。
<?php class Person { private $givenName; private $familyName; } class Employee { private $givenName; private $familyName; } ?> |
繼承 是一個很難入手的習慣,因爲構建正確繼承模型的分析通常需要花費大量時間。反過來,使用 Ctrl+C 組合鍵和 Ctrl+V 組合鍵構建新實現只需幾秒鐘。但是省下的這部分時間通常會在維護階段迅速抵銷掉,因爲應用程序實際上將花費大量進行維護。
在清單 13 中,新 Employee
類將擴展 Person
類。它現在將繼承所有通用方法並且不重新實現這些方法。此外,清單 13 顯示了抽象方法的用法,演示如何將基本功能放入基類中以及如何阻止實現類使用特定函數。
<?php abstract class Person { private $givenName; private $familyName; public function setGivenName($gn) { $this->givenName = $gn; } public function getGivenName() { return $this->givenName; } public function setFamilyName($fn) { $this->familyName = $fn; } public function getFamilyName() { return $this->familyName; } public function sayHello() { echo("Hello, I am "); $this->introduceSelf(); } abstract public function introduceSelf(); } class Employee extends Person { private $role; public function setRole($r) { $this->role = $r; } public function getRole() { return $this->role; } public function introduceSelf() { echo($this->getRole() . " " . $this->getGivenName() . " " . $this->getFamilyName()); } } ?> |
設計模式指對象和方法的常見交互,並且時間證明它可以解決某些問題。當您考慮使用設計模式時,您就需要了解類之間如何進行交互。它是構建類及其交互操作的簡單方法,無需重蹈他人的覆轍,並從經過證明的設計中獲益。
實際上沒有適當的代碼示例可以演示如何考慮使用模式(儘管有豐富的優秀示例可以顯示模式實現)。但是,一般而言,您知道在滿足以下條件時一次只能考慮一個對象:
- 不會提前設計對象模型。
- 開始編寫單一方法的實現,而無需去掉大部分模型。
- 在交談中不使用設計模式名而寧願談論實現。
一般而言,當您在執行以下操作時就是在考慮使用模式:
- 提前構建類及其交互操作。
- 根據模式套用類。
- 使用模式名,如 Factory、Singleton 和 Facade。
- 去掉大部分模型,然後開始添加實現。
在 PHP 中養成良好的 OO 習慣將幫助您構建更穩定、更易於維護和更易於擴展的應用程序。記住:
- 保持謹慎。
- 做個好鄰居。
- 避免看到美杜莎。
- 利用最弱的鏈接。
- 您是橡皮,我是膠水。
- 限制傳播。
- 考慮使用模式。
當您養成並應用這些習慣後,您很可能會驚訝地發現應用程序在質量上的飛躍。