Java泛型最全解析

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

将类型声明为超类或接口可以在一定范围内实现代码的复用,但这也只是将限制范围扩到了超类及其子类或实现了接口的类,在一些情况下这个范围还是不能满足到我们,尤其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时指定类型参数为Juice
public 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 -> AppleJuice
public 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();
复制代码

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

// Error
Bottle<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());
// error
bottle.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());
// Error
Drink drink = b.getDrink();
复制代码

4.3、无界通配符

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

Bottle<?> b = new Bottle<>(new AppleJuice());
Drink drink = (Drink) b.getDrink();

// ERROR
b.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;
}

链接:https://gitee.com/marshal-huang/idea-activation-code-323
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

猜你喜欢

转载自blog.csdn.net/qq_38082146/article/details/115115771