在 PHP 编程早期,PHP 代码在本质上是限于面向过程的。过程代码 的特征在于使用过程构建应用程序块。过程通过允许过程之间的调用提供某种程度的重用。
但是,没有面向对象的语言构造,程序员仍然可以把 OO 特性引入到 PHP 代码中。这样做有点困难并且会使代码难于阅读,因为它是混合范例(含有伪 OO 设计的过程语言)。使用 PHP 代码中的 OO 构造 — 例如能够定义和使用类、能够构建使用继承的类之间的关系以及能够定义接口 — 可以更轻松地构建符合优秀 OO 实践的代码。
虽然没有过多模块化的纯过程设计运行得很好,但是 OO 设计的优点表现在维护上。由于典型应用程序的大部分生命周期都花费在维护上,因此代码维护是应用程序生命周期的重要部分。并且在开发过程中代码维护很容易被遗忘。如果在应用程序开发和部署方面存在竞争,那么长期可维护性可能被放在比较次要的地位。
模块化 — 优秀 OO 设计的主要特性之一 — 可以帮助完成这样的维护。模块化将帮助封装更改,这样可以随着时间的推移更轻松地扩展和修改应用程序。
总的来说,虽然构建 OO 软件的习惯不止 7 个,但是遵循这里的 7 个习惯可以使代码符合基本 OO 设计标准。它们将为您提供更牢固的基础,在此基础之上建立更多 OO 习惯并构建可轻松维护与扩展的软件。这些习惯针对模块化的几个主要特性。有关独立于语言的 OO 设计优点的更多信息,请参阅 参考资料。
7 个优秀 PHP OO 习惯包括:
- 保持谦虚。
- 做个好邻居。
- 避免看到美杜莎。
- 利用最弱的链接。
- 您是橡皮;我是胶水。
- 限制传播。
- 考虑使用模式。
保持谦虚指避免在类实现和函数实现中暴露自己。隐藏您的信息是一项基本习惯。如果不能养成隐藏实现细节的习惯,那么将很难养成任何其他习惯。信息隐藏也称为封装。
直接公开公共字段是一个坏习惯的原因有很多,最重要的原因是让您在实现更改中没有应有的选择。使用 OO 概念隔离更改,而封装在确保所作更改在本质上不是病毒性(viral)更改方面扮演不可或缺的角色。病毒性 更改是开始时很小的更改 — 如将保存三个元素的数组更改为一个只包含两个元素的数组。突然,您发现需要更改越来越多的代码以适应本应十分微不足道的更改。
开始隐藏信息的一种简单方法是保持字段私有并且用公共访问方法公开这些字段,就像家中的窗户一样。并没有让整面墙都朝外部开放,而只打开一两扇窗户(我将在 “好习惯:使用公共访问方法” 中介绍访问方法的更多信息)。
除了允许您的实现隐藏在更改之后外,使用公共访问方法而非直接公开字段将允许您在基本实现的基础上进行构建,方法为覆盖访问方法的实现以执行略微不同于父方法的行为。它还允许您构建一个抽象实现,从而使实际实现委托给覆盖基本实现的类。
在清单 1 的坏代码示例中,Person
对象的字段被直接公开为公共字段而非使用访问方法。虽然此行为十分诱人,尤其对于轻量级数据对象来说更是如此,但是它将对您提出限制。
清单 1. 公开公共字段的坏习惯
<?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 类中获取信息的公共方法,这样在实现发生更改时,更改使用类的所有代码的需求很可能变小。
清单 2. 使用公共访问方法的好习惯
<?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 习惯的原因是要谨慎封装更改,这样代码将更具有可扩展性并且更容易维护。
清单 3. 使用不同内部实现的另一个示例
<?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 脚本。
清单 4. 不抛出或处理错误的坏习惯
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()
方法中创建异常以及一些新条件。
清单 5. 抛出错误的好习惯
<?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
对象。
清单 6. 不使用接口的坏习惯
<?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
。
清单 7. 使用接口的好习惯
<?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 中,降低依赖关系并不是必须降低使用对象的客户机的依赖关系。相反,该示例将演示如何降低与正确类的依赖关系并最小化这种依赖关系。
清单 8.
Address
中紧密耦合的坏习惯
<?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
而不必担心要求获得格式化方法的定义。
清单 9. 在对象之间松散耦合的好习惯
<?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
类将使用许多不同对象并且有许多依赖关系。它执行很多操作,因而很难实现重用。
清单 10. 降低内聚力的坏习惯
<?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
对象的方法。这两个拥有高度内聚力方法的新类的耦合性都很低,因为可以完全独立地使用。
清单 11. 高内聚力的好习惯
<?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
类中也很可能有一个缺陷,因为看上去似乎实现是在两个类之间复制的。
清单 12. 不使用层次结构的坏习惯
<?php class Person { private $givenName; private $familyName; } class Employee { private $givenName; private $familyName; } ?> |
继承 是一个很难入手的习惯,因为构建正确继承模型的分析通常需要花费大量时间。反过来,使用 Ctrl+C 组合键和 Ctrl+V 组合键构建新实现只需几秒钟。但是省下的这部分时间通常会在维护阶段迅速抵销掉,因为应用程序实际上将花费大量进行维护。
在清单 13 中,新 Employee
类将扩展 Person
类。它现在将继承所有通用方法并且不重新实现这些方法。此外,清单 13 显示了抽象方法的用法,演示如何将基本功能放入基类中以及如何阻止实现类使用特定函数。
清单 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 习惯将帮助您构建更稳定、更易于维护和更易于扩展的应用程序。记住:
- 保持谨慎。
- 做个好邻居。
- 避免看到美杜莎。
- 利用最弱的链接。
- 您是橡皮,我是胶水。
- 限制传播。
- 考虑使用模式。
当您养成并应用这些习惯后,您很可能会惊讶地发现应用程序在质量上的飞跃。