玻璃箱可擴展性是指這樣一種方式:軟件系統可在源代碼可以查看而不可以修改時被擴展 ― 它是黑箱設計(在這裡構建擴展時,不查看原始代碼)和開放箱設計(擴展代碼直接寫入到基礎代碼)的折衷。因為新的擴展直接建立在原始代碼基礎上,但不改動原始代碼,所以,玻璃箱設計或許是擴展一個軟件系統最有效、最安全的方法。在 診斷 Java 代碼的這一部分中,Eric Allen 詳述了上個月談及的玻璃箱可擴展性主題。讀完本文後,您將知道什麼時候使用玻璃箱,並將獲得一些如何實現它的提示。
隨著信息處理任務(和與之相關的成本)多樣性的增長,很明顯,在技術預算緊縮的形勢下,增大代碼重用的程度是一種有效的設計(和商業)策略。如果系統的可擴展性是您的目標,那就問問您自己,“系統應該具有多大的可擴展性以及我能使系統具有多大的可擴展性?”然後考慮以下事實:
增加可擴展性的代價是性能和可測試性的降低。
可測試性最好的系統通常也是最簡單的系統;增加可擴展性常常增加復雜性。
要制定一個成功的可擴展設計計劃,關鍵的一點是知道您計劃將來怎樣擴展該系統。
在本系列的 第 1 篇文章中,我概述了系統可能展現出來的各種可擴展性形式 ― 黑箱設計以及兩種 白箱設計,即 玻璃箱和 開放箱。接下來,我們將更深入地探討這些形式。
我們將從玻璃箱可擴展性開始我們的旅程。
玻璃箱中的對等元素
早些時候,我已把玻璃箱可擴展性定義為這樣一種方式:軟件系統可在源代碼可以被查看而不可以被修改時被擴展。主要有兩種方向,程序可順著這些方向成為玻璃箱可擴展,它們是:
數據的擴展
這些數據的功能性的擴展
選擇使一個程序順著這個兩維空間中的任意一維可擴展都將影響到程序的結果體系結構。在多數情況下,這個決定也將對性能產生影響。
數據作為可擴展的一維
讓我們首先來考慮最自然地包含在面向對象語言(例如 Java)中的可擴展性的維 ― 數據。在面向對象的語言中,某些最本質的構造(即類層次結構、接口和抽象方法)主要是為了允許順著該維的可擴展性而被包括。
要擴展一個復合數據結構以包含新的子類型,只需定義一個被聲明成繼承原始復合數據結構的根的新類就可以做到。例如,考慮如下簡單的用於二叉樹的類層次結構:
清單 1. 一個簡單的用於二叉樹的類層次結構
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 {
}
如果我們想增加一種新形式的二叉樹,例如非空葉節點,則我們所要做的全部是象下面這樣定義一個新的類:
清單 2. 定義一個用於非空葉的新類
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 類,如下:
清單 3. 定義一個抽象的“樹訪問者”類
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 方法)。例如,我們可以添加一個生成樹的深度拷貝功能,如下:
清單 5. 新的 TreeCopier 功能對樹進行拷貝
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方法添加一個強制轉型):
清單 6. 為非空葉而做的擴展
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 編譯器也進行 尾調用清除,這至少對防止尾遞歸方法上的堆棧溢出有幫助。
幸運的是,實際上,程序中我們想讓它具有最大可擴展性的部件,通常都不是性能最關鍵的部件。在那些情形中,按本文描述的方式使用可擴展設計是最有利的。下一次,我將討論與黑箱可擴展性有關的一些問題。