设计模式之组合模式(Composite Pattern)

一.什么是组合模式?

组合模式提供了一种层级结构,并允许我们忽略对象与对象集合之间的差别

调用者并不知道手里的东西是一个对象还是一组对象,不过没关系,在组合模式中,调用者本来就不需要知道这些

二.举个例子

假设我们要去描述文件系统,文件系统里有文件和文件夹,文件夹里又有文件夹和文件。。。

没错,这是一个层级结构,就像菜单一样,菜单里有菜单项和子菜单,子菜单里有菜单项和子子菜单。。层级结构也就是树形结构,我们很容易想到定义一个Node类,包含一组指向孩子的指针,以此构造一颗完整的树。那么我们的类图将是这样的:

注意:File仅支持类图中列出的操作,Folder类支持继承来的所有操作

类的基本设计就是这样,利用这样的类结构就可以描述文件系统了,下面来做代码实现:

定义Directory基类:

package CompositePattern;

import java.util.ArrayList;

/**
 * 定义目录类
 * @author ayqy
 */
public abstract class Directory {
	String name;
	String description;
	ArrayList<Directory> files;
	
	
	
	/**
	 * 添加指定文件/文件夹到该目录下
	 * @param dir 将要添加的文件/文件夹
	 * @return 添加成功/失败
	 */
	public boolean add(Directory dir){
		throw new UnsupportedOperationException();//默认抛出操作异常
	}
	
	/**
	 * 删除该目录下的指定文件/文件夹
	 * @param dir 将要删除的文件/文件夹
	 * @return 删除成功/失败
	 */
	public boolean remove(Directory dir){
		throw new UnsupportedOperationException();//默认抛出操作异常
	}
	
	/**
	 * 清空该目录下所有文件和文件夹
	 * @return 清空成功/失败
	 */
	public boolean clear(){
		throw new UnsupportedOperationException();//默认抛出操作异常
	}
	
	public ArrayList<Directory> getFiles() {
		throw new UnsupportedOperationException();//默认抛出操作异常
	}
	
	/**
	 * 打印输出
	 */
	public abstract void print();
	
	public String getName() {
		return name;
	}

	public String getDescription() {
		return description;
	}
	
	public String toString(){
		return name + description;
	}
}

P.S.注意我们在基类中对Folder特有的方法的处理方式(抛出异常),当然也可以用更和谐的方式来做,各有各的好处与缺陷,在后文详述采用抛出异常这样粗暴的方式的原因

注意,我们在基类中定义了一个抽象的print方法,想用通过调用print方法来输出整个文件树,组合模式允许我们以很轻松很优雅的方式实现这个麻烦的过程

下面来实现File类:

package CompositePattern;

/**
 * 实现文件类
 * @author ayqy
 */
public class File extends Directory{

	public File(String name, String desc) {
		this.name = name;
		this.description = desc;
	}

	@Override
	public void print() {
		System.out.print(this.toString());//输出文件自身信息
	}
}

File类非常简单,由于基类中对File不支持的操作都做了默认实现(抛出异常),所以File变得相当苗条

接下来是Folder类:

package CompositePattern;

import java.util.ArrayList;

/**
 * 实现文件夹类
 * @author ayqy
 */
public class Folder extends Directory{

	public Folder(String name, String desc){
		this.name = name;
		this.description = desc;
		this.files = new ArrayList<Directory>();
	}

	@Override
	public void print() {
		//打印该Folder自身信息
		System.out.print(this.toString() + "(");
		//打印该目录下所有文件及子文件
		for(Directory dir : files){
			dir.print();
			System.out.print(", ");
		}
		//打印文件夹遍历结束标志
		System.out.print(")");
	}
	
	
	@Override
	public boolean add(Directory dir){
		if(files.add(dir))
			return true;
		else
			return false;
	}
	
	@Override
	public boolean remove(Directory dir){
		if(files.remove(dir))
			return true;
		else
			return false;
	}
	
	@Override
	public boolean clear(){
		files.clear();
		
		return true;
	}
	
	@Override
	public ArrayList<Directory> getFiles() {
		return files;
	}
}

Folder类对所有支持的操作提供了自己的实现,并在print方法里做了点文章,用一个非常简单的循环实现了对当前节点所有子孙节点的打印输出(这容易让人联想到什么?没错,是装饰者模式),看起来似乎有些不可思议,不过这正是使用组合模式的好处之一(给递归提供了天然的土壤)

三.效果示例

上面实现了描述文件系统所需的类,不妨测试一下,看看效果:

测试类代码如下:

package CompositePattern;

/**
 * 实现一个测试类
 * @author ayqy
 */
public class Test {
	public static void main(String[] args){
		/*构造文件树*/
		/*
		C
			a.txt
			b.txt
			system

				sys.dat
			windows
				win32
					settings
					log.txt
				win32.config
		*/
		Directory dir = new Folder("C", "");
		dir.add(new File("a.txt", ""));
		dir.add(new File("b.txt", ""));
		Directory subDir = new Folder("system", "");
		subDir.add(new File("sys.dat", ""));
		dir.add(subDir);
		Directory subDir2 = new Folder("windows", "");
		Directory subDir3 = new Folder("win32", "");
		subDir3.add(new Folder("settings", ""));
		subDir3.add(new File("log.txt", ""));
		subDir2.add(subDir3);
		subDir2.add(new File("win32.config", ""));
		dir.add(subDir2);
		
		dir.print();//打印输出文件树
	}
}

运行结果如下:

C(a.txt, b.txt, system(sys.dat, ), windows(win32(settings(), log.txt, ), win32.config, ), )

和我们预期的结果基本相同,但美中不足的是:存在多余的逗号分隔符。要想消除多余的逗号,我们就要显示循环在走最后一趟时不输出逗号,其余时候都输出一个逗号

很容易想到用一个显式的迭代器来实现(hasNext不正好用来判断是不是最后一趟吗?别忘了ArrayList是支持迭代器的),我们修改下print方法:

public void print() {
	//打印该Folder自身信息
	System.out.print(this.toString() + "(");
	//打印该目录下所有文件及子文件
	Iterator<Directory> iter = getFiles().iterator();
	while(iter.hasNext()){
		Directory dir = iter.next();
		dir.print();
		if(iter.hasNext()){
			System.out.print(",");
		}
	}
	//打印文件夹遍历结束标志
	System.out.print(")");
}

成功消除了碍眼的多余逗号

四.多一点改变

如何打印输出所有关联程序为NotePad.exe的文件信息

那么现在先要给File添加一个新的属性linkedExe,表示与该文件关联的可执行程序,而文件夹则不支持这个属性(在这里我们规定文件夹不支持linkedExe属性,不考虑与文件夹相关联的程序是资源管理器还是别的什么)

为了实现新的需求,我们不得不做一些改动了,为了获得类型上的一致性,我们必须把linkedExe属性添加到基类Directory中(这样做或许会遭到诟病,但有些时候我们不得不牺牲一些好处来换取另一些好处。。)

矩形框中的内容是我们添加的新东西,这些东西都是File支持但Folder不支持的。做了这样的变动之后,我们就可以打印输出所有关联程序为NotePad.exe的文件信息了。当然,还要修改Folder类的print方法:

public void print() {
	//打印该目录下所有关联程序为NotePad.exe的文件
	for(Directory dir : files){
		try{
			if("NotePad.exe".equalsIgnoreCase(dir.getLinkedExe())){
				dir.print();
			}
		}catch(UnsupportedOperationException e){
			//吃掉异常,继续遍历(Folder不支持getLinkedExe操作)
		}
	}
}

发现什么了吗?似乎组合模式的缺点越来越明显了

组合模式要求忽略一个对象与一组对象之间的差异,一视同仁的对待它们

没错,照这样做我们确实获得了透明性(print方法中我们并不知道当前正在处理的是一个File还是一个Folder)。但我们甚至“滥用”异常处理机制来掩盖对象集合与单一对象的差别,以追求“一视同仁”。而这样做到底值不值得,需要视具体情况而定(我们总是在牺牲一些东西,以换取另一些更有用的东西,至于这种牺牲是否值得,当然需要权衡)

五.迭代器与组合模式

说好的迭代器呢?我怎么没有看到?它在哪里?

迭代器就藏在组合模式中,我们的print方法内部不就一直在用迭代器吗?(不是隐式迭代器就是显示迭代器。。)

上面的例子中用的迭代器被称为内部迭代器,也就是说,迭代器潜藏在组合模式的构成类中,所以不容易发现。当然,如果你喜欢的话也可以构造一个外部迭代器,就像这样:

在DirectoryIterator中,我们需要手动维护一个栈结构来记录当前的位置(内部迭代器是由系统栈提供的支持),以实现hasNext与next方法

其实还存在一个问题,File类显然不支持iterator方法,但它已经从父类继承过来了,我们应该如何处理?

  • 返回null,那么调用者必须使用if语句进行判断
  • 抛出异常,那么调用者必须做异常处理
  • (推荐做法)返回一个空迭代器(NullIterator),空迭代器如何实现?hasNext直接返回false就好了。。这样做对调用者没有任何影响

六.总结

组合模式提供的树形层次结构使得我们能够一视同仁地对待单一对象与对象集合(获得了操作上的方便),但这样的好处是以牺牲类的单一责任原则换来的,而且组合模式是用继承来实现的,缺少弹性。

所以在使用组合模式的时候应当慎重考虑,想想这样的牺牲是否值得,如果不值得的话,考虑是不是可以用其它设计模式代替。。

七.一点题外话(关于是否抛出异常)

有些时候我们可以选择返回null,返回false,返回错误码等等而不是抛出异常,这些方式或许更和谐一些,但抛出异常在有些时候是对事实最贴切的表达

举个例子,假设我们的File类有一个hasLinkedExe属性,表示是否存在与之关联的应用程序,而Folder不支持hasLinkedExe属性,同时该属性又是从父类继承得到的,我们无法删除它。

此时我们可以选择返回false或者抛出异常:

  • 返回false:表示Folder没有与之关联的应用程序
  • 抛出异常:表示Folder不支持该操作

显然,抛出异常的含义才是我们真正想要表达的

说了这么多,我们费了好大劲去用抛出异常的方式,好像只是为了表达的更贴切一些?不不不,绝对不要这样想,这一点点意义上的差异可能会导致严重的问题,比如:

假设我们要输出所有尚未关联应用程序的文件(即“未知文件”)如果我们当初采用了返回false的方式来表示文件夹不支持此操作,那么我们将会得到错误的结果(输出了所有未知文件和所有文件夹。。)