Java 泛型複雜嗎?

石頭成在〈與 metavige 和 alexchen 對話 Java 語言〉這篇文章中,直言他對 Java 語言泛型的批評:

我不認為 Java 讓我們「慢工出細活」,我覺得它帶來的是「冗餘的複雜性」。就算以 C++ 的觀點來看 Java 程式碼,我仍覺得 Java 程式碼有許多不必要的複雜度。Java 把類別變成施加在程序員身上的束縛,而是不是幫助我們進行抽象化資料處理的工具。

我個人認為,所謂「更強化的靜態型態」是跟 C++ 樣板相比, Java 泛型比起 C++ 樣板是在走回頭路。就我到目前為止的 Java 使用經驗來看,我幾乎以為泛型只是 Java 專門用來重新設計容器類別的特殊語法。在那以外的場所,你大概不會想用泛型來重構你的程式碼。

metavige 就說會想用 Strategy Pattern 來重構我在〈從 C++ Template 到 Java Generic,一步一步來〉舉的例子,而不會用泛型。但是泛型難道不是用來處理這個問題的直覺想法嗎? Java 沒有足夠理由說服我們不要用泛型來做,但是用泛型來做… 呃,似乎更困難。

石頭成用泛型來重構程式碼,是不是處理他的問題之直覺想法?我想石頭成比較偏好動態型態語言的自由,比較不欣賞「更強化的靜態型態」的做法。或許是因為 C++ 用 template 實作不會受到參數型別類型的限制,自然會讓他比較想用泛型來重構程式,以去除資料型態與演算邏輯的相依性。然而,看過石頭成所舉的實例,同人認為用泛型來實作的想法未必是直覺的做法。

表面上看起來好像實作泛型可以讓某一段程式碼重複使用,但 Java 在泛型的限制,也增加他重構程式碼的困難度與複雜度。這麼說來,假如石頭成的想法是正確的,用 Java 的泛型來重構程式碼,只會讓程式員沒事自討苦吃。然而,同人在仔細研究他的程式碼之後,發現可以用更簡潔的方式來使用 Java 的泛型。不過,還是讓我們先來看看石頭成的程式碼:

Cx.java

public class Cx<DataType extends IDataType<ReturnType>, ReturnType> {
	private DataType data;

	public Cx(DataType v) {
		data = v;
	}

	public ReturnType getData() {
		return data.value();
	}
}

IDataType.java

public interface IDataType<ReturnType> {
	     public ReturnType value();  
}

N.java

public class N implements IDataType<Integer> {
	private Integer n;

	public N() {
		n = 0;
	}

	public N(Integer v) {
		n = v;
	}

	public Integer value() {
		return -n;
	}
}

M.java

public class M implements IDataType<Integer> {
	private Integer m;

	public M() {
		m = 0;
	}

	public M(Integer v) {
		m = v;
	}

	public Integer value() {
		return m * 10;
	}
}

S.java

public class M implements IDataType<Integer> {
	private Integer m;

	public M() {
		m = 0;
	}

	public M(Integer v) {
		m = v;
	}

	public Integer value() {
		return m * 10;
	}
}

Main.java

public class Main {
	public static void main(String[] args) {
		N n = new N(1);
		Cx<N, Integer> cn = new Cx<N, Integer>(n);
		System.out.println(cn.getData());

		M m = new M(1);
		Cx<M, Integer> cm = new Cx<M, Integer>(m);
		System.out.println(cm.getData());

		S s = new S("hello");
		Cx<S, String> cs = new Cx<S, String>(s);
		System.out.println(cs.getData());
	}
}

問題

石頭成認為他的程式存在兩個問題。第一個問題是原來 N、M、S 三個類別沒有繼承關係,但為了符合 Java 泛型的特性,在重構之後這三個類別卻實現了同一個界面。石頭成原來認為用泛型解決演算法重複問題時,應該依然保持那三個類別之間無關性,但重構之後卻發現 Java 的泛型並不能保持那三個類別的無關性。雖然在這個範例中影響不大,但他還是認為 Java 在泛型方面的表現,只能算是勉強及格。

第二個問題是他不知道如何在 Cx 中增加預設建構子,來初始化 data 成員變數。如果 Java 直接 new 一個參數化型別的實例,在下面的程式碼的第 5 行的地方,會出現「Unexpected type」的編譯錯誤訊息。但在 C++ 的 template 中,這種寫法卻是可以被接受的,令他實在是不能理解,JVM 為什麼不能自己反射參數化型別來建立物件實體,而要用這個限制來難倒程式員。

public class Cx<DataType extends IDataType<ReturnType>, ReturnType> {  
  private DataType data;  
   
  public Cx() {  
    data = new DataType();
  }  
   
  public Cx(DataType v) {  
    data = v;  
  }  
   
  public ReturnType getData() {  
    return data.value();  
  }  
}

要對石頭成提出的這兩個問題有比較精確的理解,不能不對 concept 的概念有所認識。在泛型程式設計中,concept 指的是一組型別,它們提供某些語法與語意操作的支援。concept 與抽象基礎類別有關,但 concept 並不需要子型別的關係。

在 C++,concept 這個術語只是簡單地命名並簡單描述參數型別的需求,而符合此 concept 的型別,一般被稱為 model。我們不能用程式語言明確地表達 concept,只能表示某種參數型別的物件會嘗試執行什麼操作,以及期待的工作結果;也就是讓它可以正常的編譯。一般來說,Java 及 C# 的泛型與 C++ 的泛型有點像,只不過它們用 interface 來扮演 concept 的角色。

但這樣一來,符合的參數型別就只能是實作某個明確的界面,當然 concept 顯然會比 interface 更有彈性,因為 concept 可明確代替一組型別,而不只是一個實作某 interface 的類別;而且運用隱式地定義自動 concept,可以讓參數型別同時使用內建型別或其它為了非特殊用途的型別。

相依性

從以上的觀念來看,用 C++ 的 template 來實作泛型程式設計,的確比 Java 的 interface 有更大的彈性。不過,即使是使用 concept 來表示參數型別,在這組支援泛型實作的型別中,其實也必須支援 concept 定義的基本操作。就廣義來說,model 必須依存於 concept 所定義的界面。相對於物件導向的 LSP 原則,子類別應該可以替換父類別,concept 與 model 之間也有類似的關係。如果 Cb 是 Ca 的強化 concept;也就是定義更多延伸 concept 所需的操作,那麼 Cb 的 model 則必然是 Ca 的 model。

於是,我們看到石頭成的程式,N、M、S 都實作相同的界面 IDataType,這將不會是個問題。其實改動 N,並不會影響到 M、或是 S,因為三者並不直接相依,除非它們共同引用的界面變動才會影響到其它實作相同界面的程式。事實上,界面完全不牽涉到任何實作,除非界面定義得不夠完整,石頭成所擔心的問題才會發生。但如果是這樣,那將不是程式語言的問題,而是設計本身的問題。

複雜性

當然,我們也不能否認石頭成提到的另一個問題,是實際存在的事實。Java 使用 interface 來實作泛型確實有比 C++ 的型別 concept 有不足之處。以 interface 來抽象化參數型別沒辦法做到 default construction 這種型別 concept,因為它不是類別,沒有辦法自己生成物件實體,必須要靠外界呼叫者初始化參數型別物件,再傳給泛型物件才能正常工作。

再加上 Java 無法在執行期的時候取得參數型別的關係,我們無法直接以反射機制產生出參數型別的建構式來產生物件實體。雖然繞個圈,我們還是可以用另一種反射方式達到目的,但這樣會使得程式碼變得更複雜而且難以理解,實在是會令人望之卻步。那麼使用 Java 泛型,有沒有比較簡單的方法可以解決這個問題呢?

C++

DataType data; // default construction

Java

//data = new DataType();
ParameterizedType pt = (ParameterizedType) this.getClass().getGenericSuperclass();
data = ((Class) pt.getActualTypeArguments()[0]).newInstance(); 

解決方法

其實解決的方法並不難,雖然我們在 Cx 仍然很難用參數型別反射預設建構式來建立物件實體,但如果你對同人過去發表的〈降低資料存取的複雜性〉還有印象的話,你可以從這篇文章找到解決石頭成問題的方向。我們可以在這篇文章看到,在還沒使用 Java 的泛型之前,UserDataAccessorImpl 有預設建構子可供初始化 UserDataAccessor 的物件實體,但使用 Java 的泛型重構之後,泛型版本的 HibernateDataAccessor 卻只提供指定參數型別 BizObject 的 Class 建構式。所以使用同樣的原理,我們應該也可以解決石頭成的第二個問題。

	public Cx(Class<? extends IDataType<ReturnType> > dataTypeClazz) throws Exception {
		data = dataTypeClazz.newInstance();
	}

或許有人可能會說我改變了石頭成第二個問題的需求,他要的不是以 DataType 的 Class 當參數的建構式,而是不需要任何參數的預設建構式。但其實希望在 Cx 加入預設建構式並不是需求,而是為了解決問題的需求所做的假設,要認清需求我們必須質疑存在這個假設背後的問題。

當我們想到 Cx 為什麼需要預設建構式的時候會發現,有些時候因為要跨越遠端界面呼叫的藩離,可能沒辦法由呼叫者產生參數型別的物件傳給 Cx,而要由 Cx 自己初始參數型別物件。但通常傳入參數型別物件的 Class 是可行的,因為運用 Java 的反射機制,傳遞 Class 就好像傳遞 String 一樣簡單。

更直覺的做法

寫到這裡,石頭成的問題應該是解決了。不過回到這篇文章同人一開始所提到的,石頭成所舉的實例,我認為用泛型來實作的想法未必是直覺的做法。那麼同人認為更直覺的做法是什麼?其實我不會完全用泛型來解決石頭成的問題,而是運用多型來封裝演算法的差異性,再搭配泛型來強化參數型別的一致性。這樣我只需要修改石頭成的 Cx 類別與 Main 主程式碼,如下面這段程式碼所示,同人認為是更為簡單而直覺的做法。

Cx.java

public class Cx<ReturnType> {
	IDataType<ReturnType> data;
	
	public Cx(Class<? extends IDataType<ReturnType> > dataTypeClazz) throws Exception {
		data = dataTypeClazz.newInstance();
	}
	
	public Cx(IDataType<ReturnType> data) {
		this.data = data;
	}
	
	public ReturnType getData() {
		return data.value();
	}
}

Main.java

public class Main {
	public static void main(String[] args) {
		N n = new N(1);
		Cx<Integer> cn = new Cx<Integer>(n);
		System.out.println(cn.getData());

		M m = new M(1);
		Cx<Integer> cm = new Cx<Integer>(m);
		System.out.println(cm.getData());

		S s = new S("hello");
		Cx<String> cs = new Cx<String>(s);
		System.out.println(cs.getData());
	}
}

上面這一段程式碼,將類別 Cx 當中的 data 的型別,由 DataType 參數型別改成 IDataType 的多型界面。這樣一來,data 的抽象概念就從參數型別轉移到多型界面,此界面封裝了不同的資料型態行為的抽象概念。如此一來,Cx 可減少了一個參數型別,程式碼也變得簡明易懂。從 Main 呼叫端的程式碼就可以發現,少了一個參數型別,程式碼也變得簡潔許多。

這段程式碼也讓我們發現泛型可以強化多型界面的資料型別。假如沒有泛型,我們就只能在 DataType 的 value() 回傳值定義成可以包容各種資料的型別;例如 String 或 Object,然後再運用資料轉換或強制轉型的方式取得我們需要的資料格式,但這些方法很容易出錯,而且會造成程式開發不小的負擔。當我們運用泛型來強化多型界面的型別,程式員就不用在這方面傷腦筋了,而且設計的完整概念也更容易維持。

泛型與多型的差別

就設計的觀點來看,參數型別並不是真正的實體型別,如果沒有 default construction 的 concept,我們沒辦法拿它來產生任何實體型別的實體。多型的解決方案,是運用型別來抽象化型別,與泛型用型別的 concept 來抽象化型別是不同的抽象觀點。後者如果想在 Java 的泛型來取代多型的解法,其實是會讓程式員遇到很多麻煩的。

其實即使換成 C++,同人不會把不同資料型態以 DataType 參數型別抽象化。雖然 C++ 的 template 真的很有彈性,但那並不意味著使用它不需要為這些彈性付出代價。例如,使用 C++ template 編程,在除錯上或處理編譯的錯誤會比沒有使用 template 還要困難許多。

尤其是 C++ template 的編輯錯誤訊息,常讓人搞不清楚錯誤在那裡,往往要花費很大的工夫才找到程式員無心犯下微不足道的錯誤。所以, C++ 的 template 給予程式員相當大的彈性,但過度地使用它,也很容易讓程式碼產生不必要的複雜度。

依同人的經驗顯示,許多表面上泛型似乎可以用來解決的多型問題,但一旦用泛型來解,常會因為抽象化層次不同的隔閡而使問題更為複雜。

泛型的原理是引用 concept 來代表各種可供參數化的型別,而多型則是使用抽象類別或界面來封裝具體類別。兩者的抽象化的觀點不一樣,一種是型別的集合對個別型別的關係,另一種則是型別與型別的關係。

前者的關係我們稱為某些型別是某個 concept 的 model,而後者的關係則是型別之間的一般化或實現關係。兩者在本質上的概念是不一樣的,因此當我們想用 concept 來代替 inheritance,或是用 inheritance 來取代 concept,最後都必然會讓我們遇到實作與概念上的隔閡。

當一個類別 A 使用到泛型類別 B 時,我們就必須指明使用 B 所需要的參數型別,要不然就必須由使用到類別 A 的類別 C 指定需要的參數型別。所以如果類別 A 想要不侷限在某種資料型別,那麼使用泛型類別 B 的代價就必須是讓類別 A 也變成泛型物件。所以大量使用泛型的結果,會使你一層又一層地使用參數型別,這當然是比多型複雜許多。

此外,以泛型來取代多型更麻煩的是,不同參數型別的泛型物件是完全不同的類別,它們不像多型可以用同樣的方式來處理不同資料型態。所以雖然表面上泛型省去繼承類別或實作界面的工作,看起來比多型的解決方法有更大的自由度。但這也意味著泛型可能會缺少對問題語意封裝抽象概念,只是站在實作觀點以一致性的方法來滿足需求。

程式員不該為了貪圖一致性而忽略多樣性,而是應該按照實際的需要來解決問題。所以重點不是 Java 泛型是否複雜,而是我們能不能運用設計使它變得更簡單。語言的限制未必是加諸在程式員身上的束縛,而可能是讓我們進行思考以發揮設計創意的機會呀。

Please follow and like us:
分類: 分析設計建模, 品質文化, 問題解決, 學習, 編程技巧, 職場, 設計原則, 軟體開發。這篇內容的永久連結

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *