二十三、状态
$> git checkout state
$> composer update
目的
允许对象在其内部状态改变时改变其行为。该对象看起来会改变它的类。 1
应用
状态模式是手套模式。当它合适的时候,它正好合适。换句话说,你会知道你什么时候需要它。您可以使用状态模式对在其生命周期中改变行为的事物进行建模。
有没有发现自己使用一些名为$type
的字符串变量和一大串switch/case/if/else
语句来决定调用哪些方法?国家模式将清理所有这些令人讨厌的情况。自动售货机和许多其他现实生活中的机器一样,使用状态模式。你看不到那些外在的状态。自动售货机的所有状态都发生在内部。外在,你体验不同的行为。例如,如果你在自动售货机上点击购买按钮而没有付款,会发生什么?你付款后呢?内部状态会改变该按钮的行为。按钮永远不会对您(客户端)改变。
抽象结构
-
Client
是使用的主类。底层方法使用上下文和状态来改变运行时的行为。客户端通常不直接与状态对象交互。换句话说,用户永远不会知道一个new SomeState
类。它只使用这个客户端上的公共方法。客户端的内部状态可以在用户完全不知道的情况下改变。当客户端调用event()
时,它会转发给底层状态的处理程序。您不必将状态存储在上下文中。可以直接存储在这个类里面。如果您不需要存储状态之间的上下文数据,这一点尤其正确。为什么不把$state
放在客户端对象里面?因为你将上下文传递给每个州的handle
方法。因此,任何状态都能够使用上下文对象进行转换。如果你不这样做,你将不得不在你的客户端上公开暴露一个setState()
。这意味着您可以在类之外控制状态,这可能不是一个好主意,因为它给这个客户端类增加了额外的复杂性(和责任)。 -
Context
被传递到所有状态,这允许状态之间的通信。上下文被传递给每个状态的处理程序。一个上下文可以像数据存储(StdClass
)一样简单,也可以包含帮助器方法(比如下图所示的method
)。可以从任何状态类调用method
。有时状态是“无状态”的,不需要与其他状态通信。在这种情况下,您不需要上下文对象。我在网上看到的一些 State 模式的实现实际上将Context
和Client
组合成了一个类。组合Client
和Context
的缺点是它暴露了你的内在。这给client
类增加了额外的责任。你要把它们分开,避免公开上下文。 -
State
是一个接口。所有具体的状态都将实现这个接口。它为一个状态定义了不同的处理方法。句柄方法应该以可能发生的事件命名。我很快会谈到这一点。现在,只要知道有一些术语会出现在你面前。-
状态:一些状态类
-
事件:状态类上的可用方法
-
转换:当状态和事件导致状态改变时
-
-
ConcreteState1
/ConcreteState2
各自代表不同的状态。里面的方法向上下文对象提供指令。换句话说,上下文对象应该为这个状态和事件做什么?这些类也可以使用上下文对象的setState
方法来改变状态。每当你改变状态,这就是所谓的转变。
图 23-1。
Abstract structure
例子
让我们建立一个自动售货机的模型。你可以按自动售货机上的按钮。根据它的内部状态,它会做不同的事情。同样的按钮,不同的回应。您可以使用有限状态机的绘图来模拟自动售货机。见图 23-2 。
图 23-2。
Vending machine example
您可以将此图表转换为事件表。顶部是您需要创建的状态。左侧是每个状态的事件。表格内容描述了每个转换应该发生的情况(状态和事件对)。
| 事件/状态 | 空闲状态 | HasMoneyState | | --- | --- | --- | | 插入 | 切换到有钱状态。 | 给机器多加点钱。 | | 退还 | 告诉用户“不退款。” | 返还用户投入机器的所有钱。将机器恢复到空闲状态。 | | 购买 | 告诉用户,他们需要插入钱,然后才能购买东西。 | 如果产品可用,并且用户已经输入足够的钱来购买,则调用机器上的存款并将状态设置为 IdleState。 |示例结构
图 23-3 为结构示意图。
图 23-3。
Example structure
实现:示例 1 -状态模式
你从你的VendingMachine
课开始。通过使用上面的状态/事件表,您可以看到自动售货机有三个事件:insert, refund
和purchase
。您为每个事件创建一个方法。每种方法是做什么的?它是底层状态事件的通道。您还必须用产品列表初始化您的上下文。这些产品将在不同的州之间共享。这种类型的上下文信息对于每个状态都很重要。自动售货机上下文与它可购买的产品数量保持一致。例如,您不能从这台机器上购买任何胡椒博士,因为金额为零。
app/example 1/vending machine . PHP
namespace App\Example1;
class VendingMachine;
{
protected $context;
protected $products = [
'Dr. Pepper' => ['amount' => 0, 'price' => 125],
'Pepsi' => ['amount' => 1, 'price' => 125],
'Mountain Dew' => ['amount' => 0, 'price' => 125],
];
public function construct()
{
$this->context = new VendingMachineContext($this->products);
$this->context->setState(new IdleState);
}
public function insert($money)
{
return $this->context->state()->insert($this->context, $money);
}
public function refund()
{
return $this->context->state()->refund($this->context);
}
public function purchase($product)
{
return $this->context->state()->purchase($this->context, $product
}
}
现在我们来看看VendingMachineContext
。这个类保存有价值的信息,比如当前状态、插入了多少钱、当前存入了多少钱,以及关于产品价格和库存的信息。
app/example 1/vending machine context . PHP
namespace App\Example1;
class VendingMachineContext
{
protected $state;
public $insertedMoney;
public $totalMoney;
public $products;
public function __construct($products, $totalMoney = 0, $insertedMoney = 0)
{
$this->products = $products;
$this->totalMoney = 0;
$this->insertedMoney = 0;
}
public function state()
{
return $this->state;
}
public function setState(VendingMachineState $state)
{
$this->state = $state;
}
}
这个上下文类应该被传递给每个自动售货机状态。这里是你的起始状态,IdleState
。
app/Example1/IdleState.php
namespace App\Example1;
class IdleState implements VendingMachineState
{
public function insert($machine, $money)
{
$hasMoney = new HasMoneyState;
$machine->setState($hasMoney);
$hasMoney->insert($machine, $money);
}
public function refund($machine)
{
print "no refund available in idle state\n";
}
public function purchase($machine, $product)
{
print "you'll need to enter money to purchase $product\n";
}
}
插入实际上显示了向有钱状态的转变。请注意,当您试图在空闲状态下发布退款或购买事件时会发生什么。你仍然需要定义在这些事件中发生了什么。有时一个事件什么也不做(即使你让它打印出一些文本)。
如果你发现自己需要很多不同的事件,而这些事件对于一个特定的状态来说从来不会发生,那么你可以使用一个抽象类来代替
VendingMachineState
的接口。在VendingMachineState
内部,您可以为每个事件方法实现空方法。然而,我喜欢指定每个事件,即使它是空的。即使更啰嗦,在我看来也更干净。
再来看看下一个状态,HasMoney
。
app/Example1/HasMoneyState.php
namespace App\Example1;
class HasMoneyState implements VendingMachineState
{
public function insert($machine, $money)
{
if ($money < 0) throw new \Exception('You cannot insert negative money');
print "you have inserted {$money} cents\n";
$machine->insertedMoney += $money;
}
public function refund($machine)
{
print "refunding {$machine->insertedMoney} cents\n";
$machine->insertedMoney = 0;
$machine->setState(new IdleState);
}
当您处于“有钱”状态并插入更多的钱时,您只需继续添加到$machine
的insertedMoney
属性中。这就是为什么语境很重要。与其使用上下文,不如将$insertedMoney
属性放在这个HasMoneyState
中。那会有用的。然而,还有其他的属性,比如$totalMoney
和$products
,即使你转换到另一个状态也是需要的。上下文跨状态传递属性。您将在下一个事件方法中使用这些属性:purchase
。
app/Example1/HasMoneyState.php
23 public function purchase($machine, $productName)
24 {
25 if ($machine->products[$productName]['amount'] < 1) {
26
27 print "sorry, you are out of $productName, please choose another
28 product\n";
29 return;
30 }
31
32 if ($machine->products[$productName]['price'] > $machine->insertedMoney) {
33
34
35 print "sorry, you need at least {$machine->products[$productName]
36 'price']} to buy $productName\n";
37 return;
38 }
39
40 $machine->totalMoney += $machine->insertedMoney;
41 $machine->insertedMoney = 0;
42
43 print "[vending machine now has {$machine->totalMoney} cents]\n";
44 print "[vending machine spits out $productName]\n";
45
46 $machine->setState(new IdleState);
47 }
首先,你要确保你选择的产品有库存。接下来,您检查以确保用户插入了足够的钱。现在,您可以通过添加insertedMoney
到totalMoney
来继续购买。最后,您需要将状态重置回空闲。让我们来看看你的自动售货机在运行!
app/example1.php
$machine = new \App\Example1\VendingMachine;
$machine->refund();
// no refund available in idle state
$machine->insert(50);
// you have inserted 50 cents
$machine->refund();
// refunding 50 cents
$machine->insert(100);
// you have inserted 100 cents
$machine->purchase('Mountain Dew');
// sorry, you are out of Mountain Dew, please choose another product
$machine->insert(25);
// you have inserted 25 cents
$machine->purchase('Dr. Pepper');
// sorry, you are out of Dr. Pepper, please choose another product
$machine->purchase('Pepsi');
// [vending machine now has 125 cents]
// [vending machine spits out Pepsi]
$machine->refund(); // because you all hit
// that button after you
// buy a soda, right?
// no refund available in idle state
示例 2 -输入状态机
在下一个例子中,您将看到一种不同类型的模式,它是从 state 模式发展而来的。你可以称之为状态机。状态机试图集成状态模式的一部分。你可以在 https://github.com/definitely246/state
machine
看到这个状态机。这个想法是你创建一个可重用的架构来切换你的客户对象的状态。有许多不同的方法可以做到这一点。一种方法是将状态模式抽象出来。上下文不再知道状态。上下文只提供关于它自己的信息和帮助器方法。让我们来看看您的新自动售货机(上下文)类。
app/example 2/vending machine . PHP
namespace App\Example2;
class VendingMachine
{
protected $insertedMoney;
protected $totalMoney;
protected $products = [
'Dr. Pepper' => ['amount' => 0, 'price' => 125],
'Pepsi' => ['amount' => 1, 'price' => 125],
'Mountain Dew' => ['amount' => 0, 'price' => 125],
];
public function __construct($totalMoney = 0, $insertedMoney = 0)
{
$this->totalMoney = $totalMoney;
$this->insertedMoney = $insertedMoney;
}
public function insertMoney($money)
{
$this->insertedMoney += $money;
}
public function insertedMoney()
{
return $this->insertedMoney;
}
public function refundMoney()
{
$refund = $this->insertedMoney;
$this->insertedMoney = 0;
return $refund;
}
public function products()
{
return $this->products;
}
public function numberOfRemaining($product)
{
return $this->products[$product]['amount'];
}
public function priceOf($product)
{
return $this->products[$product]['price'];
}
public function purchase($product)
{
$this->totalMoney = $this->insertedMoney;
$this->insertedMoney = 0;
print "[vending machine now has {$this->totalMoney} cents]\n";
print "[vending machine spits out $product]\n";
}
}
注意这里没有关于州的内容。您只提供方法来做非常具体的事情。如果您在这个类上调用 purchase 方法,它将购买一个产品,而不管这个自动售货机周围的条件如何。这为您的客户端提供了一个更简单、更干净的界面,因为您不再需要担心状态。这也意味着您可以在以后的任意时间向现有的类添加一个状态机。因此,如果这个VendingMachine
类不再管理它的内部状态,那么在哪里做呢?目前,这段代码留在了example2.php
中,但是它可以进一步封装在另一个(客户端)类中。
app/example2.php
$transitions = [
[
'event' => 'insert', // inserting money
'from' => 'idle', // changes idle state
'to' => 'has money', // to has money state
'start' => true, // this is starting state
],
[
'event' => 'insert', // inserting more
'from' => 'has money', // money is okay
'to' => 'has money', // state does not change
],
[
'event' => 'refund', // allow idle to refund
'from' => 'idle', // transition prints msg
'to' => 'idle', // and state stays the
],
[
'event' => 'refund', // refunding when in
'from' => 'has money', // has money state
'to' => 'idle', // sets you back
],
[
'event' => 'purchase', // stops the fsm because
'from' => 'has money', // all items have been
'to' => 'out of stock', // purchased and there is
'stop' => true, // no more idle state
],
[
'event' => 'purchase', // when you make it to this
'from' => 'has money', // transition, you purchase item.
'to' => 'idle', // order matters, see true above?
],
];
$vendingMachine = new \App\Example2\VendingMachine;
$machine = new \StateMachine\FSM($transitions, $vendingMachine, '\App\Example2\Transitions');
这种方法的好处是,您可以通过这个数组轻松地管理事件转换。状态机只需要两样东西:转换数组和上下文。第三个参数是一个完全限定的名称空间,状态机可以在其中找到转换类。过渡类处理从一个事件到另一个事件的过渡。您需要为数组中的每个过渡定义一个过渡类。那么如何定义一个过渡类呢?现在我们来看一下数组中的第一个转换。
[
'event' => 'insert', // inserting money
'from' => 'idle', // changes idle state
'to' => 'has money', // to has money state
'start' => true, // this is starting state
],
类名是根据该数组上的事件、from 和 to 属性自动生成的。因此,您需要创建的类名应该叫做InsertChangesIdleToHasMoney
。读起来不错,对吧?这种命名约定可以改变。您可以访问状态机文档了解如何做到这一点。因此,让我们创建您的类。
app/example 2/insertchangesidletohasmoney . PHP
namespace App\Example2;
class InsertChangesIdleToHasMoney
{
public function allow($vendingMachine)
{
// always allow the user to insert money
// when sitting around in the idle state
return true;
}
public function handle($vendingMachine, $money)
{
print "inserting {$money} coins\n";
return $vendingMachine->insertMoney($money);
}
}
每个过渡类需要两个方法。第一个方法让你知道你是否被允许处理这个方法。这给了你询问状态机是否转换的能力。
$machine->canInsert($money) // returns true because allow() returns
true
当您真正想要调用这个转换时,您调用事件名称。在这种情况下,事件名称为insert
。
$machine->insert($money) // invokes InsertChangesIdleToHasMoney::handle($vendingMachine,
$money);
在状态机调用了handle
方法之后,状态机会自动从IdleState
状态切换到HasMoney
状态,因为您在$transitions
数组中告诉它这样做。还定义了其他五个转换类。如果你想看得更详细,请查看源2。你可以在这里跳过这些。让我们再来看看example2.php
的用法。
app/example2.php
$machine = new \StateMachine\FSM($transitions, $vendingMachine, '\App\Example2\Transitions');
print "machine state: [{$machine->state()}]\n";
$ourMoney = 300;
print "you have $ourMoney coins\n";
$ourMoney -= 125;
$machine->insert(125);
print "machine state: [{$machine->state()}]\n";
print "attempting to purchase Dr. Pepper\n";
// you can easily turn off exceptions
$machine->whiny = false;
if(! $machine->purchase('Dr.Pepper')) {
print "asking machine for refund\n";
$ourMoney += $machine->refund();
}
此代码产生以下输出:
machine state: [idle]
you have 300 coins
inserting 125 coins
machine state: [has money]
attempting to purchase Dr. Pepper
you are out of Dr. Pepper, sorry...
asking machine for refund
您可能已经注意到了状态机上的whiny
属性。它打开和关闭异常。如果 whiny mode 为真,那么每当状态机被要求进行无效转换时,就会抛出一个异常。当状态机处于空闲状态时,没有购买事件处理程序。如果没有关闭 whiny 模式,状态机就会抛出一个异常。当 whiny 模式关闭时,它只为无效的过渡返回 false。接下来,你在抱怨模式下购物(推荐)。
app/example2.php
// put exception handling back on
$machine->whiny = true;
print "\nyou now have $ourMoney coins\n";
print "machine state: [{$machine->state()}]\n";
$ourMoney -= 100;
$machine->insert(100);
try {
$machine->purchase('Pepsi');
} catch (\StateMachine\Exceptions\CannotTransitionForEvent $e) {
...
}
print "---------------------------------------------\n";
print "caught CannotTransitionForEvent exception\n";
print "when whiny mode is active, you get exceptions\n";
print "for invalid state transitions\n";
print "---------------------------------------------\n";
}
if ($machine->canPurchase('Pepsi')) {
$machine->purchase('Pepsi');
}
$ourMoney -= 25;
$machine->insert(25);
$machine->purchase('Pepsi');
产出 2
you now have 300 coins
machine state: [idle]
inserting 100 coins
not enough money for Pepsi. machine needs 25 more coins.
---------------------------------------------
caught CannotTransitionForEvent exception
when whiny mode is active, you get exceptions
for invalid state transitions
---------------------------------------------
not enough money for Pepsi. machine needs 25 more coins.
inserting 25 coins
[vending machine now has 125 cents]
[vending machine spits out Pepsi]
现在你已经看到了购买,让我们看看机器如何处理用完的产品。这将调用StateMachineIsStopped
异常,因为在你的转换数组中有stop
。一旦您的状态机停止,它就不再处理任何进一步的转换。
example2.php
print "\nyou now have $ourMoney coins\n";
print "machine state: [{$machine->state()}]\n";
print "inserting 25 coins\n";
try {
$machine->insert(25); // throws StateMachineIsStopped exception
// probably should handle
// though, since a user w
// you should just spit the
// out and message the you
// you are out of stock br
} catch (\StateMachine\Exceptions\StateMachineIsStopped $e) {
print "---------------------------------------------\n";
print "Caught the StopMachineIsStopped exception...\n";
print "This means that the insert you just tried failed...\n";
print "---------------------------------------------\n";
}
产出 3
you now have 175 coins
machine state: [out of stock]
inserting 25 coins
---------------------------------------------
Caught the StopMachineIsStopped exception...
This means that the insert you just tried failed...
---------------------------------------------
您可能已经注意到,与上一个示例相比,您添加了更多的过渡。这只是为了说明在使用状态机时创建新的转换是多么简单。这个例子不像你在第一个例子中的更传统的状态模式类。尽管目的是一样的。请记住,本例中您的客户机分布在example2.php
上。您可以轻松地添加如下所示的客户端类:
示例 2 客户端
class VendingMachineClient extends \StateMachine\FSM
{
protected $transitions = [
// transitions listed here ...
];
public function __construct()
{
parent:: __construct($this->transitions, new VendingMachine, '\App\Example2\Transitions');
}
}
这种状态机方法的问题在于它很神奇。在不知道有限状态机如何工作的情况下,你无法推断出VendingMachineClient
上的公共方法。您知道您可以调用 transitions 数组中的事件。这是因为在FSM
中有一个神奇的call
方法让这一切发生。然而,对于任何新来者来说,这并不明显。第一次看到这个类的人可能会大吃一惊。这里有反射和神奇的方法调用。这并不意味着这是一个糟糕的设计;这只是意味着如果你喜欢一个不那么隐含的状态机,你可以用不同的方式来做。这就引出了下一个例子。
例 3
下一种方法利用特征。您将状态放回到VendingMachine
类中。现在看起来是这样的:
app/example 3/vending machine . PHP
namespace App\Example3;
class VendingMachine extends \StateMachine\DefaultContext
{
use \StateMachine\Stateful;
protected $state = '\App\Example3\IdleState';
protected $context = 'this';
// the rest of this class looks the same as it did
// in example 2 protected and is omitted to keep it short
您的自动售货机仍然以与示例 2 相同的方式处理购买和产品。这里的区别在于您使用了一个名为Stateful
的特性,它允许您自动地调用状态的底层方法。它没有前面的例子那么神奇,因为您可以查看每个 state 类中可用的特征和方法。让我们看看IdleState
和HasMoneyState
类。
app/Example3/IdleState.php
namespace App\Example3;
class IdleState implements State
{
public function __construct(VendingMachine $machine)
{
$this->machine = $machine;
}
public function insert($money)
{
$this->machine->insertMoney($money);
$this->machine->setState('\App\Example3\HasMoneyState');
}
public function refund()
{
print "no refund available in idle state\n";
}
public function purchase($product)
{
print "you'll need to enter money to purchase $product\n";
}
接下来是HasMoney
状态。
app/Example3/HasMoneyState.php
class HasMoneyState implements State
{
public function __construct($machine)
{
$this->machine = $machine;
}
public function insert($money)
{
if ($money < 0) throw new \Exception('You cannot insert negative money');
$this->machine->insertMoney($money);
}
public function refund()
{
$this->machine->setState('\App\Example\IdleState');
return $this->machine->refundMoney();
}
public function purchase($product)
{
if (! $this->machine->canPurchase($product)) {
return;
}
$this->machine->setState('Example3\IdleState');
$this->machine->makePurchase($product);
}
您可能会注意到这看起来更像传统的状态模式。这种方法让你的自动售货机设计更加清晰。在我看来它是最干净的。我不太喜欢特质,因为它们经常被滥用。我认为在这种情况下是可行的。不过,每个人都有自己的偏好。我为状态模式提供了三种不同的方法。选择你的毒药。微笑。
结论
在本章开始时,我提到这是一个手套图案。除非合身,否则不要用。状态模式的一个缺点是它增加了要维护的类的数量。如果您的设计中只有几个状态和事件,那么这可能不值得。在客户端类中包含几个条件语句可能比拥有多个状态类更容易。
四人帮的书提到,如果你的状态不包含内部变量,你就不需要一遍又一遍地构造状态类。您可以重用相同的状态类。有些人会将这种方法视为 flyweight 模式,因为您可以重用状态对象。重用状态对象可能会更快,因为您不必一次又一次地重新构建它们。这并不意味着它是轻量级的。flyweight 是关于减少内存占用的。你可能没有几百万个不同的状态对象。重用几个状态类并不意味着更少的内存(正如你在 flyweight 一章中看到的)。不过,我能反驳谁呢?
还有另一个相关的模式,看起来与 state 模式相同。这就是所谓的策略模式。两种模式都使用合成来修改内部行为。主要区别在于策略是针对算法的,而不是内部状态。您可能不会对自动售货机使用策略模式,因为已知自动售货机在现实生活中处于不同的状态。尽管状态和策略模式之间的代码结构看起来基本相同,但意图不同。我还没有谈到策略模式,但那是下一章。我们开始吧!
Footnotes 1
设计模式:可重用面向对象软件的元素,第 338 页
2
https://github.com/kdocki/larasign/tree/state/app/Example2/Transitions
二十四、策略
$> git checkout strategy
目的
定义一系列算法,封装每一个算法,并使它们可以互换。策略让算法独立于使用它的客户端而变化。 1
应用
当您的算法共享相同的公共接口,但在幕后以不同的方式工作时,策略模式是有用的。策略模式通常依靠组合来传递不同的算法。Laravel 加密 2 组件使用相同的方法加密和解密消息。不过,基本的密码算法是可以改变的。这是策略模式的松散实现:
$crypt = Illuminate\Encryption\Encrypter('secret key', $cipher)
$encrypted = $crypt->encrypt('secret');
$decrypted = $crypt->decrypt($encrypted);
注意Crypt
如何保持了encrypt
和decrypt
相同的接口方法,然而当你改变密码时,底层的行为也改变了。这个例子体现了策略模式的概念。缺少的部分和我说的松散实现的原因是在这个例子中$cipher
是一个string,
而不是一个class
。
抽象结构
-
Context
是保存strategy
对象的类。这是客户将与之交互的内容。当method()
被调用时,它会调用$strategy->algorithm()
。因为这个类使用了复合,你可以通过改变$strategy
对象轻松地替换掉策略算法。见图 24-1 。图 24-1。
Abstract structure
-
Strategy
是一个抽象类或接口。它定义了所有算法的公共接口方法。 -
ConcreteStrategy1/2
是一个策略的不同算法实现。
例子
在本例中,您将制作一只鸡。不是美味的烤鸡。会发出声音的鸡。鸡会发出不同的声音。公鸡打鸣。母鸡咯咯叫。一只小鸡唧唧喳喳。图 4-2 显示了鸡发出的不同声音。
图 24-2。
Chicken noises
让我们看看这个Chicken
类会是什么样子,如果你为每种类型的鸡使用一个巨大的 switch 语句。
app/ChickenBeforePattern.php
namespace App;
class Chicken
{
public function construct($noisetype)
{
$this->noisetype = $noisetype;
}
public function speaks()
{
switch ($this->noisetype) {
case 'hen': return 'cluck, cluck';
case 'chick': return 'chirp, chirp';
case 'rubber': return 'squeek!';
case 'muted': return '';
case 'rooster': return 'cock-a-doodle-doo!';
}
return '';
}
public function scratch()
{
print 'scratches some dirt' . PHP_EOL;
}
每只鸡说话的方式都不一样。这个简单的算法是为给定类型的鸡制造一个噪音。类似于 Laravel Encrypter
类,您的噪声生成算法输出一个字符串。算法在产生的噪声方面有所不同,但是您的Chicken
(上下文)类保持不变。注意所有的鸡都会抓痒。您将使用策略模式来删除在speaks
方法中找到的 switch 语句。
示例结构
图 24-3 为结构示意图。
图 24-3。
Example structure
履行
你要做的第一件事是改变你的Chicken
类来接受一个噪声制造者类。
app/Chicken.php
namespace App;
class Chicken
{
public function __construct(Noises\Noise $noise)
{
$this->noise = $noise;
}
public function speaks()
{
print $this->noise->make();
}
public function scratch()
{
print 'scratches some dirt' . PHP_EOL;
}
}
现在,当鸡说话时,它依靠噪音策略发出声音。任何时候你需要创建一个新的噪音,就像添加一个新的类一样简单。你不再需要重新打开Chicken
类。关门了。这就是我在书的开头讲的开/闭立体原理。
这将我们带回编程中的一个重要概念,一个你可能已经非常熟悉的概念。不要囊括所有的东西。您只封装应用中可能会发生变化的内容。假设您永远不会添加另一种类型的鸡噪声。在这种情况下,您之前看到的switch
语句可能是更好的方法。不要努力去解决不存在的问题。
那么你怎么知道你是否应该抽象一些算法呢?这是一个提示。你有没有发现自己重新打开同一个文件来一遍又一遍地修改某些东西?该文件中的类很可能做得太多了。把文件分成几份。使用合成将文件分割成不同文件。如果你在这些文件中有一个算法,就使用策略模式。
如您所见,策略模式使用组合来拆分算法。然而,仅仅因为你在某个地方使用了组合并不意味着你在使用策略模式。很多模式使用构图。策略的目的是制造可互换的算法。在这个例子中,你的算法是关于如何制造噪音的。诚然,它并不复杂,但它仍然是一个算法。
app/Noises/BabyChickNoise.php
namespace App\Noises;
class BabyChickNoise implements Noise
{
public function make()
{
return "chirp, chrip\n";
}
}
想看看另一个噪声实现吗?这里有一个Hen
噪音。
app/Noises/HenNoise.php
namespace App\Noises;
class HenNoise implements Noise
{
public function make()
{
return "cluck, cluck, BA-cawk!\n";
}
}
如果你想看其他的,请查看资源库 3 中的噪音。现在让我们看看如何使用这个类。
app/simulator.php
$chicken = new \App\Chicken(new \App\Noises\BabyChickNoise);
$chicken->speaks(); // chirp, chirp
$chicken = new Chicken(new \App\Noises\HenNoise);
$chicken->speaks(); // cluck, cluck
$chicken = new Chicken(new \App\Noises\RoosterNoise);
$chicken->speaks(); // cock-a-doodle-doo!!!
$chicken = new Chicken(new \App\Noises\RubberChickenNoise);
$chicken->speaks(); // squeeek!
$chicken = new Chicken(new \App\Noises\Muted);
$chicken->speaks(); //
$chicken->scratch(); // scratches some dirt
之前你的鸡类有一个$noisetype
字符串和switch
语句。现在,通过一个Noise
算法,你有了更多的控制权。这也允许你在以后添加新的算法。
结论
您已经看到了策略模式如何将您从条件语句中解放出来。条件中的每条语句都表达了自己的行为。这通常会使代码更容易理解。策略模式允许您做的另一件事是选择您想要使用的算法。一种算法在 80%的情况下可能有更好的性能。你有 80%的机会可以选择它。另外 20%的时间你可能会选择另一个不常用的算法。
这种策略模式有几个缺点。客户必须知道不同的策略对象。这种额外的复杂性是最小的,并且可以通过服务容器自动解析依赖性来消除。这个缺点不应该阻止您使用 Laravel 中的策略模式。
下一个缺点是增加了应用中的类的数量。不过,这真的是个问题吗?越多越好,对吧?之前我们讨论了如何保留switch
声明。当算法足够简单时,有时这是一个不错的选择。添加更多的类会增加复杂性。复杂性在于理解如何设计多个类一起工作。然而,选择不使用策略模式的后果是一个巨大的单块类,它违反了开放/封闭原则。
Footnotes 1
设计模式:可重用面向对象软件的元素,第 349 页
2
https://laravel.com/docs/master/encryption
3
https://github.com/kdocki/larasign/tree/strategy/app/Noises
二十五、模板方法
$> git checkout template_method
目的
在操作中定义算法的框架,将一些步骤推迟到子类。模板方法允许子类在不改变算法结构的情况下重新定义算法的某些步骤。 1
应用
当您有一个需要内部进一步指令才能正确运行的算法时,模板方法非常有用。当算法有步骤时,它非常适合,就像烹饪食谱一样。《头脑优先设计模式》一书举了一个制作不同含咖啡因饮料的例子。泡一杯茶很像泡一杯咖啡 2 。一些步骤有所不同,但在大多数情况下,您可以重用许多步骤。当同一算法的不同版本共享许多步骤时,模板模式很有帮助。算法的变化步骤在子类中定义。
抽象结构
-
AbstractAlgorithm
包含算法变体之间的所有共享片段。可以重用的基本方法放在这里。见图 25-1 。图 25-1。
Abstract structure
-
ConcreteAlgorithm
1/2
包含对抽象算法缺失步骤的覆盖。算法的变化在这里。
例子
在此示例中,您将遵循一般编写器创建文档的步骤。这些通用的步骤为任何作者提供了一个配方(或算法)。
-
打个草稿。
-
在文档未通过审查流程后对其进行修订。
有不同类型的作家。你是哪种类型的作家取决于你写的文件类型。在本例中,您将创建软件作者和杂志作者。
示例结构
图 25-2 为结构示意图。
图 25-2。
Example structure
履行
你从你的基础抽象算法Writer
开始。所有作家都会写作。作家写作时通常遵循相同的食谱。一个作家会创作一个草稿,并不断修改草稿,直到它足够好。
app/Writer.php
namespace App;
abstract class Writer
{
abstract protected function draft();
abstract protected function failsReview($document);
abstract protected function revise($document);
public function write()
{
$document = $this->draft();
while ($this->failsReview($document)) {
$document = $this->revise($document);
}
return $document;
}
注意有三个抽象方法。要求所有具体算法都实现这三个抽象方法。根据作者的类型,作者可能会以不同的方式审阅他们的文档。软件作者将使用单元测试来审查。一个杂志作者会使用一个评论团队。
app/SoftwareWriter.php
namespace App;
class SoftwareWriter extends Writer
{
public $testedCount = 0;
protected function draft()
{
print "drafting software program\n";
return "software";
}
protected function failsReview($document)
{
print "do unit tests pass for {$document}?\n";
return $this->testedCount++ < 3;
}
protected function revise($document)
{
print "correcting mistakes for {$document} (revision #{$this->tesedCount})\n";
return $document;
}
这是一位杂志作家。同样,这种不同类型的作者可以有完全不同的起草,审查和修改步骤。
app/MagazineWriter.php
namespace App;
class MagazineWriter extends Writer
{
protected function draft()
{
$document = "magazine";
print "drafting {$document} document\n";
return $document;
}
protected function failsReview($document)
{
print "reviewing {$document} document\n";
return false;
}
protected function revise($document)
{
print "revising {$document} document\n";
return $document;
}
运行模拟器会产生以下输出。
app/simulator.php
$writer = new \App\MagazineWriter;
$writer->write();
// drafting magazine document
// reviewing magazine document
$writer = new \App\SoftwareWriter;
$writer->write();
// drafting software program
// do unit tests pass for software?
// correcting mistakes for software (revision #1)
// do unit tests pass for software?
// correcting mistakes for software (revision #2)
// do unit tests pass for software?
// correcting mistakes for software (revision #3)
// do unit tests pass for software?
结论
模板方法的一个主要缺点是随着时间的推移变得复杂。实际上,随着您添加更多具体的算法,该模式变得更加难以维护。如果您在上面添加了另一个版权发布步骤会怎么样?
public function publish()
{
$document = $this->draft();
while ($this->failsReview($document)) {
$document = $this->revise($document);
}
$this->copyright($document);
return $document;
}
为软件程序或杂志申请版权可能是有意义的。然而,如果你有另一个叫做HighschoolEssayWriter?
的具体算法呢?一个高中生不需要为他的论文文档申请版权。不过,这个具体的算法仍然需要覆盖abstract copyright
方法。这是拥有一个通用模板来管理你的算法的一个缺点。在你的子算法类中进行非常特殊的定制会很麻烦。添加copyright
方法会影响每个子类。你必须改变从abstract Writer
类继承的每一个类。这意味着你的抽象算法会变得混乱。这可能是一个棘手的问题,许多人认为这是一个交易破坏者。
有些人抱怨测试基础抽象类。这可以通过创建一个继承抽象类的模拟子类来解决。
请阅读 http://tech.puredanger.com/2007/07/03/pattern-hate
template/
的博客文章,了解模板模式的更多缺点。这个人给出了一些不使用模板方法模式的很好的理由。
-
它没有很好地传达意图。
-
很难组合功能。
-
理解程序流程很难。
-
很难维持。
当你看一看模板方法模式的结构时,它使用继承而不是组合。我已经谈到了写作如何帮助你避免违反坚实的原则。长话短说,使用这种模式要非常小心。
Footnotes 1
设计模式:可重用面向对象软件的元素,第 360 页
2
头先设计图案,第章第 8 ,第 276 页
二十六、访问者
$> git checkout visitor
目的
表示要在对象结构的元素上执行的操作。Visitor 允许您定义一个新的操作,而不改变它所操作的元素的类。 1
应用
这可能是我见过的最复杂的 GoF 模式之一。第一次盯着 UML 图看了一会儿我说:“嗯,窟 2 ?”在其基本形式上,这种模式完全是关于从半相关或不相关的类中提取方法。当你这样做的时候,你不再需要改变或者重新访问(双关语)代码。在阅读了更多关于这种模式的内容后,我发现一些喜欢花言巧语的人喜欢称之为双重调度 3 。
什么是双重派遣?基本上,当你调用一个方法时,那个方法会为你调用另一个方法。因此该方法进行了双倍的调用,因此得名。像 C++或 Java 这样的语言允许你重载方法 4 。在 PHP 中不需要方法重载。在 PHP 中,您需要在运行时使用反射进行双重调度。但是,您稍后会发现,访问者模式不需要双重分派。我之所以在这里列出它,是因为其他语言经常使用带有方法重载的双重分派来实现访问者模式。
双重派遣在行动
$dispatch->method(new Car); // calls $dispatch->carMethod($car)
$dispatch->method(new Dog); // calls $dispatch->dogMethod($dog)
访问者模式的一个类比是一个修理工访问一所房子。房子的主人可以选择接受或拒绝修理工。如果业主要求管道工,但电工出现了,他可以把电工赶走。假设修理工被主人接受了,他就能做好他的工作。房主不需要知道任何关于管道的细节。除了几个问题和账单之外,房主不再参与此事。修理工和房主的责任是分开的。与访客模式相反的是 DIY(自己动手)房主,他们可以修理自己的管道。问题是,没有其他人的帮助,一个自己动手的房主能做的事情就这么多了。
这个“什么都自己做”的问题也存在于软件中。你不想要一个什么都做的巨型类。然而,有时现实生活中的模型可以做很多事情。此外,现实生活中的模型将增加更多的功能和发展。通过使用 visitor 模式,您可以在以后添加到您的模型化类中,而不必一遍又一遍地重新打开该类,也不会违反打开/关闭原则。
抽象结构
图 26-1 为结构示意图。
图 26-1。
Abstract structure
例子
女人和鸡有什么共同点?你可以把你的笑话发到@kdocki 5 上,这样我就可以告诉我的妻子了。在这个例子中,女人和鸡都可以被戳。您可以将poke
方法放在每个类中。
class Chicken {
public function poke() { ... }
// ... other methods related to Chicken
}
class Woman
{
public function poke() { ... }
// ... other methods related to Wife
}
这有时行得通。但是,如果您现在想添加更多的方法,该怎么办呢?怎么样tickle, kiss
和chase?
最终你不断地在你的类中增加操作,给一个巨大的整体类增加越来越多的责任。还有,问问自己,女人真的需要知道怎么戳自己吗?那不是应该放在别处的责任吗?在这个例子中,您学习了如何使用访问者模式向Chicken
和Woman
模型添加新的操作。
示例结构
图 26-2 为结构示意图。
图 26-2。
Example structure
履行
如图 26-2 所示,你将操作抽象成Visitor
类。任何可以运行这些Visitor
操作的类都应该实现Visitable
。一个Visitable
可以选择接受或拒绝一个新的访客。
app/visit table . PHP
namespace App;
interface Visitable
{
public function accept(Visitor $visitor);
}
本例中的Visitables
是一个Woman
和一个Chicken
。
app/Woman.php
namespace App;
class Woman implements Visitable
{
public function __construct($name)
{
$this->name = $name;
}
public function accept(Visitor $visitor)
{
return $visitor->visitWoman($this);
}
}
别忘了家禽!
app/Chicken.php
namespace App;
class Chicken implements Visitable
{
public function construct($type)
{
$this->type = $type;
}
public function accept(Visitor $visitor)
{
return $visitor->visitChicken($this);
}
}
注意,这两个类都调用了 visitor 上的一个方法。我见过一些使用通用的visit
方法而不是visitWoman
的例子。这些例子里有一个巨大的switch
语句和is_a
6里面的visit
方法。双重调度将visitable
类型中继到更具体的方法。我选择跳过这一步,因为没有必要。你已经知道了visitable
的类型,可以直接调用。PHP 中没有方法重载,但是可以简单地命名每个方法visit<VisitableType>
来绕过这个限制。
接下来看Visitor
。
app/Visitor.php
namespace App;
interface Visitor
{
function visitWoman(Woman $woman);
function visitChicken(Chicken $chicken);
}
每个Visitor
都必须知道如何拜访一个Woman
和Chicken
。poke
访客戳一个Woman
和Chicken
(图 26-3 )。
图 26-3。
Poking a chicken
app/PokeVisitor.php
namespace App;
class PokeVisitor implements Visitor
{
public function visitWoman(Woman $woman)
{
print "the woman named {$woman->name} was poked\n";
}
public function visitChicken(Chicken $chicken)
{
print "the {$chicken->type} chicken was poked\n";
}
}
后来你决定要添加一个tickle
操作。这就像创建一个新的访问者一样简单。
app/TickleVisitor.php
namespace App;
class TickleVisitor implements Visitor
{
public function visitWoman(Woman $woman)
{
print "the woman named {$woman->name} was tickled\n";
}
public function visitChicken(Chicken $chicken)
{
print "the {$chicken->type} chicken was tickled\n";
}
}
您可以用新的访问者类添加越来越多的操作。我将把chase
和kiss
访问者操作留给您来实现。现在让我们看看你将如何使用你的访问者和可访问者。
app/simulator.php
$woman = new \App\Woman("Sally");
$woman->accept(new \App\PokeVisitor);
$woman->accept(new \App\TickleVisitor);
$chicken = new \App\Chicken('Dominecker');
$chicken->accept(new PokeVisitor);
$chicken->accept(new TickleVisitor);
运行此模拟输出
the woman named Sally was poked
the woman named Sally was tickled
the Dominecker chicken was poked
the Dominecker chicken was tickled
最后要指出的是。因为PokeVisitor
是类,不是方法,所以可以保存属性。这些属性可用于保存有状态信息。这意味着PokeVisitor
比仅仅放在Woman
类中的poke
方法具有更大的灵活性。
结论
访问者模式的主要缺点是,每次你添加一个新的Visitable
类型时,你必须在每个可用的Visitor
类中创建一个新的visit<NewType>
操作。这可以通过使用Visitor
的抽象基类而不是接口来解决。基础抽象类可以实现该方法的默认值,直到一个visitor
类需要覆盖该方法。我也看到过一些例子也用这样的方式来解决这个问题。不建议这样做,但我会告诉你如何做:
public function visit(Visitable $visitable)
{
$className = get_class($visitable);
$methodName = "visit{$className}";
if (method_exists($this, "visit{$className}")) {
return call_user_func_array([$this, "visit{$className}"] [$visitable]);
}
}
不推荐上述方法的原因是,当你并不真正需要它时,它会增加复杂性。因为它使用反射,所以排除故障也更加困难。使用反射的解决方案会使调试堆栈跟踪更具挑战性。
抛开缺点不谈,访问者模式使得添加新操作变得轻而易举。这是假设你没有添加任何新的Visitable
类。然而,如果您频繁地添加新的Visitable
类型,那么维护这种模式的成本会很高,并且会给您带来很多麻烦。
访问者模式的另一种选择是使用特征(混合)。将功能混合到现有的类中通常更容易。然而,特征仍然有缺点,会给类增加越来越多的功能。然而,对于一些开发人员来说,这种方法比实现访问者模式更容易混淆。拥有一个名为accept
的通用双重分派方法,并向其传递一个 visitor 类,可能会让项目的新手望而生畏。这是因为accept
方法可以根据它接收的访问者类型做许多不同的事情。accept
的广泛性既是其最大的弱点,也是其最大的优势。
Footnotes 1
设计模式:可重用面向对象软件的元素,第 366 页
2
www.destroyallsoftware.com/talks/wat
3
http://en.wikipedia.org/wiki/Double_dispatch
4
http://en.wikipedia.org/wiki/Function_overloading
5
6
http://php.net/manual/en/function.is-a.php
二十七、更多资源
学习模式是一场无休止的战斗。在研究过程中,我利用了几种不同的资源来帮助我。我想与你们分享这些资源。
-
德里克·巴纳斯在
www.youtube.com/playlist?list=PLF206E906175C7E07
的许多模式的 YouTube 视频 -
设计模式:可复用的面向对象软件元素
www.amazon.com/Design-Patterns-Elements-Reusable-Object-Oriented-
ebook/dp/B000SEIBB8
-
头先设计图案
www.amazon.com/Head-First-Design-Patterns-Freeman/dp/0596007124