Java

Java笔记---泛型

2020-05-17  本文已影响0人  不是你的bug

总结了泛型的基本语法、上下级通配符、泛型反射以及使用泛型的一些实践,看完还不会用泛型你顺着网线来打我(狗头)。

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

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

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

泛型是编译时的特性,在编译时编译器对会对泛型进行类型检查并在类的‘边界(入参和返回)’处添加一些额外的转型代码,以此来保证运泛型行时的类型安全。

我们在使用时看上去像是用具体的类型替换了我们申明的类型参数。但实现上在编译后参化类型信息就丢失了,我们指定的具体的类型在运行时已经被擦除了。

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

名词解释:

一、泛型类:

1、声明和使用泛型类

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

public class Holder<T> {
    T val;

    public Holder(T val) {
        this.val = val;
    }

    public T getVal() {
        return val;
    }
    
    public void setVal(T val) {
        return 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");
2、多个类型参数使用逗号分隔:
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);
    }
}
3、泛型接口

接口也可以声明为泛型,声明和使用方式同泛型方法一样:

public interface Generator<T> {
    T next();
}

4、内部类可以使用外部类的类型参数:
class A<T> {
    class B {
        T a;
    }
}
5. 匿名内部类也可以是参数化类型的
interface A<T> {
    T next();
}
new A<String>() {
    @Override
    public String next() {
        return null;
    }
};
6. 静态的无法访问类型参数

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

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

1、继承时指定类型

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

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、继承时不指定类型

若继承类或实现接口时未指定类型,则对子类而言父类或接口的就是一个普通的类或接口,而其类型参数被擦除为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

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

三、泛型方法

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

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;

四、通配符:

// 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 juice1 = new Juice();
Juice juice2 = new Juice();
juice1 = juice2;
juice2 = new AppleJuice();

但对于泛型类只要指定的类型不同,那么就是不同的参数化类型,即使他们是同一个泛型类,即使类型参数之间有继承关系,也是不能互相赋值的:

// Error
Bottle<Juice> b1 = new Bottle<AppleJuice>(new AppleJuice());

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

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

1、上界通配符

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

Bottle<? extends Juice> b = new Bottle<AppleJuice>(new AppleJuice());

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

虽然使用了extends通配符,但编译器任然不知道具体的类型是Juice或是Juice的子类,所以编译器无法保参数类型有类型参数的方法的入参的安全性,所以使用上界通配符声明的实例是不允许调用参数有类型参带的方法的。但入参为null时可以的,因为null并没有具体的类型。但返回是安全的,将子类赋给父类是安全的,所以返回类型类型参数的方法不受影响。

Bottle<? extends Juice> b = new Bottle<AppleJuice>(new AppleJuice());
Juice juice = b.getDrink();
// Error
b .setDrink(new AppleJuice());
2、下界通配符

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

Bottle<? super Juice> b = new Bottle<Drink>(new AppleJuice());

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

Bottle<? super Juice> b = new Bottle<Drink>(new AppleJuice());
// Error
Drink drink = b.getDrink();
3、无界通配符

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

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

// ERROR
b.setDrink(new AppleJuice());

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

五、泛型的边界

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

import java.sql.DriverManager;
import java.util.*;;

public class Test<T> {
    public T val;

    public void show() {
        // 编译时失败
        val.show();
    }

    public static class Show {
        public void show() {
        }
    }

    public static void main(String[] args) throws ClassNotFoundException {
        Test<Show> 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();
    }
}

六、 类型擦除

使用泛型时指定的类型只在编译时期生效,在编译后会将所有的类型参数擦除到它的第一个边界,未指定边界的情况下擦除为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<String>和List<Integer>的Class都是同一个即List.Class。

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

六、 建议

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);
    }
}
2、能使用泛型方法就不使用泛型类

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

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

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

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

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

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<Integer>的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>类型的,编译时为其插入了String类型转换的代码,而将一个Integer转化为String时非法的。

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、强制类型转化泛型时应该转为通配符类型

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

if (o instanceof List) {
    List<?> l = (List<?>) o;
}
上一篇下一篇

猜你喜欢

热点阅读