程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> 關於JAVA >> Java理論與實踐: 使用通配符簡化泛型使用

Java理論與實踐: 使用通配符簡化泛型使用

編輯:關於JAVA

自從泛型被添加到 JDK 5 語言以來,它一直都是一個頗具爭議的話題。一部 分人認為泛型簡化了編程,擴展了類型系統從而使編譯器能夠檢驗類型安全;另 外一些人認為泛型添加了很多不必要的復雜性。對於泛型我們都經歷過一些痛苦 的回憶,但毫無疑問通配符是最棘手的部分。

通配符基本介紹

泛型是一種表示類或方法行為對於未知類型的類型約束的方法,比如 “不管 這個方法的參數 x 和 y 是哪種類型,它們必須是相同的類型”,“必須為這些 方法提供同一類型的參數” 或者 “foo() 的返回值和 bar() 的參數是同一類 型的”。

通配符 — 使用一個奇怪的問號表示類型參數 — 是一種表示未知類型的類 型約束的方法。通配符並不包含在最初的泛型設計中(起源於 Generic Java (GJ)項目),從形成 JSR 14 到發布其最終版本之間的五年多時間內完成設計 過程並被添加到了泛型中。

通配符在類型系統中具有重要的意義,它們為一個泛型類所指定的類型集合 提供了一個有用的類型范圍。對泛型類 ArrayList 而言,對於任意(引用)類 型 T,ArrayList<?> 類型是 ArrayList<T> 的超類型(類似原始 類型 ArrayList 和根類型 Object,但是這些超類型在執行類型推斷方面不是很 有用)。

通配符類型 List<?> 與原始類型 List 和具體類型 List<Object> 都不相同。如果說變量 x 具有 List<?> 類型,這 表示存在一些 T 類型,其中 x 是 List<T>類型,x 具有相同的結構,盡 管我們不知道其元素的具體類型。這並不表示它可以具有任意內容,而是指我們 並不了解內容的類型限制是什麼 — 但我們知道存在 某種限制。另一方面,原 始類型 List 是異構的,我們不能對其元素有任何類型限制,具體類型 List<Object> 表示我們明確地知道它能包含任何對象(當然,泛型的類 型系統沒有 “列表內容” 的概念,但可以從 List 之類的集合類型輕松地理解 泛型)。

通配符在類型系統中的作用部分來自其不會發生協變(covariant)這一特性 。數組是協變的,因為 Integer 是 Number 的子類型,數組類型 Integer[] 是 Number[] 的子類型,因此在任何需要 Number[] 值的地方都可以提供一個 Integer[] 值。另一方面,泛型不是協變的, List<Integer> 不是 List<Number> 的子類型,試圖在要求 List<Number> 的位置提供 List<Integer> 是一個類型錯誤。這不算很嚴重的問題 — 也不是所有人 都認為的錯誤 — 但泛型和數組的不同行為的確引起了許多混亂。

我已使用了一個通配符 — 接下來呢?

清單 1 展示了一個簡單的容器(container)類型 Box,它支持 put 和 get 操作。 Box 由類型參數 T 參數化,該參數表示 Box 內容的類型, Box<String> 只能包含 String 類型的元素。

清單 1. 簡單的泛型 Box 類型

public interface Box<T> {
   public T get();
   public void put(T element);
}

通配符的一個好處是允許編寫可以操作泛型類型變量的代碼,並且不需要了 解其具體類型。例如,假設有一個 Box<?> 類型的變量,比如清單 2 unbox() 方法中的 box 參數。unbox() 如何處理已傳遞的 box?

清單 2. 帶有通配符參數的 Unbox 方法

public void unbox(Box<?> box) {
   System.out.println(box.get());
}

事實證明 Unbox 方法能做許多工作:它能調用 get() 方法,並且能調用任 何從 Object 繼承而來的方法(比如 hashCode())。它惟一不能做的事是調用 put() 方法,這是因為在不知道該 Box 實例的類型參數 T 的情況下它不能檢驗 這個操作的安全性。由於 box 是一個 Box<?> 而不是一個原始的 Box, 編譯器知道存在一些 T 充當 box 的類型參數,但由於不知道 T 具體是什麼, 您不能調用 put() 因為不能檢驗這麼做不會違反 Box 的類型安全限制(實際上 ,您可以在一個特殊的情況下調用 put():當您傳遞 null 字母時。我們可能不 知道 T 類型代表什麼,但我們知道 null 字母對任何引用類型而言是一個空值 )。

關於 box.get() 的返回類型,unbox() 了解哪些內容呢?它知道 box.get() 是某些未知 T 的 T,因此它可以推斷出 get() 的返回類型是 T 的擦除 (erasure),對於一個無上限的通配符就是 Object。因此清單 2 中的表達式 box.get() 具有 Object 類型。

通配符捕獲

清單 3 展示了一些似乎應該 可以工作的代碼,但實際上不能。它包含一個 泛型 Box、提取它的值並試圖將值放回同一個 Box。

清單 3. 一旦將值從 box 中取出,則不能將其放回

public void rebox(Box<?> box) {
   box.put(box.get());
}
Rebox.java:8: put(capture#337 of ?) in Box<capture#337 of ?> cannot be applied
  to (java.lang.Object)
   box.put(box.get());
    ^
1 error

這個代碼看起來應該可以工作,因為取出值的類型符合放回值的類型,然而 ,編譯器生成(令人困惑的)關於 “capture#337 of ?” 與 Object 不兼容的 錯誤消息。

“capture#337 of ?” 表示什麼?當編譯器遇到一個在其類型中帶有通配符 的變量,比如 rebox() 的 box 參數,它認識到必然有一些 T ,對這些 T 而言 box 是 Box<T>。它不知道 T 代表什麼類型,但它可以為該類型創建一個 占位符來指代 T 的類型。占位符被稱為這個特殊通配符的捕獲(capture)。這 種情況下,編譯器將名稱 “capture#337 of ?” 以 box 類型分配給通配符。 每個變量聲明中每出現一個通配符都將獲得一個不同的捕獲,因此在泛型聲明 foo(Pair<?,?> x, Pair<?,?> y) 中,編譯器將給每四個通配符的 捕獲分配一個不同的名稱,因為任意未知的類型參數之間沒有關系。

錯誤消息告訴我們不能調用 put(),因為它不能檢驗 put() 的實參類型與其 形參類型是否兼容 — 因為形參的類型是未知的。在這種情況下,由於 ? 實際 表示 “?extends Object” ,編譯器已經推斷出 box.get() 的類型是 Object ,而不是 “capture#337 of ?”。它不能靜態地檢驗對由占位符 “capture#337 of ?” 所識別的類型而言 Object 是否是一個可接受的值。

捕獲助手

雖然編譯器似乎丟棄了一些有用的信息,我們可以使用一個技巧來使編譯器 重構這些信息,即對未知的通配符類型命名。清單 4 展示了 rebox() 的實現和 一個實現這種技巧的泛型助手方法(helper):

清單 4. “捕獲助手” 方法

public void rebox(Box<?> box) {
   reboxHelper(box);
}
private<V> void reboxHelper(Box<V> box) {
   box.put(box.get());
}

助手方法 reboxHelper() 是一個泛型方法,泛型方法引入了額外的類型參數 (位於返回類型之前的尖括號中),這些參數用於表示參數和/或方法的返回值 之間的類型約束。然而就 reboxHelper() 來說,泛型方法並不使用類型參數指 定類型約束,它允許編譯器(通過類型接口)對 box 類型的類型參數命名。

捕獲助手技巧允許我們在處理通配符時繞開編譯器的限制。當 rebox() 調用 reboxHelper() 時,它知道這麼做是安全的,因為它自身的 box 參數對一些未 知的 T 而言一定是 Box<T>。因為類型參數 V 被引入到方法簽名中並且 沒有綁定到其他任何類型參數,它也可以表示任何未知類型,因此,某些未知 T 的 Box<T> 也可能是某些未知 V 的 Box<V>(這和 lambda 積分中 的 α 減法原則相似,允許重命名邊界變量)。現在 reboxHelper() 中的表達 式 box.get() 不再具有 Object 類型,它具有 V 類型 — 並允許將 V 傳遞給 Box<V>.put()。

我們本來可以將 rebox() 聲明為一個泛型方法,類似 reboxHelper(),但這 被認為是一種糟糕的 API 設計樣式。此處的主要設計原則是 “如果以後絕不會 按名稱引用,則不要進行命名”。就泛型方法來說,如果一個類型參數在方法簽 名中只出現一次,它很有可能是一個通配符而不是一個命名的類型參數。一般來 說,帶有通配符的 API 比帶有泛型方法的 API 更簡單,在更復雜的方法聲明中 類型名稱的增多會降低聲明的可讀性。因為在需要時始終可以通過專有的捕獲助 手恢復名稱,這個方法讓您能夠保持 API 整潔,同時不會刪除有用的信息。

類型推斷

捕獲助手技巧涉及多個因素:類型推斷和捕獲轉換。Java 編譯器在很多情況 下都不能執行類型推斷,但是可以為泛型方法推斷類型參數(其他語言更加依賴 類型推斷,將來我們可以看到 Java 語言中會添加更多的類型推斷特性)。如果 願意,您可以指定類型參數的值,但只有當您能夠命名該類型時才可以這樣做 — 並且不能夠表示捕獲類型。因此要使用這種技巧,要求編譯器能夠為您推斷 類型。捕獲轉換允許編譯器為已捕獲的通配符產生一個占位符類型名,以便對它 進行類型推斷。

當解析一個泛型方法的調用時,編譯器將設法推斷類型參數它能達到的最具 體類型。 例如,對於下面這個泛型方法:

public static<T> T identity(T arg) { return arg };

和它的調用:

Integer i = 3;
System.out.println(identity(i));

編譯器能夠推斷 T 是 Integer、Number、 Serializable 或 Object,但它 選擇 Integer 作為滿足約束的最具體類型。

當構造泛型實例時,可以使用類型推斷減少冗余。例如,使用 Box 類創建 Box<String> 要求您指定兩次類型參數 String:

Box<String> box = new BoxImpl<String>();

即使可以使用 IDE 執行一些工作,也不要違背 DRY(Don't Repeat Yourself)原則。然而,如果實現類 BoxImpl 提供一個類似清單 5 的泛型工廠 方法(這始終是個好主意),則可以減少客戶機代碼的冗余:

清單 5. 一個泛型工廠方法,可以避免不必要地指定類型參數

public class BoxImpl<T> implements Box<T> {
   public static<V> Box<V> make() {
     return new BoxImpl<V>();
   }
   ...
}

如果使用 BoxImpl.make() 工廠實例化一個 Box,您只需要指定一次類型參 數:

Box<String> myBox = BoxImpl.make();

泛型 make() 方法為一些類型 V 返回一個 Box<V>,返回值被用於需 要 Box<String> 的上下文中。編譯器確定 String 是 V 能接受的滿足類 型約束的最具體類型,因此此處將 V 推斷為 String。您還可以手動地指定 V 的值:

Box<String> myBox = BoxImpl.<String>make();

除了減少一些鍵盤操作以外,此處演示的工廠方法技巧還提供了優於構造函 數的其他優勢:您能夠為它們提高更具描述性的名稱,它們能夠返回命名返回類 型的子類型,它們不需要為每次調用創建新的實例,從而能夠共享不可變的實例 (參見 參考資料 中的 Effective Java, Item #1,了解有關靜態工廠的更多優 點)。

結束語

通配符無疑非常復雜:由 Java 編譯器產生的一些令人困惑的錯誤消息都與 通配符有關,Java 語言規范中最復雜的部分也與通配符有關。然而如果使用適 當,通配符可以提供強大的功能。此處列舉的兩個技巧 — 捕獲助手技巧和泛型 工廠技巧 — 都利用了泛型方法和類型推斷,如果使用恰當,它們能顯著降低復 雜性。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved