诊断 Java 代码: 设计可扩展的应用程序,第 2 部分
玻璃箱可扩展性是指这样一种方式:软件系统可在源代码可以查看而不可以修改时被扩展 ―
它是黑箱设计(在这里构建扩展时,不查看原始代码)和开放箱设计(扩展代码直接写入到基础代码)的折衷。因为新的扩展直接建立在原始代码基础上,但不改动
原始代码,所以,玻璃箱设计或许是扩展一个软件系统最有效、最安全的方法。在 诊断 Java 代码的这一部分中,Eric Allen 详述了上个月谈及的玻璃箱可扩展性主题。读完本文后,您将知道什么时候使用玻璃箱,并将获得一些如何实现它的提示。请在
与作者和其他读者交流本文的心得。
随着信息处理任务
(和与之相关的成本)多样性的增长,很明显,在技术预算紧缩的形势下,增大代码重用的程度是一种有效的设计(和商业)策略。如果系统的可扩展性是您的目
标,那就问问您自己,“系统应该具有多大的可扩展性以及我能使系统具有多大的可扩展性?”然后考虑以下事实:
- 增加可扩展性的代价是性能和可测试性的降低。
- 可测试性最好的系统通常也是最简单的系统;增加可扩展性常常增加复杂性。
- 要制定一个成功的可扩展设计计划,关键的一点是知道您计划将来怎样扩展该系统。
在本系列的
第 1 篇文章中,我概述了系统可能展现出来的各种可扩展性形式 ―
黑箱设计以及两种
白箱设计,即
玻璃箱和
开放箱。接下来,我们将更深入地探讨这些形式。
我们将从玻璃箱可扩展性开始我们的旅程。
玻璃箱中的对等元素
早些时候,我已把玻璃箱可扩展性定义为这样一种方式:软件系统可在源代码可以被查看而不可以被修改时被扩展。主要有两种方向,程序可顺着这些方向成为玻璃箱可扩展,它们是:
选择使一个程序顺着这个两维空间中的任意一维可扩展都将影响到程序的结果体系结构。在多数情况下,这个决定也将对性能产生影响。
数据作为可扩展的一维
让我们首先来考虑最自然地包含在面向对象语言(例如 Java)中的可扩展性的维 ― 数据。在面向对象的语言中,某些最本质的构造(即类层次结构、接口和抽象方法)主要是为了允许顺着该维的可扩展性而被包括。
要扩展一个复合数据结构以包含新的子类型,只需定义一个被声明成继承原始复合数据结构的根的新类就可以做到。例如,考虑如下简单的用于二叉树的类层次结构:
abstract class Tree { } class Branch extends Tree { private int value; private Tree left; private Tree right; public Branch(int _value, Tree _left, Tree _right) { this.value = _value; this.left = _left; this.right = _right; } public Tree getLeft() { return this.left; } public Tree getRight() { return this.right; } public int getValue() { return this.value; } } class Leaf extends Tree { } |
如果我们想增加一种新形式的二叉树,例如非空叶节点,则我们所要做的全部是象下面这样定义一个新的类:
class NonEmptyLeaf extends Tree { private int value; public NonEmptyLeaf(int _value) { this.value = _value; } public int getValue() { return value; } } |
用“访问者(Visitor)”扩展二叉树
作为一种选择,如果我们想允许容易地扩展提供给这些树的功能性,但对允许新的子类型的可扩展性并不这么感兴趣,则我们可以对树的访问者提供支持。
访问者模式(Visitor Pattern)是讨论设计模式的最初书籍(
《设计模式》,Gamma 等;请参阅
参考资料)提出的设计模式之一。这一模式背后的思想是为复合数据类型定义一个抽象的“访问者”类。这个访问者类包含明显不同的方法,分别用于每一个具体的子类型。对我们的
Tree 类这种情况来说,我们可以定义一个抽象的
TreeVisitor 类,如下:
interface TreeVisitor { public Object forBranch(Branch that); public Object forLeaf(Leaf that); } |
Tree 的每一个子类型都必须包含一个
accept 方法,该方法带一个
TreeVisitor 参数并调用自身的
TreeVisitor 的
accept方法:
清单 4. accept 方法必须调用自身对应的 accept 方法
// in class Tree: public abstract Object accept(TreeVisitor that); // in class Branch: public Object accept(TreeVisitor that) { return that.forBranch(this); } // in class Leaf: public Object accept(TreeVisitor that) { return that.forLeaf(this); } |
现在,当我们想把新的功能性添加到树中时,我们可以只是定义
TreeVisitor 的新的具体子类并适当地定义
for方法(即 forBranch 和 forLeaf 方法)。例如,我们可以添加一个生成树的深度拷贝功能,如下:
class TreeCopier implements TreeVisitor { public Object forBranch(Branch that) { return new Branch(that.getValue(), (Tree)that.getLeft().accept(this), (Tree)that.getRight().accept(this)); } public Object forLeaf(Leaf that) { // There are no subcomponents to visit in a Leaf. return new Leaf(); } } |
访问者带来的麻烦
但如果我们也想扩展类
Tree 的具体子类型集合,则采用这种办法就会碰上麻烦。
第一个问题是现有的
TreeVisitors 将不包含用于新数据类型的
for 方法。这个问题可以这样解决:用包含这些新方法的新
TreeVisitors 建立现有
TreeVisitors 的子类型,并实现一个包含这些新方法的新子接口。
在我们的深度拷贝示例(清单 5)中,如果单元素叶被添加到我们的树中,则我们可以按如下方式扩展
TreeCopier (注意,必须给
NonEmptyLeaf 的
accept方法添加一个强制转型):
interface TreeVisitor2 extends TreeVisitor { public Object forNonEmptyLeaf(NonEmptyLeaf that); } ... // in class NonEmptyLeaf public Object accept(TreeVisitor that) { return ((TreeVisitor2)that).forNonEmptyLeaf(this); } class TreeCopier2 extends TreeCopier implements TreeVisitor2 { public Object forNonEmptyLeaf(NonEmptyLeaf that) { return new NonEmptyLeaf(that.getValue()); } } |
但仅仅以这种方式扩展
TreeVisitors 是不够的。
如果访问者带来更多访问者会怎么样?
原始的
TreeVisitors 可能会构造
TreeVisitors 的新实例。
TreeVisitor 的子类的实例现在将构造它们的超类的实例。
这个问题是常见的。通常,当在访问者中包含额外参数是很自然的时候,这些额外参数被传递到访问者的构造器,然后构造器把这些参数放置到字段中。在一个递归下降数据结构中,如果必须使用递归调用中的这些参数的不同值,则将会构造一个使用了新参数的新访问者。
例如,假设我们想创建一个
TreeVisitor ,用于对
Tree 的元素进行美化打印。我们可以用
TreeVisitor 的一个字段来跟踪打印子树时的缩进程度,以下的
TreeVisitor 完成了这些:
清单 7. 用一个访问者来跟踪 TreePrinter 的缩进
import java.io.OutputStream; import java.io.PrintStream; class TreePrinter implements TreeVisitor { private int amountOfIndentation; // The stream to which we are printing. PrintStream out; public TreePrinter() { this.amountOfIndentation = 0; this.out = System.out; } public TreePrinter(OutputStream _out) { this(); this.out = new PrintStream(_out); } TreePrinter(int _amountOfIndentation) { this(); this.amountOfIndentation = _amountOfIndentation; } TreePrinter(int _amountOfIndentation, OutputStream _out) { this(); this.amountOfIndentation = _amountOfIndentation; this.out = new PrintStream(_out); } /** * Prints an amount of whitespace proportional to the * current degree of indentation. */ public void indent() { for (int i = 0; i < this.amountOfIndentation; i++) { this.out.print(" "); } } public Object forLeaf(Leaf that) { // Since leaves are empty, they are not printed. // Returns a dummy object to satisfy TreeVisitor interface. return new Object(); } public Object forBranch(Branch that) { TreePrinter innerPrinter = new TreePrinter(this.amountOfIndentation + 1, this.out); this.indent(); this.out.println(that.getValue()); that.getLeft().accept(innerPrinter); that.getRight().accept(innerPrinter); // Returns a dummy object to satisfy TreeVisitor interface. return new Object(); } } |
但另一方面,当我们扩展这个
TreeVisitor 以包含单元素叶这种情况时,先的方法将构造错误的
TreeVisitor 类型实例:
清单 8. 单元素叶导致 TreePrinter 构造类型错误的实例
class TreePrinter2 extends TreePrinter implements TreeVisitor2 { public TreePrinter2(int _amountOfIndentation) { super(_amountOfIndentation); } public Object forNonEmptyLeaf(NonEmptyLeaf that) { this.indent(); this.out.println(that.getValue()); // Returns a dummy object to satisfy TreeVisitor interface. return new Object(); } } ... // But the inherited method forBranch will construct an // instance of TreePrinter, not TreePrinter2! public Object forBranch(Branch that) { TreePrinter innerPrinter = new TreePrinter(this.amountOfIndentation + 1, this.out); this.indent(); this.out.println(that.getValue()); that.left.accept(innerPrinter); that.right.accept(innerPrinter); // Returns a dummy object to satisfy TreeVisitor interface. return new Object(); } |
如果
TreeCopier2 的一个实例试图访问一个
Tree ,而这个
Tree 带有是
NonEmptyLeaf 的双亲的一个
Branch ,则在
NonEmptyLeaf 的
accept 方法中强制类型转换成
TreeVisitor2 将失败。
一种答案:向工厂(factory)方法求助
这个问题的一个解决方案,最初是由 Krishnamurthi 等人提出的(请参阅
参考资料),该解决方案是用工厂方法而不是构造器来构造访问者的新实例。然后这些工厂方法将在原始访问者的任何子类中被覆盖。
在我们的示例中,可以通过把以下的工厂方法包含到类
TreePrinter 中做到这一点:
清单 9. 往 TreePrinter 添加工厂方法
// in class TreePrinter: TreePrinter newTree(int _amountOfIndentation, OutputStream _out) { return new TreePrinter(_amountOfIndentation, _out); } |
要这样做,类
TreePrinter 中构造新
TreePrinters 的任何方法都应该调用方法
newTree() 。
因此,
TreePrinter 的
forBranch() 方法将写成如下所示:
清单 10. TreePrinter 的 forBranch 方法
// in class TreePrinter: public Object forBranch(Branch that) { TreePrinter innerPrinter = newTree(this.amountOfIndentation + 1, this.out); this.indent(); this.out.println(that.getValue()); that.getLeft().accept(innerPrinter); that.getRight().accept(innerPrinter); // Returns a dummy object to satisfy TreeVisitor interface. return new Object(); } |
然后,如果需要扩展类
TreePrinter 以包含用于新数据类型的方法,则我们只需在新类中覆盖
newTree() 以返回适当类型的实例就行了。
例如,我们可以象下面这样覆盖类
TreePrinter2 的方法
newTree() :
清单 11. 覆盖 TreePrinter2 的 newTree 方法
// in class TreePrinter2: TreePrinter newTree(int _amountOfIndentation, OutputStream _out) { return new TreePrinter2(_amountOfIndentation, _out); } |
这个解决方案称为
可扩展访问者模式(Extensible Visitor Pattern)。
结束语:性能对可扩展性
因此,有了以上的设计,现在我们可以很容易地添加
Trees 上的功能性和类
Tree 的新的子类型。当然,我们将为这一可扩展性付出性能方面的代价。
当数据上的算法是递归地进行定义时,这种类型的可扩展性做得最好,但不幸的是,在 Java 语言中,递归调用的花费可能很昂贵,而且对大型数据结构,这种调用很容易就会产生堆栈溢出。
通过在可能的情况下对方法调用进行动态内联,最新的 JIT 编译器减轻了这个问题。此外,最新的 IBM JIT 编译器也进行
尾调用清除,这至少对防止尾递归方法上的堆栈溢出有帮助。
幸运的是,实际上,程序中我们想让它具有最大可扩展性的部件,通常都不是性能最关键的部件。在那些情形中,按本文描述的方式使用可扩展设计是最有利的。下一次,我将讨论与黑箱可扩展性有关的一些问题。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/374079/viewspace-130107/
,如需转载,请注明出处,否则将追究法律责任。