Java编程思想拾遗(13)泛型

一般的类和方法,只能使用具体的类型:要么是基本类型,要么是自定义的类。如果要编写可以应用于多种类型的代码,这种刻板的限制对代码的束缚就会很大。有时候,拘泥于单继承体系也会使程序受限太多,即便使用了接口也可能无法改善。

泛型实现了参数化类型的概念,通过解耦类或方法与所使用的类型之间的约束,使代码可以应用于多种类型。

有很多原因促成了泛型的出现,最引人注目的一个原因,就是为了创造容器类。泛型的主要目的之一就是用来指定容器要持有什么类型的对象,与其使用Object,我们更喜欢暂时不指定类型,而是稍后决定具体使用什么类型,而且由编译器来保证类型的正确(”持有类型”是本人对泛型核心概念的理解,以此决定其使用场景)。

除了可以在整个类和接口上应用泛型,也可以在类中包含泛型方法,是否拥有泛型方法与其所在的类是否是泛型没有关系。无论何时,只要你能做到,你就应该尽量使用泛型方法,如果使用泛型方法可以取代整个类泛型化,那么就应该只是用泛型方法。如果static方法需要使用泛型能力,就必须使其成为泛型方法(毕竟是独立于运行时对象的,所以需要单独使用泛型)

public class GenericMethods {
    public <T> void f(T x) {
        System.out.println(x.getClass().getName()):
    }
    public static void main(String[] args) {
        GenericMethods gm = new GenericMethods();
        gm.f("");
        gm.f(1);
    }
}
复制代码

类型擦除

擦除来龙

因为泛型不是Java语言出现时就有的组成部分,其实现是一种折中,擦除的核心动机是它使得泛化的客户端可以用是使用非泛化的类库,反之亦然。Java泛型需要支持向后兼容性:现有的代码和类文件仍然合法并且继续保持之前的含义,还需要支持迁移兼容性:使得类库按照它们自己的步调变为泛型的,并且当某个类库变为泛型时,不会破坏依赖于它的代码和应用程序,所以某个特定的类库使用了泛型这样的证据必须被擦除。

擦除表现

在泛型代码内部,无法获得任何有关泛型参数类型的信息。当你在使用泛型时,任何具体的类型信息都被擦除了,你唯一知道的就是你在使用一个对象(措辞有点小夸张了,只是在泛型内部类型擦除,但内外调用通信仍然可以保持类型灵活一致性)。泛型不能用于显式地引用运行时类型的操作之中,例如转型、instanceof操作和new表达式,因为所有关于参数的类型信息都丢失了。

pubilc class Erased<T> {
    private final int SIZE = 100;
    public static void f(Object arg) {
        // if (arg instanceof T) {}
        // T var = new T();
        // T[] array = new T[SIZE];
    }
}
复制代码

泛型类型只有在静态类型检查期间才出现,在此之后,程序中的所有泛型类型都将被擦除,替换为它们的非泛型上界,例如List<T>这样的类型注解将被擦除为List,而普通的类型变量在未指定边界的情况下将被擦除为Object。

public class HasF {
    public void f() {
        Sysytem.out.println("HasF.f()"):
    }
}

class Manipulator<T> {
    private T obj;
    public Manipulator(T x) {
        obj = x;
    }
    public void manipulate() {
        // Error: cannot find symbol: method f();
        // obj.f()
    }
}

class Manipulator2<T extends HasF> {
    private T obj;
    public Manipulator2(T x) {
        obj = x;
    }
    public void manipulate() {
        obj.f();
    }
    public T get() {
        return obj;
    }
}
复制代码

为了调用f(),我们必须协助泛型类,给定泛型类边界,以此告知编译器只能接受遵循这个边界的类型,这里重用了extends关键字,控制了类型擦除的边界,擦除后明面上跟普通的多态没什么区别,但其实泛型类内部操控的是更为具体的类型,比如可以返回具体的T类型。

擦除兼容

擦除和兼容性意味着,使用泛型并不是强制的。

class GenericBase<T> {
    private T element;
    public void set(T arg) {
        element = arg;
    }
    public T get() {
        return element;
    }
}

class Derived1<T> extends GenericBase<T> {}

class Derived2 extends GenericBase {} // No warning

// class Derived3 extends GenericBase<?> {}
// required: class or interface without bounds

public class ErasureAndInheritance {
    @SuppressWarnings("uncheck")
    public static void main(String[] args) {
        Derived2 d2 = new Derived2();
        Object obj = d2.get();
        d2.set(obj); // Warning here
    }
}
复制代码

擦除实现

因为擦除在方法体移除了类型信息,所以在运行时的问题就是边界:即对象进入和离开方法的地方,这些正是编译器在编译期执行类型检查并插入转型代码的地方。

对于set(),Java本身支持向上转型,编译器主要是进行边界合法性检查;而对于get(),返回的值需要进行向下转型是编译器自动执行的额外工作。

边界

边界与继承

Java泛型重用了extends关键字,此关键字在泛型边界上下文环境中和在普通情况下具有的意义是不同的。多重扩展在这里也是支持的。

interface HasColor {
    java.awt.Color getColor();
}

class Dimension {
    public int x, y, z;
}

// class must be first, then interfaces
class ColoredDimesion<T extends Dimesion & HasColor> {
    T item;
    ColoredDimesion(T item) {
        this.item = item;
    }
    T getItem() {
        return item;
    }
    java.awt.Color color() {
        return item.getColor();
    }
    int getX() {
        return item.x;
    }
    int getY() {
        return item.y;
    }
    int getZ() {
        return item.z;
    }
}

interface Weight {
    int weight();
}

class Solid<T extends Dimesion & HasColor & Weight> {
    T item;
    Solid(T item) {
        this.item = item;
    }
    T getItem() {
        return item;
    }
    java.awt.Color color() {
        return item.getColor();
    }
    int getX() {
        return item.x;
    }
    int getY() {
        return item.y;
    }
    int getZ() {
        return item.z;
    }
    int weight() {
        return item.weight();
    }
}
复制代码

继承和边界是可以共存的,而且基类和继承类在泛型参数上是需要关联上的。

class HoldItem<T> {
    T item;
    HoldItem(T item) {
        this.item = item;
    }
    T getItem() {
        return item;
    }
}

class Colored2<T extends HasColor> extends HoldItem<T> {
    Colored2(T item) {
        super(item);
    }
    java.awt.Color color() {
        return item.getColor();
    }
}
复制代码

从声明的角度上,就能理解为什么List<Fruit>的引用不能指向List<Apple>的对象,毕竟List声明的只有一个T,Fruit和Apple就不是同一个T。从持有逻辑的角度上,Apple虽然都是Fruit,但不是所有Fruit都是Apple,不允许一个Orange被添加到一个Apple容器内!

正如其他语法,泛型中的多重继承也可能会有冲突需要协调,一个类不能时间同一个泛型接口的两个变体,由于擦除的原因,这两个变体会成为相同的接口,意味着在重复两次地实现相同的接口,编译会失败。不过在正常的非泛型版本这个确实可以工作,没有类型参数冲突编译器可以安全地帮我们去重合并。

interface Payable<T> {}

class Employee implements Payable<Employee> {}
class Hourly extends Employ implements Payable<Hourly> {}
复制代码

边界下调

如果是调用者使用通配符对泛型引用边界下调,在赋值时会受到编译器的限制。在<? extends T> 泛式下,编译器只知道持有的类型是T的子类,但无法知道是具体哪个子类,所以在set()入口进行了全部拦截,除非入参是Object,而在get()出口因为明确是T的子类所以可以安全向上转型成T。(或许这种场景主要是用于入口安全性控制,如果想开放入口继承,完全不必对泛型边界上调,直接用类型参数多态即可)

class Fruit {}
interface Eat {}
class Apple extends Fruit implements Eat {}
class Jonathan extends Apple {}
class Orange extends Fruit {}

public class Holder<T> {
    private T value;
    public Holder() {}
    public Holder(T t) {
        value = t;
    }
    public void set(T val) {
        value = val;
    }
    public T get() {
        return value;
    }
    public boolean equals(Object obj) {
        return value.equals(obj);
    }
    
    public staic void main(String[] args) {
        Holder<Apple> apple = new Holder<>(new Apple());
        Apple d = apple.get();
        
        // Holder<Fruit> fruit = apple; // cannot upcast
        Holder<? extends Fruit> fruit = apple;
        
        Fruit p = fruit.get();
        d = (Apple) fruit.get();
        try {
            Orange c = (Orange) fruit.get(); 
        } catch(Exception e) {
            // fruit.set(new Apple()); // cannot call set
            // fruit.set(new Fruit()); // cannot call set
            System.out.println(fruit.equals(d));
        }
    }
}
复制代码

边界上调

除了使用通配符对泛型引用边界下调,还可以反向进行边界上调。在<? super T> 泛式下,任何T的超类(基类或者接口,因为多重继承的关系,不是纯粹的单继承体系)都可以接收一个T或其子类(层叠向上转型)的对象。不过在get()时只能得到Object,因为无法确定泛型持有的是哪个超类,无法直接向下转型。

public class GenericWriting {
    staitc List<Fruit> fruits = new ArrayList<>();
    staitc List<Eat> eats = new ArrayList<>();
    
    static <T> void writeWithWildcard(List<? super T> list, T item) {
        list.add(item):
        Object obj = list.get(0);
    }
            
    static void f() {
        writeWithWildcard(fruits, new Fruit());
        writeWithWildcard(fruits, new Apple());
        writeWithWildcard(fruits, new Jonathan());
        writeWithWildcard(fruits, new Orange());
        
        // writeWithWildcard(eats, new Fruit());
        writeWithWildcard(eats, new Apple());
        writeWithWildcard(eats, new Jonathan());
        // writeWithWildcard(eats, new Orange());
    }
}
复制代码

在进行调用理解时,将泛型实参的类型参数替换成方法泛型形参中的?,得到T的具体类型,以此判断编译上是否符合边界,而在返回时需要注意Java天然支持向上转型,所以外部可能用一个更通用的类型引用接收。

无界通配符

无界通配符<?>看起来意味着“任何事物”,因此使用无界通配符好像等价于使用原生类型。实际上,它是在声明:”我是想用Java的泛型来编写这段代码,我在这里并不是要用原生类型,但是在当前这种情况下,泛型参数可以持有任何类型”。

由于泛型参数将擦除到它的第一个边界,因此List<?>看起来等价于List<Object>,List实际上表示“持有任何Object类型的原生List”,而List<?>表示“具有某种特定类型的非原生List”,只是我们不知道那种类型是什么。

public class UnboundedWildCards1 {
    static List list1;
    static List<?> list2;
    static List<? extends Object> list3;

    static void assign1(List list) {
        list1 = list;
        list2 = list;
        list3 = list; // Warning: Unchecked assignment: 'java.util.List' to 'java.util.List<? extends java.lang.Object>'
    }
    static void assign2(List<?> list) {
        list1 = list;
        list2 = list;
        list3 = list;
    }
    static void assign3(List<? extends Object> list) {
        list1 = list;
        list2 = list;
        list3 = list;
    }

    public static void main(String[] args) {
        assign1(new ArrayList());
        assign2(new ArrayList());
        assign3(new ArrayList()); // Warning: Unchecked assignment: 'java.util.List' to 'java.util.List<? extends java.lang.Object>'

        assign1(new ArrayList<String>());
        assign2(new ArrayList<String>());
        assign3(new ArrayList<String>());

        List<?> wildList = new ArrayList<String>();
        assign1(wildList);
        assign1(wildList);
        assign1(wildList);
        
        List list4 = new ArrayList();
        list4.add("s");
        Object o = list4.get(0);
        
        List<?> list5 = new ArrayList<String>();
        // list5.add("s"); // Error: Required type: capture of ?  Provided: String
        Object o = list5.get(0);
    }
}
复制代码

从list4和list5的区别表现上看,这里的?和前面的边界下调?extends T一样具有写入安全保护性质。

使用确切类型来替代通配符类型的好处是,可以用泛型参数来做更多的事,但是使用通配符使得你必须接受范围更宽的参数化类型作为参数,因此必须逐个情况地权衡利弊,找到更适合你需求的方法。

自限定

有一种看似套娃的用法

class SelfBounded<T extends SelfBounded<T>> {}
复制代码

SelfBounded接受一个泛型参数T,对T有一个边界限定,就是T需要继承SelfBounded。其本质是基类用导出类替代其参数,这意味着泛型基类变成了一种其所有导出类的公共功能的模板,这些功能对于其所有参数和返回值,将使用导出类型。

class SelfBounded<T extends SelfBounded<T>> {
    T element;
    SelfBounded<T> set(T arg) {
        element = arg;
        return this;
    }
    T get() {
        return element;
    }
}

class A extends SelfBounded<A> {}
class B extends SelfBounded<A> {} // it's ok

class C extends SelfBounded<C> {
    C setAndGet(C arg) {
        set(arg);
        return get();
    }
}

class D {}
// class E extends SelfBounded<D> {} // Error

class F extends SelfBounded {}
复制代码

从编译语法上分析,SelfBounded<T> 是指泛型类SelfBounded持有类型参数T,从set方法的返回值可以看出,而T extends SelfBounded<T> 是限定T需要继承上面说的SelfBounded,所以第一个T是用来限定T的边界,第二个T是说明泛型类已经持有的类型参数,合起来就是T需要继承一个持有T作为类型参数的SelfBounded,这样解释可以从无限套娃中解脱出来。至于为什么B是合法的,因为A已经实现了自限定,SelfBounded只是对其参数类型T作限制,而不是对其导出类做限制,这些符合条件的T的诞生刚好就是类的声明。

因为SelfBounded明确持有T,即便T继承了SelfBounded,也不能在这里应用多态把SelfBounded当作T处理,这是泛型类型明确的一个特点。

泛型Class

尽量可以声明ArrayList.class,但是不能声明ArrayList<Integer>.class,ArrayList<String>和ArrayList<Integer>很容易被认为是不同的类型,但实际上它们是相同的类型。

public class ErasedTypeEquivalence {
    public static void main(String[] args) {
        Class c1 = new ArrayList<String>().getClass();
        Class c2 = new ArrayList<Integer>().getClass();
        System.out.println(c1 == c2); // return true
    }
}
复制代码

Class.getTypeParameters()返回一个TypeVariable对象数组,表示有泛型声明所声明的类型参数,然而实际返回的只是用作参数占位符的标识符T、E、K、V(这也阻止了一些泛型类进行动态类型反序列化,因为标识符对序列化无实际用处)。

有时必须引入类型标签来对擦除进行补偿,这意味着你需要显式地传递你的类型的Class对象,以便你可以在类型表达式中使用它,编译器将确保类型标签可以匹配泛型参数(Class果然待遇不一样)。

class Building {}
class House extends Building {}

public class ClassTypeCaptute<T> {
    Class<T> kind;
    public ClassTypeCapture(Class<T> kind) {
        this.kind = kind;
    }
    public boolean f(Object arg) {
        return kind.isInstance(arg);
    }
    
    public static void main(String[] args) {
        ClassTypeCapture<Building> ctt1 = new ClassTypeCapture<Building>(Building.class);
        System.out.println(ctt1.f(new Building());
        System.out.println(ctt1.f(new House());
        
        ClassTypeCapture<Building> ctt2 = new ClassTypeCapture<House>(House.class);
        System.out.println(ctt2.f(new Building());
        System.out.println(ctt2.f(new House());
    }
}
复制代码

泛型数组

如上Erased.java,不能创建泛型数组,一般的解决方案是在任何想要创建泛型数组的地方都使用ArrayList。

小结

Java泛型作为Java语言结构最难啃的骨头,我花了一个周末重温并写出了这篇文章,本专栏写到这里,对于Java语言核心特性基本可以告一段落了,后面的容器、枚举、注解、并发、IO主要偏工具API,就像幽州铁骑南下华北平原,基本可以长驱直入了,哈哈。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享