Java 泛型程式設計的注意事項

codemee

codemee

Posted on January 2, 2023

Java 泛型程式設計的注意事項

Java 提供的泛型功能雖然很好用, 不過使用上如果不小心, 就可能會在編譯或是執行時遇到奇奇怪怪的錯誤, 尤其是使用過其他動態語言, 像是使用過 Python 的人, 很可能會對編譯錯誤的訊息感到困惑, 以下先說明泛型機制是如何達成的, 藉此就能瞭解幾個會發生問題的例子。

泛型機制

請先看以下這個簡單的程式:

class A {
    int id;
    public A(int id) {
        this.id = id;
    }
}

public class Test0 {
  public static void main(String[] args) {
    A a1 = new A(20);
    int i = a1.id;
    System.out.println(i);
  }
} 
Enter fullscreen mode Exit fullscreen mode

如果我們希望同樣以 A 類別建立的物件中, 有的物件的 id 成員可以是整數、而有的可以是字串, 該怎麼達成呢?

在 Java 未提供泛型機制的時代, 最簡單的作法就是把 id 成員的型別改成兩種資料型別的共同祖先, 以包裝 int 的 Integer 和代表字串的 String 來說, 他們的共同祖先就是 Object, 並在程式必要的地方強制轉型, 像是這樣:

class A {
    Object id;
    public A(Object id) {
        this.id = id;
    }
}

public class Test1 {
  public static void main(String[] args) {
    A a1 = new A(20);
    int i = (Integer)a1.id;
    System.out.println(i);
    A a2 = new A("hello");
    String s = (String)a2.id;
    System.out.println(s);
  }
} 
Enter fullscreen mode Exit fullscreen mode

由於現在 id 成員是 Object 類別, 所以不管你丟整數還是字串都可以, 只是在取出成員的時候, 要記得強制轉型回正確的型別。

這樣的作法雖然可以正確運作, 不過需要人工處理強制轉型的地方很容易疏忽, 如果可以由編譯器自動化處理, 自然就可以免除這樣的麻煩與問題。

將型別變成參數的泛型類別

前述需求 Java 看到了, 所以導入了泛型的機制:

class A<T> {
    T id;
    public A(T id) {
        this.id = id;
    }
}

public class Test2 {
  public static void main(String[] args) {
    A<Integer> a1 = new A<Integer>(20);
    int i = a1.id;
    System.out.println(i);
    A<String> a2 = new A<Integer>("hello");
    String s = a2.id;
    System.out.println(s);
  }
} 
Enter fullscreen mode Exit fullscreen mode

有了泛型機制, 就可以明確表示建立物件時想要處理的類別, 並且由編譯器幫我們檢查型別是否正確, 像是上例中傳入建構方法的引數是否與角括號中指定的型別參數相容?更棒的是在引用 id 成員時, 也會幫我們加上強制轉型的程式, 這樣就可以確保不會因為人工疏失而出錯。

型別抹除法 (type erasure)

實際上泛型機制就是利用將型別參數 T 在類別定義中出現的位置都改為 Object, 並且在引用泛型類別的地方加上必要的轉型動作達成泛型的功能。如果觀察編譯器實際產生的結果, 就會發現使用泛型的版本和前面我們自己使用 Object 以及強制轉型的版本根本一模一樣, 以下透過 bytecode viewer 觀察反組譯 A 類別的結果, 首先是非泛型的版本:

再來是泛型的版本:

圖中的結果左邊窗格是 Fern Flower Decompiler 輸出, 右邊則是 bytecode disassember 的輸出, 你可以看到兩個版本在左邊窗格根本是一樣的, 而在右邊窗格基本上也完全相同, 除了泛型版本多了以角括號註記跟型別參數相關的標籤。

也就是說, 不論你指定哪一種型別透過泛型類別建立物件, 都不會額外定義新的類別, 以前面的範例來說, 雖然有以整數和字串建立 A 類別的物件, 但實際上只有一個 id 成員是 Object 型別的 A 類別。

Java 將這種透過使用共同祖先類別來取代型別參數, 並自動加上轉型動作的機制, 稱為型別抹除法 (type erasure)

型別推導 (Type inference)

在建立泛型物件的時候, 如果編譯器可以推導出正確的型別, 那麼就可以省略叫用建構方法時角括號內的型別名稱, 例如:

class A<T> {
    T id;
    public A(T id) {
        this.id = id;
    }
}

public class Test2 {
  public static void main(String[] args) {
    A<Integer> a1 = new A<>(20);
    int i = a1.id;
    System.out.println(i);
    A<String> a2 = new A<>("hello");
    String s = a2.id;
    System.out.println(s);
  }
} 
Enter fullscreen mode Exit fullscreen mode

由於在宣告 a1 以及 a2 的地方已經明確指出型別參數個別是 Integer 以及 String, 因此在叫用建構方法建立物件時, 編譯器可以推導出對應型別參數的正確型別, 就不需要再重複指明型別了。後續我們都會以這種簡潔的寫法建構泛型物件。

要特別注意的是, 雖然可以省略型別名稱, 但是角括號是不能省去的, 這在稍後不加工類別的問題一節會說明差別。

限縮可套用的型別範圍

你也可以使用 extends 限制型別參數可指定的範圍 (這稱為 bounds), 那麼型別抹除法用來取代型別參數的就是限制範圍內最上層的類別, 例如以下的範例:

class Parent {}
class Child extends Parent {}
class Grandson extends Child {}

class A<T extends Parent> {
  T item;
  public A(T item) {
    this.item = item;
  }

  public T getItem() {
    return item;
  }
}

public class Test3 {
  public static void main(String[] args) {
    A<Parent> a1 = new A<>(new Parent());
    A<Child> a2 = new A<>(new Child());
    A<Grandson> a3 = new A<>(new Grandson());

    Parent p = a1.getItem();
    Child c = a2.getItem();
    Grandson g = a3.getItem();
  }
}
Enter fullscreen mode Exit fullscreen mode

這裡限制型別參數 T 只能是 Parent 的衍生類別。如果看反組譯的結果, 就會發現 A 類別編譯後的結果如下:

class A {
   Parent item;

   public A(Parent var1) {
      this.item = var1;
   }

   public Parent getItem() {
      return this.item;
   }
}
Enter fullscreen mode Exit fullscreen mode

你可以看到所有原本出現型別參數 T 的地方都變成最上層的 Parent 了。實際使用時, 也會強制轉型, 以下就是 Test3 類別反組譯的結果:

public class Test3 {
   public static void main(String[] var0) {
      A var1 = new A(new Parent());
      A var2 = new A(new Child());
      A var3 = new A(new Grandson());
      Parent var4 = var1.getItem();
      Child var5 = (Child)var2.getItem();
      Grandson var6 = (Grandson)var3.getItem();
   }
}
Enter fullscreen mode Exit fullscreen mode

在限制型別參數的範圍時也可以在類別之後用 & 符號串接加上需要實作的介面, 例如:

class Parent {}
class RunnableChild extends Parent implements Runnable, Cloneable{
  public void run() {}
  public int compareTo() {return 1;}
}

class A<T extends Parent & Runnable & Cloneable> {
  T item;
  public A(T item) {
    this.item = item;
  }

  public T getItem() {
    return item;
  }
}

public class Test4 {
  public static void main(String[] args) {
    A<RunnableChild> a2 = new A<>(new RunnableChild());

    RunnableChild r1 = a2.getItem();
    Runnable r2 = a2.getItem();
    Cloneable c = a2.getItem();
  }
}
Enter fullscreen mode Exit fullscreen mode

這就表示要建立 A 物件時, 必須指定一個繼承自 Parent 而且實作 Cloneable 與 Runnable 介面的型別。編譯器實際產生的 A 類別也會以寫在限制範圍內最前面的類別取代型別參數:

class A {
   Parent item;

   public A(Parent var1) {
      this.item = var1;
   }

   public Parent getItem() {
      return this.item;
   }
}
Enter fullscreen mode Exit fullscreen mode

編譯器也會在實際使用處幫你強制轉型到對應的類別或是介面:

public class Test4 {
   public static void main(String[] var0) {
      A var1 = new A(new RunnableChild());
      RunnableChild var2 = (RunnableChild)var1.getItem();
      Runnable var3 = (Runnable)var1.getItem();
      Cloneable var4 = (Cloneable)var1.getItem();
   }
}
Enter fullscreen mode Exit fullscreen mode

限定的範圍也可以只有要實作的介面, 那麼一樣會採用寫在限制範圍最前面的介面來取代型別參數, 例如:

class A<T extends Cloneable & Runnable> {
  T item;
  public A(T item) {
    this.item = item;
  }

  public T getItem() {
    item.run();
    return item;
  }
}
Enter fullscreen mode Exit fullscreen mode

編譯後的 A 類別如下:

class A {
   Cloneable item;

   public A(Cloneable var1) {
      this.item = var1;
   }

   public Cloneable getItem() {
      ((Runnable)this.item).run();
      return this.item;
   }
}
Enter fullscreen mode Exit fullscreen mode

注意到型別抹除法只是為了讓泛型的程式碼可以順利編譯執行, 編譯後的類別定義對於型別參數實際對應的型別資訊已經不完整, 但在編譯的階段編譯器會確保建立物件時型別的正確性以及該強制轉型的地方。像是上例中雖然 A 類別內的 item 是 Cloneable, 但編譯器會確認實際的 item 符合實作 Cloneable 與 Runnable 介面的要求, 並在需要時強制轉型到其他型別。

瞭解泛型背後的實作後, 對於遇到的問題就會比較容易理解了。

不加工 (raw) 類別的問題

如同前面所看到, 不論指定了哪些型別來建立泛型物件, 實際上經過型別抹除法都是採用同一份類別定義, 就語法上來說, 你也可以直接使用型別抹除法產生的類別, 像是這樣:

class A<T> {
    public T id;
    public A(T id) {
        this.id = id;
    }
}

public class Test7 {
  public static void main(String[] args) {
    A<Integer> a1 = new A<>(20);
    A a2 = a1;
    Integer i1 = a1.id;
    Integer i2 = a2.id;
  }
} 
Enter fullscreen mode Exit fullscreen mode

這裡 a2 宣告為 A 類別, 注意它沒有指定型別參數實際的型別, 這稱為不加工 (raw) 類別, 意思就是因為編譯器缺乏型別參數對應的實際型別這項資訊, 所以沒有辦法幫我們檢查型別正確性, 或是自動加上強制轉型的處理。因此, 如果編譯這個程式, 會出現以下的編譯錯誤:

# javac Test7.java
Test7.java:13: error: incompatible types: Object cannot be converted to Integer
    Integer i2 = a2.id;
                   ^
1 error
Enter fullscreen mode Exit fullscreen mode

這是因為編譯器不會幫我們強制轉型, 但型別抹除後的 A 類別中, id 是 Object 型別, 而 Object 物件是不能自動轉成整數的。如果要讓此程式可編譯執行, 就要自行強制轉型。

未受檢查的運算

不加工的類別還會衍生其他的問題, 請看以下這個可以正常編譯執行的例子:

class A<T> {
  public T item;
  public void setItem(T item) {
    this.item = item;
  }
}

class B extends A<Integer> {
}

public class Test8 {
  public static void main(String[] args) {
    B b1 = new B();
    b1.setItem(20);
    A a1 = b1;
    a1.setItem("hello");
    System.out.println(b1.item);
  }
}
Enter fullscreen mode Exit fullscreen mode

由於 a1 是不加工的 A 類別, 所以叫用 setItem() 時編譯器並不會檢查傳入的參數是否符合型別參數對應的真實型別。因此即使實際上 a1 參照的是衍生自 A<Integer> 的 B 類別物件, 但 B 類別中並沒有定義 setItem(), 所以會叫用 A 類別中的 setItem(), 該方法的參數是 Object 型別, 所以可以傳入字串而正常執行。實際結果如下:

# java Test8
hello
Enter fullscreen mode Exit fullscreen mode

雖然可以編譯執行, 不過在編譯的時候, 你可能已經注意到編譯器有噴出一段訊息:

# javac Test8.java
Note: Test8.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details. 
Enter fullscreen mode Exit fullscreen mode

指出程式中使用了不受檢查 (unchecked)或是不安全的運算, 就是指利用 a1 叫用 setItem() 時編譯器無法根據型別參數的實際型別檢查傳入參數的型別。你可以依照訊息內的指示加上顯示不受檢查警告的 -Xlint:unchecked 選項重新編譯程式, 就可以看到如下比較詳細的說明:

# javac -Xlint:unchecked Test8.java
Test8.java:16: warning: [unchecked] unchecked call to setItem(T) as a member of the raw type A
    a1.setItem("hello");
              ^
  where T is a type-variable:
    T extends Object declared in class A
1 warning
Enter fullscreen mode Exit fullscreen mode

警告訊息中很明確的指出叫用不加工類別 A 中的 setItem() 是不受到編譯器檢查的, 最後兩行訊息也指出 A 中的 T 是型別參數, 而且被替換成 Object, 所以才會出現上述警告。

編譯器自動產生的橋接方法

為了避免上述範例這種語法上正確但實際上不應該正常執行的情況, 可以在衍生類別 B 加上只能傳入 Integer 的 setItem(), 像是這樣:

class A<T> {
  public T item;
  public void setItem(T item) {
    this.item = item;
  }
}

class B extends A<Integer> {
  public void setItem(Integer item) {
    super.setItem(item);
  }
}

public class Test9 {
  public static void main(String[] args) {
    B b1 = new B();
    b1.setItem(20);
    A a1 = b1;
    a1.setItem("hello");
    System.out.println(b1.item);
  }
}
Enter fullscreen mode Exit fullscreen mode

這個程式仍然可以成功編譯, 但是在執行時卻會引發例外:

# java Test9
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer 
(java.lang.String and java.lang.Integer are in module java.base of loader 'bootstrap')  
        at B.setItem(Test9.java:8) 
        at Test9.main(Test9.java:19)                                  
Enter fullscreen mode Exit fullscreen mode

你可能會覺得奇怪, 幫 B 類別加上了只能傳入 Integer 版本的 setItem() 後, 父類別 A 中不是還有可傳入 Object 物件的版本, 透過繼承關係, 應該還是會叫用該版本而可以傳入字串正常運作才對, 為什麼會出現轉型失敗的例外呢?

要瞭解這一點, 就要查看編譯器做了什麼事?以下是透過反組譯查看編譯器實際產生的 B 類別:

class B extends A {
   public void setItem(Integer var1) {
      super.setItem(var1);
   }

   // $FF: synthetic method
   // $FF: bridge method
   public void setItem(Object var1) {
      this.setItem((Integer)var1);
   }
}
Enter fullscreen mode Exit fullscreen mode

你會發現除了剛剛加上的 Integer 版本的 setItem() 外, 還多了一個可傳入 Object 的版本, 這是由編譯器自動產生的方法, 稱為合成 (synthetic) 方法

之所以會產生這個方法, 是因為在 A<Ineteger> 的衍生類別中, 撰寫 setItem(Integer item) 的語意原本應該是覆寫父類別中同名方法, 可是因為有型別抹除機制, 實際上父類別中的是 setItem(Object item), 因此原本以為的覆寫變成是多形, 如果不處理, 就會像是前一個範例那樣造成意外的結果。

為了解決這個問題, 編譯器就幫我們加入了真正覆寫父類別中可傳入 Object 的 setItem() 方法, 並將該方法橋接轉移到可傳入 Integer 的 setItem()。有了這個編譯器合成的方法, 就會因為多形的關係, 在傳入非 Integer 的參數, 像是字串時, 叫用這個合成的 setItem(), 並在此方法中先將傳入的參數強制轉型成 Integer 再叫用 Integer版本的 setItem(), 也就是在這裡, 發生 String 物件無法轉成 Integer 物件的例外。

這個合成方法因為其專門的功用, 所以特別稱為橋接 (bridge) 方法。當你從指定型別的泛型類別衍生子類別時, 這個機制就可以發揮關鍵的作用。

無法判斷未知資料型別

對於泛型的程式, 最關鍵的就是要讓編譯器有足夠的資訊判斷是否可進行指定的動作, 以底下的程式為例:

class A<T> {
  T id;
  public A(T id) {
    this.id = id;
  }

  public void show() {
    System.out.println(id * 3);
  }
}
Enter fullscreen mode Exit fullscreen mode

編譯時會出現以下的錯誤:

# javac Test11.java
Test11.java:8: error: bad operand types for binary operator '*'
    System.out.println(id * 3);
                          ^
  first type:  T
  second type: int
  where T is a type-variable:
    T extends Object declared in class A
1 error
Enter fullscreen mode Exit fullscreen mode

看起來再正常不過的乘法, 但是因為編譯器無法確認 id 的型別, 所以並不能預知可否進行 val * 3 的運算, 舉例來說, 若是 T 是 String, 就沒有乘法的運算, 因此編譯器會對該行發出錯誤, 表示乘法的運算元不能是未定的型別 T 和整數。

如果以前面說明過的型別抹除法來看, 就更清楚了, 因為在類別 A 中的 id 成員實際上會被替換成 Object 型別, 自然無法進行乘法運算了。

你可能會想說, 可是在實際使用 A 類別時就會明確指明型別了, 如果指定的型別不符合乘法運算, 執行時再引發例外不就好了?這可能是習慣像是 Python 等動態語言的人會有的疑問, 可惜的是, Java 雖然有 Reflection 等執行時期動態取得型別資訊的機制, 不過如同之前所說明, Java 的泛型機制完全是在編譯時期處理, 沒有足夠的型別資訊造成編譯不過就是不會過, 根本就不會有機會執行。

繼承關係不會延續到泛型物件

指定不同型別建立的泛型物件, 即使指定的型別之間有繼承關係, 也跟建立的泛型物件沒有關連。以底下這個計算串列元素平均值的泛型方法來說:

import java.util.*;

public class Test12 {
  public static double avg(List<Number> l) {
    double total = 0;
    for(Number n:l) {
      total += n.doubleValue();
    }  
    return total / l.size();
  }

  public static void main(String[] args) {
    ArrayList<Integer> l = new ArrayList<>();
    l.add(20);
    l.add(30);
    System.out.println(avg(l));
  }
}
Enter fullscreen mode Exit fullscreen mode

雖然 Integer 是 Number 的衍生類別, 但是編譯會出現錯誤:

# javac Test12.java
Test12.java:16: error: incompatible types: ArrayList<Integer> cannot be converted to List<Number>
     System.out.println(avg(l));
                            ^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output   1 error
Enter fullscreen mode Exit fullscreen mode

錯誤訊息表示 ArrayList<Integer> 並不能轉換成 ArrayList<Number>, 這是因為 ArrayList<Number> 裡面的元素都是 Number 物件, 但 Number 物件卻不一定是 Integer 物件, 如果拿 ArrayList<Integer> 去用在本來該用 Array<Number> 的地方, 就可能會遇到要放入串列的元素不是 Integer 物件的問題。因此, 編譯時就會發生錯誤。

接受由指定型別的衍生類別建立的泛型物件當參數

如果希望這個方法可以計算以 Number 的衍生類別建立的泛型串列內的元素平均值, 要改成這樣:

public class Test13 {
  public static double avg(List<? extends Number> l) {
    double total = 0;
    for(Number n:l) {
      total += n.doubleValue();
    }  
    return total / l.size();
  }

  public static void main(String[] args) {
    ArrayList<Integer> l = new ArrayList<>();
    l.add(20);
    l.add(30);
    System.out.println(avg(l));
  }
}
Enter fullscreen mode Exit fullscreen mode

其中, ? 稱為萬用字元 (wildcard), 表示任意型別, 而 extends Number 則將範圍限縮在衍生自 Number 的子類別, 這樣就可以接受以 Integer 建立的串列了, 執行結果如下:

# java Test13
25.0
Enter fullscreen mode Exit fullscreen mode

要注意的是透過這種方式傳入泛型方法的串列, 因為無法知道實際會傳入的是哪一種串列, 編譯器只能將之視為 List<Number> 做最保險的解釋, 因此從串列中讀出元素時只能像是上面的範例那樣當成是 Number 物件, 也不准加入新的元素到串列中, 例如以下的方法在編譯時就會出錯:

import java.util.*;

public class Test14 {
  public static void add(List<? extends Number> l, int item) {
    l.add(item);
  }

  public static void main(String[] args) {
    ArrayList<Integer> l = new ArrayList<>();
    add(l, 20);
  }
}

Enter fullscreen mode Exit fullscreen mode

錯誤訊息如下:

# javac Test14.java
Test14.java:5: error: incompatible types: int cannot be converted to CAP#1
    l.add(item);
          ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Number from capture of ? extends Number
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error
Enter fullscreen mode Exit fullscreen mode

訊息中第 4 行的 CAP#1 表示在處理泛型方法時捕捉 (capture) 到因為 ? 而自動產生一個新的型別參數, 編譯器將這個新型別參數對應到 Number 類別。當我們叫用 l.add() 時, 編譯器只准許其引數是 Number 物件, 但這裡傳入的是 Integer 物件, 導致編譯錯誤, 因為 Integer 物件無法自動轉成 Number 物件。意思就是編譯器只知道 l 是一個以 Number 的衍生類別建立的泛型串列, 但卻不知道實際上到底是哪一種, 如果要把 Integer 物件放入串列中, 編譯器無法判斷是否會有問題?舉例來說, 若實際上 l 是以 Short 建立的串列, 那麼就無法放入 Integer 物件。

萬用字元在需要用到泛型的集合物件當餐數值非常有用, 例如 HashSet<E> 就有一個如下宣告的建構方法:

public HashSet(Collection<? extends E> c)
Enter fullscreen mode Exit fullscreen mode

這個建構方法可以將傳入的集合中所有的元素都加入要建立的新集合, 這裡就限縮傳入的集合內元素的型別一定要是新建立集合內元素型別的衍生類別, 以便能和新集合相容。

接受由指定型別的祖先類別所建立的泛型物件當參數

要讓傳入的串列可以增加新元素, 必須用 super 改寫如下:

import java.util.*;

public class Test14 {
  public static void add(List<? super Integer> l, int item) {
    l.add(item);
  }

  public static void main(String[] args) {
    ArrayList<Integer> l = new ArrayList<>();
    add(l, 20);
  }
}
Enter fullscreen mode Exit fullscreen mode

這個寫法表示傳入的串列是以 Integer 或是 Integer 的祖先類別建立的, 因此, 即使 l 是以 Object 建立的串列, 要加入 Integer 物件到串列中也沒問題, 因為 Object 型別的變數本來就可以參照到其子類別所建立的物件。

不過如果想要用這樣的方式傳入的泛型串列讀取元素, 也會編譯錯誤, 例如:

import java.util.*;

public class Test16 {
  public static double avg(List<? super Integer> l) {
    double total = 0;
    for(Integer n:l) {
      total += n;
    }  
    return total / l.size();
  }

  public static void main(String[] args) {
    ArrayList<Integer> l = new ArrayList<>();
    l.add(20);
    l.add(30);
    System.out.println(avg(l));
  }
}
Enter fullscreen mode Exit fullscreen mode

錯誤訊息如下:

# javac Test16.java
Test16.java:6: error: incompatible types: CAP#1 cannot be converted to Integer
    for(Integer n:l) {
                  ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object super: Integer from capture of ? super Integer
1 error
Enter fullscreen mode Exit fullscreen mode

這是編譯器不知道 l 是以哪一種類別建立的泛型串列, 因此若要將讀出的元素當成 Integer 物件, 就可能會有問題。

以 <? extends > 方式稱為設定上限 (upper bound);而以 <? super> 方式稱為設定下限 (lower bound);符合上限範圍傳入的泛型物件參數只能讀取內容, 符合下限範圍傳入的泛型物件參數才能變更內容。這是一開始使用上下限方式設定泛型物件參數時不容易弄懂的觀念。如果只單純使用 ? 不加上 extends 或是 super 限制範圍, 那麼就跟 <? extends Object> 是一樣的意思。

結語

在這篇文章中, 我們解釋了泛型背後的機制, 並且說明了幾個使用泛型時常會遇到的問題, 希望能對大家有幫助。

💖 💪 🙅 🚩
codemee
codemee

Posted on January 2, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related