继承的问题
继承是一种强大的设计方式,但是它也会限制灵活性,特别是类承担多职责时。
下面是一个继承的简单示例。
抽象类 Food
表示食物,它定义了抽象方法 make()
方法。两个实现类 SweetFood
和 SaltyFood
分别代表了食物的两种口味。
通过使用这种继承机制,客户端代码只知道它在使用一个 Food
对象,食物制作的细节被封装了。
但如果还需要引入其他有特殊需求的实现类,会怎样?假设我们需要处理月饼和粽子,它们会以不同的方式制作(甜或咸),所以它们应该是独立的类型。因此,设计上会出现两个分支,我们需要不同的制作方式,同时还要区分月饼和粽子。
这个继承层次有明显的缺陷。如果想要再用这棵继承树管理食物的价格,那么就需要在 Mooncake 类和 Dumplings 类里面增加价格计算代码,现在有很多重复的代码。
这时,我们可能会考虑在 Food
类中用条件语句来移除重复代码。
<?php
abstract class Food
{
const SALTY = 0;
const SWEET = 1;
private $makeType;
private $taste = '无味的';
public $name = '食物';
public function __construct(int $makeType = 1)
{
$this->makeType = $makeType;
}
public function make()
{
switch ($this->makeType) {
case self::SALTY:
$this->taste = '咸';
break;
case self::SWEET:
$this->taste = '甜';
break;
}
return $this->taste . $this->name;
}
}
class Mooncake extends Food
{
public $name = '月饼';
}
class Dumplings extends Food
{
public $name = '粽子';
}
$mooncake = new Mooncake(Food::SALTY);
echo $mooncake->make();
$dumplings = new Dumplings(Food::SWEET);
echo $dumplings->make();
// output
// 咸月饼
// 甜粽子
重复代码没有避免,只不过从子类的多态代码中转移到了父类的条件判断语句。
使用组合
我们可以创建一个 MakeStrategy
的抽象类,表示一种制作食物的方式,其中定义了 make()
。make()
方法会用到 Food
的一个实例,以制作食物。此时,Food
对象只与 MakeStrategy
类型打交道,而不是它的实现类。
这个UML图表现这种思想:
下面是一个新的 Food
类的简化版本:
<?php
abstract class Food
{
public $name = '';
private $makeStrategy;
public function __construct(MakeStrategy $makeStrategy)
{
$this->makeStrategy = $makeStrategy;
}
public function make()
{
return $this->makeStrategy->make($this);
}
public function getName()
{
return $this->name;
}
}
class Mooncake extends Food
{
public $name = '月饼';
}
class Dumplings extends Food
{
public $name = '粽子';
}
Food
类的构造方法接收一个 MakeStrategy
对象作为参数并将其存储在 $makeStrategy
中,Food::make()
方法只是简单的调用 MakeStrategy::make()
。这种显式调用另一个对象的方法来执行请求的方式称为委托
。在这个例子中,MakeStrategy
对象就是 Food
的委托。Food
类不再负责食物的制作,它将这个任务交给 MakeStrategy
的实现类。
下面是 CostStrategy
类及其实现类的定义:
<?php
abstract class MakeStrategy
{
abstract public function make(Food $food);
}
class SweetMakeStrategy extends MakeStrategy
{
public function make(Food $food)
{
return '甜' . $food->getName();
}
}
class SaltyMakeStrategy extends MakeStrategy
{
public function make(Food $food)
{
return '咸' . $food->getName();
}
}
通过在运行时传递不同的 MakeStrategy
对象给 Food
对象,我们可以改变食物的口味。各个类的职责更加集中,MakeStrategy
类负责制作食物,Food
类只负责管理食物数据。
$food1 = new Mooncake(new SaltyMakeStrategy());
$food2 = new Dumplings(new SweetMakeStrategy());
echo $food1->make();
echo $food2->make();
// output
// 咸月饼
// 甜粽子
一般来说,有一个设计原则叫做:「组合优于继承」,相比于只使用继承,组合能够实现更高的灵活性,因为对象能够以多种方式动态地组合来处理任务。不过这也会降低可读性。要想使用组合,必须要创建更多的类型,而且这些类型间的关系并不像继承关系中那样固定,因此理解系统中的类和对象的关系会更加困难。
组合(Composite)模式
定义:将一组对象组合为可像单个对象那样使用的结构。
一个问题
假设我们在玩一个经营餐厅的游戏,餐厅中会放置一些餐桌,有的是中餐餐桌,有的是西餐餐桌,不同的餐桌食物不一样,所包含的热量(卡路里)也不一样。我们可以将中餐桌和西餐桌组合起来一起放到餐厅中。
下面定义了几种餐桌类型:
<?php
abstract class Table
{
// 卡路里
abstract public function calorie();
}
class WesternTable extends Table
{
public function calorie()
{
return 1000;
}
}
class EasternTable extends Table
{
public function calorie()
{
return 1500;
}
}
Table
类定义了一个 calorie()
抽象方法,返回该餐桌的热量数,然后我们在 EasternTable
和 WesternTable
类中实现了这个方法。
现在我们定义一个餐厅类将它们组合在一起:
class Restaurant
{
private $tables = [];
public function addTable(TableFood $table)
{
array_push($this->tables, $table);
}
public function calorie()
{
$ret = 0;
foreach ($this->tables as $table) {
$ret += $table->calorie();
}
return $ret;
}
}
$table1 = new WesternTableFood();
$table2 = new EasternTableFood();
$rest = new Restaurant();
$rest->addTable($table1);
$rest->addTable($table2);
echo $rest->calorie();
如果问题一直很简单,那么这样的模型还是可以接受的。
如果我们经营的餐厅收入很好,有充足的资金可以把我们隔壁的一家餐厅买过来,合并为一个新的餐厅,或者说经营很差,需要变卖掉一部分餐桌。因此,我们要修改 Restautant
类,使之可以添加 Restautant
对象,就像添加 Table
对象那样;还要能移除餐桌。
新的餐厅类如下:
class Restaurant
{
private $tables = [];
private $restaurant = [];
public function addRestaurant(Restaurant $restaurant)
{
array_push($this->restautants, $restaurant);
}
public function addTable(TableFood $table)
{
array_push($this->tables, $table);
}
public function calorie()
{
$ret = 0;
foreach ($this->tables as $table) {
$ret += $table->calorie();
}
foreach ($this->restaurant as $restaurant) {
$ret += $restaurant->calorie();
}
return $ret;
}
}
虽然此时的复杂性有所增加,但问题还不算大。
但是,如果餐厅还有 cost()
(计算成本),people()
(计算顾客数量)这些方法的话,那么在这些方法里面也要做类似的修改,每个方法里面都要有针对不同对象的重复判断代码。
另外,如果经营范围变大,我们合并了一间酒吧,酒吧和餐厅类似,也有餐桌和食物,但是却有一些额外的特性。这时候,如果去修改 Resturant
类的话,就不太合适了(职责太多),我们需要一种更加灵活的模型。
组合模式
如上图所示,现在所有的类都继承自 Table
类,这样客户端代码就知道所有的对象都支持 calorie()
方法,因此它们完全可以像处理 Table
对象一样处理 Resturant
。Resturant
和 Bar
类都是组合对象,用于保存 Table
对象,EsternTable
和 WesternTable
对象都是叶子对象,用于支持一个餐桌所需要的操作,但无法保存 Table
对象。
<?php
abstract class Table
{
abstract public function addTable();
abstract public function removeTable();
abstract public function calorie();
}
class WesternTable extends Table
{
public function calorie()
{
return 1000;
}
}
class EasternTable extends Table
{
public function calorie()
{
return 1500;
}
}
class Restaurant extends Table
{
private $tables = [];
public function addTable(Table $table)
{
if (in_array($table, $this->tables, true)) {
return;
}
$this->tables[] = $table;
}
public function removeTable(Table $table)
{
$idx = array_search($table, $this->tables, true);
if (is_int($idx)) {
array_splice($this->tables, $idx, 1, []);
}
}
public function calorie()
{
$ret = 0;
foreach ($this->tables as $table) {
$ret += $table->calorie();
}
return $ret;
}
}
// 创建一个 餐厅
$rest = new Restaurant();
// 添加一些 餐桌
$rest->addTable(new WesternTable());
$rest->addTable(new WesternTable());
echo $rest->calorie();
// 创建另一个 餐厅
$rest2 = new Restaurant();
// 添加一些餐桌
$rest2->addTable(new EasternTable());
$rest2->addTable(new EasternTable());
echo $rest2->calorie();
// 将第二个餐厅合并到第一个餐厅
$rest->addTable($rest2);
echo $rest->calorie();
// output
// 2000
// 4000
// 6000
组合模式中的问题是添加和移除对象功能的实现。一般的组合模式会将 add()
和 remove()
方法放在抽象父类中,这样可以确保组合模式中的所有类都具有相同的接口。
叶子对象中并不需要 addTable()
和 removeTable()
方法,所以在抽象类中提供一种默认实现来替代抽象的 addTable()
和 removeTable()
会好一点。
abstract class Table
{
public function addTable()
{
throw new Exception('这是一个' . get_class($this));
}
public function removeTable()
{
throw new Exception('这是一个' . get_class($this));
}
abstract public function calorie();
}
组合模式的优点:
- 灵活性:因为组合模式中所有的类属于同一个父类型,所以要加入新的组合对象对外部没有任何影响
- 简单性:客户端只需要调用一个简单的接口即可,无须知道所使用的对象是组合对象还是叶子对象,调用组合对象时会产生一些幕后的委托调用,但对于客户端来说,与调用叶子对象效果一样。
- 树形结构遍历起来非常方便。
组合模式的问题
其实你们很容易发现,明明叶子对象不需要 addTable()
和 removeTable()
,为什么还要放在抽象父类中呢?
原因是 Table
是提供给客户端的一个接口,客户端只要知道一个对象是 Table
时就知道它肯定有 addTable()
方法,组合模式的原则就是叶子类和组合类具有同样的接口。
但实际上我们仍然无法确定一个 Table
对象是否能够调用 addTable()
方法。但是如果把 addTable()
下放到组合对象中时,那么在传递一个 Table
对象时又无法确定一个是否支持 addTable()
。
解决办法就是增加一个 CompositeTable
类型作为组合类型的父类:
abstract class Table
{
public function getComposite()
{
return null;
}
abstract public function calorie();
}
abstract class CompositeTable extends Table
{
private $tables = [];
public function getComposite()
{
return $this;
}
public function addTable()
{
if (in_array($table, $this->tables, true)) {
return;
}
$this->tables[] = $table;
}
public function removeTable(Table $table)
{
$idx = array_search($table, $this->tables, true);
if (is_int($idx)) {
array_splice($this->tables, $idx, 1, []);
}
}
public function getTables()
{
return $this->tables;
}
}
那些多余 addTable()
已经从 Table
类中移除了,但客户端在调用 addTable()
时需要判断调用的对象是否为 CompositeTable
对象,所以,提供了一个 getComposite()
方法,当为叶子对象时,该方法返回 null,当为 CompositeTable
类时返回一个对象。
客户端调用:
function join(Table $table1, Table $table2)
{
$comp = $table1->getComposite();
if (!is_null($comp)) {
$comp->addTable($table2);
} else {
$comp = new Table();
$comp->addTable($table1);
$comp->addTable($table2);
}
return $comp;
}
这个正是组合模式的缺陷之一。我们让所有的类都继承自相同的基类以实现简单性,但是这种简单性有时又是以牺牲类型安全为代价的。随着模型变得越来越复杂,需要手动进行的类型检查也会变多。
组合模式的缺点:
- 适合于大部分叶子对象都可互换时的情形,如果代码中都有太多特殊情况(比如规定哪个组合对象可以持有哪些叶子对象),那么组合模式就显得弊大于利
- 操作成本,如果一个对象树中有大量的叶子对象,那么一次调用可能引发雪崩式的方法调用。
- 数据持久化,组合模式不适合于将对象存储到关系数据库,因为默认情况下是通过级联引用访问这个结构的。
总结:如果你希望能够像处理单个对象那样处理集合,那么组合模式十分有用。但组合依赖于组成成分的相似性,随着我们引入复杂的规则,代码也会变得越来越难以维护。