写点什么

Java 泛型最全指南

用户头像
xcbeyond
关注
发布于: 2021 年 03 月 24 日

一般的代码要求类型必须是确定的,这对可以被不同的类型复用的代码产生了极大的限制。

将类型声明为超类或接口可以在一定范围内实现代码的复用,但这也只是将限制范围扩到了超类及其子类或实现了接口的类,在一些情况下这个范围还是不能满足到我们,尤其 java 是单根继承的。我们希望的是“非特定类型”的编码,而不是一个具体的类或接口。


Java 5 开始引入的泛型可以支持我们编写出“非特定类型”的代码。泛型实现了将类型参数化,在定义类、接口或方法时声明类型参数,到使用时再决定其具体的类型。


泛型是编译时的特性,在编译时编译器对会对泛型进行类型检查并在类的‘边界(入参和返回)’处添加一些额外的转型代码,以此来保证泛型运行时的类型安全。我们在使用时看上去像是用具体的类型替换了我们申明的类型参数。但实现上在编译后参化类型信息就丢失了,我们指定的具体的类型在运行时已经被擦除了。


Java 采用类型擦除,而不像 c++一样的类型替换也是无奈之举,因为 java 5 之前没有泛型,为了兼容 java 5 之前的代码而无奈选择类型擦除。


名词解释:

  • 类型参数:声明泛型类、接口或方法时在尖括号中申明的类型参数,如 List 的 E

  • 泛型类:声明了类型参数的类、接口和方法分别称为泛型类、泛型接口和泛型方法。

  • 参数化类型:在使用泛型类时指定了具体的类型后称为参数化类型,如 List

  • 原始类型:参数化类型的泛型类的 Class,如 List 的原始类型为 List,List[]的原始类型也为 List

一、泛型定义和使用:

1.1、泛型类

在类名之后使用尖括号声明类型参数,声明的类型参数可以像普通类型一样用在类型声明处使用,到使用时再决定其具体类型,然后编译器会帮我们处理一些类型类型转换的细节。

public class Holder<T> {    T val;
public Holder(T val) { this.val = val; }
public T getVal() { return val; } public void setVal(T val) { this.val = val; } public static void main(String[] args) { Holder<String> strHolder = new Holder<String>("abc"); String s = h.getVal(); }}
复制代码

在使用时指定了的 Holder 的类型参数为 String。可以将 getVal()的返回值直接赋给一个 String 变量,而不用显示的转型。在使用 setVal 时也必须传入 String 类或其子类,若入参不是 String 或其子类那么编译时会报错。


在 Java7 之前 new 参数化类型时需要指定类型,但在 Java7 之后 new 操作可以不用显示指定类型,编译器会自动推导出来:

 Holder<String> h = new Holder<>("abc");
复制代码

多个类型参数使用逗号分隔:

public class Holder<A, B, C> {
public A v1; public B v2; public C v3;
public Holder(A v1, B v2, C v3) { this.v1 = v1; this.v2 = v2; this.v3 = v3; }
public static void main(String[] args) { Holder<String, Integer, Float> h = new Holder<>("abc", 1, 2.5); }}
复制代码

内部类可以使用外部类的类型参数:

class A<T> {    class B {        T a;    }}
复制代码

匿名内部类也可以是参数化类型的

interface A<T> {    T next();}new A<String>() {    @Override    public String next() {        return null;    }};
复制代码

静态的属性、静态方法、和静态内部类是无法使用类的泛型参数的。如果要使 static 方法具有泛型能力,可以使用泛型方法。

public class Calculate<T> {    // 静态方法时无法使用T的,编译时就会报错    public static T add(T a, T b) {        T c = a + b;    }}
复制代码

1.2 泛型接口

接口也可以声明为泛型,声明方式同泛型类一样。

public interface Generator<T> {    T next();}
复制代码

在实现泛型类时需要为类型参数指定具体的类型:

public interface Bottle<T> {    void pourInto(T t);    T pourOut();}
// 实现Bottle时指定类型参数为Juicepublic class GlassBottle implements Bottle<Juice> { public void pourInto(Juice juice) {
} public Juice pourOut() { return null; }}
复制代码

1.3 泛型方法

可以单独为方法声明泛型,而这个类不必是泛型类。定义泛型方法,只需要将泛型参数列表置于返回值之前。声明的类型参数在方法中定义类型的地方像普通类一样使用。

public class Test {    public static <T> void t(T x) {        System.out.println(x.getClass().getName());    }        public static <K,V> Map<K, V> newMap() {        return new HashMap<K, V>();    }
public static void main(String[] args) { t(11); // java.lang.Integer t("abc"); // java.lang.String
Map<String, Date> m = newMap(); m.put("now", new Date()); }}
复制代码

使用泛型方法时不用显示的指定出具体的类型,编译器会根据方法类型参数的入参或返回赋值的类型推断出具体的类型,但若将调用结果直接作为一个参数传递给另外一个方法,这时编译器并不会进行类型推断。如果是基本类型则会自动装箱为包装类型。

public static <T> String className(T v) {    return v.getClass().getSimpleName();}
public static void main(String[] args) { // 输出Integer,自动推断出是Integer System.out.println(Test.className(11));}
复制代码

在调用泛型方法时也可以显示的指明类型,在点操作符与方法名之间插入尖括号,然后把类型置于其中。

Test.<String, Date>newMap();
复制代码

变长参数列表也可以使用泛型参数:

public static <T> List<T> toList(T... args) {    List<T> l = new ArrayList<T>(args.length);    for (T e : args) {        l.add(e);    }    return l;}
复制代码

当调用一个可变参数方法时,会创建一个数组来存放可变参数,若参数的类型是泛型的,那么将创建泛型的数组,但 Java 不是允许直接使用泛型创建数组吗?这里 java 做了一些妥协允许为可变参数创建一个泛型数组。

但可变参数列表的入参是可以为不同类型的,所以有时编译也无法决定泛型可变参数的具体类型,只能选择一个最通用的类型。

public class Test {
public static void main(String[] args) { System.out.println(toArray(Integer.valueOf(11), Double.valueOf(13)).getClass()); }
public static <T> T[] toArray(T... args) { return args; }}
复制代码

输出:

class [Ljava.lang.Number;

二、 继承泛型类/实现泛型接口

2.1、继承时指定类型

在继承一个泛型类或实现一个泛型接口时需要指定具体类型,指定了具体的类型后对子类而言它的父类或实现的接口就是参数化类型的,通过 Class 的 getGenericSuperclass 获取父类的类型时返回的类型为 ParameterizedType 的。

public class Holder<T> {
private T val;
public Holder(T val) { this.val = val; }
public T getVal() { return val; }
public void setVal(T val) { this.val = val; }}
class Apple { public void show() { System.out.println(getClass().getSimpleName()); }}
public class AppleHolder extends Holder<Apple> {
public AppleHolder(Apple apple) { super(apple); }
public static void main(String[] args) { AppleHolder appleHolder = new AppleHolder(new Apple()); Apple apple = appleHolder.getVal(); apple.show();
System.out.println(appleHolder.getClass().getGenericSuperclass() instanceof ParameterizedType); }}
复制代码

输出:

Apple

true

2.2、继承时不指定类型

若继承类或实现接口时未指定类型,则对子类而言父类或接口的就是一个普通的类或接口,而其类型参数被擦除为 Object,通过 Class 的 getGenericSuperclass 返回的类型是 Class 的。

public class CommonHolder extends Holder {
public CommonHolder(Object val) { super(val); }
public static void main(String[] args) { System.out.println(CommonHolder.class.getGenericSuperclass() instanceof Class); }}
复制代码

输出:

true

2.3、指定为子类中的类型参数

也可以将子类中声明的类型参数给到父类,后面为子类指定类型时父类也获得同样的类型。对子类而言它的父类仍是参数化类型的,通过 Class 的 getGenericSuperclass 的返回类型仍是 ParameterizedType 的。

public class CommonHolder<T> extends Holder<T> {
public CommonHolder(T val) { super(val); }
public static void main(String[] args) { System.out.println(CommonHolder.class.getGenericSuperclass() instanceof ParameterizedType); }}
复制代码

输出:

true

三、泛型的边界

由于类型擦除,对于类型参数我们是无法直接使用到具体的属性或方法的。如下面的调用会编译失败:

import java.sql.DriverManager;import java.util.*;;
public class Test<T> { public T val;
public void show() { // 编译时失败 val.show(); }
public static class Apple { public void show() {
} }
public static void main(String[] args) throws ClassNotFoundException { Test<Apple> t = new Test<>(); t.show(); }}
复制代码

上面例子中即使我们知道 val 的类型后面会是时 Show,但因为类型擦除后无法保证这样做的安全性,所以编译器禁止这样的用法。

不过可以通过 extends 显示的声明类型参数的上界,若没有声明那么上界就是 Object。声明类上界后,在使用该泛型类时指定的类型只能为上界或其子类。

public class Show {    public void show() {}}    public class Test<T extends Show> {    public T val;
public void show() { // 可以调用 val.show(); } public static void main(String[] args) { Test<Show> t = new Test<>(); t.show(); }}
复制代码

在没有声明上界时默认上界为 Object,所有我们可以在没有声明上界的情况下调用 Object 的方法。

public class Test<T> {    public T val;    public void show() {        val.getClass();        val.toString();        val.hashCode();    }}
复制代码

四、通配符:

// 继承关系:Drink -> Juice -> AppleJuicepublic class Drink {}public class Juice extends Drink {}public class AppleJuice extends Juice {}
public class Bottle<T> { private T drink;
public Bottle(T drink) { drink = drink; }
public T getDrink() { return drink; }
public void setDrink(T drink) { drink = drink; }}
复制代码

对于普通的类,同一个类的对象之间是可以互相赋值的,也可以将子类对象赋值给父类对象。

Juice juice = new Juice();juice = new AppleJuice();
复制代码

但对于泛型类只要指定的类型参数不同,,即使他们是同一个泛型类,它们也是不同的参数化类型,互相直接时不能赋值的:

// ErrorBottle<Juice> b1 = new Bottle<AppleJuice>(new AppleJuice());
复制代码

虽然在类型擦除后他们都是 Bottle,但在编译时编译器在泛型类的边界插入的类型处理代码是不同的,显然不能用处理 AppleJuice 的代码去处理其他类型,所以在编译器角度它们是不同的类型,编译时会报错。


为了解决的类型参数有继承关系的泛型实例之间的赋值问题,java 提供了通配符。

4.1、上界通配符

在定义泛型变量时可以使用 extends 关键指定类型的上界,从而使声明的变量可以被赋值为类型参数为上界类及其子类的泛参数化类型,当然前提是泛型类是相同的或父子类。

Bottle<? extends Juice> b = new Bottle<AppleJuice>(new AppleJuice());
复制代码

声明上界为 Juice 的 b 可以被赋值为 Bottle。但使用上界通配符后泛型实例的使用也受到了一定限制。

虽然使用了 extends 通配符,但编译器任然不知道 b 的具体类型是 AppleJuice 还是 OrangeJuice 的子类,所以编译器无法保证参数类型有类型参数的方法的入参的安全性,例如:

Bottle<? extends Juice> bottle = new Bottle<AppleJuice>(new AppleJuice());// errorbottle.setDrink(new OrangeJuice());
复制代码

setDrink 的定义为:

void setDrink(T drink)
复制代码

那么显然 bottle 变量的实际类型为 Bottle,所以 setDrink 会编译为:

setDrink((AppleJuice) val)
复制代码

显然同级类型之间强制类型转化时不安全的,所以使用上界通配符声明的实例是不允许调用参数有类型参带的方法的。但入参为 null 时可以的,因为 null 并没有具体的类型。但返回是安全的,将子类赋给父类是安全的,所以返回类型类型参数的方法不受影响。

Bottle<? extends Juice> b = new Bottle<AppleJuice>(new AppleJuice());Juice juice = b.getDrink();
复制代码
4.2、下界通配符

使用 super 关键字指定下界的泛型变量,指定了下界的变量只能赋值为类型参数为指定的下界或下界的父类的类型。

Bottle<? super Juice> b = new Bottle<Drink>(new AppleJuice());
复制代码

在编译时入参会被转换为实际的类型 Drink:

setDrink((Drink) val)
复制代码

用父类型来操作子类型是安全的,所以下界通配符声明的实例使用入参带类型参数的方法是安全的。但由于不能将父类赋值给子类,所以下界通配符声明的实例不能将返回类型为参数类型的方法的返回值赋给其他变量。

Bottle<? super Juice> b = new Bottle<Drink>(new AppleJuice());// ErrorDrink drink = b.getDrink();
复制代码

4.3、无界通配符

参数类型指定为?号,表示任意类型都可以。

Bottle<?> b = new Bottle<>(new AppleJuice());Drink drink = (Drink) b.getDrink();
// ERRORb.setDrink(new AppleJuice());
复制代码

使用无界通配符看起来和原始生类型没有什么区别,但无界通配符的意义在于在我们明确知道这里使用任意类型,并且无界通配符会进行类型检查,因为无界通配符不知道确切的类型所以无法保证安全性,所无界通配符的变量不能调用入参类型为类型参数的方法。

五、类型擦除

使用泛型时指定的类型只在编译期生效,在编译后会将所有的类型参数擦除到它的第一个边界,未指定边界的情况下擦除为 Object。

因为类型擦除,类型参数在运行时已经不存在,所以不能在运行时显式的使用泛型的类型操作,如 instanceof、new、T.class 等,但前置类型转换时可以的:

public class Test<T> {    Class<?> type;
public Test(Class<?> type) { this.type = type; }
public T[] newArray(int size) { return (T[]) Array.newInstance(type, size); }
public static void main(String[] args) { Test<String> t = new Test<>(String.class); String[] strArr = t.newArray(10); }
复制代码

虽然我们可以指定不同类型参数然,但在擦除后这些都指向同一个类型。如 List 和 List 的 Class 都是同一个即 List.Class。

因为在编译时擦除了具体的类型信息,为类保证运行时正确的类型行为,编译器在编译时对泛型‘边界’,即对类中有泛型入参和放回的方法做了类型检查和插入强制转型代码,调用方法时对入参进行类型转换,返回时对返回值进行转换。

六、 建议

6.1、指定类型信息

因为类型擦除,我们无法在运行时获取具体的参数类型信息,若需要具体的类型信息可以显示的传递类型的 Class 对象。

public class Test<T> {    private Class<T> kind;    public T val;    public Test(Class<T> kind) {        this.kind = kind;    }    public boolean isType(Object o) {        return kind.isInstance(o);    }}
复制代码

6.2、能使用泛型方法就不使用泛型类

如果使用泛型方法可以取代泛型类,那么应该尽量使用泛型方法替换类的泛型类。

6.3、尽量使用参数化泛型:

如果一个类或接口是泛型的那么应该尽量使用其参数化的类型,这样编译器在编译时会为我们做一些类型的检查,避免在运行时报错。

若确是没有具体的类型也建议使用通配符,如 List<?>。使用通配符可以在编译时进行检查,并阻止我们调用有类型参数的方法。

直接使用泛型的原始类型时有风险的,原始类型在编译时并不会进行类型检查,且类型参数被擦除为 Object,Object 可以接受任意类型的实例,若给到类的是不同类型的实例,那么在类中操作这些实例是有一定安全隐患且这些隐患可能在运时才暴露出来。而 java 之所以支直接使用泛型的原始类型只是为了兼容性 Java5 之前的代码。

6.4、不要将参数化类型赋给原始类型使用

为了兼容性,java 没有禁止将参数化类型的变量转为原始类型,若这样做了只是在编译时产生告警。但将参数化类型赋给原始类型后,编译器不会再对原始类型实例的操作进行类型检查,这可能会造成运行时的错误。

class Calculator<T> {    public int intAdd(T v1, T v2) {        return ((Number) v1).intValue() + ((Number) v1).intValue();    }}public class Test {    public static void main(String[] args) {        Calculator<Integer> intCal= new Calculator<>();        Calculator cal = intCal;        cal.intAdd("a", "b");    }}
复制代码

Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Number

at com.test.java.Calculator.intAdd(Test.java:10)  at com.test.java.Test.main(Test.java:20)
复制代码

将参数化类型 Calculator 的 intCal 赋给 Calculator 的 cal,后面对 cal 的的操作编译器不会进行类型检查,这个错误在运行时才会抛出。

public static void main(String[] args) {    List<String> strList = new ArrayList<>();    List list = strList;    list.add(Integer.valueOf(11));    String s = strList.get(0);}
复制代码

Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String

at com.test.java.Test.main(Test.java:27)  
复制代码

上面代码在编译时只会产生一个警告,但在运行时会报出一个致命错误。因为将 strList 赋给了 List 类型的 list,锁编译时不会对 list 变量的操作进行类型检查。而因为类型擦除,在运行时 String 被擦除为 Object,所以 list.add(Integer.valueOf(11))可以正常运行。但因为 strList 是 List 类型的,编译时为其插入了 String 类型转换的代码,而将一个 Integer 转化为 String 时非法的。

6.5、尽量不要使用泛型的可变参数列表

泛型的可变参数有时应为无法确定具体的类型,只能将可变参数的数组类型定位一个通用的类型。对于可变参数列表的数组,我们不仅仅是用来传递值,可能会对其进行操作,这就带来了类型安全的风险。 应该尽量避免使用泛型的可变参数或使用 List 的参数化类型代替可变参数。

effective java 有一个经典的例子,传入三个对象随机选取两个最为预估数组返回:

public class Test {
public static void main(String[] args) { String[] strArr = pickTwo("a", "b", "c"); }
public static <T> T[] toArray(T... args) { return args; }
public static <T> T[] pickTwo(T a, T b, T c) { switch (ThreadLocalRandom.current().nextInt(3)) { case 0: return toArray(a, b); case 1: return toArray(a, c); case 2: return toArray(b, c); } throw new AssertionError(); }}
复制代码

Exception in thread "main" java.lang.ClassCastException: [Ljava.lang.Object; cannot be cast to [Ljava.lang.String;

at cn.ly.test.java.Test.main(Test.java:21)复制代码
复制代码

这个类在编译时并不会报错,但运行时会抛出 ClassCastException 异常。pickTwo 的参数是类型参数的的在将这一类型的参数传递给 toArray 方法时编译器无法判断类型参数的,只能创建一个 Object[]数组来持有可变参数。对 pickTwo 的返回编译为我们插入了一个 String[]的类型转换,但此时实际类型是 Object[]是不能转换为 String[]的。

6.6、强制类型转化泛型时应该转为通配符类型

在强制类型转化时,若目的类型是一个泛型那么应该将其转化为该类型的通配符参数化类型,而非原始类型。这样转型后变量收到编译器的检查。

if (o instanceof List) {    List<?> l = (List<?>) o;}
复制代码


用户头像

xcbeyond

关注

不为别的,只为技术沉淀、分享。 2019.06.20 加入

公众号:程序猿技术大咖,专注于技术输出、分享。

评论

发布
暂无评论
Java泛型最全指南