本文首发于 http://www.YoungZY.com/
意图
- 将对象复合成树形结构来表示“整体-部分”的层次结构。复合模式使客户端能够统一地处理单个对象和复合对象
- 递归复合
- “目录下有多个条目,每个条目也可能是个目录”
- 将一对多的“has-a”结构变为“is-a”结构
问题
应用程序需要处理包含原始对象和复合对象的分层集合。处理原始对象是一种方式,处理复合对象是另一种方式。每次在处理前都要检查一下对象所属的类别是不可取的。
讨论
定义一个抽象基类(Component),它指定了需要在原始对象和复合对象中统一执行的行为。原始对象和复合对象都继承Component类。只有在管理自己的“孩子”时,复合类才去关联抽象基类。
遇到类似”目录下有多个条目,每个条目也可能是个目录”的场景,可以使用这种模式。
通常,管理“孩子”的方法(如 addChild()
removeChild()
)应该被定义在复合对象里。不幸的是,统一处理原始对象和复合对象的需求使得这些方法必须移到抽象类Component中。参见下方“观点”里关于“安全性”和“透明度”的讨论。
结构
复合对象包含多个组件对象,每个组件对象可能又是个复合对象。
菜单包含子菜单,子菜单可能又包含子菜单。
行-列类的GUI布局管理器包含小组件,每个小组件可能也是一个行-列布局管理器。
容器里包含元素,每个元素可能也是个容器。
举例
尽管这个例子很抽象,但它是个好例子。数学表达式由操作数、操作符(加减乘除),和另一个操作数组成。操作数可能是一个数字,也可能是另一个数学表达式。所以,2+3
和 (2+3)+(4*6)
都是有效的表达式。
核查清单(也可以理解为应用步骤)
- 确认你的问题是关于“整体-部分”的层次关系的
- 考虑这样的场景:容器里包含元素,每个元素可能也是个容器。例如,集合里包含对象,每个对象可能也是个集合。把你的问题对应到这样的例子里。
- 创建最低限度的共通性接口,使得容器和元素可以互换。它应该定义了容器类和元素类都能无差别执行的方法。
- 所有的容器类和元素类同接口之间是“is-a”的关系
- 所有的容器类和接口类形成了一个“一对多”的“has-a”的关系
- 容器类利用多态性将责任委托给元素类
- 将管理“孩子”的方法(如
addChild()
removeChild()
)放到抽象基类里
观点
复合模式的要点是可以像处理单个元素一样处理集合。如果你想增加一个迭代的功能,没问题。但个人觉得这超出了模式本身的范围。该模式的核心功能是让客户端在处理一个对象时不需要知道里面是否包含了很多对象。
为了能够自动地(或透明地)处理各种对象的集合,需要把管理子组件的接口定义在组件基类里(如上文中的Component类)。然而这会破坏安全性,因为客户端可以做些毫无意义的事情,如给叶子组件添加或删除对象。另一方面,如果为了安全把管理子组件的接口定义在复合对象中,就失去了透明性,因为叶子组件和复合组件有了不同的接口。
Smalltalk在实现复合模式时通常将管理组件的接口定义在复合对象中,而不是基类中。C++更倾向于将其放在基类中。这一点非常有趣,也经常让我陷入沉思。我可以用一些理论来解释,但没人能确定它就是完全正确的。
组件对象(基类)不知道复合对象的存在。组件对象不会操作复合对象,也不会修改复合对象。这是因为我希望所有的类(包括衍生类)在不需要复合对象时就可以重复使用。对于某个给定的对象引用,如果我确实需要知道它是否是一个复合对象,我会使用类型转换。如果类型转换的代价太高,我会使用观察者模式。
常见的一个抱怨是:”如果把复合接口变成复合类,怎么去遍历一个复杂的结构?”我的回答是当我有类似于复合模式的层次结构的行为时,我会使用观察者模式。所以遍历不是问题 —— “观察者”知道它正在处理的是哪一类对象。观察者模式不需要每一个对象都提供一个遍历接口。
复合模式不是强迫你把所有的组件都当做复合对象。它仅仅是说把那些需要统一对待的操作放在基类里。如果添加、删除和其他类似操作不能或者不允许统一对待,那就不要把他们放在基类中。还有,要记住,我们的模式结构图不是用来定义模式的,它仅仅表示我们对模式的一种认识。比如这里的复合模式的结构图显示对子组件的管理操作被定义在基类里,但这不代表所有的实现都要这么做。
(译者注:详细的代码实例请移步 GitHub )
加入讨论