如果单纯从字面上来看,我们很容易将“组合模式”和“组合关系”搞混。组合模式最初只是用于解决树形结构的场景,更多的是处理对象组织结构之间的问题。而组合关系则是通过将不同对象封装起来完成一个统一功能。

虽然组合模式并不常用,但是学习它的原理能够帮助我们获得更多复杂结构上的思考。比如,MySQL 的索引设计中就是用了 B+ 树算法的组合模式设计,极大地提升了数据查询时的性能。组合模式的原理很容易理解,但是在代码实现上却是反直觉的,如果一下子看不懂其原理,可以多看几遍,并结合例子来反复思考。

一、模式原理分析

组合模式的定义是:将对象组合成树形结构以表示整个部分的层次结构。组合模式可以让用户统一对待单个对象和对象的组合。

这个定义中有两个关键点:一个是用树形结构来分层,另一个是通过统一对待来简化操作。之所以要使用树形结构,其实就是为了能够在某种层次上进行分类,并且能够通过统一的操作来对待复杂的结构。比如,我们常见的公司统计多个维度的人员的工资信息,如果一个一个统计单人的工资信息会比较费时费力,但如果我们将人员工资信息按照组织结构构建成一棵“树”,那么按照一定的分类标示(比如,部门、岗位),就能快速找到相关人员的工资信息,而不是每次都要查找完所有人的数据后再做筛选。

下面我们就来看看组合模式的 UML 图:

组合模式:如何用树形结构处理对象之间的复杂关系?_树形结构

从图中,我们可以看到组合模式中包含了三个关键角色。

  • 抽象组件:定义需要实现的统一操作。

  • 组合节点:代表一个可以包含多个节点的复合对象,意味着在它下面还可以有其他组合节点或叶子节点。

  • 叶子节点:代表一个原子对象,意味着在它下面不会有其他节点了。

组合模式最常见的结构就是树形结构,通过上面的三个角色就可以很方便地构建树形结构。我们可以结合现实中的例子来理解,比如,一个公司中有总经理,在总经理之下有经理、秘书、副经理等,而在经理之下则有组长、开发人员等,其结构图大致如下:

组合模式:如何用树形结构处理对象之间的复杂关系?_树形结构_02
除了树形结构以外,组合模式中还有环形结构和双向结构(如下图),其中,环形结构和数据结构中的单向链表很相似,而双向结构其实就是 Spring 中 Bean 常用的结构。

组合模式:如何用树形结构处理对象之间的复杂关系?_组合模式_03
接下来我们再来看下组合模式对应 UML 的代码实现:

//抽象组件
public abstract class Component{
     public abstract void operation();
}
//叶子节点
public class Leaf extends Component{
    @Override
    public void operation() {
        //叶子节点的操作放这里
    }
}

//组合节点
public class Node extends Component {
    private List<Component> myChildren;  //存放子节点列表
    @Override
    public void operation() {
        for (Component component: myChildren) {
            component.operation();
        }
    }
}

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.

这段代码的实现看上去特别简单,但是这里有一个经常容易被我们搞混淆的地方,那就是 Node 中 operation() 方法下的 for 循环。很多时候,我们以为这个 for 循环是一个固定实现的代码,但其实这个理解是错的。实际上,这里的 for 循环想要表达的意思是,遍历组合节点下的所有子节点时才需要用到循环,而不是这里只能用循环。

从上面的分析中,我们会发现组合模式封装了如下变化:

  • 叶子节点和组合节点之间的区别;

  • 真实的数据结构(树、环、网等);

  • 遍历真实结构的算法(比如,广度优先,还是深度优先等);

  • 结构所使用的策略(比如,用来汇总数据,还是寻找最佳/最坏的节点等)。

所以说,组合模式本质上封装了复杂结构的内在变化,让使用者通过一个统一的整体来使用对象之间的结构。

二、使用场景分析

组合模式一般的使用场景有:

  • 处理一个树形结构,比如,公司人员组织架构、订单信息等;

  • 跨越多个层次结构聚合数据,比如,统计文件夹下文件总数;

  • 统一处理一个结构中的多个对象,比如,遍历文件夹下所有 XML 类型文件内容。

这里我们就拿其中的“订单信息”来举例。假设你正在开发一个新的商品订单系统(如下图),那你会如何计算每个订单的总费用呢?

组合模式:如何用树形结构处理对象之间的复杂关系?_树形结构_04

从上面的简图可以看到,一个订单中可能通常会包含各类商品、发票等信息。在现实中,每个商品都会被放到一个快递盒中,然后小的盒子又可以被放入另一个更大的盒子中,以此类推,整个结构看上去像是一棵“倒过来的树”。

过去,我们在计算价格时的做法是:打开所有快递盒,找到每件商品,然后计算总价。但在程序中,你会发现这不是使用简单的 for 循环语句(一次打开盒子的动作)就能完成的,因为你还需要知道:对应的商品类别有哪些,这个订单使用了哪些商品,价格是多少,赠送的商品有哪些,要抵消多少价格……

而组合模式就是专门为需要反复计算或统计的场景而生的。

三、为什么要使用组合模式?

通过上面的原理和使用场景分析,我们还可以总结出使用组合模式的三个主要原因。

第一,希望一组对象按照某种层级结构进行管理,比如,管理文件夹和文件,管理订单下的商品等。树形结构天然有一种层次的划分特性,能够让我们自然地理解多个对象间的结构。

第二,需要按照统一的行为来处理复杂结构中的对象,比如,创建文件,删除文件,移动文件等。在使用文件时,我们其实并不关心文件夹和文件是如何被组织和存储的,只要我们能够正确操作文件即可,这时组合模式就能够很好地帮助我们组织复杂的结构,同时按照定义的统一行为进行操作。

第三,能够快速扩展对象组合。 比如,订单下的手机商品节点可以自由挂接不同分类的手机(品牌类的,如华为、苹果),并且还可以按照商品的特性(如,价格、图片、商家、功能等)再自由地挂接新的节点组合,而查找时可以从手机开始查找,不断增加节点类型,直到找到合适的手机商品。

四、组合模式的优缺点是什么?

使用组合模式主要有以下三大优点。

  • 清晰定义分层结构。组合模式在实现树形结构时,能够非常清楚地定义层次,并且能让使用者忽略层次的差异,以方便对整个层次结构进行控制。

  • 简化了使用者使用复杂结构数据的代码。由于组合模式中的对象只有组合节点和叶子节点两种类型,而节点的使用操作是一样的,比如,查找子节点,查找父节点等,那么对于使用者来说,就能使用一致的行为,而不用不关心当前处理的是单个对象还是整个结构,间接简化了使用者的代码。

  • 快速新增节点,提升组合灵活性。 在组合模式中,新增节点会很方便,而无须对现有代码进行任何修改,符合“开闭原则”;同时还能够在局部节点上按照相关性再进行自由的组合,大大提升了对象结构的灵活性。

同样,组合模式也有一些缺点。

  • 难以限制节点类型。 比如,在上面的订单例子中,订单树中除了组合商品类的节点外,实际上只要不约束,就可以组合任意类型的节点,因为它们都来自同一个根节点,都属于节点类型,只不过节点里包含的信息各不相同罢了,所以,在使用组合模式时,通常需要在设计时从逻辑层面上进行一定的约束。

  • 需要增加很多运行时的检查,增加了代码复杂度。 一旦对象类型不能做限制后,就必须通过运行时来进行类型检查,而这个实现过程比较复杂,会增加很多额外的代码耦合性,同时还会增加代码的理解难度。

  • 错误的遍历算法可能会影响系统性能。 组合模式实现树形结构虽然好用,但是一旦使用了错误的遍历算法,就会在数据量剧增的情况下拖慢系统速度,比如,当使用简单多层 for 循环嵌套来查找全量的数据时,算法的时间复杂度可能是 m 次方 O(n^m),会造成外部服务阻塞等待,这样很可能会直接导致其他服务因为长时间等待而出现超时错误。因此,使用组合模式时一定要谨慎选择遍历算法。

文章(专栏)将持续更新,欢迎关注公众号:服务端技术精选。欢迎点赞、关注、转发