用建構方法封裝子類別

Encapsulate Subclasses with Creation Methods

 

撰文/Joshua Kerievsky     編譯/透明

Copyright © 2002, Joshua Kerievsky, Industrial Logic, Inc. All Rights Reserved.

透明保留中文譯本一切權利

 

不同的類別隱藏在套件的內部、實作了共同的介面,但是客戶端卻直接實例化這些類別

將類別的建構函數設為非公開,並讓客戶端通過父類別的建構方法來得到它們的實例

動機

如果客戶端(client)需要知道每個具體類別的存在,那麼讓客戶端直接控制這些類別的實例化也是個不錯的選擇。但是,如果客戶端不想知道這些,又該怎麼辦呢?如果這些具體類別都被放在一個套件的內部,並且都實作了同一個介面,而這個介面又不太可能發生變化,那麼就應該把這些具體類別隱藏起來,讓套件外部的客戶端去使用父類別公開的建構方法(Creation Method),並通過建構方法得到滿足需要的實例。

這樣做的動機有幾點。首先,可以確保客戶端只能通過通用的介面來存取(access)不同的類別,以確保“分離介面與實作” [GoF];其次,可以將不必為外界所知的類別隱藏起來,從而減少一個 套件的“概念重量” [Bloch];第三,由於物件的建構都通過建構方法來進行,而建構方法的名稱可以更好地揭示建構過程的意圖,所以可以使實例的建構過程更容易為開發者所理解。

儘管有所有這些好處,還是有人對這個重整持保留態度。對於他們的疑惑,我做出了如下回應:

  1. 他們不喜歡讓父類別知道子類別的資訊,因為這會導致迴圈依賴——如果你建構了一個新的子類別或者對子類別的建構函數做了增改,就不得不在父類別中添加新的建構方法。不過我會告訴他們:這個重整發生的場景是一個套件,其中的子類別都實作同一個介面。這時他們就會閉嘴了。

  2. 他們不喜歡在父類別中把建構方法和其他實作方法混在一起。我並不擔心這個問題,除非建構方法讓我難以看清父類別的行為——如果真是這樣,我就會使用“粹取建構類別”(Extract Creation Class)的重整。

  3. 在原始程式碼編譯成目標程式碼之後,他們就不願意再做這個重整,因為使用目標程式碼的程式師是不能再添加或修改非公開的類別和建構方法的。對此我更加同情。如果 套件內部的可擴展性的確很重要,而用戶又得不到原始程式碼,我就不會把類別都封裝起來,而是會提供一個建構類別(Creation Class)來產生實例。

本文開始處的那幅圖簡單畫出了關聯資料庫映射過程中的一些物件關係。在採用這個重整之前,程式師們(包括我自己)有時會選錯了要實例化的子類別,或者用錯了參數(比如說,我們可能會呼叫一個接受 Java 內建的 int 類型作為參數的建構函數,而真正需要的卻是接受 Integer 物件作為參數的那個建構函數)。這個重整會把關於子類別的資訊封裝起來,客戶端只能從一個意義明確的地方得到子類別實例,從而減少了建構錯誤的機會。

溝通

重複

簡化

如果你希望客戶端程式碼只通過一個介面與你的類別溝通,那麼你就必須用程式碼來反映出自己的要求。公有的建構函數沒有任何幫助,因為客戶端通過公有建構函數就可以跟子類型耦合在一起。要達到你的要求,就需要把建構函數隱藏起來,然後通過父類別中的建構方法來產生物件,並且將建構方法的傳回類型定為所有子類別共同的介面或抽象類別。

使用這個重整的時候,重複不是問題。

如果你想讓客戶端只通過一個介面與所有的子類別打交道,那麼把這些類別公開只會把事情搞得更複雜:程式師們會直接實例化子類別,並把自己的程式碼和子類型耦合在一起。這種做法就好像在說:去擴展這些類別的介面吧,沒有關係。

如果不允許直接實例化這些子類別,只通過父類別的建構方法提供實例,那麼情況就簡單多了。

約束

這是根本的條件,因為在此重整完成之後,所有的客戶端程式碼都只能通過這個共同的介面來存取所有這些類別的實例。

過程[1]

  1. 在父類別中為每種實例(一個建構函數產生的實例稱為“一種”)編寫建構方法,建構方法的名字應該能清楚地說明自己的意圖。建構方法的返回類型應該是所有可建構物件共有的介面類型。讓建構方法去呼叫相應的建構函數。

  2. 選定一種實例,將所有對應於這種實例的建構函數替換成父類別中相對應的建構方法。

  3. 編譯、測試。

  4. 重複步驟 1~3,直到一個類別中的每種實例都通過建構方法來建構。

  5. 將這個類別的建構函數宣告為非公有(例如 protected 或者 default)。

  6. 編譯。

  7. 重複上面的步驟,直到所有的建構函數都變成非公有、所有的實例都可以並且只能通過建構方法來獲取。

範例

  1. 我們從一個比較小的類別體系開始,這個類別體系被放在 descriptors 套件堶情C這些類別在物件-關聯資料庫的映射中起輔助作用,可以把數據庫屬性轉換成實例變數。

package descripors;

public abstract class AttributeDescriptor {

protected AttributeDescriptor(…)

public class BooleanDescriptor extends AttributeDescriptor {

public BooleanDescriptor(…) {

super(…);

}

public class DefaultDescriptor extends AttributeDescriptor {

public DefaultDescriptor(…) {

super(…);

}

public class ReferenceDescriptor extends AttributeDescriptor {

public ReferenceDescriptor(…) {

super(…);

}

抽象類別 AttributeDescriptor 的建構函數是 protected 的,三個子類別的建構函數則是 public 的。由於三個子類別情況相似,所以我們只需注意 DefaultDescriptor 就可以了。首先,我們需要識別出 DefaultDescriptor 的建構函數建構的那一種實例,所以請看下面的客戶端程式碼:

protected List createAttributeDescriptors() {

Vector result = new Vector();

result.add(new DefaultDescriptor("remoteId", getClass(), Integer.TYPE));

result.add(new DefaultDescriptor("createdDate", getClass(), Date.class));

result.add(new DefaultDescriptor("lastChangedDate", getClass(), Date.class));

result.add(new ReferenceDescriptor("createdBy", getClass(),

User.class,RemoteUser.class));

result.add(new ReferenceDescriptor("lastChangedBy", getClass(),

User.class,RemoteUser.class));

result.add(new DefaultDescriptor("optimisticLockVersion",

getClass(), Integer.TYPE));

return result;

}

我看出來了:DefaultDescriptor 被用來表現 Integer 和 Date 之間的映射。它還可以用來映射其他類型,但是此刻我只能注意一種實例。所以,我先編寫一個建構方法來為 Integer 物件提供屬性描述:

public abstract class AttributeDescriptor {

public static AttributeDescriptor forInteger(...) {

return new DefaultDescriptor(...);

}

我把建構方法的返回類型規定為 AttributeDescriptor,因為我希望讓客戶端通過AttributeDescriptor這個介面與其子類別進行交流,從而使 descriptors 套件之外的客戶端無需知道這些子類別的存在。

如果你有“測試優先(test-first)”的編程習慣,那麼在開始這個重整之前,你應該首先編寫一段測試程式碼,從父類別的建構方法中獲取 AttributeDescriptor 的子類別實例,並判斷所得實例的類型是否正確。

  1. 現在,客戶端如果想產生 Integer 版本的 DefaultDescriptor,就必須呼叫父類別中的建構方法:

protected List createAttributeDescriptors() {

List result = new ArrayList();

result.add(AttributeDescriptor.forInteger("remoteId", getClass()));

result.add(new DefaultDescriptor("createdDate", getClass(), Date.class));

result.add(new DefaultDescriptor("lastChangedDate", getClass(), Date.class));

result.add(new ReferenceDescriptor("createdBy", getClass(), User.class,

RemoteUser.class));

result.add(new ReferenceDescriptor("lastChangedBy", getClass(),

 User.class,RemoteUser.class));

result.add(AttributeDescriptor.forInteger("optimisticLockVersion", getClass()));

return result;

}

  1. 編譯、測試,確保新的程式碼運轉正常。

  2. 現在,我繼續為 DefaultDescriptor 的建構函數能建構的其他種類的實例編寫建構方法。我又得到了另外的兩個建構方法:

public abstract class AttributeDescriptor {

public static AttributeDescriptor forInteger(...) {

return new DefaultDescriptor(...);

}

public static AttributeDescriptor forDate(...) {

return new DefaultDescriptor(...);

}

public static AttributeDescriptor forString(...) {

return new DefaultDescriptor(...);

        }

  1. 現在,將 DefaultDescriptor 的建構函數聲明為 protected:

public class DefaultDescriptor extends AttributeDescriptor {

protected DefaultDescriptor(…) {

super(…);

}

 }

  1. 編譯一下,一切盡在掌握。

  2. 現在,針對AttributeDescriptor的每個子類別,重複上面的步驟。完成以後,新的程式碼應該 :

封裝內嵌類別

JDK 中的 java.util.Collections 類別是“用建構方法封裝子類別”的一個典型範例。這個類別的作者 Joshua Bloch 需要為程式師們提供一種辦法來保證 Collection、 List、 Set 和 Map 的不可修改性和(或)同步性。一開始,他很聰明地用Decorator樣式來實作。但是,他沒有建構公開的java.util.Decorator類別並要求程式師們用它來裝飾自己的 Collections 子類別。他的做法是:將 Decorator 定義為 Collections 類別的非公開內嵌類別(Inner Class),並在Collections類別中設計了一組建構方法,程式師可以通過這些建構方法得到自己需要的、經過裝飾的容器。下面是 Collections 類別中的內嵌類別和建構方法的設計草圖:

請注意,java.util.Collections 類別甚至還包含了一個小小的內嵌類別繼承體系,其中所有的類別都是非公開的。每個內嵌類別都有一個相應的建構方法,這個方法接受一個容器,在其上進行裝飾,並用預先定義的常用介面類型(例如 List 或者 Set)返回裝飾後的實例。通過這個方案,程式師無須再去瞭解那麼多的類別,並且需要的功能也一點不少。同時, java.util.Collections 類別也是一個建構類別(Creation Class)的典型範例(參見“粹取建構類別”)。 


 

[1] mechanics一詞,以前我譯為“技巧”。但是看完《Refactoring》之後,愈加覺得“技巧”這個譯法不好。《英漢能源大詞典》中對mechanics一詞的解釋是:“n.力學,機械,例行手續”,我採用“例行手續”之意,譯為“過程”。請讀者指正。——譯者