回到顶部 暗色模式

Java泛型

        泛型允许一个类应用于多种类型,实现了参数化类型的概念。促使泛型产生的原因之一就是容器类,泛型可以用于指定一个容器具体要持有怎样的类型。当然,也可以通过泛型实现持有多个类型的类,如元组类:

public class TwoTuple<A, B> {
    public final A first;
    public final B second;

    public TwoTuple(A a, B b) {
        this.first = a;
        this.second = b;
    }
}

        如果需要长度更长的元组如三元组,我们可以在二元组的基础上声明三元组类,并令其继承二元组类。
        除了具体类外,泛型还可以应用于抽象类和接口当中。
        Java泛型存在着很多局限性,第一个就是不能以基本类型作为其类型参数。当然,通过Java自动包装和自动拆包的功能,基本类型和其包装类型可以很方便地进行转换。
        之前提到的泛型都是应用于一个类上的,但是泛型同样可以应用于方法上,而且不仅可以应用于泛型类的方法上,也可以应用于普通类的方法上。如果通过使用泛型方法就可以避免使用泛型类,那么推荐使用泛型方法。此外,由于 $static$ 方法无法获取类型参数,因此如果要让 $static$ 方法使用泛型,那么就必须让其成为泛型方法。与使用泛型类不同,使用泛型方法时可以不用指定具体类型,通过类型参数推断,编译器可以为我们找出具体类型。

1. 擦除

        通过泛型,我们可以声明一个 $ArrayList<Integer>$ 对象,然后我们可以获取 $ArraysList.class$ ,但是却不能获取 $ArrayList<Integer>.class$ 对象。这个行为说明了在编译器的眼中,$ArrayList<Integer>$ 和 $ArrayList<String>$ 是同一类型。更加夸张的是,不同于C++,在Java的泛型内,你无法获取任何有关于泛型参数类型的信息。这些行为的原因都是因为Java的泛型是使用擦除实现的。在基于擦除的实现中,泛型被认为是第二类类型,既不能在某些重要的上下文环境中使用的类型。泛型类型只有在静态类型检查期间才会出现,之后泛型类型将被擦除,并替换为非泛型边界如 $List$ , $Object$ 等。
        在C++中,我们可以实现以下代码:

template<class T> class Test {
    T obj;
public:
    Test(T t) {
        this.obj = t;
    }
    void f() {
        obj.f();
    }
}

        但是在Java中,这种行为就无法实现了。因为擦除的存在,Java编译器无法认为 $obj$ 含有 $f(\ )$ 方法。而为了调用 $f(\ )$ ,在Java中需要这样实现:

interface HasF {
    void f();
}

public class Test<? extends HasF> {
    private T obj;
    Test(T t) {
        this.obj = t;
    }
    public void f() {
        obj.f();
    }
}

        通过给定泛型类的边界,泛型类只能接受符合这个边界的类型参数,从而就能确保含有 $f(\ )$ 方法了。
        虽然通过擦除,Java保证了向后兼容。但是带来的代价也是显著的,所有关于类型参数的信息都丢失了。也因为这个原因,泛型并不是强制使用的:

class A<T> {}

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

class Derived2 extends A {}

        但是擦除机制带来的最令人困惑的并不是这个,而是“看起来好像持有”:

public class ArrayMaker<T> {
    private Class<T> kind;

    public ArrayMaker(Class<T> kind) {
        this.kind = kind;
    }
    @SuppressWarnings("unchecked")
    T[] create(int size) {
        return (T[]) Array.newInstance(kind, size);
    }
}

        即使使用了 $Class<T>$ 储存 $kind$ ,因为擦除的存在,它会被存储为 $Class$ 对象,从而使得创建数组时不会产生具体结果,只能配合转型使用。

2. 擦除的补偿

        因为擦除的原因,类内部无法得到类型参数信息,因此 $instanceof$ 不能使用。但是可以使用动态 $isInstance(\ )$ :

class Building {}

class House extends Building {}

class ClassType<T> {
    Class<T> kind;

    public ClassType(Class<T> kind) {
        this.kind = kind;
    }

    public boolean f(Object obj) {
        return kind.isInstance(obj);
    }

    public static void main(String[] args) {
        ClassType<Building> classType = new ClassType<>(Building.class);
        System.out.println(classType.f(new Building())); // true
        System.out.println(classType.f(new House())); // true
    }
}

        在泛型类中,通过类似 $new\ \ T(\ )$ 的方式创建类是不行的,因为Java编译器无法确认类型拥有默认构造器。而在C++中由于编译器的定期检查,这种行为的安全性得以确保。如果想要在Java中以类似方式创建对象,可以通过工厂模式:

class ClassAsFactory<T> {
    T x;
    public ClassAsFactory(Class<T> kind) throws NoSuchMethodException {
        try {
            x = kind.getDeclaredConstructor().newInstance();
        } catch (IllegalAccessException | InstantiationException | InvocationTargetException e) {
            e.printStackTrace();
        }
    }
}

        虽然编译可以通过,但是不一定每次都能成功创建,比如 $ClassAsFactory<Integer>$ 就会失败,因为 $Integer$ 类没有默认构造器。为了避免这个问题,可以使用显示工厂:

interface Factory<T> {
    T create();
}

class IntegerFactory implements Factory<Integer> {
    public Integer create() {
        return new Integer(0);
    }
}

class Foo<T> {
    private T x;
    public <F extends Factory<T>> Foo(F factory) {
        x = factory.create();
    }
}

        除了工厂方法之外,还可以使用模板方法:

abstract class GenericWithCreate<T> {
    private final T element;

    public GenericWithCreate() {
        element = create();
    }

    public abstract T create();
}

class X {}

class Creator extends GenericWithCreate<x> {
    @Override
    public X create() {
        return new X();
    }
}

        在泛型类内,你无法直接通过 $new$ 创建泛型数组,一般情况下都是使用 $ArrayList$ 进行替代。但是在一些需要建立泛型数组的情况下,通过 $new$ 建立之后再进行转型这种方式是无效的。因为Java数组将跟踪它们被创建时的实际类型,信息在编译期存储,因此无论怎么转型,它都是 $Object$ 数组。因此最好是在集合内部使用 $Object[\ ]$ ,然后在取出元素时再将其转型为 $T$ 。

public class GenericArray<T> {
    private Object[] array;

    public GenericArray(int size) {
        array = new Object[size];
    }

    public void put(int index, T item) {
        array[index] = item;
    }

    @SuppressWarnings("unchecked")
    public T get(int index) {
        return (T) array[index];
    }
}

        如果要获得整个数组对象,那么就不能使用 $new$ ,而要改为使用 $Class$ 对象:

public class GenericArrayWithTypeToken<T> {
    private T[] array;

    @SuppressWarnings("unchecked")
    public GenericArrayWithTypeToken(Class<T> type, int size) {
        array = (T[]) Array.newInstance(type, size);
    }

    public void put(int index, T item) {
        array[index] = item;
    }

    public T get(int index) {
        return array[index];
    }

    public T[] rep() {
        return array;
    }
}

        可以发现,这两种情况都会产生大量警告。当然,警告是可以忽略的。

3. 边界

        边界提供了一种限制泛型参数类型的机制,潜在的效果是允许泛型调用一些方法。如果能够将参数限制为某个类型的子集,那么就可以运用子集来调用方法。同时,如果你需要的话,还可以在继承的每个层次上添加不同的限制:

class A<T> {}

class B<T> {}

class C<<T extends B> extends A<T>> {}

class D<T extends A & B> {}

        再考虑一种情况,假设 $Apple$ 是 $Fruit$ 的子类,那么:

List<Fruit> list = new ArrayList<Apple>();

        虽然看起来好像是正确的,但却是错误的,因为 $Apple$ 的 $List$ 在类型上并不等价于 $Fruit$ 的 $List$ 。如果你想要让其正确地向上转型,就需要使用通配符:

List<? extends Fruit> list = new ArrayList<Apple>();

        这种方式给定了一个“任何扩展了 $Fruit$ 对象”的边界,$List$ 就可以接受任何可以向上转型为 $Fruit$ 的类型了。除了这种方式外,还可以使用超类通配符,即声明某个特定类的基类:$<?\ \ super\ \ MyClass>$ ,甚至使用类型参数 $<?\ \ super\ \ T>$ 。
        在上面的例子中使用了无界通配符 $?$ 。如果单独使用无界通配符,看起来好像就等价于原生类型。事实也确实是这样,编译器很少关心原生类型和仅使用无界通配符之间的区别。但这也并不意味着仅使用无界通配符没有意义,它可以标识你想在这里使用泛型。此外,无界通配符表示可以接受任何类型的特性还使得它成为存储泛型类的场所,可以将此特性应用于传参中。当然,编译器也会有关注它们间差异的时候:

class Holder<T> {
    private T obj;

    public void setObj(T obj) {
        this.obj = obj;
    }

    public T getObj() {
        return this.obj;
    }
}

public class Wildcards {
    public static void rawArgs(Holder holder, Object object) {
        holder.set(object); // warning
    }

    public static void unboundedArg(Holder<?> holder, Object object) {
        holder.set(object); // error
    }
}

        当 $holder$ 是一个 $Holder<?>$ 时,调用 $rawArgs(\ )$ 后,因为编译器仍然知道它是个泛型类型,因此认为传递一个 $Object$ 类型对象是不安全的,所以会产生警告。同样的,在调用 $unboundedArg(\ )$ 时,因为 $Holder<?>$ 要持有一个具体类型的,因此不能只是向其传递 $Object$ 。
        上面演示了需要使用原生类型的情况,也有情况需要使用 $<?>$ :

public class CaptureConversion {
    public static <T> void f1(Holder<T> holder) {
        T t = holder.get();
    }

    public static <T> void f2(Holder holder) {
        f1(holder);
    }
}

        在上面的例子中将原生类型传入 $f1(\ )$ 会产生异常,而传入 $f2(\ )$ 不会。因为 $f2(\ )$ 使用 $<?>$ 作为参数,编译器可能会推断出实际类型,这种行为称为捕获转换。

4. 自限定类型

        先看下面这个例子:

class GenericType<T> {}

public class CuriouslyRecurringGeneric extends GenericType<CuriouslyRecurringGeneric> {}

        这种泛型称为古怪的循环泛型 ( $CRG$ ) ,本质是将基类的类型参数使用导出类来代替。类似的我们还可以进行下面的声明:

class SelfBounded<T extends SelfBounded<T>> {}

        这样 $T$ 的类型就被限定为类似于:

class A extends SelfBounded<A> {}

        通过使用自限定类型,可以保证类型参数与当前正在被定义的类相同。当然,这个规则也可以通过下面方式避免:

class B extends SelfBounded {}

        上面的代码可以编译,而且不会产生警告。因此自限定并非强制保证类型相同,如果你确实需要强制保证的话,就需要外部工具的辅助。
        自限定类型的价值在于它们可以产生协变参数类型:方法参数类型随子类变化。可以对比下下面的两个例子:

class Base {}

class Derived extends Base {}

interface OrdinaryGetter {
    void set(Base base);
}

interface DerivedGetter extends OrdinaryGetter {
    void set(Derived derived);
}

        在这个例子中,$DerivedGetter$ 重写了 $set(\ )$ 方法,这是合理的,但是会存在两个 $set(\ )$ 方法。有时候我们想要让方法接受子类作为参数,又不想因为重写而同时存在两个同名方法,那么可以通过自限定实现:

interface SelfBounded<T extends SelfBounded<T>> {
    void set(T arg);
}

interface Setter extends SelfBounded<Setter> {}

5. 混型

        混型可以混合多个类,产生一个表示混型中所有类型的类,使得组装多个类变得简单。在C++中可以这样使用混型:

<template T> class A : public T {}

        但是在Java中因为擦除的原因,并不能使用这种方式。一种常见的解决方案是通过接口:

interface A {}

class AImpl implements A {}

        第二种方案是通过装饰器模式:

class Basic {}

class Decorator extends Base {
    public Basic basic;

    public Decorator(Basic basic) {
        this.basic = basic;
    }
}

class A extends Decorator {
    public A(Basic basic) {
        super(basic);
    }
}

class B extends Decorator {
    public B(Basic basic) {
        super(basic);
    }
}

6. 潜在类型机制

        潜在类型机制或结构化类型机制,允许我们横跨类继承结构,可以调用不属于公共接口的方法。当然,因为Java擦除机制的存在,类内部无法保证传入的类型包含特定方法,因此无法像C++那样直接使用潜在类型机制。一个简单的模仿方法是通过接口:

interface I {
    void f1();
}

class A {
    public static <T extends I>
    void f2(T t) {
        t.f1();
    }
}

        但是通过接口方式显然存在着局限性,为了消除这个局限性,我们需要付出更多工作,使用反射机制来实现:

class B {
    public void f3() {}
}

class CommunicateReflectively {
    public static void f4(Object obj) {
        Class<?> c = obj.getClass();
        try {
            Method f = c.getMethod("f3");
            f.invoke(obj);
        } catch(NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            e.printStackTrace();
        }
    }
}

        反射会在运行时进行动态检查。通过反射可以在运行时动态地确定所需要的方法是否需要并且调用,而且通过 $try-catch$ ,可以在缺少必须方法时部分实现目标。

Java泛型