11

Java 泛型详解

 3 years ago
source link: http://www.cnblogs.com/Yee-Q/p/14019635.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

本文部分摘自 On Java 8

概述

在 Java5 以前,普通的类和方法只能使用特定的类型:基本数据类型或类类型,如果编写的代码需要应用于多种类型,这种严苛的限制对代码的束缚就会很大

Java5 的一个重大变化就是引入泛型,泛型实现了参数化类型,使得你编写的组件(通常是集合)可以适用于多种类型。泛型的初衷是通过解耦类或方法与所使用的类型之间的约束,使得类或方法具备最宽泛的表达力。然而很快你就会发现,Java 中的泛型并没有你想的那么完美,甚至存在一些令人迷惑的实现

泛型类

促成泛型出现的最主要动机之一就是为了创建集合类,集合用于存放要使用到的对象。现有一个只能持有单个对象的类:

class Automobile {}

public class Holder1 {
    private Automobile a;
    public Holder1(Automobile a) { this.a = a; }
    Automobile get() { return a; }
}

如果没有泛型,那么就必须明确指定其持有的对象的类型,会导致该复用性不高,它无法持有其他类型的对象,我们当然不希望为每个类型都编写一个新类

在 Java5 以前,为了解决这个问题,我们可以让这个类直接持有 Object 类型的对象,这样就可以持有多种不同类型的对象了。但通常而言,我们只会用集合存储同一类型的对象。泛型的主要目的之一就是用来约定集合要存储什么类型的对象,并且通过编译器确保规约得以满足

所以,与其使用 Object,我们更希望先指定一个类型占位符,稍后再决定具体使用什么类型。由此我们需要使用类型参数,用尖括号括住,放在类名后面。然后在使用这个类时,再用实际的类型替换此类型参数

public class GenericHolder<T> {
    private T a;
    public GenericHolder() {}
    public void set(T a) { this.a = a; }
    public T get() { return a; }

    public static void main(String[] args) {
        // 在 Java7 中右边的尖括号可以为空
        GenericHolder<Automobile> h2 = new GenericHolder<Automobile>();
        GenericHolder<Automobile> h3 = new GenericHolder<>();
        h3.set(new Automobile()); // 此处有类型校验
        Automobile a = h3.get();  // 无需类型转换
        //- h3.set("Not an Automobile"); // 报错
    }
}

元组类库

有时一个方法需要能返回多个对象,而 return语句只能返回单个对象,解决的方法就是创建一个对象,用它来打包想要返回的多个对象。元组的概念正是基于此,元组将一组对象直接打包存储于单一对象中,可以从该对象读取其中元素,却不允许向其中存储新对象(这个概念也称数据传输对象或信使)

元组可以具有任意长度,元组中的对象可以是不同类型的,我们希望能为每个对象指明类型,这时泛型就派上用场了。例如下面是一个可以存储两个对象的元组:

public class Tuple<A, B> {
    public final A a1;
    public final B a2;
    public Tuple(A a, B b) { a1 = a; a2 = b; }
    public String rep() { return a1 + ", " + a2; }

    @Override
    public String toString() {
        return "(" + rep() + ")";
    }
}

使用 final 修饰成员变量可以保证其不被修改,如果用户想存储不同的元素,那么就必须创建新的 Tuple 对象。当然也可以允许用户重新对 a1、a2 赋值,但无疑前一种形式会更加安全

利用继承机制可以实现长度更长的元组:

public class Tuple3<A, B, C> extends Tuple2<A, B> {
    public final C a3;
    public Tuple3(A a, B b, C c) {
        super(a, b);
        a3 = c;
    }

    @Override
    public String rep() {
        return super.rep() + ", " + a3;
    }
}

泛型方法

到目前为止,我们已经研究了参数化整个类,其实还可以参数化类中的方法。类本身是否是泛型,与它的方法是否是泛型并没有什么直接关系。我们应该尽可能使用泛型方法,通常将单个方法泛型化要比将整个类泛型化要更加清晰易懂

要定义泛型方法,请将泛型参数列表放置在返回值之前:

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);
        gm.f(1.0);
        gm.f(1.0F);
        gm.f('c');
        gm.f(gm);
    }
}

使用泛型方法时,通常不需要指定参数类型,因为编译器会找出这些类型,这称为类型参数推断,因此,对 f() 的调用看起来像普通的方法调用,而且像是被重载了无数次一样

泛型擦除

当你开始深入研究泛型时,你会发现一个残酷的现实:在泛型代码内部,无法获取任何有关泛型参数类型的信息

class Frob {}
class Fnorkle {}
class Quark<Q> {}
class Particle<POSITION, MOMENTUM> {}

public class LostInformation {

    public static void main(String[] args) {
        List<Frob> list = new ArrayList<>();
        Map<Frob, Fnorkle> map = new HashMap<>();
        Quark<Fnorkle> quark = new Quark<>();
        Particle<Long, Double> p = new Particle<>();
        System.out.println(Arrays.toString(list.getClass().getTypeParameters()));
        System.out.println(Arrays.toString(map.getClass().getTypeParameters()));
        System.out.println(Arrays.toString(quark.getClass().getTypeParameters()));
        System.out.println(Arrays.toString(p.getClass().getTypeParameters()));
    }
}

/* Output:
[E]
[K,V]
[Q]
[POSITION,MOMENTUM]
*/

正如上例中输出所示,你只能看到用作参数占位符的标识符,这并非有用的信息。Java 泛型是使用擦除实现的,这意味着当你在使用泛型时,任何具体的类型信息都被擦除了,你唯一知道的就是你在使用一个对象。因此 List<String> 和 List 在运行时实际上是相同的类型,它们都被擦除成原生类型 List

再来看一个例子:

class Manipulator<T> {
    private T obj;

    Manipulator(T x) {
        obj = x;
    }

    // Error: cannot find symbol: method f():
    public void manipulate() {
        obj.f();
    }
}

public class Manipulation {
    public static void main(String[] args) {
        HasF hf = new HasF();
        Manipulator<HasF> manipulator = new Manipulator<>(hf);
        manipulator.manipulate();
    }
}

因为擦除,Java 编译器无法将 manipulate() 方法能调用 obj 的 f() 方法这一需求映射到 HasF 具有 f() 方法这个事实上。为了调用 f(),我们必须协助泛型类,为泛型类给定一个边界,以此告诉编译器只能接受遵循这个边界的类型。这里重用了 extends 关键字。由于有了边界,下面的代码就能通过编译:

public class Manipulator2<T extends HasF> {
    private T obj;

    Manipulator2(T x) {
        obj = x;
    }

    public void manipulate() {
        obj.f();
    }
}

边界 <T extends HasF> 声明 T 必须是 HasF 类型或其子类。如果情况确实如此,就可以安全地在 obj 上调用 f() 方法。泛型类型参数会擦除到它的第一个边界(可能有多个边界,稍后你将看到)。我们还提到了类型参数的擦除。编译器实际上会把类型参数替换为它的擦除,就像上面的示例,T 擦除到了 HasF,就像在类的声明中用 HasF 替换了 T 一样。如果我们愿意,完全可以把上例的 T 替换成 HashF,效果也是一样的,那么泛型的意义又何在呢?

这提出了很重要的一点:泛型只有在类型参数比某个具体类型(以及其子类)更加“泛化”,代码能跨多个类工作时才有用。因此,使用类型参数通常比简单的声明类更加复杂。但是,不能因此认为使用 <T extends HasF> 形式就是有缺陷的。你必须查看所有的代码,从而确定代码是否复杂到必须使用泛型的程度

有关泛型擦除的困惑,其实是 Java 为实现泛型的一种妥协,因为泛型并不是 Java 语言出现时就有的。擦除减少了泛型的泛化性,泛型类型只有在静态类型检测期间才出现,在此之后,程序中的所有泛型类型都将被擦除,替换为它们的非泛型上界。例如, List<T> 这样的类型注解会被擦除为 List,普通的类型变量在未指定边界的情况下会被擦除为 Object

在 Java5 以前编写的类库是没有使用泛型的,而作者可能打算重新用泛型编写,或者根本不打算这样做。Java 设计者们既要保证旧代码和类文件依然合法,还得考虑当某个类库变为泛型时,不会破坏依赖于它的代码和应用。Java 设计者们最终认为泛型是唯一可行的解决方案,擦除使得向泛型的迁移成为可能,为了实现非泛型的代码和泛型代码共存,必须将某个类库使用了泛型这样的“证据”擦除

基于上述观点,当你在编写泛型代码时,必须时刻提醒自己,你只是看起来拥有有关参数的类型信息而言。因为擦除,我们无法在运行时知道确切的类型,为了补偿擦除带来的弊端,我们可以为所需的类型显示传递一个 Class 对象,以在类型表达式中使用它

class Building {
}

class House extends Building {
}

public class ClassTypeCapture<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.class);
        System.out.println(ctt1.f(new Building()));
        System.out.println(ctt1.f(new House()));
        ClassTypeCapture<House> ctt2 =
                new ClassTypeCapture<>(House.class);
        System.out.println(ctt2.f(new Building()));
        System.out.println(ctt2.f(new House()));
    }
}

边界和通配符

由于擦除会删除类型信息,因此唯一可用于无限制泛型参数的方法是那些 Object 可用的方法。边界允许我们对泛型使用的参数类型施以类型,将参数限制为某类型的子集,那么就可以调用该子集中的方法。为了应用约束,Java 泛型使用了 extends 关键字

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

interface Weight {
    int weight();
}

class Solid<T extends Coord & Weight> {
    T item;

    Solid(T item) {
        this.item = item;
    }

    T getItem() {
        return item;
    }

    int getX() {
        return item.x;
    }

    int getY() {
        return item.y;
    }

    int getZ() {
        return item.z;
    }

    int weight() {
        return item.weight();
    }
}

class Bounded
        extends Coord implements Weight {

    @Override
    public int weight() {
        return 0;
    }
}

public class BasicBounds {
    public static void main(String[] args) {
        Solid<Bounded> solid =
                new Solid<>(new Bounded());
        solid.getY();
        solid.weight();
    }
}

引入通配符可以在泛型实例化时更加灵活地控制,也可以在方法中控制方法的参数,具体语法如下:

  • ? extends T:表示 T 或 T 的子类
  • ? super T:表示 T 或 T 的父类
  • ?:表示可以是任意类型

值得注意的问题

在这里主要阐述在使用 Java 泛型时会出现的各类问题

1. 任何基本数据类型不能作为类型参数

Java 泛型的限制之一是不能将基本类型用作类型参数。因此,不能创建 ArrayList<int> 之类的东西。 解决方法是使用基本类型的包装器类以及自动装箱机制。如果创建一个 ArrayList<Integer>,并将基本类型 int 应用于这个集合,那么你将发现自动装箱机制将自动地实现 int 到 Integer 的双向转换,这几乎就像是有一个 ArrayList<int> 一样

2. 实现参数化接口

一个类不能实现同一个泛型接口的两种变体,由于擦除的原因,这两个变体会成为相同的接口。下面是产生这种冲突的情况:

interface Payable<T> {}

class Employee implements Payable<Employee> {}

class Hourly extends Employee implements Payable<Hourly> {}

Hourly 不能编译,因为擦除会将 Payable<Employe> 和 Payable<Hourly> 简化为相同的类 Payable,这样,上面的代码就意味着在重复两次地实现相同的接口。十分有趣的是,如果从 Payable 的两种用法中都移除掉泛型参数(就像编译器在擦除阶段所做的那样)这段代码就可以编译

3. 转型和警告

使用带有泛型类型参数的转型不会有任何效果,例如:

class Storage<T> {
    
    private Object obj;

    Storage() {
        obj = new Object();
    }

    @SuppressWarnings("unchecked")
    public T pop() {
        return (T)obj;
    }
}

public class GenericCast {

    public static void main(String[] args) {
        Storage<String> storage = new Storage<>();
        System.out.println(storage.pop());
    }
}

如果没有 @SuppressWarnings 注解,编译器将对 pop() 产生 “unchecked cast” 警告。由于擦除的原因,编译器无法知道这个转型是否是安全的,并且 pop() 方法实际上并没有执行任何转型。 这是因为,T 被擦除到它的第一个边界,默认情况下是 Object,因此 pop() 实际上只是将 Object 转型为 Object

4. 重载

下面的程序是不能编译的,因为擦除,所以重载方法产生了相同的类型签名

public class UseList<W, T> {
    void f(List<T> v) {}
    void f(List<W> v) {}
}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK