PHP8 对象、模式和实践(五)
十、对象编程的灵活模式
在介绍了生成对象的策略之后,我们现在可以自由地看一些构造类和对象的策略了。我将特别关注组合比继承提供更大灵活性的原则。我在这一章中考察的模式再次取自四人帮的目录。
本章将介绍三种模式:
-
组合模式(Composite pattern):组合结构,其中对象组可以像单独的对象一样使用
-
装饰模式:一种在运行时组合对象以扩展功能的灵活机制
-
门面模式:创建复杂或可变系统的简单接口
构造类以允许灵活的对象
在第三章中,我说过初学者经常混淆对象和类。这话只对了一半。事实上,我们大多数人偶尔会对 UML 类图感到困惑,试图将它们显示的静态继承结构与它们的对象将要进入的动态对象关系协调起来。
还记得模式原则“重组合轻继承”吗?这个原则提炼了类和对象的组织之间的紧张关系。为了在我们的项目中建立灵活性,我们构建我们的类,以便它们的对象可以在运行时组成有用的结构。
这是贯穿本章前两个模式的共同主题。继承在两者中都是一个重要的特性,但是它的部分重要性在于提供了一种机制,通过这种机制可以使用组合来表示结构和扩展功能。
组合模式
组合模式可能是组合服务中部署的继承的最极端的例子。这是一个简单而优雅的设计。它也非常有用。不过,要小心;它如此简洁,你可能会忍不住过度使用这个策略。
组合模式是一种简单的聚集和管理相似对象组的方法,这样客户端就无法将单个对象与对象集合区分开来。事实上,这种模式非常简单,但也经常令人困惑。其中一个原因是模式中的类的结构与其对象组织的相似性。继承层次是树,从根的超类开始,分支到专门的子类。由组合模式建立的类的继承树被设计成允许简单地生成和遍历对象的树。
如果您还不熟悉这种模式,那么在这一点上您完全有理由感到困惑。让我们用一个类比来说明单个实体可以像事物的集合一样被对待。给定谷物和肉类(或者大豆,如果你喜欢的话)等基本不可减少的成分,我们可以制作一种食品——比如香肠。然后,我们作为一个单一的实体对结果采取行动。正如我们吃、煮、买或卖肉一样,我们也可以吃、煮、买或卖部分由肉组成的香肠。我们可以把香肠和其他复合材料成分混合在一起做成馅饼,从而把一种复合材料卷成一种更大的复合材料。我们对集合的行为方式与对零件的行为方式相同。组合模式帮助我们在代码中对集合和组件之间的关系进行建模。
问题
管理对象组可能是一项相当复杂的任务,尤其是当所讨论的对象可能还包含它们自己的对象时。这种问题在编码中很常见。想想发票,带有总结附加产品或服务的行项目,或者带有项目的待办事项列表,这些项目本身包含多个子任务。在内容管理中,我们不能为章节、页面、文章或媒体组件的树而移动。从外部管理这些结构会很快变得令人生畏。
让我们回到之前的场景。我正在设计一个基于游戏《文明》的系统。玩家可以在构成地图的数百个方块周围移动单位。单个柜台可以组合在一起,作为一个整体进行移动、战斗和防御。在这里,我定义了几个单元类型:
// listing 10.01
abstract class Unit
{
abstract public function bombardStrength(): int;
}
class Archer extends Unit
{
public function bombardStrength(): int
{
return 4;
}
}
class LaserCannonUnit extends Unit
{
public function bombardStrength(): int
{
return 44;
}
}
Unit类定义了一个抽象的bombardStrength()方法,该方法设置一个单位轰击相邻磁贴的攻击强度。我在Archer和LaserCannonUnit类中都实现了这一点。这些类也将包含关于移动和防御能力的信息,但是我将保持事情简单。我可以定义一个单独的类来将单元组合在一起,就像这样:
// listing 10.02
class Army
{
private array $units = [];
public function addUnit(Unit $unit): void
{
array_push($this->units, $unit);
}
public function bombardStrength(): int
{
$ret = 0;
foreach ($this->units as $unit) {
$ret += $unit->bombardStrength();
}
return $ret;
}
}
// listing 10.03
$unit1 = new Archer();
$unit2 = new LaserCannonUnit();
$army = new Army();
$army->addUnit($unit1);
$army->addUnit($unit2);
print $army->bombardStrength();
Army类有一个接受Unit对象的addUnit()方法。Unit对象存储在一个名为$units的数组属性中。我用bombardStrength()方法计算我的军队的综合实力。这只是遍历聚合的Unit对象,调用每个对象的bombardStrength()方法。以下是输出:
48
只要问题仍然像这样简单,这个模型是完全可以接受的。但是,如果我添加一些新的需求,会发生什么呢?这么说吧,一支军队要能和其他军队联合起来。每支军队都应该保留自己的身份,以便日后能够脱离整体。大公爵的勇敢的部队今天可能会和索姆斯将军一起攻击敌人暴露的侧翼,但是国内的叛乱可能会让他的军队随时逃回老家。由于这个原因,我不能把每个军队的单位都编入一个新的部队。
我可以修改Army类来接受Army对象和Unit对象:
// listing 10.04
public function addArmy(Army $army): void
{
array_push($this->armies, $army);
}
然后我需要修改bombardStrength()方法来遍历所有的军队和单位:
// listing 10.05
public function bombardStrength(): int
{
$ret = 0;
foreach ($this->units as $unit) {
$ret += $unit->bombardStrength();
}
foreach ($this->armies as $army) {
$ret += $army->bombardStrength();
}
return $ret;
}
这种额外的复杂性目前还不是太大的问题。但是,请记住,我需要在类似于defensiveStrength()、movementRange()等方法中做一些类似的事情。我的游戏将会有丰富的特色。这个商业组织已经开始呼吁能够装载十个单位的运兵船来提高他们在特定地形上的移动范围。很明显,运兵船和军队相似,都是由单位组成的。它也有自己的特点。我可以进一步修改Army类来处理TroopCarrier对象,但是我知道还需要更多的单位分组。很明显,我需要一个更灵活的模型。
让我们再来看看我一直在构建的模型。我创建的所有类都需要一个bombardStrength()方法。实际上,客户端不需要区分军队、单位或运兵船。它们功能相同。他们需要移动、进攻和防守。那些包含其他对象的对象需要提供添加和移除它们的方法。这些相似之处让我们得出一个必然的结论。因为容器对象与其包含的对象共享一个接口,所以它们自然适合共享一个类型族。
履行
组合模式定义了一个单一的继承层次结构,它划分了两组不同的职责。在我们的示例中,我们已经看到了这两种情况。模式中的类必须支持一组通用的操作,这是它们的主要职责。对我们来说,这意味着bombardStrength()方法。类还必须支持添加和移除子对象的方法。
图 10-1 显示了一个类图,展示了应用于我们问题的组合模式。
图 10-1
组合模式
如你所见,这个模型中的所有单元都扩展了Unit类。那么,客户可以确信任何一个Unit对象都将支持bombardStrength()方法。所以,一只Army可以和一只Archer完全一样的方式被对待。
Army和TroopCarrier类是复合:它们被设计用来保存Unit对象。Archer和LaserCannon类是叶,用于支持单位操作,但不支持其他Unit物体。实际上存在一个问题,即叶子是否应该遵循与复合物相同的接口,如图 10-1 所示。该图显示了聚合其他单元的TroopCarrier和Army,即使叶类也被绑定来实现addUnit()。我将很快回到这个问题。下面是抽象的Unit类:
// listing 10.06
abstract class Unit
{
abstract public function addUnit(Unit $unit): void;
abstract public function removeUnit(Unit $unit): void;
abstract public function bombardStrength(): int;
}
如您所见,我在这里为所有的Unit对象提供了基本的功能。现在,让我们看看复合对象如何实现这些抽象方法:
// listing 10.07
class Army extends Unit
{
private array $units = [];
public function addUnit(Unit $unit): void
{
if (in_array($unit, $this->units, true)) {
return;
}
$this->units[] = $unit;
}
public function removeUnit(Unit $unit): void
{
$idx = array_search($unit, $this->units, true);
if (is_int($idx)) {
array_splice($this->units, $idx, 1, []);
}
}
public function bombardStrength(): int
{
$ret = 0;
foreach ($this->units as $unit) {
$ret += $unit->bombardStrength();
}
return $ret;
}
}
在将同一个Unit对象存储到私有的$units数组属性之前,addUnit()方法检查我是否已经添加了它。removeUnit()使用类似的检查从属性中删除给定的Unit对象。
Note
在检查我是否已经向addUnit()方法添加了一个特定的对象时,我使用了带有第三个布尔true参数的in_array()。这加强了in_array()的严格性,使得它只匹配对同一对象的引用。array_search()的第三个参数以同样的方式工作,仅当提供的搜索值是在数组中找到的等价对象引用时,才返回数组索引。
然后,Army对象可以存储任何种类的Units,包括其他的Army对象,或者像Archer或LaserCannonUnit这样的树叶。因为所有单元都保证支持bombardStrength(),所以我们的Army::bombardStrength()方法简单地遍历存储在$units属性中的所有子Unit对象,对每个对象调用相同的方法。
组合模式的一个问题是添加和删除功能的实现。经典模式将add()和remove()方法放在抽象超类中。这确保了模式中的所有类共享一个公共接口。正如您在这里看到的,这也意味着叶类必须提供一个实现:
// listing 10.08
class UnitException extends \Exception
{
}
// listing 10.09
class Archer extends Unit
{
public function addUnit(Unit $unit): void
{
throw new UnitException(get_class($this) . " is a leaf");
}
public function removeUnit(Unit $unit): void
{
throw new UnitException(get_class($this) . " is a leaf");
}
public function bombardStrength(): int
{
return 4;
}
}
我不想让添加一个Unit对象到一个Archer对象成为可能,所以如果调用了addUnit()或removeUnit(),我会抛出异常。我需要对所有的叶子对象都这样做,所以我也许可以通过用默认实现替换Unit中的抽象addUnit()/removeUnit()方法来改进我的设计:
// listing 10.10
abstract class Unit
{
public function addUnit(Unit $unit): void
{
throw new UnitException(get_class($this) . " is a leaf");
}
public function removeUnit(Unit $unit): void
{
throw new UnitException(get_class($this) . " is a leaf");
}
abstract public function bombardStrength(): int;
}
// listing 10.11
class Archer extends Unit
{
public function bombardStrength(): int
{
return 4;
}
}
这消除了叶类中的重复,但缺点是在编译时没有强制复合来提供addUnit()和removeUnit()的实现,这可能会导致问题。
在下一节中,我将更详细地讨论组合模式带来的一些问题。让我们通过研究它的一些好处来结束这一部分:
-
灵活性:因为组合模式中的所有东西都共享一个公共的超类型,所以在不改变程序更广泛的上下文的情况下,向设计中添加新的复合或叶对象是非常容易的。
-
简单性:使用复合结构的客户端有一个简单明了的界面。客户端不需要区分由其他组件组成的对象和叶对象(除非添加新组件)。对
Army::bombardStrength()的调用可能会在幕后导致委托调用的级联;但是对于客户端来说,这个过程和结果与调用Archer::bombardStrength()是完全等价的。 -
隐式到达:复合图案中的对象被组织成一棵树。每个组合都包含对其子组合的引用。因此,对树的特定部分的操作会产生广泛的影响。我们可以从它的
Army父对象中移除一个Army对象,并将其添加到另一个对象中。这个简单的动作是在一个对象上完成的,但是它具有改变Army对象的被引用的Unit对象和它们自己的子对象的状态的效果。 -
显式到达:树形结构容易遍历。为了获取信息或执行转换,可以对它们进行迭代。在下一章处理访问者模式时,我们将会看到一个特别强大的技术。
通常,你只能从客户的角度看到一个模式的好处,所以这里有几支军队:
// listing 10.12
// create an army
$main_army = new Army();
// add some units
$main_army->addUnit(new Archer());
$main_army->addUnit(new LaserCannonUnit());
// create a new army
$sub_army = new Army();
// add some units
$sub_army->addUnit(new Archer());
$sub_army->addUnit(new Archer());
$sub_army->addUnit(new Archer());
// add the second army to the first
$main_army->addUnit($sub_army);
// all the calculations handled behind the scenes
print "attacking with strength: {$main_army->bombardStrength()}\n";
我创建了一个新的Army对象,并添加了一些原始的Unit对象。我为第二个Army对象重复这个过程,然后添加到第一个。当我在第一个Army对象上调用Unit::bombardStrength()时,我构建的结构的所有复杂性都被完全隐藏了。以下是我的输出:
attacking with strength: 60
结果
如果你和我一样,那么当你看到Archer类的代码摘录时,你会听到警钟响起。为什么我们在不需要支持它们的叶子类中忍受这些多余的addUnit()和removeUnit()方法?一个答案在于Unit类型的透明性。
如果一个客户端被传递了一个Unit对象,它知道addUnit()方法将会出现。坚持组合模式原则,即基本(叶)类与复合类具有相同的接口。这实际上对你没有多大帮助,因为你仍然不知道在你可能遇到的任何Unit对象上调用addUnit()有多安全。
如果我将这些添加/移除方法下移,使它们只对复合类可用,那么将一个Unit对象传递给一个方法会给我带来一个问题,我不知道默认情况下它是否支持addUnit()。然而,在 leaf 类中留下陷阱方法让我很不舒服。它没有增加任何价值,并且混淆了系统的设计,因为界面实际上依赖于它自己的功能。
您可以很容易地将复合类分解成它们自己的CompositeUnit子类型。首先,我删除了Unit中的添加/删除行为:
// listing 10.13
abstract class Unit
{
public function getComposite(): ?CompositeUnit
{
return null;
}
abstract public function bombardStrength(): int;
}
注意新的getComposite()方法。过一会儿我会回到这个话题。现在,我需要一个新的抽象类来保存addUnit()和removeUnit()。我甚至可以提供默认实现:
// listing 10.14
abstract class CompositeUnit extends Unit
{
private array $units = [];
public function getComposite(): ?CompositeUnit
{
return $this;
}
public function addUnit(Unit $unit): void
{
if (in_array($unit, $this->units, true)) {
return;
}
$this->units[] = $unit;
}
public function removeUnit(Unit $unit): void
{
$idx = array_search($unit, $this->units, true);
if (is_int($idx)) {
array_splice($this->units, $idx, 1, []);
}
}
public function getUnits(): array
{
return $this->units;
}
}
CompositeUnit类被声明为抽象的,即使它本身没有声明抽象方法。然而,它扩展了Unit,并且没有实现抽象的bombardStrength()方法。Army(以及任何其他复合类)现在可以扩展CompositeUnit。我的例子中的类现在如图 10-2 所示。
图 10-2
将添加/移除方法移出基类
叶子类中恼人的、无用的 add/remove 方法的实现已经消失了,但是客户机仍然必须在使用addUnit()之前检查它是否有一个CompositeUnit。
这就是getComposite()方法发挥作用的地方。默认情况下,此方法返回空值。只有在一个CompositeUnit类中,它才返回CompositeUnit。因此,如果对这个方法的调用返回一个对象,我们应该能够对它调用addUnit()。这里有一个使用这种技术的客户:
// listing 10.15
class UnitScript
{
public static function joinExisting(
Unit $newUnit,
Unit $occupyingUnit
): CompositeUnit {
$comp = $occupyingUnit->getComposite();
if (! is_null($comp)) {
$comp->addUnit($newUnit);
} else {
$comp = new Army();
$comp->addUnit($occupyingUnit);
$comp->addUnit($newUnit);
}
return $comp;
}
}
joinExisting()方法接受两个Unit对象。第一个是瓷砖的新用户,第二个是先前的用户。如果第二只Unit是一只CompositeUnit,那么第一只将试图加入它。如果没有,那么将创建一个新的Army来涵盖这两个部门。我一开始无法知道$occupyingUnit参数是否包含一个CompositeUnit。然而,调用getComposite()解决了这个问题。如果getComposite()返回一个对象,我可以直接将新的Unit对象添加到其中。如果没有,我创建新的Army对象并添加两者。
我可以通过让Unit::getComposite()方法返回一个预先填充了当前Unit的Army对象来进一步简化这个模型。或者我可以回到以前的模型(它没有在结构上区分复合对象和叶对象),让Unit::addUnit()做同样的事情:创建一个Army对象,并向其中添加两个Unit对象。这很简洁,但是它预先假定您知道您想要用来聚合您的单元的复合类型。当您设计像getComposite()和addUnit()这样的方法时,您的业务逻辑将决定您可以做出的假设的种类。
这些扭曲是合成图案缺陷的症状。简单性是通过确保所有的类都是从一个公共库派生出来的来实现的。简单的好处有时是以牺牲类型安全为代价的。你的模型变得越复杂,你需要做的手工类型检查就越多。假设我有一个Cavalry对象。如果游戏规则规定你不能把马放在运兵船上,我没有自动的方法用组合模式来强制执行:
// listing 10.16
class TroopCarrier extends CompositeUnit
{
public function addUnit(Unit $unit): void
{
if ($unit instanceof Cavalry) {
throw new UnitException("Can't get a horse on the vehicle");
}
parent::addUnit($unit);
}
public function bombardStrength(): int
{
return 0;
}
}
我被迫使用instanceof操作符来测试传递给addUnit()的对象的类型。如果有太多这种特殊情况,这种模式的缺点就会开始超过它的好处。当大多数组件可以互换时,复合材料的效果最好。
要记住的另一个问题是一些复合操作的成本。Army::bombardStrength()方法的典型之处在于它引发了对同一方法的级联调用。对于一棵有很多子树的大树来说,一个简单的调用就能在幕后引发雪崩。bombardStrength()本身并不昂贵,但是如果一些树叶执行复杂的计算来得出它们的返回值,会发生什么呢?解决这个问题的一种方法是在父对象中缓存这种方法调用的结果,这样后续调用的开销就不会太大。但是,您需要小心,以确保缓存的值不会过时。您应该制定策略,以便在树上发生任何操作时清除任何缓存。这可能需要您为子对象提供对其父对象的引用。
最后,关于持久性的说明。组合模式很优雅,但是它不适合存储在关系数据库中。这是因为,默认情况下,您只能通过级联引用来访问整个结构。要以自然的方式从数据库构建一个复合结构,您必须进行多次昂贵的查询。您可以通过为整个树分配一个 ID 来解决这个问题,这样就可以一次从数据库中提取所有组件。然而,获得所有对象后,您仍然需要重新创建父/子引用,这些引用本身必须存储在数据库中。这个不难,但是有些乱。
尽管复合数据库与关系数据库不太兼容,但它们确实非常适合 XML 或 JSON 存储,因此也适合各种 NoSQL 商店,如 MongoDB、CouchDB 和 Elasticsearch。这是因为在这两种情况下,元素本身通常由子元素树组成。
综合总结
因此,当您需要以对待个人的方式对待事物集合时,组合模式非常有用,因为集合本质上就像一个组件(军队和弓箭手),或者因为上下文赋予了集合与组件相同的特征(发票中的行项目)。组合以树的形式排列,因此整体上的操作可以影响部分,而来自部分的数据可以通过整体透明地获得。组合模式使得这样的操作和查询对客户端是透明的。树很容易穿过(我们将在下一章看到)。向复合结构中添加新的组件类型很容易。
不利的一面是,复合材料依赖于其部件的相似性。一旦我们引入了复杂的规则,比如哪个复合对象可以容纳哪组组件,我们的代码就会变得难以管理。复合不太适合存储在关系数据库中。
装饰图案
组合模式帮助我们创建聚合组件的灵活表示,装饰模式使用类似的结构来帮助我们修改具体组件的功能。同样,这种模式的关键在于运行时合成的重要性。继承是一种在父类特性的基础上构建的简洁方式。这种整洁会导致您将变体硬编码到继承层次结构中,这通常会导致不灵活。
问题
将所有功能构建到一个继承结构中会导致系统中类的激增。更糟糕的是,当您试图对继承树的不同分支应用类似的修改时,您很可能会看到重复出现。
让我们回到我们的游戏。在这里,我定义了一个Tile类和一个派生类型:
// listing 10.17
abstract class Tile
{
abstract public function getWealthFactor(): int;
}
// listing 10.18
class Plains extends Tile
{
private int $wealthfactor = 2;
public function getWealthFactor(): int
{
return $this->wealthfactor;
}
}
一个方块代表一个方块,我的单位可能会出现在这个方块上。每个瓷砖都有一定的特点。在这个例子中,我定义了一个getWealthFactor()方法,这个方法影响一个特定方块如果被玩家拥有可能产生的收入。如你所见,Plains物体的财富因子为 2。显然,tiles 管理其他数据。它们可能还包含对图像信息的引用,这样就可以绘制板子了。再说一次,我会保持事情简单。
我需要修改Plains对象的行为来处理自然资源和人类滥用的影响。我希望模拟钻石在景观中的出现和污染造成的破坏。一种方法可能是从Plains对象继承:
// listing 10.19
class DiamondPlains extends Plains
{
public function getWealthFactor(): int
{
return parent::getWealthFactor() + 2;
}
}
// listing 10.20
class PollutedPlains extends Plains
{
public function getWealthFactor(): int
{
return parent::getWealthFactor() - 4;
}
}
我现在可以轻而易举地获得一块被污染的瓷砖:
// listing 10.21
$tile = new PollutedPlains();
print $tile->getWealthFactor();
以下是输出:
-2
你可以在图 10-3 中看到这个例子的类图。
图 10-3
将变异构建到继承树中
这个结构显然是不灵活的。我可以得到有钻石的平原。我可以得到被污染的平原。但是我能两个都要吗?显然不会,除非我愿意犯下那种令人恐惧的罪行。这种情况在我引入Forest级的时候只能变得更糟,它也可以有钻石和污染。
当然,这是一个极端的例子,但这一点是明确的。完全依赖继承来定义您的功能会导致类的多样性和重复的趋势。
在这一点上,让我们举一个更普通的例子。严肃的 web 应用通常必须在启动任务形成响应之前对请求执行一系列操作。例如,您可能需要对用户进行身份验证,并记录请求。也许您应该处理从原始输入构建数据结构的请求。最后,您必须执行您的核心处理。你面临着同样的问题。
您可以通过在派生的LogRequest类、StructureRequest类和AuthenticateRequest类中进行额外的处理来扩展基本ProcessRequest类的功能。你可以在图 10-4 中看到这个阶级等级。
图 10-4
更多硬编码变体
但是,当您需要执行日志记录和身份验证,而不是数据准备时,会发生什么呢?你创建了一个LogAndAuthenticateProcessor类吗?显然,现在是找到更灵活的解决方案的时候了。
履行
Decorator模式使用组合和委托,而不是仅仅使用继承来解决功能变化的问题。本质上,Decorator类管理对它们自己类型的另一个类的实例的引用。一个Decorator将实现一个操作,这样它在执行自己的动作之前(或之后)对它所引用的对象调用相同的操作。这样,就有可能在运行时构建一个Decorator对象的管道。
让我们重写我们的游戏示例来说明这一点:
// listing 10.22
abstract class Tile
{
abstract public function getWealthFactor(): int;
}
// listing 10.23
class Plains extends Tile
{
private int $wealthfactor = 2;
public function getWealthFactor(): int
{
return $this->wealthfactor;
}
}
// listing 10.24
abstract class TileDecorator extends Tile
{
protected Tile $tile;
public function construct(Tile $tile)
{
$this->tile = $tile;
}
}
这里,我像以前一样声明了Tile和Plains类,但是我也引入了一个新类:TileDecorator。这个没有实现getWealthFactor(),所以必须声明抽象。我定义了一个需要一个Tile对象的构造函数,它存储在一个名为$tile的属性中。我创建这个属性protected,以便子类可以访问它。现在我将重新定义Pollution和Diamond类:
// listing 10.25
class DiamondDecorator extends TileDecorator
{
public function getWealthFactor(): int
{
return $this->tile->getWealthFactor() + 2;
}
}
// listing 10.26
class PollutionDecorator extends TileDecorator
{
public function getWealthFactor(): int
{
return $this->tile->getWealthFactor() - 4;
}
}
每个类都扩展了TileDecorator。这意味着它们引用了一个Tile对象。当getWealthFactor()被调用时,这些类中的每一个在做出自己的调整之前都会调用其Tile引用上的相同方法。
通过像这样使用组合和委托,可以很容易地在运行时组合对象。因为模式中的所有对象都扩展了Tile,所以客户端不需要知道它正在使用哪个组合。可以肯定的是,getWealthFactor()方法可用于任何Tile对象,无论它是否在幕后装饰另一个对象:
// listing 10.27
$tile = new Plains();
print $tile->getWealthFactor(); // 2
Plains是组件。它只是返回 2:
// listing 10.28
$tile = new DiamondDecorator(new Plains());
print $tile->getWealthFactor(); // 4
DiamondDecorator引用了一个Plains对象。它在添加自己的权重 2 之前调用getWealthFactor():
// listing 10.29
$tile = new PollutionDecorator(new DiamondDecorator(new Plains()));
print $tile->getWealthFactor(); // 0
PollutionDecorator引用了一个DiamondDecorator对象,该对象有自己的Tile引用。
你可以在图 10-5 中看到这个例子的类图。
图 10-5
装饰图案
这个模型是非常可扩展的。您可以非常容易地添加新的装饰器和组件。有了许多装饰器,你可以在运行时构建非常灵活的结构。在这种情况下,组件类Plains可以以多种方式进行显著修改,而无需将所有修改构建到类层次结构中。简单地说,这意味着你可以拥有一个带有钻石的被污染的Plains物体,而不必创建一个PollutedDiamondPlains物体。
Decorator模式构建了对创建过滤器非常有用的管道。java.io包充分利用了装饰类。客户端编码器可以将装饰对象与核心组件结合起来,为核心方法如read()添加过滤、缓冲、压缩等功能。我的 web 请求示例也可以开发成可配置的管道。下面是一个使用Decorator模式的简单实现:
// listing 10.30
class RequestHelper
{
}
// listing 10.31
abstract class ProcessRequest
{
abstract public function process(RequestHelper $req): void;
}
// listing 10.32
class MainProcess extends ProcessRequest
{
public function process(RequestHelper $req): void
{
print __CLASS__ . ": doing something useful with request\n";
}
}
// listing 10.33
abstract class DecorateProcess extends ProcessRequest
{
public function __construct(protected ProcessRequest $processrequest)
{
}
}
和以前一样,我们定义了一个抽象超类(ProcessRequest)、一个具体组件(MainProcess)和一个抽象装饰器(DecorateProcess)。MainProcess::process()什么也不做,只是报告它已经被调用。DecorateProcess代表其子节点存储一个ProcessRequest对象。下面是一些简单的具体装饰类:
// listing 10.34
class LogRequest extends DecorateProcess
{
public function process(RequestHelper $req): void
{
print __CLASS__ . ": logging request\n";
$this->processrequest->process($req);
}
}
// listing 10.35
class AuthenticateRequest extends DecorateProcess
{
public function process(RequestHelper $req): void
{
print __CLASS__ . ": authenticating request\n";
$this->processrequest->process($req);
}
}
// listing 10.36
class StructureRequest extends DecorateProcess
{
public function process(RequestHelper $req): void
{
print __CLASS__ . ": structuring request data\n";
$this->processrequest->process($req);
}
}
每个process()方法在调用被引用的ProcessRequest对象自己的process()方法之前输出一条消息。现在,您可以在运行时组合从这些类实例化的对象来构建过滤器,以不同的顺序对请求执行不同的操作。下面是将所有这些具体类中的对象合并到一个过滤器中的一些代码:
// listing 10.37
$process = new AuthenticateRequest(
new StructureRequest(
new LogRequest(
new MainProcess()
)
)
);
$process->process(new RequestHelper());
该代码给出以下输出:
popp\ch10\batch07\AuthenticateRequest: authenticating request
popp\ch10\batch07\StructureRequest: structuring request data
popp\ch10\batch07\LogRequest: logging request
popp\ch10\batch07\MainProcess: doing something useful with request
Note
事实上,这个例子也是一个名为拦截过滤器的企业模式的实例。拦截过滤器在 Alur 等人的核心 J2EE 模式:最佳实践和设计策略(Prentice Hall,2001) 中有所描述。
结果
像Composite模式一样,Decorator可能会令人困惑。重要的是要记住,组合和继承是同时起作用的。所以LogRequest从ProcessRequest继承了它的接口,但是它是另一个ProcessRequest对象的包装器。
因为 decorator 对象在子对象周围形成了一个包装器,所以它有助于保持接口尽可能稀疏。如果您构建了一个功能强大的基类,那么 decorators 将被迫委托给它们所包含的对象中的所有公共方法。这可以在抽象装饰类中完成,但是它仍然引入了会导致错误的耦合。
一些程序员创建的装饰器与他们修改的对象不共享一个公共类型。只要它们实现与这些对象相同的接口,这种策略就能很好地工作。您得到的好处是能够使用内置的拦截器方法来自动化委托(实现call()来捕捉对不存在的方法的调用,并在子对象上自动调用相同的方法)。然而,这样做,您也失去了类类型检查所提供的安全性。在我们到目前为止的例子中,客户端代码可以在其参数列表中要求一个Tile或一个ProcessRequest对象,并确定它的接口,不管所讨论的对象是否被大量修饰。
立面图案
在过去,您可能有机会将第三方系统整合到您自己的项目中。无论代码是否面向对象,它通常都是令人望而生畏的、庞大的和复杂的。您自己的代码也可能对只需要访问少数几个特性的客户端程序员构成挑战。Facade模式是一种为复杂系统提供简单、清晰界面的方式。
问题
系统倾向于进化出大量的代码,这些代码实际上只在系统内部有用。正如类定义了清晰的公共接口并对外界隐藏了它们的本质一样,设计良好的系统也应该如此。然而,并不总是清楚系统的哪些部分被设计为供客户端代码使用,哪些部分最好隐藏起来。
当您使用子系统(如 web 论坛或图库应用)时,您可能会发现自己在深入代码逻辑中进行调用。如果子系统代码随着时间的推移会发生变化,并且您的代码在许多不同的点上与之交互,那么随着子系统的发展,您可能会发现自己面临着严重的维护问题。
类似地,当您构建自己的系统时,将不同的部分组织到不同的层中是一个好主意。通常,您可能有一个层负责应用逻辑,另一个层负责数据库交互,另一个层负责表示,等等。您应该努力保持这些层尽可能地相互独立,这样项目中一个区域的变化对其他区域的影响就很小。如果一层的代码紧密集成到另一层的代码中,那么这个目标就很难实现。
下面是一些故意混淆的程序代码,它们将从文件中获取日志信息并将其转换为对象数据的简单过程变成了歌舞升平的例行程序:
// listing 10.38
function getProductFileLines(string $file): array
{
return file($file);
}
function getProductObjectFromId(string $id, string $productname): Product
{
// some kind of database lookup
return new Product($id, $productname);
}
function getNameFromLine(string $line): string
{
if (preg_match("/.*-(.*)\s\d+/", $line, $array)) {
return str_replace('_', ' ', $array[1]);
}
return '';
}
function getIDFromLine($line): int|string
{
if (preg_match("/^(\d{1,3})-/", $line, $array)) {
return $array[1];
}
return -1;
}
class Product
{
public string $id;
public string $name;
public function __construct(string $id, string $name)
{
$this->id = $id;
$this->name = $name;
}
}
让我们假设这段代码的内部比实际更复杂,我坚持使用它,而不是从头开始重写。例如,假设我必须将包含如下行的文件转换成一个对象数组:
234-ladies_jumper 55
532-gents_hat 44
为此,我必须调用所有这些函数(注意,为了简洁起见,我没有提取代表价格的最终数字):
// listing 10.39
$lines = getProductFileLines(__DIR__ . '/test2.txt');
$objects = [];
foreach ($lines as $line) {
$id = getIDFromLine($line);
$name = getNameFromLine($line);
$objects[$id] = getProductObjectFromID($id, $name);
}
print_r($objects);
以下是输出:
Array
(
[234] => Product Object
(
[id] => 234
[name] => ladies jumper
)
[532] => Product Object
(
[id] => 532
[name] => gents hat
)
)
如果我在整个项目中像这样直接调用这些函数,我的代码将与它所使用的子系统紧密相连。如果子系统发生变化,或者如果我决定将其完全切换出去,这可能会导致问题。我真的需要在系统和其余代码之间引入一个网关。
履行
下面是一个简单的类,它为您在上一节中遇到的过程代码提供了一个接口:
// listing 10.40
class ProductFacade
{
private array $products = [];
public function __construct(private string $file)
{
$this->compile();
}
private function compile(): void
{
$lines = getProductFileLines($this->file);
foreach ($lines as $line) {
$id = getIDFromLine($line);
$name = getNameFromLine($line);
$this->products[$id] = getProductObjectFromID($id, $name);
}
}
public function getProducts(): array
{
return $this->products;
}
public function getProduct(string $id): ?\Product
{
if (isset($this->products[$id])) {
return $this->products[$id];
}
return null;
}
}
从客户端代码的角度来看,从日志文件访问Product对象要简单得多:
// listing 10.41
$facade = new ProductFacade(__DIR__ . '/test2.txt');
$object = $facade->getProduct("234");
结果
A Facade真的是一个很简单的概念。这只是为层或子系统创建一个单一入口点的问题。这有许多好处。它有助于将项目中不同的区域相互分离。对于客户端编码人员来说,使用简单的方法来实现清晰的目标是非常有用和方便的。它通过将子系统的使用集中在一个地方来减少错误;对子系统的更改应该会导致可预测位置的故障。复杂子系统中的Facade类也可以最小化错误,否则客户端代码可能会错误地使用内部函数。
尽管Facade模式很简单,但是很容易忘记使用它,尤其是如果您熟悉您正在使用的子系统。当然,需要找到一个平衡点。一方面,为复杂系统创建简单接口的好处应该是显而易见的。另一方面,人们可以不顾后果地对系统进行抽象,然后再进行抽象。如果您为了客户代码的明显好处而进行了显著的简化,并且/或者将它从可能改变的系统中屏蔽掉,那么您实现Facade模式可能是正确的。
摘要
在这一章中,我看了一些在系统中组织类和对象的方法。特别是,我关注的原则是,在继承失败的情况下,组合可以用来产生灵活性。在Composite和Decorator模式中,继承被用来促进组合,并定义一个为客户端代码提供保证的公共接口。
您还看到了在这些模式中有效使用的委托。最后,我看了看简单但功能强大的Facade模式。Facade是许多人已经使用多年却没有名字的模式之一。Facade允许您为层或子系统提供一个干净的入口点。在 PHP 中,Facade模式也用于创建封装程序代码块的对象包装器。
十一、执行和表示任务
在这一章中,我们开始行动。我着眼于帮助你完成事情的模式,无论是解释一种迷你语言还是封装一种算法。
本章将带您了解几种模式:
-
解释器模式:构建一个小型语言解释器,可以用来创建可脚本化的应用
-
策略模式:识别系统中的算法,并将它们封装成自己的类型
-
观察者模式:创建钩子来提醒不同的对象关于系统事件
-
访问者模式:对对象树中的所有节点应用操作
-
命令模式:创建可以保存和传递的命令对象
-
空对象模式:使用非操作对象代替空值
解释器模式
语言是用其他语言写的(至少一开始是这样)。例如,PHP 本身就是用 c 语言编写的。同样,尽管听起来有些奇怪,但你可以使用 PHP 定义和运行你自己的语言。当然,你可能创造的任何语言都将是缓慢的,而且有些局限。尽管如此,小语种还是非常有用的,你会在本章中看到。
问题
当您在 PHP 中创建 web(或命令行)界面时,您给了用户访问功能的权限。界面设计的取舍是在功能和易用性之间。通常,你给用户的权力越多,你的界面就变得越混乱。当然,好的界面设计在这里会有很大帮助。但是如果 90%的用户都在使用你的 30%的功能,增加功能的成本可能会超过收益。您可能希望为大多数用户简化您的系统。但是那 10%使用你的系统的高级功能的超级用户呢?也许你可以用不同的方式来适应它们。通过为这些用户提供一种领域语言(通常称为 DSL——特定于领域的语言),您实际上可以扩展应用的功能。
当然,你马上就有了一门编程语言。它叫 PHP。以下是允许用户编写系统脚本的方法:
// listing 11.01
$form_input = $_REQUEST['form_input'];
// contains: "print file_get_contents('/etc/passwd');"
eval($form_input);
这种使应用可脚本化的方法显然是疯狂的。以防原因不明显,它们归结为两个问题:安全性和复杂性。这个例子很好地解决了安全问题。通过允许用户通过您的脚本执行 PHP,您实际上是给了他们访问运行脚本的服务器的权限。复杂性问题也是一大缺点。无论你的代码有多清晰,普通用户都不太可能轻易扩展它,当然更不可能从浏览器窗口扩展。
然而,迷你语言可以解决这两个问题。您可以在语言中设计灵活性,减少用户造成损害的可能性,并保持事情的重点。
设想一个用于编写测验的应用。制作人设计问题,并制定规则,对参赛者提交的答案进行评分。要求测验必须在没有人工干预的情况下进行标记,即使用户可以将一些答案键入文本字段中。
这里有个问题:
How many members in the Design Patterns gang?
你可以接受“四”或“4”作为正确答案。您可以创建一个 web 界面,允许生成器使用正则表达式来标记响应:
⁴|four$
然而,大多数生产者并不是因为他们对正则表达式的了解而被雇佣的。为了使每个人的生活更容易,您可以实现一个更用户友好的机制来标记响应:
$input equals "4" or $input equals "four"
你提出了一种支持变量的语言,一个叫做equals的操作符,以及布尔逻辑(or和and)。程序员喜欢给东西命名,我们就叫它 MarkLogic 吧。它应该很容易扩展,因为你可以想象对更丰富功能的大量请求。让我们暂时把解析输入的问题放在一边,专注于一种在运行时将这些元素组合在一起以产生答案的机制。如您所料,这就是解释器模式的用武之地。
履行
语言包含表达式(即解析为值的事物)。正如你在表 11-1 中看到的,即使是像 MarkLogic 这样微小的语言也需要跟踪大量的元素。
表 11-1
标记逻辑语法的要素
|描述
|
EBNF 元标识符
|
类别名
|
例子
|
| --- | --- | --- | --- |
| 可变的 | variable | VariableExpression | $input |
| 字符串文字 | stringLiteral | LiteralExpression | "four" |
| 布尔与 | andExpr | BooleanAndExpression | $input equals '4' and $other equals '6' |
| 布尔或 | orExpr | BooleanOrExpression | $input equals '4' or $other equals '6' |
| 平等测试 | eqExpr | BooleanEqualsExpression | $input equals '4' |
表 11-1 列出了 EBNF 的名字。那么 EBNF 到底是什么呢?EBNF 是一种句法元语言,你可以用它来描述语言语法。EBNF 代表扩展的巴克斯-诺尔形式。它由一系列行(称为产品)组成,每一行都由一个名称和一个描述组成,描述的形式是对其他产品和终端的引用(即元素本身不是由对其他产品的引用组成的)。下面是用 EBNF 描述我的语法的一种方式:
Expr = operand { orExpr | andExpr }
Operand = ( '(' expr ')' | ? string literal ? | variable ) { eqExpr } orExpr = 'or' operand
andExpr = 'and' operand
eqExpr = 'equals' operand
variable = '$' , ? word ?
一些符号有特殊的含义(从正则表达式符号中应该很熟悉):|(更确切地说是一个定义分隔符)可以粗略地认为是或,例如。您可以使用括号对标识符进行分组。所以在这个例子中,一个表达式(expr)由一个operand后跟零个或多个orExpr或andExpr组成。一个operand可以是一个带括号的expr(即,一个用文字“(”和“)”字符包装的expr)、一个带引号的字符串(我已经省略了它的产生),或者一个后面跟有零个或多个eqExpr实例的variable。一旦你掌握了从一部作品提到另一部作品的诀窍,《EBNF》就变得很容易读懂了。
在图 11-1 中,我将我的语法元素表示为类。
图 11-1
组成 MarkLogic 语言的解释器类
如您所见,BooleanAndExpression和它的兄弟从OperatorExpression继承而来。这是因为这些类都在其他Expression对象上执行它们的操作。VariableExpression和LiteralExpression直接和价值观打交道。
所有的Expression对象都实现了一个在抽象基类Expression中定义的interpret()方法。interpret()方法需要一个用作共享数据存储的InterpreterContext对象。每个Expression对象可以在InterpreterContext对象中存储数据。然后InterpreterContext将被传递给其他Expression对象。为了方便从InterpreterContext中检索数据,Expression基类实现了一个返回唯一句柄的getKey()方法。让我们通过Expression的实现来看看这在实践中是如何工作的:
// listing 11.02
abstract class Expression
{
private static int $keycount = 0;
private string $key;
abstract public function interpret(InterpreterContext $context);
public function getKey(): string
{
if (! isset($this->key)) {
self::$keycount++;
$this->key = (string)self::$keycount;
}
return $this->key;
}
}
// listing 11.03
class LiteralExpression extends Expression
{
private mixed $value;
public function __construct(mixed $value)
{
$this->value = $value;
}
public function interpret(InterpreterContext $context): void
{
$context->replace($this, $this->value);
}
}
// listing 11.04
class InterpreterContext
{
private array $expressionstore = [];
public function replace(Expression $exp, mixed $value): void
{
$this->expressionstore[$exp->getKey()] = $value;
}
public function lookup(Expression $exp): mixed
{
return $this->expressionstore[$exp->getKey()];
}
}
// listing 11.05
$context = new InterpreterContext();
$literal = new LiteralExpression('four');
$literal->interpret($context);
print $context->lookup($literal) . "\n";
以下是输出结果:
four
我将从第InterpreterContext课开始。如您所见,它实际上只是一个关联数组$expressionstore的前端,我用它来保存数据。replace()方法接受一个Expression对象作为键和一个任意类型的值,然后将这一对添加到$expressionstore。它还提供了一个用于检索数据的lookup()方法。
Expression类定义了抽象的interpret()方法和具体的getKey()方法,后者使用静态计数器值来生成、存储和返回字符串标识符。
这个方法被InterpreterContext::lookup()和InterpreterContext::replace()用来索引数据。
LiteralExpression类定义一个接受值参数的构造函数。interpret()方法需要一个InterpreterContext对象。我简单地使用getKey()调用replace()来定义检索的键和$value属性。在您研究其他Expression类时,这将成为一种熟悉的模式。interpret()方法总是将其结果写在InterpreterContext对象上。
我还包含了一些客户端代码,实例化了一个InterpreterContext对象和一个LiteralExpression对象(值为"four")。我把InterpreterContext对象递给LiteralExpression::interpret()。interpret()方法将键/值对存储在InterpreterContext中,我通过调用lookup()从那里检索值。
这是剩下的终端类。VariableExpression稍微复杂一点:
// listing 11.06
class VariableExpression extends Expression
{
public function __construct(private string $name, private mixed $val = null)
{
}
public function interpret(InterpreterContext $context): void
{
if (! is_null($this->val)) {
$context->replace($this, $this->val);
$this->val = null;
}
}
public function setValue(mixed $value): void
{
$this->val = $value;
}
public function getKey(): string
{
return $this->name;
}
}
// listing 11.07
$context = new InterpreterContext();
$myvar = new VariableExpression('input', 'four');
$myvar->interpret($context);
print $context->lookup($myvar) . "\n";
// output: four
$newvar = new VariableExpression('input');
$newvar->interpret($context);
print $context->lookup($newvar) . "\n";
// output: four
$myvar->setValue("five");
$myvar->interpret($context);
print $context->lookup($myvar) . "\n";
// output: five
print $context->lookup($newvar) . "\n";
// output: five
VariableExpression类接受存储在属性变量中的名称和值参数。我提供了setValue()方法,这样客户端代码可以随时更改值。
interpret()方法检查$val属性是否具有非空值。如果$val属性有一个值,它就在InterpreterContext上设置它。然后我将$val属性设置为null。这是在VariableExpression的另一个同名实例更改了InterpreterContext对象中的值后再次调用interpret()的情况下。这是一个非常有限的变量,只接受字符串值。如果你打算扩展你的语言,你应该考虑让它与其他Expression对象一起工作,这样它就可以包含测试和操作的结果。不过现在,VariableExpression将完成我需要它做的工作。注意,我已经覆盖了getKey()方法,因此变量值链接到变量名,而不是任意的静态 ID。
语言中的运算符表达式都与另外两个Expression对象一起工作,以完成它们的工作。因此,让它们扩展一个公共超类是有意义的。下面是OperatorExpression类:
// listing 11.08
abstract class OperatorExpression extends Expression
{
public function __construct(protected Expression $l_op, protected Expression $r_op)
{
}
public function interpret(InterpreterContext $context): void
{
$this->l_op->interpret($context);
$this->r_op->interpret($context);
$result_l = $context->lookup($this->l_op);
$result_r = $context->lookup($this->r_op);
$this->doInterpret($context, $result_l, $result_r);
}
abstract protected function doInterpret(
InterpreterContext $context,
$result_l,
$result_r
): void;
}
OperatorExpression是一个抽象类。它实现了interpret(),但是它也定义了抽象的dointerpret()方法。
构造函数需要两个Expression对象,$l_op和$r_op,并将其存储在属性中。
interpret()方法首先在其两个操作数属性上调用interpret()(如果您已经阅读了前一章,您可能会注意到我在这里创建了组合模式的一个实例)。一旦操作数已经运行,interpret()仍然需要获取它产生的值。它通过为每个属性调用InterpreterContext::lookup()来做到这一点。然后它调用dointerpret(),让子类来决定如何处理这些操作的结果。
Note
dointerpret()是模板方法模式的一个实例。在这种模式中,父类既定义又调用抽象方法,让子类来提供实现。这可以简化具体类的开发,因为共享功能是由超类处理的,让孩子专注于干净、狭窄的目标。
下面是BooleanEqualsExpression类,它测试两个Expression对象是否相等:
// listing 11.09
class BooleanEqualsExpression extends OperatorExpression
{
protected function doInterpret(
InterpreterContext $context,
mixed $result_l,
mixed $result_r
): void {
$context->replace($this, $result_l == $result_r);
}
}
BooleanEqualsExpression只实现了dointerpret()方法,该方法测试由interpret()方法传递的操作数结果的相等性,并将结果放在InterpreterContext对象中。
总结一下Expression类,下面是BooleanOrExpression和BooleanAndExpression:
// listing 11.10
class BooleanOrExpression extends OperatorExpression
{
protected function doInterpret(
InterpreterContext $context,
mixed $result_l,
mixed $result_r
): void {
$context->replace($this, $result_l || $result_r);
}
}
// listing 11.11
class BooleanAndExpression extends OperatorExpression
{
protected function doInterpret(
InterpreterContext $context,
mixed $result_l,
mixed $result_r
): void {
$context->replace($this, $result_l && $result_r);
}
}
BooleanOrExpression类应用逻辑or运算并通过InterpreterContext::replace()方法存储运算结果,而不是测试相等性。BooleanAndExpression当然应用了逻辑and运算。
我现在有足够的代码来执行我前面引用的迷你语言片段。又来了:
$input equals "4" or $input equals "four"
下面是我如何用我的Expression类建立这个语句:
// listing 11.12
$context = new InterpreterContext();
$input = new VariableExpression('input');
$statement = new BooleanOrExpression(
new BooleanEqualsExpression($input, new LiteralExpression('four')),
new BooleanEqualsExpression($input, new LiteralExpression('4'))
);
我实例化了一个名为"input"的变量,但是没有为它提供值。然后我创建一个BooleanOrExpression对象来比较两个BooleanEqualsExpression对象的结果。这些对象中的第一个将存储在$input中的VariableExpression对象与包含字符串"four"的LiteralExpression进行比较;第二个将$input与包含字符串"4"的LiteralExpression对象进行比较。
现在,准备好语句后,我准备为输入变量提供一个值并运行代码:
// listing 11.13
foreach ([ "four", "4", "52" ] as $val) {
$input->setValue($val);
print "$val:\n";
$statement->interpret($context);
if ($context->lookup($statement)) {
print "top marks\n\n";
} else {
print "dunce hat on\n\n";
}
}
事实上,我用三个不同的值运行了代码三次。第一次,我将临时变量$val设置为"four",使用其setValue()方法将它赋给输入VariableExpression对象。然后我调用最顶层的Expression对象上的interpret()(BooleanOrExpression对象包含对语句中所有其他表达式的引用)。下面是这个调用的内部过程,一步一步来:
-
$statement在其$l_op属性上调用interpret()(第一个BooleanEqualsExpression对象)。 -
第一个
BooleanEqualsExpression对象调用上的interpret()及其$l_op属性(对输入VariableExpression对象的引用,该对象当前设置为"four")。 -
输入
VariableExpression对象通过调用InterpreterContext::replace()将其当前值写入提供的InterpreterContext对象。 -
第一个
BooleanEqualsExpression对象在其$r_op属性上调用interpret()(一个值为"four"的LiteralExpression对象)。 -
LiteralExpression对象向InterpreterContext注册它的键和值。 -
第一个
BooleanEqualsExpression对象从InterpreterContext对象中检索$l_op("four")和$r_op("four")的值。 -
第一个
BooleanEqualsExpression对象比较这两个值是否相等,然后向InterpreterContext对象注册结果(true)及其键。 -
回到树的顶端,
$statement对象(BooleanOrExpression)在其$r_op属性上调用interpret()。这以与$l_op属性相同的方式解析为一个值(在本例中为false)。 -
$statement对象从InterpreterContext对象中检索每个操作数的值,并使用||进行比较。它在比较true和false,所以结果是true。这个最终结果存储在InterpreterContext对象中。
所有这些只是针对循环的第一次迭代。以下是最终输出:
four:
top marks 4:
top marks 52:
dunce hat on
您可能需要通读几遍这一部分,然后才能点击程序。在这里,对象与类树的老问题可能会让你困惑。Expression类被安排在一个继承层次中,就像Expression对象在运行时被组成一棵树。当您通读代码时,请记住这一区别。
图 11-2 显示了该示例的完整类图。
图 11-2
部署的解释器模式
翻译问题
一旦为解释器模式实现设置了核心类,扩展就变得容易了。您所付出的代价在于您最终可能创建的类的数量。由于这个原因,解释器最好应用于相对较小的语言。如果你需要一种通用编程语言,你最好找一个第三方工具来使用。
因为解释器类经常执行非常相似的任务,所以关注您创建的类以消除重复是值得的。
许多第一次接触解释器模式的人,在最初的兴奋之后,失望地发现它没有解决解析问题。这意味着你还不能为你的用户提供一种友好的语言。附录 B 包含一些粗略的代码来说明一种解析小型语言的策略。
战略模式
类经常试图做太多。这是可以理解的:你创建了一个执行一些相关动作的类;而且,当您编码时,这些动作中的一些需要根据环境而变化。同时,你的类需要拆分成子类。在你意识到之前,你的设计已经被竞争的力量撕裂了。
问题
因为我最近构建了一种标记语言,所以我坚持使用测验示例。测验需要问题,所以你构建了一个Question类,给它一个mark()方法。一切都很好,直到您需要支持不同的标记机制。
假设要求你支持简单的 MarkLogic 语言,通过直接匹配和正则表达式进行标记。你的第一个想法可能是对这些差异进行子类化,如图 11-3 所示。
图 11-3
根据标记策略定义子类
这将很好地为你服务,只要分数仍然是这门课唯一不同的方面。想象一下,你被要求支持不同类型的问题:基于文本的问题和支持富媒体的问题。这就给你带来了一个问题,如何将这些力量整合到一个继承树中,如图 11-4 所示。
图 11-4
根据两种力量定义子类
不仅层次结构中的类数量激增,而且还必然会引入重复。您的标记逻辑会在继承层次结构的每个分支中复制。
每当您发现自己在一个继承树中跨兄弟重复一个算法时(无论是通过子类化还是重复的条件语句),考虑将这些行为抽象成它们自己的类型。
履行
和所有最好的模式一样,策略简单而强大。当类必须支持一个接口的多种实现(例如,多种标记机制)时,最好的方法通常是提取这些实现并将它们放在自己的类型中,而不是扩展原始类来处理它们。
所以,在这个例子中,你的标记方法可能被放在一个Marker类型中。图 11-5 显示了新的结构。
图 11-5
将算法提取到它们自己的类型中
还记得“四人帮”的“重作文轻继承”的原则吗?这是一个极好的例子。通过定义和封装标记算法,可以减少子类化并增加灵活性。您可以随时添加新的标记策略,而完全不需要更改Question类。所有的Question类都知道它们拥有一个Marker的实例,并且它的接口保证它支持一个mark()方法。实施的细节完全是别人的问题。
下面是呈现为代码的Question类:
// listing 11.14
abstract class Question
{
public function __construct(protected string $prompt, protected Marker $marker)
{
}
public function mark(string $response): bool
{
return $this->marker->mark($response);
}
}
// listing 11.15
class TextQuestion extends Question
{
// do text question specific things
}
// listing 11.16
class AVQuestion extends Question
{
// do audiovisual question specific things
}
如你所见,我把TextQuestion和AVQuestion之间的差异的确切本质留给了想象。Question基类提供所有真正的功能,存储一个提示属性和一个Marker对象。当终端用户响应调用Question::mark()时,该方法简单地将问题解决委托给它的Marker对象。
现在是时候定义一些简单的Marker对象了:
// listing 11.17
abstract class Marker
{
public function __construct(protected string $test)
{
abstract public function mark(string $response): bool;
}
// listing 11.18
class MarkLogicMarker extends Marker
{
private MarkParse $engine;
public function __construct(string $test)
{
parent:: __construct($test);
$this->engine = new MarkParse($test);
}
public function mark(string $response): bool
{
return $this->engine->evaluate($response);
}
}
// listing 11.19
class MatchMarker extends Marker
{
public function mark(string $response): bool
{
return ($this->test == $response);
}
}
// listing 11.20
class RegexpMarker extends Marker
{
public function mark(string $response): bool
{
return (preg_match("$this->test", $response) === 1);
}
}
对于Marker类本身,应该没有什么特别令人惊讶的。注意,MarkParse对象被设计为与附录 b 中开发的简单解析器一起工作。这里的关键在于我定义的结构,而不是策略本身的细节。我可以把RegexpMarker换成MatchMarker,对Question职业没有影响。
当然,您仍然必须决定使用什么方法在具体的Marker对象之间进行选择。我见过两种现实世界中解决这个问题的方法。首先,生产者使用单选按钮来选择首选的标记策略。在第二种情况下,使用标记条件本身的结构;也就是说,match 语句是空白的:
five
MarkLogic 语句前面有一个冒号:
:$input equals 'five'
一个正则表达式使用了正斜杠:
/f.ve/
下面是一些运行这些类的代码:
// listing 11.21
$markers = [
new RegexpMarker("/f.ve/"),
new MatchMarker("five"),
new MarkLogicMarker('$input equals "five"')
];
foreach ($markers as $marker) {
print get_class($marker) . "\n";
$question = new TextQuestion("how many beans make five", $marker);
foreach ([ "five", "four" ] as $response) {
print " response: $response: ";
if ($question->mark($response)) {
print "well done\n";
} else {
print "never mind\n";
}
}
}
我构建了三个策略对象,依次使用每个对象来帮助构建一个TextQuestion对象。然后对两个样本响应尝试使用TextQuestion对象。以下是输出(包括名称空间):
popp\ch11\batch02\RegexpMarker
response: five: well done
response: four: never mind
popp\ch11\batch02\MatchMarker
response: five: well done
response: four: never mind
popp\ch11\batch02\MarkLogicMarker
response: five: well done
response: five: never mind
在这个例子中,我通过mark()方法将特定的数据($response变量)从客户端传递给策略对象。有时,您可能会遇到这样的情况:当调用策略对象的操作时,您并不总是预先知道策略对象将需要多少信息。您可以通过将策略传递给客户端本身的一个实例来委托获取哪些数据的决策。然后,该策略可以查询客户端,以构建它需要的数据。
观察者模式
正交性是我之前描述过的一个优点。作为程序员,我们的目标之一应该是构建可以在对其他组件影响最小的情况下进行更改或移动的组件。如果我们对一个组件所做的每一个更改都必然会在代码库中的其他地方引起连锁反应,那么开发任务很快就会变成错误创建和消除的螺旋。
当然,正交往往只是一个梦想。系统中的元素必须嵌入对其他元素的引用。但是,您可以部署各种策略来最小化这种情况。您已经看到了各种各样的多态例子,在这些例子中,客户端理解组件的接口,但是实际的组件可能在运行时发生变化。
在某些情况下,您可能希望在组件之间插入比这更大的楔子。考虑一个负责处理用户访问系统的类:
// listing 11.22
classLogin
{
public const LOGIN_USER_UNKNOWN = 1;
public const LOGIN_WRONG_PASS = 2;
public const LOGIN_ACCESS = 3;
private array $status = [];
public function handleLogin(string $user, string $pass, string $ip): bool
{
$isvalid = false;
switch (rand(1, 3)) {
case 1:
$this->setStatus(self::LOGIN_ACCESS, $user, $ip);
$isvalid = true;
break;
case 2:
$this->setStatus(self::LOGIN_WRONG_PASS, $user, $ip);
$isvalid = false;
break;
case 3:
$this->setStatus(self::LOGIN_USER_UNKNOWN, $user, $ip);
$isvalid = false;
break;
}
print "returning " . (($isvalid) ? "true" : "false") . "\n";
return $isvalid;
}
private function setStatus(int $status, string $user, string $ip): void
{
$this->status = [$status, $user, $ip];
}
public function getStatus(): array
{
return $this->status;
}
}
当然,在现实世界的例子中,handleLogin()方法将根据存储机制来验证用户。实际上,这个类使用rand()函数来伪造登录过程。给handleLogin()打电话有三种可能的结果。状态标志可以设置为LOGIN_ACCESS、LOGIN_WRONG_PASS或LOGIN_USER_UNKNOWN。
因为Login类是保护你的业务团队财富的门户,它可能会在开发期间和以后的几个月里激发很多兴趣。营销人员可能会打电话给你,要求你记录 IP 地址。您可以向系统的Logger类添加一个调用:
// listing 11.23
public function handleLogin(string $user, string $pass, string $ip): bool
{
switch (rand(1, 3)) {
case 1:
$this->setStatus(self::LOGIN_ACCESS, $user, $ip);
$isvalid = true;
break;
case 2:
$this->setStatus(self::LOGIN_WRONG_PASS, $user, $ip);
$isvalid = false;
break;
case 3:
$this->setStatus(self::LOGIN_USER_UNKNOWN, $user, $ip);
$isvalid = false;
break;
}
Logger::logIP($user, $ip, $this->getStatus());
return $isvalid;
}
由于担心安全性,系统管理员可能会要求通知失败的登录。同样,您可以返回到登录方法并添加一个新呼叫:
// listing 11.24
if (! $isvalid) {
Notifier::mailWarning(
$user,
$ip,
$this->getStatus()
);
}
业务开发团队可能会宣布与特定 ISP 的合作,要求在特定用户登录时设置 cookie。等等,等等。
这些都是很容易满足的要求,但是解决它们需要为您的设计付出代价。类很快就非常紧密地嵌入到这个特定的系统中。如果不一行一行地检查代码,删除旧系统特有的所有内容,就不能把它取出来放到另一个产品中。当然,这并不太难,但是这样你就走上了剪切粘贴编码的道路。现在你的系统中有了两个相似但不同的Login类,你会发现对其中一个的改进需要对另一个进行同样的修改——直到它们不可避免地、不体面地彼此脱离。
那么你能做些什么来拯救Login类呢?观察者模式非常适合这里。
履行
观察者模式的核心是将客户端元素(观察者)从中心类(主体)中分离出来。当受试者知道的事件发生时,观察者需要被告知。同时,您不希望 subject 与其 observer 类有硬编码的关系。
为了实现这一点,您可以允许观察者向主题注册他们自己。您给了Login类三个新方法,attach()、detach()和notify(),并使用一个名为Observable的接口来强制执行:
// listing 11.25
interface Observable
{
public function attach(Observer $observer): void;
public function detach(Observer $observer): void;
public function notify(): void;
}
// listing 11.26
class Login implements Observable
{
private array $observers = [];
public const LOGIN_USER_UNKNOWN = 1;
public const LOGIN_WRONG_PASS = 2;
public const LOGIN_ACCESS = 3;
public function attach(Observer $observer): void
{
$this->observers[] = $observer;
}
public function detach(Observer $observer): void
{
$this->observers = array_filter(
$this->observers,
function ($a) use ($observer) {
return (! ($a === $observer ));
}
);
}
public function notify(): void
{
foreach ($this->observers as $obs) {
$obs->update($this);
}
}
// ...
}
所以Login类管理一个观察者对象列表。这些可以由第三方使用attach()方法添加,并通过detach()删除。调用notify()方法是为了告诉观察者发生了一些有趣的事情。该方法简单地遍历观察器列表,对每个观察器调用update()。
Login类本身从它的handleLogin()方法中调用notify():
// listing 11.27
public function handleLogin(string $user, string $pass, string $ip): bool
{
switch (rand(1, 3)) {
case 1:
$this->setStatus(self::LOGIN_ACCESS, $user, $ip);
$isvalid = true;
break;
case 2:
$this->setStatus(self::LOGIN_WRONG_PASS, $user, $ip);
$isvalid = false;
break;
case 3:
$this->setStatus(self::LOGIN_USER_UNKNOWN, $user, $ip);
$isvalid = false;
break;
}
$this->notify();
return $isvalid;
}
下面是Observer类的接口:
// listing 11.28
interface Observer
{
public function update(Observable $observable): void;
}
任何使用这个接口的对象都可以通过attach()方法添加到Login类中。这里有一个具体的例子:
// listing 11.29
class LoginAnalytics implements Observer
{
public function update(Observable $observable): void
{
// not type safe!
$status = $observable->getStatus();
print __CLASS __ . ": doing something with status info\n";
}
}
注意 observer 对象如何使用Observable的实例来获得关于事件的更多信息。由 subject 类提供观察者可以查询以了解状态的方法。在这种情况下,我定义了一个名为getStatus()的方法,观察者可以调用它来获得游戏当前状态的快照。
不过,这一增加也凸显了一个问题。通过调用Login::getStatus(),LoginAnalytics类承担了比它安全所能承担的更多的知识。它在一个Observable对象上进行这个调用,但是不能保证这个对象也会是一个Login对象。我有几个选择。我可以扩展Observable接口,使其包含一个getStatus()声明,并可能将其重命名为类似于ObservableLogin的名称,以表明它是特定于Login类型的。
或者,我可以保持Observable接口的通用性,让Observer类负责确保它们的主题是正确的类型。他们甚至可以把自己和主题联系起来。由于将会有不止一种类型的Observer,并且由于我计划执行一些对它们都通用的内务处理,这里有一个抽象超类来处理这些繁琐的工作:
// listing 11.30
abstract class LoginObserver implements Observer
{
private Login $login;
public function __construct(Login $login)
{
$this->login = $login;
$login->attach($this);
}
public function update(Observable $observable): void
{
if ($observable === $this->login) {
$this->doUpdate($observable);
}
}
abstract public function doUpdate(Login $login): void;
}
LoginObserver类在其构造函数中需要一个Login对象。它存储一个引用并调用Login::attach()。当调用update()时,它检查提供的Observable对象是否是正确的引用。然后它调用一个模板方法:doUpdate()。我现在可以创建一套LoginObserver对象,所有这些对象都是安全的,它们使用的是Login对象,而不是任何旧的Observable:
// listing 11.31
class SecurityMonitor extends LoginObserver
{
public function doUpdate(Login $login): void
{
$status = $login->getStatus();
if ($status[0] == Login::LOGIN_WRONG_PASS) {
// send mail to sysadmin
print __CLASS__ . ": sending mail to sysadmin\n";
}
}
}
// listing 11.32
class GeneralLogger extends LoginObserver
{
public function doUpdate(Login $login): void
{
$status = $login->getStatus();
// add login data to log
print __CLASS__ . ": add login data to log\n";
}
}
// listing 11.33
class PartnershipTool extends LoginObserver
{
public function doUpdate(Login $login): void
{
$status = $login->getStatus();
// check $ip address
// set cookie if it matches a list
print __CLASS__ . ": set cookie if it matches a list\n";
}
}
创建和附加LoginObserver类现在可以在实例化时一次性完成:
// listing 11.34
$login = new Login();
new SecurityMonitor($login);
new GeneralLogger($login);
new PartnershipTool($login);
所以现在我在主题类和观察者之间创建了一个灵活的关联。你可以在图 11-6 中看到例子的类图。
图 11-6
观察者模式
PHP 通过捆绑的 SPL(标准 PHP 库)扩展提供了对观察者模式的内置支持。SPL 是一套帮助解决常见的、主要面向对象的问题的工具。这把 OO 瑞士军刀的观察者方面由三个元素组成:SplObserver、SplSubject和SplObjectStorage。SplObserver和SplSubject是接口,与本节示例中的Observer和Observable接口完全平行。SplObjectStorage是一个实用程序类,旨在提供改进的对象存储和移除。下面是 Observer 实现的编辑版本:
// listing 11.35
class Login implements \SplSubject
{
private \SplObjectStorage $storage;
// ...
public function __construct()
{
$this->storage = new \SplObjectStorage();
}
public function attach(\SplObserver $observer): void
{
$this->storage->attach($observer);
}
public function detach(\SplObserver $observer): void
{
$this->storage->detach($observer);
}
public function notify(): void
{
foreach ($this->storage as $obs) {
$obs->update($this);
}
}
// ...
}
// listing 11.36
abstract class LoginObserver implements \SplObserver
{
public function __construct(private Login $login)
{
$login->attach($this);
}
public function update(\SplSubject $subject): void
{
if ($subject === $this->login) {
$this->doUpdate($subject);
}
}
abstract public function doUpdate(Login $login): void;
}
就SplObserver(即Observer)和SplSubject(即Observable)而言,没有真正的区别——当然,除了我不再需要声明接口,并且我必须根据新名称改变我的类型提示。SplObjectStorage为你提供了一个真正有用的服务,不过。您可能已经注意到,在我最初的例子中,我的Login::detach()实现将array_filter(以及一个匿名函数)应用于$observers数组,以便找到并移除参数对象。SplObjectStorage类在幕后为您完成这项工作。它实现了attach()和detach()方法,可以传递给foreach并像数组一样迭代。
Note
你可以在 www.php.net/spl 的 PHP 文档中读到更多关于 SPL 的内容。特别是,你会发现那里有很多迭代器工具。我将在第十三章中介绍 PHP 内置的迭代器接口。
另一种解决Observable类和它的Observer类之间通信问题的方法是通过update()方法传递特定的状态信息,而不是 subject 类的实例。对于快速和肮脏的解决方案,这通常是我最初采取的方法。所以在这个例子中,update()将期望一个状态标志、用户名和 IP 地址(为了便于携带,可能在一个数组中),而不是一个Login的实例。这使您不必在Login类中编写状态方法。另一方面,在 subject 类存储大量状态的情况下,将它的一个实例传递给update()允许观察者有更大的灵活性。
您也可以完全锁定类型,通过让Login类拒绝与除了特定类型的 observer 类(也许是LoginObserver)之外的任何东西一起工作。如果您想这样做,那么您可以考虑对传递给attach()方法的对象进行某种运行时检查;否则,您可能需要重新考虑Observable接口。
我再一次在运行时使用了组合来构建一个灵活且可扩展的模型。Login类可以从它的上下文中提取出来,放到一个完全不同的项目中,没有任何限制。在那里,它可能与一组不同的观察者一起工作。
访问者模式
如您所见,许多模式旨在运行时构建结构,遵循组合比继承更灵活的原则。无处不在的组合模式就是一个很好的例子。当您处理对象集合时,可能需要对结构应用各种操作,这些操作涉及处理每个单独的组件。这种操作可以内置到组件本身中。毕竟,组件通常最适合相互调用。
这种方法并非没有问题。您并不总是知道可能需要在结构上执行的所有操作。如果您在逐个案例的基础上向您的类添加对新操作的支持,您可能会使您的接口膨胀,并承担不真正适合的责任。正如您可能猜到的,访问者模式解决了这些问题。
问题
回想一下上一章的复合示例。对于一个游戏,我创造了一个组件大军,这样整体和它的部分可以互换。您看到了操作可以构建到组件中。通常,叶对象执行操作,复合对象调用其子对象来执行操作:
// listing 11.37
class Army extends CompositeUnit
{
public function bombardStrength(): int
{
$strength = 0;
foreach ($this->units() as $unit) {
$strength += $unit->bombardStrength();
}
return $strength;
}
}
// listing 11.38
class LaserCanonUnit extends Unit
{
public function bombardStrength(): int
{
return 44;
}
}
如果这个操作是复合类职责的一部分,就没有问题。然而,有更多的外围任务在界面上可能不那么愉快。
这里有一个转储关于叶节点的文本信息的操作。它可以被添加到抽象的Unit类:
// listing 11.39
abstract class Unit
{
// ...
public function textDump($num = 0): string
{
$txtout = "";
$pad = 4 * $num;
$txtout .= sprintf("%{$pad}s", "");
$txtout .= get_class($this) . ": ";
$txtout .= "bombard: " . $this->bombardStrength() . "\n";
return $txtout;
}
// ...
}
然后可以在CompositeUnit类中覆盖这个方法:
// listing 11.40
abstract class CompositeUnit extends Unit
{
// ...
public function textDump($num = 0): string
{
$txtout = parent::textDump($num);
foreach ($this->units as $unit) {
$txtout .= $unit->textDump($num + 1);
}
return $txtout;
}
}
我可以继续创建方法来计算树中单位的数量,将组件保存到数据库中,以及计算军队消耗的食物单位。
为什么我要在组合的接口中包含这些方法?只有一个真正令人信服的答案。我在这里包含了这些不同的操作,因为这是操作可以轻松访问复合结构中相关节点的地方。
尽管遍历的便利性确实是组合模式的一部分,但这并不意味着需要遍历树的每个操作都应该在组合模式的接口中占有一席之地。
所以这些是起作用的力量:我想充分利用我的对象结构提供的简单遍历,但是我想在不膨胀接口的情况下做到这一点。
履行
我将从接口开始。在抽象的Unit类中,我定义了一个accept()方法:
// listing 11.41
abstract class Unit
{
// ...
public function accept(ArmyVisitor $visitor): void
{
$refthis = new \ReflectionClass(get_class($this));
$method = "visit" . $refthis->getShortName();
$visitor->$method($this);
}
protected function setDepth($depth): void
{
$this->depth = $depth;
}
public function getDepth(): int
{
return $this->depth;
}
}
如您所见,accept()方法期望一个ArmyVisitor对象被传递给它。PHP 允许您动态地在您希望调用的ArmyVisitor上定义方法,所以我基于当前类的名称构造了一个方法名,并在提供的ArmyVisitor对象上调用该方法。如果当前的类是Army,那么我调用ArmyVisitor::visitArmy()。如果当前类是TroopCarrier,那么我调用ArmyVisitor::visitTroopCarrier()等等。这让我不用在类层次结构中的每个叶节点上实现accept()。我在专区的时候,还加了两个方便的方法:getDepth()和setDepth()。这些可以用来存储和检索树中一个单元的深度。setDepth()由单元的父单元调用,当它从CompositeUnit::addUnit()添加到树中时:
// listing 11.42
abstract class CompositeUnit extends Unit
{
// ...
public function addUnit(Unit $unit): void
{
foreach ($this->units as $thisunit) {
if ($unit === $thisunit) {
return;
}
}
$unit->setDepth($this->depth + 1);
$this->units[] = $unit;
}
public function accept(ArmyVisitor $visitor): void
{
parent::accept($visitor);
foreach ($this->units as $thisunit) {
$thisunit->accept($visitor);
}
}
}
我在这个片段中包含了一个accept()方法。这将调用Unit::accept()来调用所提供的ArmyVisitor对象上的相关 visit()方法。然后它遍历调用accept()的所有子对象。事实上,因为accept()覆盖了它的父操作,所以accept()方法允许我做两件事:
-
为当前组件调用正确的访问者方法
-
通过
accept()方法将 visitor 对象传递给所有当前元素的子元素(假设当前组件是复合的)
我还没有为ArmyVisitor定义接口。accept()方法应该会给你一些提示。visitor 类将为类层次结构中的每个具体类定义accept()方法。这允许我为不同的对象提供不同的功能。在这个类的我的版本中,我还定义了一个默认的visit()方法,如果实现类选择不为特定的Unit类提供特定的处理,这个方法就会被自动调用:
// listing 11.43
abstract class ArmyVisitor
{
abstract public function visit(Unit $node);
public function visitArcher(Archer $node): void
{
$this->visit($node);
}
public function visitCavalry(Cavalry $node): void
{
$this->visit($node);
}
public function visitLaserCanonUnit(LaserCanonUnit $node): void
{
$this->visit($node);
}
public function visitTroopCarrierUnit(TroopCarrierUnit $node): void
{
$this->visit($node);
}
public function visitArmy(Army $node): void
{
$this->visit($node);
}
}
所以现在只需要提供ArmyVisitor的实现,我已经准备好了。下面是作为一个ArmyVisitor对象重新实现的简单文本转储代码:
// listing 11.44
class TextDumpArmyVisitor extends ArmyVisitor
{
private string $text = "";
public function visit(Unit $node): void
{
$txt = "";
$pad = 4 * $node->getDepth();
$txt .= sprintf("%{$pad}s", "");
$txt .= get_class($node) . ": ";
$txt .= "bombard: " . $node->bombardStrength() . "\n";
$this->text .= $txt;
}
public function getText(): string
{
return $this->text;
}
}
让我们看一些客户端代码,然后浏览整个过程:
// listing 11.45
$main_army = new Army();
$main_army->addUnit(new Archer());
$main_army->addUnit(new LaserCanonUnit());
$main_army->addUnit(new Cavalry());
$textdump = new TextDumpArmyVisitor();
$main_army->accept($textdump);
print $textdump->getText();
该代码产生以下输出:
popp\ch11\batch08\Army: bombard: 50
popp\ch11\batch08\Archer: bombard: 4
popp\ch11\batch08\LaserCanonUnit: bombard: 44
popp\ch11\batch08\Cavalry: bombard: 2
我创建了一个Army对象。因为Army是复合的,所以它有一个addUnit()方法,我用它来添加更多的Unit对象。然后我创建了TextDumpArmyVisitor对象,并将其传递给Army::accept()。accept()方法构造一个方法调用并调用TextDumpArmyVisitor::visitArmy()。在这种情况下,我没有为Army对象提供特殊处理,所以调用被传递给通用的visit()方法。已经向visit()传递了对Army对象的引用。它调用它的方法(包括新添加的getDepth(),它告诉任何需要知道该单元在组合树中的位置的人)来生成汇总数据。对visitArmy()的调用已经完成,所以Army::accept()操作现在依次调用其子操作accept(),传递访问者。这样,ArmyVisitor类访问树中的每个对象。
只添加了几个方法,我就创建了一种机制,通过这种机制,新功能可以插入到我的复合类中,而不会损害它们的接口,也不会有大量重复的遍历代码。
在游戏的某些方格中,军队需要缴税。税收官拜访军队,并对每一个找到的单位征收费用。不同的单位按不同的税率征税。在这里,我可以利用 visitor 类中的专用方法:
// listing 11.46
class TaxCollectionVisitor extends ArmyVisitor
{
private int $due = 0;
private string $report = "";
public function visit(Unit $node): void
{
$this->levy($node, 1);
}
public function visitArcher(Archer $node): void
{
$this->levy($node, 2);
}
public function visitCavalry(Cavalry $node): void
{
$this->levy($node, 3);
}
public function visitTroopCarrierUnit(TroopCarrierUnit $node): void
{
$this->levy($node, 5);
}
private function levy(Unit $unit, int $amount): void
{
$this->report .= "Tax levied for " . get_class($unit);
$this->report .= ": $amount\n";
$this->due += $amount;
}
public function getReport(): string
{
return $this->report;
}
public function getTax(): int
{
return $this->due;
}
}
在这个简单的例子中,我没有直接使用传递给各种访问方法的Unit对象。然而,我确实使用了这些方法的特殊性质,根据调用Unit对象的具体类型征收不同的费用。
下面是一些客户端代码:
// listing 11.47
$main_army = new Army();
$main_army->addUnit(new Archer());
$main_army->addUnit(new LaserCanonUnit());
$main_army->addUnit(new Cavalry());
$taxcollector = new TaxCollectionVisitor();
$main_army->accept($taxcollector);
print $taxcollector->getReport();
print "TOTAL: ";
print $taxcollector->getTax() . "\n";
像以前一样,TaxCollectionVisitor对象被传递给Army对象的accept()方法。再次,Army在调用其子节点上的accept()之前,将对自身的引用传递给了visitArmy()方法。组件并不知道访问者执行的操作。它们只是与它的公共接口协作,每个接口都尽职尽责地将自己传递给其类型的正确方法。
除了在ArmyVisitor类中定义的方法之外,TaxCollectionVisitor还提供了两个汇总方法,getReport()和getTax()。调用这些函数会提供您可能期望的数据:
Tax levied for popp\ch11\batch08\Army: 1
Tax levied for popp\ch11\batch08\Archer: 2
Tax levied for popp\ch11\batch08\LaserCanonUnit: 1
Tax levied for popp\ch11\batch08\Cavalry: 3
TOTAL: 7
图 11-7 显示了本例中的参与者。
图 11-7
访问者模式
访客问题
那么,访问者模式是另一种结合了简单性和强大功能的模式。然而,在部署这种模式时,需要记住一些事情。
首先,尽管 Visitor 非常适合组合模式,但它实际上可以用于任何对象集合。因此,您可以将它用于对象列表,例如,其中每个对象都存储对其兄弟对象的引用。
通过外部化操作,您可能会冒损害封装的风险。也就是说,您可能需要公开您访问过的对象的内部,以便让访问者对它们做任何有用的事情。例如,您看到,对于第一个访问者示例,我被迫在Unit接口中提供一个额外的方法,以便为TextDumpArmyVisitor对象提供信息。您在之前的观察者模式中也看到了这种困境。
因为迭代与访问者对象执行的操作是分开的,所以您必须放弃一定程度的控制。例如,您不能很容易地创建一个在子节点迭代前后都做一些事情的visit()方法。解决这个问题的一种方法是将迭代的责任转移到 visitor 对象中。这样做的问题是,您可能会在不同的访问者之间重复遍历代码。
默认情况下,我更喜欢将遍历保持在被访问类的内部,但是将它外部化会为您提供一个独特的优势。您可以在逐个访问者的基础上改变您处理被访问的类的方式。
命令模式
近年来,我很少在没有部署这种模式的情况下完成一个 web 项目。最初是在图形用户界面设计的环境中构思的,命令对象有助于良好的企业应用设计,鼓励控制器(请求和分派处理)和域模型(应用逻辑)层之间的分离。更简单地说,命令模式使系统组织良好,易于扩展。
问题
所有系统都必须决定如何响应用户的请求。在 PHP 中,决策过程通常由一系列接触点页面来处理。在选择页面(feedback.php)时,用户清楚地表明了她需要的功能和界面。PHP 开发人员越来越多地选择单点联系方式(我将在下一章讨论)。然而,在这两种情况下,请求的接收者必须委派给更关心应用逻辑的层。在用户可以向不同页面发出请求的情况下,这种委托尤其重要。没有它,重复不可避免地进入项目。
因此,假设您有一个项目,其中包含一系列需要执行的任务。特别是,系统必须允许一些用户登录,另一些用户提交反馈。您可以创建处理这些任务的login.php和feedback.php页面,实例化专家类来完成工作。不幸的是,系统中的用户界面很少能清晰地映射到系统设计要完成的任务。例如,您可能需要在每个页面上都有登录和反馈功能。如果页面必须处理许多不同的任务,那么也许您应该将任务视为可以封装的东西。通过这样做,您可以轻松地向系统添加新任务,并在系统的各层之间建立一个边界。这就把我们带到了命令模式。
履行
命令对象的界面再简单不过了。它需要一个简单的方法:execute()。
在图 11-8 中,我已经将Command表示为一个抽象类。在这个简单的层次上,它可以被定义为一个接口。我倾向于为此使用抽象,因为我经常发现基类也可以为它的派生对象提供有用的公共功能。
图 11-8
命令类
command 模式中有三个其他参与者:客户机,它实例化 Command 对象;调用程序,它部署对象;和命令在其上操作的接收器。
接收方可以由客户端在其构造函数中提供给命令,也可以从某种工厂对象中获取。我喜欢后一种方法,保持构造函数方法没有参数。所有的Command对象都可以用完全相同的方式实例化。
下面是抽象基类:
// listing 11.48
abstract class Command
{
abstract public function execute(CommandContext $context): bool;
}
这里有一个具体的Command类:
// listing 11.49
class LoginCommand extends Command
{
public function execute(CommandContext $context): bool
{
$manager = Registry::getAccessManager();
$user = $context->get('username');
$pass = $context->get('pass');
$user_obj = $manager->login($user, $pass);
if (is_null($user_obj)) {
$context->setError($manager->getError());
return false;
}
$context->addParam("user", $user_obj);
return true;
}
}
LoginCommand被设计成与AccessManager对象一起工作。AccessManager是一个虚构的类,处理登录用户进入系统的细节。注意,Command::execute()方法需要一个CommandContext对象——这在 Alur 等人的核心 J2EE 模式:最佳实践和设计策略 (Prentice Hall,2001)中被称为RequestHelper。这是一种机制,请求数据可以通过它传递给Command对象,响应可以通过它返回到视图层。以这种方式使用对象很有用,因为我可以在不破坏接口的情况下向命令传递不同的参数。CommandContext本质上是一个关联数组变量的对象包装器,尽管它经常被扩展来执行额外的有用任务。下面是一个简单的CommandContext实现:
// listing 11.50
class CommandContext
{
private array $params = [];
private string $error = "";
public function __construct()
{
$this->params = $_REQUEST;
}
public function addParam(string $key, $val): void
{
$this->params[$key] = $val;
}
public function get(string $key): string
{
if (isset($this->params[$key])) {
return $this->params[$key];
}
return null;
}
public function setError($error): string
{
$this->error = $error;
}
public function getError(): string
{
return $this->error;
}
}
因此,有了CommandContext对象,LoginCommand就可以访问请求数据:提交的用户名和密码。我使用Registry,一个简单的类,用静态方法生成公共对象,返回LoginCommand需要使用的AccessManager对象。如果AccessManager报告了一个错误,该命令将错误消息与CommandContext对象一起提交给表示层使用,并返回false。如果一切正常,LoginCommand简单地返回true。注意Command对象本身并不执行太多的逻辑。它们检查输入、处理错误情况、缓存数据,以及调用其他对象来执行操作。如果您发现应用逻辑悄悄进入您的命令类,这通常是您应该考虑重构的信号。这样的代码会导致重复,因为它不可避免地要在命令之间复制和粘贴。您至少应该看看这样的功能属于哪里。它可能最好向下移动到您的业务对象中,或者可能移动到外观层中。在我的例子中,我仍然缺少客户机(生成命令对象的类)和 invoker(处理生成的命令的类)。选择在 web 项目中实例化哪个命令的最简单方法是在请求本身中使用一个参数。这是一个简化的客户端:
// listing 11.51
class CommandFactory
{
private static string $dir = 'commands';
public static function getCommand(string $action = 'Default'): Command
{
if (preg_match('/\W/', $action)) {
throw new \Exception("illegal characters in action");
}
$class = __NAMESPACE__ . "\\commands\\" . UCFirst(strtolower($action)) . "Command";
if (! class_exists($class)) {
throw new CommandNotFoundException("no '$class' class located");
}
$cmd = new $class();
return $cmd;
}
}
CommandFactory类只是寻找一个特定的类。使用CommandFactory类自己的名称空间、字符串“\commands”和CommandContext对象的$action参数构建一个完全限定的类名。请求中的最后一项应该已经传递给系统。感谢 Composer 的自动加载功能,我们不需要担心显式地要求一个类。如果该类存在,那么实例化一个对象并返回给调用者。我可以在这里添加更多的错误检查,确保找到的类属于Command家族,并且构造函数不需要参数;然而,这个版本可以满足我的需求。这种方法的优点是您可以在任何时候用正确的名称空间创建一个可发现的Command对象,并且系统会立即支持它。
调用者现在变得简单了:
// listing 11.52
class Controller
{
private CommandContext $context;
public function __construct()
{
$this->context = new CommandContext();
}
public function getContext(): CommandContext
{
return $this->context;
}
public function process(): void
{
$action = $this->context->get('action');
$action = (is_null($action)) ? "default" : $action;
$cmd = CommandFactory::getCommand($action);
if (! $cmd->execute($this->context)) {
// handle failure
} else {
// success
// dispatch view
}
}
}
下面是调用该类的一些代码:
// listing 11.53
$controller = new Controller();
$context = $controller->getContext();
$context->addParam('action', 'login');
$context->addParam('username', 'bob');
$context->addParam('pass','tiddles');
$controller->process();
print $context->getError();
在我调用Controller::process()之前,我通过在控制器的构造函数中实例化的CommandContext对象上设置参数来伪造一个 web 请求。process()方法获取"action"参数(如果没有动作参数,则返回到字符串"default")。然后,该方法将对象实例化委托给CommandFactory对象。它对返回的命令调用execute()。请注意,控制器对命令的内部结构一无所知。正是这种与命令执行细节的独立性,使得您可以添加新的Command类,而对这个框架的影响相对较小。
这里还有一门课:
// listing 11.54
class FeedbackCommand extends Command
{
public function execute(CommandContext $context): bool
{
$msgSystem = Registry::getMessageSystem();
$email = $context->get('email');
$msg = $context->get('msg');
$topic = $context->get('topic');
$result = $msgSystem->send($email, $msg, $topic);
if (! $result) {
$context->setError($msgSystem->getError());
return false;
}
return true;
}
}
Note
我将回到第十二章中的命令模式,用一个Command工厂类的更完整的实现。这里给出的运行命令的框架是您将遇到的另一种模式的简化版本:前端控制器。
这个类将响应一个"feedback"动作字符串而运行,不需要控制器或CommandFactory类的任何改变。
图 11-9 显示了Command模式的参与者。
图 11-9
命令模式参与者
空对象模式
程序员面临的一半问题似乎都与类型有关。这是 PHP 越来越支持方法声明和返回的类型检查的原因之一。如果处理一个包含错误类型的变量是一个问题,那么处理一个不包含任何类型的变量至少同样糟糕。这种情况经常发生,因为当函数无法生成有用的值时,它们通常会返回null。通过在项目中使用空对象模式,可以避免给自己和他人带来这个问题。正如你将看到的,当本章中的其他模式试图完成一些事情时,空对象被设计成尽可能优雅地什么也不做。
问题
如果你的方法已经被赋予了找对象的任务,有时候除了认输就没什么可做的了。由调用代码提供的信息可能是陈旧的,或者资源可能是不可用的。如果失败是灾难性的,您可以选择抛出一个异常。不过,通常情况下,你会想变得宽容一点。在这种情况下,返回一个null值似乎是向客户端发出失败信号的好方法。
这里的问题是你的方法违反了它的契约。如果它已经承诺使用某个方法返回一个对象,那么返回 null 将强制客户端代码根据意外情况进行调整。
让我们再一次回到我们的游戏。让我们假设一个名为TileForces的类跟踪某个特定图块上单元的信息。我们的游戏维护系统中单元的本地保存信息,一个名为UnitAcquisition的组件负责将这些元数据转换成一个对象数组。
下面是TileForces构造函数:
// listing 11.55
class TileForces
{
private int $x;
private int $y;
private array $units = [];
public function __construct(int $x, int $y, UnitAcquisition $acq)
{
$this->x = $x;
$this->y = $x;
$this->units = $acq->getUnits($this->x, $this->y);
}
// ...
}
TileForces对象除了委托给所提供的UnitAcquisition对象来获得一个单元对象数组之外,没有做什么。让我们建造一个假的UnitAcquisition物体:
// listing 11.56
class UnitAcquisition
{
public function getUnits(int $x, int $y): array
{
// 1\. looks up x and y in local data and gets a list of unit ids
// 2\. goes off to a data source and gets full unit data
// here's some fake data
$army = new Army();
$army->addUnit(new Archer());
$found = [
new Cavalry(),
null,
new LaserCanonUnit(),
$army
];
return $found;
}
}
在这个类中,我隐藏了获取Unit数据的过程。当然,在真实的系统中,这里会执行一些实际的查找。我已经满足于一些直接的实例化。不过,请注意,我在$found数组中嵌入了一个偷偷摸摸的null值。例如,如果我们的网络游戏客户端保存的元数据与服务器上的数据状态不一致,就会发生这种情况。
有了Unit对象数组的武装,TileForces可以提供一些功能:
// listing 11.57
// TileForces
public function firepower(): int
{
$power = 0;
foreach ($this->units as $unit) {
$power += $unit->bombardStrength();
}
return $power;
}
让我们测试一下代码:
// listing 11.58
$acquirer = new UnitAcquisition();
$tileforces = new TileForces(4, 2, $acquirer);
$power = $tileforces->firepower();
print "power is {$power}\n";
由于这个隐藏的空值,这段代码导致了一个错误:
Error: Call to a member function bombardStrength() on null
TileForces::firepower()循环通过它的$units数组,在每个Unit上调用bombardStrength()。当然,试图对一个null值调用一个方法会导致错误。
最显而易见的解决方案是在使用数组之前检查它的每个元素:
// listing 11.59
// TileForces
public function firepower(): int
{
$power = 0;
foreach ($this->units as $unit) {
if (! is_null($unit)) {
$power += $unit->bombardStrength();
}
}
return $power;
}
就其本身而言,这不是太大的问题。但是想象一下TileForces的一个版本,它对其$units属性中的元素执行各种操作。一旦我们开始在多个地方复制is_null()检查,我们又一次被呈现出一种特殊的代码味道。通常,解决并行客户端代码块的方法是用多态替换多个条件。我们在这里也可以这样做。
履行
空对象模式允许我们把什么都不做委托给一个期望类型的类。在这种情况下,我将创建一个NullUnit类。
// listing 11.60
class NullUnit extends Unit
{
public function bombardStrength(): int
{
return 0;
}
public function getHealth(): int
{
return 0;
}
public function getDepth(): int
{
return 0;
}
}
Unit的这个实现尊重接口,但是不做任何事情。现在,我可以修改UnitAcquisition来创建一个NullUnit,而不是使用一个null:
// listing 11.61
public function getUnits(int $x, int $y): array
{
$army = new Army();
$army->addUnit(new Archer());
$found = [
new Cavalry(),
new NullUnit(),
new LaserCanonUnit(),
$army
];
return $found;
}
TileForces中的客户端代码可以在NullUnit对象上调用它喜欢的任何方法,而不会出现问题或错误:
// listing 11.62
// TileForces
public function firepower(): int
{
$power = 0;
foreach ($this->units as $unit) {
$power += $unit->bombardStrength();
}
return $power;
}
看一看任何一个重要的项目,统计一下返回空值的方法对其编码人员强制进行的不恰当检查的数量。如果更多的人使用空对象,那么这些检查中有多少可以省去?
当然,有时你将需要知道你正在处理一个空对象。最明显的方法是用instanceof操作符测试一个对象。然而,这甚至不如最初的is_null()呼叫优雅。
也许最简洁的解决方案是给基类(返回false)和空对象(返回true)都添加一个isNull()方法:
// listing 11.63
if (! $unit->isNull()) {
// do something
} else {
print "null - no action\n";
}
这让我们两全其美。可以安全地调用NullUnit对象的任何方法。并且可以查询任何Unit对象的空状态。
摘要
在这一章中,我总结了对“四人帮”模式的研究,重点强调了如何把事情做好。我首先向您展示了如何设计一种迷你语言,并使用解释器模式构建它的引擎。
在策略模式中,您遇到了另一种使用组合来增加灵活性和减少重复子类化的方法。使用 Observer 模式,您学习了如何解决向不同的组件通知系统事件的问题。您还回顾了复合示例;使用访问者模式,您学习了如何访问树中的每个组件,并对其应用许多操作。您甚至看到了命令模式如何帮助您构建可扩展的分层系统。最后,您用空对象模式为自己节省了大量检查空值的工作。
在下一章中,我将进一步超越“四人帮”,研究一些专门面向企业编程的模式。