Producer extends

June 21, 2022

在定義泛型時,可以定義型態的邊界。例如:

class Animal {}

class Human extends Animal {}

class Toy {}

class Duck<T extends Animal> {}

public class BoundDemo {
    public static void main(String[] args) {
        Duck<Animal> ad = new Duck<Animal>();
        Duck<Human> hd = new Duck<Human>();
        Duck<Toy> td = new Duck<Toy>();  // 編譯錯誤
    }
}

使用 extends

在上例中,使用 extends 限制 T 實際型態,必須是 Animal 的子類別,你可以使用 AnimalHuman 指定 T 實際型態,但不可以使用 Toy,因為 Toy 不是 Animal 子類別。

實際應用的場景,可以用快速排序法的例子來說明:

package cc.openhome;

import java.util.Arrays;

public class Sort {
    public static <T extends Comparable<T>> T[] sorted(T[] array) {
        T[] arr = Arrays.copyOf(array, array.length);
        sort(arr, 0, arr.length - 1);
        return arr;
    }
    
    private static <T extends Comparable<T>> void sort(
                                  T[] array, int left, int right) {
        if(left < right) { 
            var q = partition(array, left, right); 
            sort(array, left, q - 1); 
            sort(array, q + 1, right); 
        } 
    }

    private static <T extends Comparable<T>> int partition(
                                  T[] array, int left, int right) {  
        var i = left - 1; 
        for(var j = left; j < right; j++) { 
            if(array[j].compareTo(array[right]) <= 0) {
                i++; 
                swap(array, i, j); 
            } 
        } 
        swap(array, i+1, right); 
        return i + 1; 
    } 

    private static void swap(Object[] array, int i, int j) {
        var t = array[i]; 
        array[i] = array[j]; 
        array[j] = t;
    }
} 

物件要能排序,方式之一是物件本身能比較大小,因此 Sort 類別實例要求 sorted 方法傳入的陣列,其中元素必須是 T 型態,<T extends Comparable<T>> 限制 T 必須實作 java.lang.Comparable<T> 介面。可以如下使用 sorted 方法:

package cc.openhome;

public class SortDemo {
    public static void main(String[] args) {
       String[] strs = {"3", "2", "5", "1"};
       for(var s : Sort.sorted(strs)) {
           System.out.println(s);
       } 
    }
}

String 實作了 Comparable 介面,因此可如粗體字方式使用 sorted 方法排序,傳回新的已排序陣列。

extends 之後指定了類別或介面,想再指定其他介面,可以使用 & 連接。例如:

public class Some<T extends Iterable<T> & Comparable<T>> {
    ...
}

通配字元 ?

接著來看看型態通配字元 ?。若定義了以下類別:

package cc.openhome;

public class Node<T> {
    public T value;
    public Node<T> next;
    
    public Node(T value, Node<T> next) {
        this.value = value;
        this.next = next;
    }
} 

如果有個 Shape 介面與實作如下:

interface Shape {
    double area(); // 計算面積
}

record Circle(double x, double y, double radius) implements Shape {
    @Override
    public double area() {
        return radius * radius * 3.14159;
    }
}

record Square(double x, double y, double length) implements Shape {
    @Override
    public double area() {
        return length * length;
    }
}

這邊為了簡化範例, CircleSquare 單純地設計為資料載體,也就是定義為 record 類別,這邊的 Shape 要求必須實現計算面積的 area 方法。

若有以下程式片段,會發生編譯錯誤:

Node<Circle> circle = new Node<>(new Circle(0, 0, 10), null);
Node<Shape> shape = circle;  // 編譯錯誤,incompatible types

在這個片段中,circle 型態宣告為 Node<Circle>shape 型態宣告為 Node<Shape>,那麼 Node<Circle> 具有 Node<Shape> 的行為嗎?顯然地,編譯器認為不是,不允許通過編譯。

接下來的說明中,若 BA 的子類別,或者 B 實現了 A 介面,統稱 BA 的次型態(subtype)。

BA 的次型態,而 Node<B> 也視為 Node<A> 的次型態,因為 Node 保持了次型態的關係,稱 Node 具有共變性(Covariance)。從以上編譯結果可看出,Java 的泛型不具有共變性,不過可使用型態通配字元 ?extends 宣告變數,達到類似共變性。例如以下可以通過編譯:

Node<Circle> circle = new Node<>(new Circle(0, 0, 10), null);
Node<? extends Shape> shape = circle; // 類似共變性效果

在上面片段使用了 <? extends Shape> 語法,? 代表 shape 參考的 Node 物件,不知道 T 實際型態,加上 extends Shape 表示雖然不知道 T 型態,但一定會是 Shape 的次型態。由於 circle 宣告為 Node<Circle>CircleShape 的次型態,可以通過編譯。

一個實際應用的例子是:

package cc.openhome;

public class CovarianceDemo {
    public static void main(String[] args) {
        var c1 = new Node<>(new Circle(0, 0, 10), null);
        var c2 = new Node<>(new Circle(0, 0, 20), c1);
        var c3 = new Node<>(new Circle(0, 0, 30), c2);
        
        var s1 = new Node<>(new Square(0, 0, 15), null);
        var s2 = new Node<>(new Square(0, 0, 30), s1);

        show(c3);
        show(s2);
    }
    
    public static void show(Node<? extends Shape> n) { 
        Node<? extends Shape> node = n;
        do {
            System.out.println(node.value);
            node = node.next;
        } while(node != null);
    }
} 

在上例中,適當地搭配了 var 來宣告,可以減輕泛型撰寫上的負擔。show 方法目的是顯示全部的形狀節點,若參數 n 宣告為 Node<Shape> 型態,就只能接受 Node<Shape> 實例。

範例中 show 方法使用型態通配字元 ?extends 宣告參數,令 n 具備類似共變性,show 方法就可接受 Node<Circle> 實例,也可接受 Node<Square> 實例。執行結果如下:

Circle[x=0.0, y=0.0, radius=30.0]
Circle[x=0.0, y=0.0, radius=20.0]
Circle[x=0.0, y=0.0, radius=10.0]
Square[x=0.0, y=0.0, length=30.0]
Square[x=0.0, y=0.0, length=15.0] 

若宣告 ? 不搭配 extends,則預設為 ? extends Object。例如:

Node<?> node; // 相當於 Node<? extends Object>

以上的 node 可接受 Node<Object>Node<Shape>Node<Circle> 等物件,只要角括號中的物件是 Object 的次型態,都可通過編譯。

這與宣告為 Node<Object> 不同,若 node 宣告為 Node<Object>,就只能參考至 Node<Object> 實例,也就是以下會編譯錯誤:

Node<Object> node = new Node<Integer>(1, null);

但以下會編譯成功:

Node<?> node = new Node<Integer>(1, null);

作為生產者

使用通配字元 ?extends 限制 T 型態,T 宣告的變數取得之物件,只能指定給 ObjectShape,或將 T 宣告的變數指定為 null,除此之外不能進行其他動作。例如:

Node<? extends Shape> node = new Node<>(new Circle(0, 0, 10), null);
Object o = node.value; 
node.value = null;
Circle circle = node.value;          // 編譯錯誤
node.value = new Circle(0, 0, 10);   // 編譯錯誤

以上程式片段,只知道 value 參考的物件會繼承 Shape,實際上會是 Circle 還是 Square 呢?若 node.valueSquare 實例,指定給 Circle 型態的 circle 當然不對,因而編譯錯誤。如果建立 Node 時指定 T 型態是 Square,將 Circle 實例指定給 node.value 就不符合原先要求,因此也是編譯錯誤。

泛型的型態資訊僅提供編譯器進行型態檢查,編譯器不無法考慮執行時期物件實際型態(又稱型態抹除),因而造成以上的限制。

扣除可將 T 宣告的名稱指定為 null 的情況,簡單來說,對於支援泛型的類別,像是先前範例的 Node 類別定義,若使用 ? extends Shape 時,Node 類別定義中的 T 宣告之變數,只能作為資料的提供者,不能被重新設定,若 Node 類別使用 T 宣告某方法的參數,那麼呼叫該方法時會編輯錯誤,若使用T宣告某方法的傳回型態,呼叫該方法是沒有問題的。

實際應用案例之一是支援泛型的 java.util.List,若有個 List<? extends Shape> lt = new ArrayList<>(),那麼 lt.add(shape) 會引發編譯錯誤,然而 lt.get(0) 沒有問題。

既然泛型不考慮執行時期物件型態,因而造成這類限制,不如就活用這個限制, 記憶的口訣就是「Producer extends」,若想限定接收到的 List 實例,只作為資料的提供者,就使用 ? extends 這樣的宣告。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter