组合模式
组合模式(Composite)是针对由多个节点对象(部分)组成的树形结构的对象(整体)而发展出的一种结构型设计模式,它能够使客户端在操作整体对象或者其下的每个节点对象时做出统一的响应,保证树形结构对象使用方法的一致性,使客户端不必关注对象的整体或部分,最终达到对象复杂的层次结构与客户端解耦的目的。
叉树结构
在现实世界中,某些具有从属关系的事物之间存在着一定的相似性。大家一定见过蕨类植物的叶子吧。如图所示,从宏观上看,这只是一片普通的叶子,当继续观察其中一个分支的时候,我们会发现这个分支其实又是一片全新的叶子,当我们再继续观察这片新叶子的一个分支的时候,又会得到相同的结果
因此,我们可以得出结论,不管从哪个层级观察这片叶子,我们都会得到一个固定的结构,这意味着组成植物叶子的部分或整体都有着相同的生长方式,这正是孢子植物的DNA特征。大自然中存在的这种奇妙的结构在人类文明中同样有大量应用,例如文字就具有类似的结构
如图所示,字可以组成词,词组成句子,句子再组成段落、章节……直至最终成书。
这种结构类似于经典的“叉树”结构。以最简单的“二叉树”为例,此结构始于其开端的“根”节点,往下分出来两个“枝”节点(左右2个节点),接着每个枝节点又可以继续“分枝”,直至其末端的“叶”节点为止
不管是二叉树还是多叉树,道理都是一样的。无论数据元素是“根”“枝”,还是“叶”,甚至是整体的树,都具有类似的结构。具体来讲,除了叶节点没有子节点,其他节点都具有本级对象包含多个次级子对象的结构特征。所以,我们完全没有必要为每个节点对象定义不同的类(如为字、词、句、段、节、章……等每个节点都定义一个类),否则会造成代码冗余。我们可以用组合模式来表达“部分/整体”的层次结构,提取并抽象其相同的部分,特殊化其不同的部分,以提高系统的可复用性与可扩展性,最终达到以不变应万变的目的。
文件系统
通过对叉树结构的观察,我们发现,无论拿出哪一个“部分”,其与“整体”的结构都是类似的,所以首先我们需要模糊根、枝、叶之间的差异,以实现节点的统一。下面开始代码实战部分,我们就以类似于树结构的文件系统的目录结构为例
文件系统从根目录“C:”开始分支,其下级可以包含“文件夹”或者“文件”,其中文件夹属于“枝”节点,其下级可以继续存放子文件夹或文件,而文件则属于“叶”节点,其下级不再有任何子节点。基于此前的分析,我们可以定义一个抽象的“节点”类来模糊“文件夹”与“文件
抽象节点类Node
package combination;
public abstract class Node {
protected String name;
//节点命名
public Node(String name){//构造方法需传入节点名
this.name = name;
}
//添加下级子节点方法
protected abstract void add(Node child);
}
如代码所示,文件夹或文件都有一个名字,所以在第4行的构造方法中接收并初始化在第2行已定义的节点名,否则不允许节点被创建,这也是可以固化下来的逻辑。对于如何实现代码中的添加子节点方法add(Node child)暂时还不能确定,所以我们声明其为抽象方法,模糊此行为并留给子类去实现。需要注意的是,对于抽象节点类Node的抽象方法其实还可以更加丰富,例如“删除节点”“获取节点”等,这里为了简化代码只声明了“添加节点”方法。下面我们就来实现文件夹类,此类肩负着确立树形结构的重任,这也是组合模式数据结构的精髓所在
文件夹类Folder
package combination;
import java.util.ArrayList;
import java.util.List;
//1 3 4 1 2
public class Folder extends Node {
private List<Node> childrenNodes = new ArrayList<>();
public Folder(String name) {
super(name);
}
@Override
protected void add(Node child) {
childrenNodes.add(child);
}
}
首先,文件夹类继承了抽象节点类Node,并在第3行定义了一个次级节点列表List,此处的泛型Node既可以是文件夹又可以是文件,也就是说,文件夹下级可以包含任意多个文件夹或者文件。然后,代码第5行中的构造方法直接调用父类的构造方法,以初始化其文件夹名。最后,在第10行实现了添加子节点方法add(Node child),将传入的子节点添加至次级节点列表List中。对于“叶”节点文件类,其作为末端节点,不应该具备添加子节点的功能,我们来看如何定义文件类
文件类File
package combination;
public class File extends Node {
public File(String name) {
super(name);
}
@Override
protected void add(Node child) {
System.out.println("不能添加子节点");
}
}
添加子节点方法add(Node child),文件类与文件夹类的代码大同小异。如之前提到的,文件属于“叶”节点,不能再将这种结构延续下去,所以我们在第9行输出一个错误消息,告知用户“不能添加子节点”。其实更好的方式是以抛出异常的形式来确保此处逻辑的正确性,外部如果捕获到该异常则可以做出相应的处理,读者可以自行实践。一切就绪,用户就可以构建目录树了。我们来看客户端类怎样添加节点
package combination;
public class Client {
public static void main(String[] args) {
Node driveD = new Folder("D盘");
Node doc = new Folder("文档");
doc.add(new File("简历.doc"));
doc.add(new File("项目介绍.ppt"));
driveD.add(doc);
Node music = new Folder("音乐");
Node jay = new Folder("周杰伦");
jay.add(new File("双截棍.mp3"));
jay.add(new File("听妈妈的话.mp3"));
jay.add(new File("告白气球.mp3"));
Node jack = new Folder("张学友");
jack.add(new File("吻别.mp3"));
jack.add(new File("一千个伤心的理由.mp3"));
music.add(jack);
music.add(jack);
driveD.add(music);
}
}
正如我们规划文件时常做的操作,用户以“D盘”文件夹作为根节点构建了目录树,接着开始创建了“文档”和“音乐”两个文件夹作为“枝”节点,再将相应类型的文件分别置于相应的目录下,其中对音乐文件多加了一级文件夹来区分歌手,以便日后分类管理、查找。如此一来,只要能持有根节点对象“D盘”,就能延伸出整个目录
目录树展示
目录树虽已构建完成,但要体现出组合模式的优势还在于如何运用这个树结构。假如用户现在要查看当前根目录下的所有子目录及文件,这就需要分级展示整棵目录树,正如Windows系统的“tree”命令所实现的
要模拟这种树形展示方式,我们就得在输出节点名称(文件夹名/文件名)之前加上数个空格以表示不同层级,但具体加几个空格还是个未知数,需要根据具体的节点级别而定。而作为抽象节点类则不应考虑这些细节,而应先把这个未知数作为参数变量传入,我们来修改抽象节点类Node并加入展示方法
package combination;
public abstract class Node {
protected String name;
//节点命名
public Node(String name){//构造方法需传入节点名
this.name = name;
}
//添加下级子节点方法
protected abstract void add(Node child);
protected void tree(int space){
for (int i = 0; i<space;i++){
System.out.print(" ");
}
System.out.println(name);
}
}
如代码所示,我们在实现了以接收空格数量space为传入参数的展示方法tree(int space),其中的循环体会输出space个连续的空格,最后再输出节点名称。因为此处是抽象节点类的实体方法,所以要保持其通用性。我们抽离出所有节点“相同”的部分作为“公有”的代码块,而“不同”的行为部分则留给子类去实现。首先来看文件类如何实现
package combination;
public class File extends Node {
public File(String name) {
super(name);
}
@Override
protected void add(Node child) {
System.out.println("不能添加子节点");
}
@Override
protected void tree(int space) {
super.tree(space);
}
}
作为末端节点的文件类只需要输出space个空格再加上自己的名称即可,这里与父类的展示方法tree(int space)应该保持一致,所以我们直接调用父类的展示方法。其实文件类可以不做任何修改,而是直接继承父类的展示方法,此处是为了让读者更清晰直观地看到这种继承关系,同时方便后续做出其他修改。接下来的文件夹类就比较特殊了,它不仅要先输出自己的名字,还要换行再逐个输出子节点的名字,并且要保证空格逐级递增
package combination;
import java.util.ArrayList;
import java.util.List;
//1 3 4 1 2
public class Folder extends Node {
private List<Node> childrenNodes = new ArrayList<>();
public Folder(String name) {
super(name);
}
@Override
protected void add(Node child) {
childrenNodes.add(child);
}
@Override
protected void tree(int space) {
super.tree(space);
space++;
for (Node node:childrenNodes){
node.tree(space);//调用子节点的tree方法
}
}
}
如代码所示,同样,文件夹类也重写并覆盖了父类的tree()方法,并且调用父类的通用tree()方法输出本文件夹的名字。接下来的逻辑就非常有意思了,对于下一级的子节点我们需要依次输出,但前提是要把当前的空格数加1,如此一来子节点的位置会往右偏移一格,这样才能看起来像树形结构一样错落有致。可以看到,在第19行的循环体中我们直接调用了子节点的展示方法并把“加1”后的空格数传递给它即可完成展示。至于当前文件夹下的子节点到底是“文件夹”还是“文件”,我们完全不必操心,因为子节点们会使用自己的展示逻辑。如果它们还有下一级子节点,则与此处逻辑相同,继续循环,把逐级递增的空格数传递下去,直至抵达叶节点为止—始于“文件夹”而终于“文件”,非常完美的递归逻辑。
最后,客户端在任何一级节点上只要调用其展示方法并传入当前目录所需的空格偏移量,就可出现树形列表了,比如若要紧挨控制台左侧展示,客户端则需要以“0”作为偏移量调用根目录的展示方法tree(0)
需要注意的是,空格偏移量这个必传参数可能让用户非常困惑,或许我们可以为抽象节点类添加一个无参的展示方法“tree()”,在其内部调用“tree(0)”,如此一来就不再需要用户传入偏移量了,使用起来更加方便
package combination;
public abstract class Node {
protected String name;
//节点命名
public Node(String name){//构造方法需传入节点名
this.name = name;
}
//添加下级子节点方法
protected abstract void add(Node child);
protected void tree(int space){
for (int i = 0; i<space;i++){
System.out.print(" ");
}
System.out.println(name);
}
protected void tree(){
this.tree(0);
}
}
自相似性的涌现
组合模式将树形结构的特点发挥得淋漓尽致,作为最高层级抽象的抽象节点类(接口)泛化了所有节点类,使任何“整体”或“部分”达成统一,枝(根)节点与叶节点的多态化实现以及组合关系进一步勾勒出的树形结构,最终使用户操作一触即发,由“根”到“枝”再到“叶”,逐级递归,自动生成。我们来看组合模式的类结构
组合模式的各角色定义如下。
Component(组件接口):所有复合节点与叶节点的高层抽象,定义出需要对组件操作的接口标准。对应本章例程中的抽象节点类,具体使用接口还是抽象类需根据具体场景而定。
Composite(复合组件):包含多个子组件对象(可以是复合组件或叶端组件)的复合型组件,并实现组件接口中定义的操作方法。对应本章例程中作为“根节点/枝节点”的文件夹类。
Leaf(叶端组件):不包含子组件的终端组件,同样实现组件接口中定义的操作方法。对应本章例程中作为“叶节点”的文件类。
Client(客户端):按所需的层级关系部署相关对象并操作组件接口所定义的接口,即可遍历树结构上的所有组件。
go版本代码
node
package combination
import "fmt"
type Node struct {
name string
}
type node interface {
Add(child node)
Tree(opt ...NodeOption)
}
type NodeOption func() int
func WithSpace(sapce int) NodeOption {
return func() int {
return sapce
}
}
// Functional Options
func (n *Node) Tree(opt ...NodeOption) {
space := 0
for _, option := range opt {
space = option()
}
for i := 0; i < space; i++ {
fmt.Print(" ")
}
fmt.Println(n.name)
}
node的实现
package combination
import "fmt"
//File
type File struct {
Node
}
func NewFile(name string) node {
return &File{
Node: Node{
name: name,
},
}
}
func (f *File) Add(child node) {
fmt.Println("不能添加子节点")
}
func (f *File) Tree(opt ...NodeOption) {
f.Node.Tree(opt...)
}
//Folder
type Folder struct {
childrenNodes []node
Node
}
func NewFolder(name string) node {
return &Folder{
childrenNodes: make([]node, 0),
Node: Node{
name: name,
},
}
}
func (f *Folder) Add(child node) {
f.childrenNodes = append(f.childrenNodes, child)
}
func (f *Folder) Tree(opt ...NodeOption) {
var temp int
if len(opt) == 0 {
temp = 0
} else {
temp = opt[0]()
}
f.Node.Tree(WithSpace(temp))
temp++
for _, nod := range f.childrenNodes {
nod.Tree(WithSpace(temp))
}
}
main.go
package main
import "desginPatterns/combination"
func main() {
driveD := combination.NewFolder("D盘")
doc := combination.NewFolder("文档")
doc.Add(combination.NewFile("简历.doc"))
doc.Add(combination.NewFile("项目介绍.ppt"))
driveD.Add(doc)
music := combination.NewFolder("音乐")
jay := combination.NewFolder("周杰伦")
jay.Add(combination.NewFile("双截棍.mp3"))
jay.Add(combination.NewFile("听妈妈的话.mp3"))
jay.Add(combination.NewFile("告白气球.mp3"))
jack := combination.NewFolder("张学友")
jack.Add(combination.NewFile("吻别.mp3"))
jack.Add(combination.NewFile("一千个伤心的理由"))
music.Add(jay)
music.Add(jack)
driveD.Add(music)
driveD.Tree()
}
运行效果