翻譯 | Java 中的變型(Variance)

廣州蘆葦科技Java開發團隊發表於2019-06-23

原文自國外Java社群javacodegeeks,作者為 George Aristy,傳送門

前幾天,我在偶然的情況下看到一篇文章,講述了文章作者在使用了 GO 8個多月後對其的利弊看法。在使用 GO 工作了相當長的一段時間的我來說,基本上同意作者說的點。

儘管是這種序言,但這篇文章時關於 Java 的變型的,目標是重新理解什麼是變型,以及在 Java 實現中的一些細微差別。

什麼是變型?

維基上是這樣描述變型的:

所謂的變型是指如何根據組成型別之間的子型別關係,來確定更復雜的型別之間的子型別關係。

“更復雜的型別”在這裡是指諸如容器、函式等高階別的結構。因此,變型是關於由通過型別層次結構(Type Hierarchy)連線的引數組成的容器及函式之間的賦值相容。它允許引數和子型別多型性的安全整合。例如,是否可以將一個方法中返回的cat 列表賦值到型別為 “list of animals” 的變數中?我能否一將奧迪汽車的物件列表傳遞給一個接受 Cars 列表的方法當中?

在 Java,是定義在使用點變型(use-site)當中。

變型的4種型別

在維基中的闡述中,型別構造器指:

  • 協變(Covariant):接受子型別不接受超型別
  • 逆變(Contravariant):接受超型別不接受子型別
  • 雙變(Bivariant):同時接受子型別和超型別
  • 不可變(Invariant):不接受子型別和超型別

(顯然,宣告的型別引數在所有情況下都是可以接受的)

Java 中的不可變性(Invariance)

使用點變型在型別引數中必須不設定邊界。

如果 AB 的其中一個超型別,那麼 GenericType<A> 並不是 GenericType<B> 的超型別,反之亦然。

這表示兩種型別彼此沒有聯絡,並且在任何情況下都無法轉換成對方。

不變容器

在 Java 中,不變數可能是你遇到過的第一個,並且是最直觀的泛型示例。正如所期望的,型別引數的方法是可使用的。引數型別的所有方法都是可訪問的。

但它們無法互換:

// 型別層級:Person :> Joe :> JoeJr
List<Person> p = new ArrayList<Joe>(); // 編譯錯誤
List<Joe> j = new ArrayList<Person>(); // 編譯錯誤
複製程式碼

但能夠新增物件:

// 型別層級:Person :> Joe :> JoeJr
List<Person> p = new ArrayList<>();
p.add(new Person()); // ok
p.add(new Joe()); // ok
p.add(new JoeJr()); // ok
複製程式碼

也能夠讀取到:

// 型別層級:Person :> Joe :> JoeJr
List<Joe> joes = new ArrayList<>();
Joe j = joes.get(0); // ok
Person p = joes.get(0); // ok
複製程式碼

Java 中的協變

使用點變型必須對型別引數有一個公開的下界。

如果 BA 的子型別,那麼 GenericType<B>GenericType<? extends A> 的子型別。

Java 中的陣列一直是協變的

在 Java 1.5 引入泛型之前,陣列是唯一可用的泛型容器。它們一直具有協變性,例如,Integer[]Object[] 的子型別。編譯器允許你將 Integer[] 傳遞給接收 Object[] 的方法中。如果方法插入一個 Integer 的超型別, ArrayStoreException 異常會在執行時丟擲。協變泛型型別規則在編譯時實現了此類檢查,在第一時間防止錯誤的發生。

public static void main(String... args) {
  Number[] numbers = new Number[]{1, 2, 3, 4, 5};
  trick(numbers);
}
 
private static void trick(Object[] objects) {
  objects[0] = new Float(123);  // ok
  objects[1] = new Object();  // ArrayStoreException 在執行時丟擲
}
複製程式碼

協變容器

Java 允許子型別(協變)泛型型別,但是它根據最小驚訝原則(POLA)限制了這些泛型型別怎樣做到“流入和流出”。換而言之,返回型別引數值的方法是可訪問的,而具有型別引數輸入引數的方法是不可訪問的。

你可以將超型別替換為子型別:

// 型別層級:Person :> Joe :> JoeJr
List<? extends Joe> = new ArrayList<Joe>(); // ok
List<? extends Joe> = new ArrayList<JoeJr>(); // ok
List<? extends Joe> = new ArrayList<Person>(); // 編譯錯誤
複製程式碼

從容器中讀取也很直觀:

// 型別層級:Person :> Joe :> JoeJr
List<? extends Joe> joes = new ArrayList<>();
Joe j = joes.get(0); // ok
Person p = joes.get(0); // ok
JoeJr jr = joes.get(0); //
複製程式碼

但不允許跨層寫入(違反直覺),以預防陣列陷阱。例如,在下面的例子中,List<Joe> 的呼叫者/擁有者會感到驚訝如果其他帶有協變引數 List<? extends Person> 的方法新增一個 Jill 物件。

// 型別層級:Person :> Joe :> JoeJr
List<? extends Joe> joes = new ArrayList<>();
joes.add(new Joe());  // 編譯錯誤 (你不清楚哪種 Joe 的超型別在列表中)
joes.add(new JoeJr()); // 編譯錯誤 (同上)
joes.add(new Person()); // 編譯錯誤
joes.add(new Object()); // 編譯錯誤
複製程式碼

Java 中的逆變

使用點變型必須對型別引數有一個公開的界。

如果 AB 的超型別,那麼 GenericType<A>GenericType<? extends B> 的超型別。

逆變容器

逆變容器的行為和常識相反:與協變容器相反,訪問具有型別引數返回值的方法是不可行的,而訪問具有型別引數入參的方法是可行的:

你可以將子型別替換為超型別:

// 型別層級:Person :> Joe :> JoeJr
List<? super Joe> joes = new ArrayList<Joe>();  // ok
List<? super Joe> joes = new ArrayList<Person>(); // ok
List<? super Joe> joes = new ArrayList<JoeJr>(); // 編譯錯誤
複製程式碼

無法在讀取時捕獲特定型別:

// 型別層級:Person :> Joe :> JoeJr
List<? super Joe> joes = new ArrayList<>();
Joe j = joes.get(0); // 編譯錯誤 (能夠為 Object 或者 Person)
Person p = joes.get(0); // 編譯錯誤 (同上)
Object o = joes.get(0); // 允許,因為在 Java everything IS-A Object
複製程式碼

可以新增“下界”的子型別:

// 型別層級:Person :> Joe :> JoeJr
List<? super Joe> joes = new ArrayList<>();
joes.add(new JoeJr()); // 允許
複製程式碼

但你不能新增超型別:

// 型別層級:Person :> Joe :> JoeJr
List<? super Joe> joes = new ArrayList<>();
joes.add(new Person()); // 編譯錯誤
joes.add(new Object()); // 編譯錯誤
複製程式碼

雙變型別

使用點變型必須在型別引數中宣告無界萬用字元

具有無界萬用字元的泛型型別是同一泛型型別的所有有界變體的超型別。例如,GenericType<?>GenericType<String> 的超型別。由於無界型別是 hierarchy 型別的根,因此,對於它的引數型別,它只能訪問繼承自 java.lang.Object 的方法。

GenericType<?> 視為 GenericType<Object>

N型引數結構的變型

Java 允許使用協變返回型別和異常型別重寫方法:

interface Person {
  Person get();
  void fail() throws Exception;
}
 
interface Joe extends Person {
  JoeJr get();
  void fail() throws IOException;
}
 
class JoeImpl implements Joe {
  public JoeJr get() {} // 重寫
  public void fail() throws IOException {} // 重寫
}
複製程式碼

但是試圖用協變引數覆蓋方法只會導致過載:

interface Person {
  void add(Person p);
}
 
interface Joe extends Person {
  void add(Joe j);
}
 
class JoeImpl implements Joe {
  public void add(Person p) {}  // 過載
  public void add(Joe j) {} // 過載
 }
複製程式碼

結語

變型為 Java 帶來了額外的複雜性。雖然圍繞變型的型別規則很容易理解,但是關於型別引數方法的可訪問性規則是違反常識的。理解它們不僅僅要達到“顯而易見” – 需要停下來來思考當中的邏輯。

然而,我的日常經驗是告訴我,這些細微的差別通常都不礙事:

  • 我一直沒試過必須宣告一個逆變引數的例項,而且我也很少遇到它們(儘管它們確實存在)
  • 協變引數視似乎更常見一些,但幸運的是他們也更容易推理出來

考慮到子型別是物件導向程式設計中其中一種基本的技術,而變型就是其最大的一個優點。

結論:變型在我日常程式設計中提供適當的收益,特別是當需要與子型別相容的時候(這在物件導向程式設計中很常見)。


小喇叭

廣州蘆葦科技Java開發團隊

蘆葦科技-廣州專業網際網路軟體服務公司

抓住每一處細節 ,創造每一個美好

關注我們的公眾號,瞭解更多

想和我們一起奮鬥嗎?lagou搜尋“ 蘆葦科技 ”或者投放簡歷到 server@talkmoney.cn 加入我們吧

翻譯 | Java 中的變型(Variance)

關注我們,你的評論和點贊對我們最大的支援

相關文章