软件构造(05):GraphPoet的实现:委托优于继承


Lab2中P1的GraphPoet需要利用已经实现的Graph及其两个具体实现类来编写,有两种实现方案。这篇博客结合委托和继承两种实现机制的特点,对这两种方案进行比较。

委托的实现(复合+转发)

(由于在本实验中Graph接口拥有一个工厂方法empty(),在用户使用接口的时候可以调用empty()来生成一个“Graph”对象,而实际上生成的是ConcreteVerticesGraph对象或ConcreteEdgesGraph对象,委托机制中GraphPoet相当于一个使用接口Graph的用户,在GraphPoet看来Graph的行为与一个具体实现类完全相同,所以后面为了方便起见,笔者会用“Graph类”这一称谓来代替“Graph接口调用静态工厂方法创建的具体实现类”)。

在P1的rep中,我使用了简单的委托机制,不扩展现有的Graph类,而是在GraphPoet类中设置一个Graph类型的私有域,命名为graph(复合),在类GraphPoet对象被创建的时候就调用Graph的静态工厂方法empty()将graph指向的类实例化,这样graph就成为GraphPoet的一个组件,而GraphPoet是Graph的一个包装类,也即使用了“修饰者模式”。

public class GraphPoet {
    
    private final Graph<String> graph = Graph.empty();
    //other codes
}

然后将GraphPoet类中的每个实例方法,如GraphPoet()、poem()、toString等都可以调用Graph类中在图上进行的一切操作来实现(** 转发 **)。

 /**
     * Create a new poet with the graph from corpus (as is described above).
     * 
     * @param corpus text file from which to derive the poet's affinity graph
     * @throws IOException if the corpus file cannot be found or read
     */
	public GraphPoet(File corpus) throws IOException {
		List<String> words = new ArrayList<>();
		BufferedReader in = new BufferedReader(new FileReader(corpus));
		String line;
		while ((line = in.readLine()) != null) {
			if (line.equals("")) {
				continue;
			}
			words.addAll(Arrays.asList(line.split(" ")));
		}
		in.close();
		if (words.size() == 0) {
			checkRep();
			return;
		} else if (words.size() == 1) {
			graph.add(words.get(0).toLowerCase());
			checkRep();
			return;
		}
		for (int i = 0; i < words.size() - 1; i++) {
			String source = words.get(i).toLowerCase();
			String target = words.get(i + 1).toLowerCase();
			graph.add(source);
			graph.add(target);
			if (graph.targets(source).containsKey(target)) {
				graph.set(source, target, graph.targets(source).get(target) + 1);
			} else {
				graph.set(source, target, 1);
			}
		}
		checkRep();
	}

继承的实现

另一种存在潜在隐患做法是直接用GraphPoet继承ConcreteVerticesGraph类或ConcreteEdgesGraph(这里以ConcreteVerticesGraph为例):

public class GraphPoet extends ConcreteVerticesGraph<String>{
	//codes
}

这种做法使GraphPoet继承了与父类ConcreteVerticesGraph完全相同的域vertices,后续的操作就可以直接调用父类中的add、set、remove等方法对vertices域进行操作。

public GraphPoet(File corpus) throws IOException {
		List<String> words = new ArrayList<>();
		BufferedReader in = new BufferedReader(new FileReader(corpus));
		String line;
		while ((line = in.readLine()) != null) {
			if (line.equals("")) {
				continue;
			}
			words.addAll(Arrays.asList(line.split(" ")));
		}
		in.close();
		if (words.size() == 0) {
			checkRep();
			return;
		} else if (words.size() == 1) {
			add(words.get(0).toLowerCase());
			checkRep();
			return;
		}
		for (int i = 0; i < words.size() - 1; i++) {
			String source = words.get(i).toLowerCase();
			String target = words.get(i + 1).toLowerCase();
			add(source);
			add(target);
			if (targets(source).containsKey(target)) {
				set(source, target, graph.targets(source).get(target) + 1);
			} else {
				set(source, target, 1);
			}
		}
		checkRep();
	}

为什么使用委托而不是继承?

由于本次实验的要求,Graph和GraphPoet放在了同一个包内。但是在实际开发的时候,很有可能是Graph类先被开发出来,作为一个组件发布,然后GraphPoet的开发人员使用这个组件进行进一步的开发,下面的论述都基于这样的假设。
(因为软件构造这门课程还是要为实际的开发服务的,在实验中代码的设计都是理想化的,可能有的情况不会发生,但如果因此而不去考虑它,就无法真正发挥实验的价值)

从类的功能角度看

Graph不是专门为继承而设计的,在本例中,Graph类的定位应当是一个组件,其中集成了一系列包括创建图、修改图和获取图中信息的方法,也就是说,这个类在设计的时候,注重的是内部功能的实现,而不是关注其可扩展性。

GraphPoet的实现也不是要扩展图上的操作,而是调用图上的这些操作实现更高层的抽象,其关注点在于如何生成一个单词关系图,然后调用图上的操作来作诗。

从类的安全性角度看

对继承的不当使用可能会导致软件变得脆弱。

在本例中,委托实现调用Graph类中的方法来构建GraphPoet,只调用Graph中的add、set、remove等方法对Graph进行操作,但它对Graph内部的工作机理是一无所知的,这样就很好地维护了Graph类的封装性。

而继承的方式则与此相反,它打破了ConcreteVerticesGraph类的封装性。

一方面,子类的这种继承实际上是默认GraphPoet的开发者在实现Graph的时候是知道ConcreteVerticesGraph的内部实现的。这一点在本次实验中当然可以保证,因为Graph的开发者就是我们自己。

但是,我们对Graph的具体实现进行封装,其目的不就是隐藏其内部实现吗?如果我们在开发GraphPoet的时候依赖于ConcreteVerticesGraph的细节,本质上是假设GraphPoet的开发者已经知道了Graph的具体实现细节,这一点在实际开发中,如果是调用其他人员开发的封装性好的模块,则几乎是不可能实现的。


另一方面,即使父类的实现细节已知,子类的这种继承使得GraphPoet依赖于其父类ConcreteVerticesGraph中特定功能的实现细节。
在本例中,ConcreteVerticesGraph的rep如下:

private final List<Vertex<L>> vertices = new ArrayList<>();

ConcreteVerticesGraph的所有操作都是在数据结构vertices上进行的,子类GraphPoet继承了父类ConcreteVerticesGraph类中的vertices域,但如果子类在vertices的基础上进行开发,会产生毁灭性的灾难。

因为父类的实现完全有可能会随着发行版本的不同而有所变化,比如开发者考虑到效率问题而将Graph静态工厂方法中ConcreteVerticesGraph换成了ConcreteEdgesGraph等等。如果真的发生了变化,即使子类的代码完全没有改变,其功能也有可能会遭到破坏。 因而子类必须要跟着其父类的更新而演变,这显然是不合理的。

从Graph更新的角度看

当父类有新的发行版本时,子类的这种继承还可能导致一些难以预料的错误。
Effective Java中给出了一个例子(在本例中没有这种情况):

子类的超类在后续的发行版本中可以获得新的方法。 假设一个程序的安全性依赖于这样的事实:所有被插入某个集合中的元素都满足某个先决条件。 下面的做法就可以确保这一点 :对集合进行子类化,并覆盖所有能够添加元素的方法,以便确保在加入每个元素之前它是满足这个先决条件的。如果在后续的发行版本中,超类中没有增加能插入元素的新方法,这种做法就可以正常工作。 然而,一旦超类增加了这样的新方法,则很可能仅仅由于调用了这个未被子类覆盖的新方法,而将“非法的”元素 添加到子类的实例中。这不是一个纯粹的理论问题。 在把Hashtable 和 Vector 加入到 Collections Framework 中的时候,就修正了几个这类性质的安全漏洞。

在本例中比较有可能发生的一个错误如下:
Graph的开发者在后续的发行版本中可能加入了一个新的方法,如求最小生成树等,这是完全有可能的。但GraphPoet的开发者在开发GraphPoet的时候,为了实现某些方法,提供了一个与父类中新增的方法签名相同但返回类型不同的方法, 那么这样的子类将无法通过编译。
这个错误是非常隐蔽的,它导致GraphPoet在代码完全不变的情况下违反了新的Graph的规约。而这一点显然不应该让Graph来考虑,到最后只是增加了GraphPoet开发者的工作量而已。

与此相比,使用委托机制则要灵活得多,由于GraphPoet实质上是Graph的一个包装,即使在GraphPoet类中提供了与父类中新增的方法签名相同但返回类型不同的方法,由于父类的更新是对Graph进行的,调用GraphPoet中的方法与Graph中新增的方法是不冲突的,因此可以同时使用两个名称相同(但所属的类不同)的方法。

So What?

何时使用继承?

必须满足的条件:
子类真正是父类的子类型,即两者之间确实存在“ is-a”关系

还需要满足以下至少一点:

  • 在包内继承,此时子类和父类的实现都处在同一个程序员的控制之下
  • 被继承的类是专门为了继承而设计的并且具有很好的文档说明

何时使用委托?

满足以下任何一点:

  • 对普通的具体类进行跨越包边界的继承
  • 正试图扩展的类可能有隐含的缺陷,且不愿意把这些缺陷传播到新类的中

值得注意的是,Effective Java中给出了一个不适合使用包装类的场景

包装类几乎没有什么缺点。 需要注意的一点是, 包装类不适合用于回调框架;在回调框架中,对象把自身的引用传递给其他的对象,用于后续的调用(“回调”)。因为被包装起来的对象并不知道它外面的包装对象,所以它传递一个指向自身的引用,回调时避开了外面的包装对象。这被称为 SELF 问题 。 有些人担心转发方法调用所带来的性能影响,或者包装对象导致的内存占用。 在实践中,这两者都 不会造成很大的影响。 编写转发方法倒是有点琐碎,但是只需要给每个接口编写一次构造器,转发类则可以通过包含接口的包提供。

写在最后

简而言之,继承的功能非常强大,但是也存在诸多问题,因为它违背了封装原则。 只有当子类和父类之间确实存在子类型关系时,使用继承才是恰当的。 即使如此,如果子类和父类处在不同的包中,并且父类并不是为了继承而设计的,那么继承将会使软件变得脆弱。 为了避免这种脆弱性,可以用委托机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。此时包装类不仅比子类更加健壮,而且功能也更加强大。在实际的开发中,由于Graph和GraphPoet的开发者可能不同,因此Graph的开发者有责任对Graph进行很好的封装,同时GraphPoet的开发者也有责任使用合适的实现方式,使GraphPoet能够更好地兼容Graph的更新版本,且不依赖于Graph的具体实现。在设计GraphPoet的时候,使用委托明显要由于继承。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值