组合与组合模式

继承的问题

继承是一种强大的设计方式,但是它也会限制灵活性,特别是类承担多职责时。

下面是一个继承的简单示例。

抽象类 Food 表示食物,它定义了抽象方法 make() 方法。两个实现类 SweetFoodSaltyFood 分别代表了食物的两种口味。

通过使用这种继承机制,客户端代码只知道它在使用一个 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() 抽象方法,返回该餐桌的热量数,然后我们在 EasternTableWesternTable 类中实现了这个方法。

现在我们定义一个餐厅类将它们组合在一起:

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 对象一样处理 ResturantResturantBar 类都是组合对象,用于保存 Table 对象,EsternTableWesternTable 对象都是叶子对象,用于支持一个餐桌所需要的操作,但无法保存 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;
}

这个正是组合模式的缺陷之一。我们让所有的类都继承自相同的基类以实现简单性,但是这种简单性有时又是以牺牲类型安全为代价的。随着模型变得越来越复杂,需要手动进行的类型检查也会变多。

组合模式的缺点:

  • 适合于大部分叶子对象都可互换时的情形,如果代码中都有太多特殊情况(比如规定哪个组合对象可以持有哪些叶子对象),那么组合模式就显得弊大于利
  • 操作成本,如果一个对象树中有大量的叶子对象,那么一次调用可能引发雪崩式的方法调用。
  • 数据持久化,组合模式不适合于将对象存储到关系数据库,因为默认情况下是通过级联引用访问这个结构的。

总结:如果你希望能够像处理单个对象那样处理集合,那么组合模式十分有用。但组合依赖于组成成分的相似性,随着我们引入复杂的规则,代码也会变得越来越难以维护。