• 0

  • 500

  • 收藏

温故知新-原来泛型还有这么多知识点可挖

黑猫

我不是黑客

7个月前

1. 泛型基础

泛型是jdk 5.0引入的新特性,它类似于C++的模板类,意味着编写的代码可以被不同类型的对象所重用。

在使用泛型之前,如果要你实现一个通用的,可以处理不同类型数据的方法,那么我们会使用对象的公共父类Object来实现:

public class Storage {
    Object value;
    public Object getValue() {
        return value;
    }
    public void setValue(Object value) {
        this.value = value;
    }
}
复制代码

这样,Storage类就可以存取任意类型的数据。只要不发生类型强转异常即可。

Storage storage = new Storage();
storage.setValue(134);
int value = (int) storage.getValue();
storage.setValue("hello");
String value1 = (String) storage.getValue();
复制代码

而改写为泛型的形式,会把value属性的类型参数化,外部调用时需传入该参数:

public class Storage<T> {
    T value;
    public T getValue() {
        return value;
    }
    public void setValue(T value) {
        this.value = value;
    }
}
复制代码

泛型Storage使用时,需要把类型作为参数传入,之后也不需对getValue()返回值进行类型强转了。

Storage<Integer> storage1 = new Storage<Integer>();
storage1.setValue(134);
int value =  storage1.getValue();

Storage<String> storage2 = new Storage<String>();
storage2.setValue("hello");
String value1 = (String) storage2.getValue();
复制代码

对比以上两种方式,可以得出:

  • 使用Object来实现时,所有的参数、返回值类型都是Object,每次使用都需要执行类型强转。而且编译器也无法得知类型转换是否正确,只有在运行时才知道,程序极不安全。
  • 而使用泛型来实现时,一旦类型确定,编译器就能明确所有T位置的类型,不再需要执行类型强转。且如果出现类型不匹配,就会编译不通过,比如,无法执行storage1.setValue("abc");,它只接受类型为Integer的参数。

所以,我们可以概括出泛型的几个特点:

  • 泛型的本质是参数化类型,它把类的数据类型参数化了,使得它们可以从外部传入,从而扩展了类处理数据的范围。
  • 当类型确定后,泛型会对类型进行检测,若不符合类型,则编译不通过;
  • 提高代码的可读性,不需等到运行时才做强制转换。在定义或实例化时就能明确操作的数据类型。

2. 泛型的使用

泛型可以应用在类、接口和方法中。

2.1 泛型类

<T>可理解为类型参数声明,T代表类型参数,用于指代任何类型。

public class testGenerics<T> {
    private T value;
    public testGenerics(T v) {
        value = v;
    }
}
//泛型类还可以接受多个类型参数
public class testGenerics<T, E> {
    private T value;
    private E element;
    public testGenerics(T v, E e) {
        value = v;
        element = e;
    }
}
复制代码

T其实是一个形参,实际可以用其他字符代替,只是出于编码规范,java建议我们用单个大写字母来代表类型参数。常见的有:

  • T:Type,一般代表任何类;
  • E:Element,一般代表元素类型,或者Exception代表异常;
  • K:Key,key值类型;
  • V:Value,value值类型,一般与K搭配使用;
  • S:Subtype,子类型。

使用泛型类创建实例时,只需在尖括号中声明相应的类型,T,E等类型参数就会被替换成对应的类型。

2.2 泛型接口

泛型接口的使用与泛型类基本相同:

public interface Comparator<T> {
    public int compare(T lhs, T rhs);
    public boolean equals(Object object);
}
复制代码

实现接口时,指明具体的类型:

public class StringCompare implements Comparator<String> {
    @Override
    public int compare(String lhs, String rhs) {
        ......
    }
}
复制代码

2.3 泛型方法

如果泛型方法所在的类是个泛型类,且方法处理的数据类型也与泛型类所声明的类型相同,则直接使用类声明的参数即可。

public class testGenerics<T> {
    private T value;
    public testGenerics(T v) {
        value = v;
    }
    
    public T compare(T a, T b) {
        ......
    }
}
复制代码

如果在非泛型类中或方法需要处理的数据类型不同于泛型类声明的类型时,就需要自己声明类型。定义泛型方法时,必须在返回值前面加一个<T>(T也可以由其他字符代替),来声明这是一个泛型方法。

public class testGenerics<T> {
    public <E> E compare(E a, E b) {
        ......
    }
    public <E> T compare(T a, T b) { //这种写法则与没有<E>声明无区别,编译时仍会把T替代成指定的类型
        ......
    }
}
复制代码

泛型方法中的类型参数与泛型类是没有联系的,泛型方法的类型是在运行时确定的,且由于E并没有像泛型类一样通过传参指定,所以在调用泛型方法时,虽然形参可能都声明为类型E,但实际上实参的类型可以不同,参数类型并没有关联。

public class testGenerics<T> {
    public <E> E compare(E a, E b) {
        ......
    }
}
testGenerics<Integer> g = new testGenerics<Integer>();
g.compare("abc", "abc");
g.compare("abc", 1);//虽然两个参数都声明为类型E,但实际上并没有关联,可以传入两个类型不同的参数
复制代码

3. 泛型通配符

3.1 无限制通配符<?>

表示可以持有任何类型,当不确定或不关心实际要操作的类型时,可以使用。?与Object不同,List<?>表示未知类型的列表,而List<Object>表示一个Object类型的列表。

<?>删减了增改的写功能,只保留了与具体类型无关的读与删功能,像增加或修改具体类型元素的语句是无法编译通过的。比如List<?>,即使我们增加或修改的元素仍保证与容器当前装载元素类型相同,也无法编译通过。

public void setList(List<?> list) {
    if(list.get(0) != null && list.get(1) != null) {
        list.add(list.get(0));//编译不通过
        list.set(0, list.get(1));//编译不通过
    }
    if(list.size() > 0) {
        list.remove(0);//与类型无关的写操作则可以执行
    }
}
复制代码

3.2 上界通配符 <? extends E>

表示泛型中的参数必须是E或E的子类,如果传入的类型不是E或者E的子类,则编译不成功,且泛型中可以使用E的方法。定义了上限,且只有读的能力而不能写。

<? extends E>告诉编译器处理的类型为E或E的子类,所以这个类还无法确定,为保证类型安全,编译器就不允许往其中加入任何类型的数据,以免发生类型转换异常。

E类型数据也不能写入的原因:父类无法强转子类。比如List的类型为Apple,却要将Fruit强转为Apple放到Apple列表中,当然无法执行。

public void test(List<? extends Fruit> list) {
    list.add(new Fruit()); //编译不通过
}
复制代码

3.3 下界通配符<? super E>

super表示这个泛型的参数必须是E或者E的父类。定义了下限,有读的能力以及部分写的能力,子类可以强转为父类。

<? super E>描述了一个E的父类对象/父类容器,但由于其父类是无法确定的,所以只能add/set E/E的子类,因为E的子类都可以转化为任何一个E的父类,set/add操作是绝对安全的。但由于不清楚实际是哪个父类,所以只能get到一个Object。

public void test(List<? super Fruit> list) {
    list.get(0).read();//编译不通过,因为可能Fruit的父类没有定义read方法
    Fruit f = list.get(0); //编译不通过,比如传入的是List<Food> list,Food为Fruit父类,那么存储的非Fruit类强转时会发生异常,只能转为Object
    list.add(new Apple("apple"));
    list.add(new Fruit("fruit"));
}
复制代码

3.4 PECS原则

PECS原则是使用通配符的基本原则:Producer extends, Consumer super (生产者有上限,消费者有下限)。

如果参数化类型表示一个生产者,就使用<? extends E>;如果表示一个消费者,则使用<? super E>;如果既是生产者又是消费者,则不应使用通配符。

生产者和消费者是从参数容器的角度来区分:如果参数容器适合读而不能写,只出不进,则为生产者;而如果参数容器适合写而不适合读,只进不出,则为消费者。

从上面的分析中可知,<? extends E>由于类型不确定,无法往其中写入数据,以免发生类型异常;但其读出的数据可以保证为E或E的子类,所以只读不写,只出不进,属于生产者一类;而<? super E>允许写入E或E的子类数据,但由于父类的不确定,读出来的只能为Object实例,所以适合写不适合读,只进不出,属于消费者一类。

4. 泛型类型擦除

4.1 擦除机制

java的泛型在设计时,为了兼容原有的旧代码,为泛型引入了擦除机制。java的泛型实际是属于编译层的概念,编译后生成的字节码中是不包含泛型中的类型信息的,编译器会自动完成从Generic java到普通java的翻译,java虚拟机运行时是不会感知到泛型的。

泛型擦除:当编译器编译带有泛型的代码时,会去执行类型检查和类型推断,然后生成普通的不带泛型的字节码。

擦除后只保留原始类型:原始类型就是擦除去了泛型信息,最后在字节码中的类型变量的真正类型。

定义泛型类型时,相应的原始类型都会被自动提供。编译时类型变量被擦除,并使用其限定类型(无限定类型用Object)替换。

比如定义的一个简单泛型类:

public class Storage<T> {
    T value;

    public T getValue() {
        return value;
    }

    public void setValue(T v) {
        value = v;
    }
}
复制代码

从编译后的字节码可以看出,属性值value的类型、getValue()的返回值以及setValue()的参数都被替换成Object:

泛型-泛型擦除

编译器编译时,会将泛型参数T擦除,并替换成原始类型,这里为Object,Storage类经编译器处理后等同于以下形式:

public class Storage {
    Object value;

    public Object getValue() {
        return value;
    }

    public void setValue(Object v) {
        value = v;
    }
}
复制代码

所以,泛型实际上也是java的一种语法糖,它经泛型擦除后,实际上与我们在泛型出现前定义一个通用的类的方法是一致的,也是通过一个原始可替代的类来替换。

4.2 Signature属性表

可能有人看到这里会有疑问:既然无论传入什么类型参数最后都会被擦除替换成原始类型,那么传入的类型参数去哪了?

实际上,擦除并不意味着JVM内部就丢失了传入的类型参数,任何对象的实际类型在运行时对JVM来说都是可见的,基于此反射系统才能实现,类型转换异常才能被触发。

在jdk 5引入泛型时,为支持泛型,JVM也做了相应的修改,其中最重要的就是新增了Signature属性表,java编译为字节码后,其声明的泛型信息都存储在Signature中,能够通过反射获取的泛型信息都来源于此。

比如我们创建一个泛型类Storage的实例对象,以及带有泛型参数的方法:

public class testGenerics extends Storage<String>{

    Storage<Integer> storage;

    public testGenerics() {
        storage = new Storage<>();
    }

    public void test(List<? extends Number> list) {
        for(int i = 0; i < list.size(); i++) {
            if(list.get(i) instanceof Integer) {
                storage.setValue((Integer)list.get(i));
                break;
            }
        }
        int value =  storage.getValue();
    }

    public List<Integer> getList() {
        return null;
    }
}
复制代码

通过javap命令查看其编译字节码: 泛型-Signature 可以看到,父类泛型、成员变量、方法参数以及返回值的泛型参数都会保留到Signature表中,在运行过程中需要使用到该泛型参数时都会到其中获取。

5. 关于泛型擦除引入的问题及解决方法

5.1 如何保证泛型变量的限定类型?

泛型擦除后,无论是String还是Integer,都统一变换成Object,那么是如何确定变量是使用String还是Integer的?

为保证使用类型的正确,java会在类型擦除前检查引用的类型,检查无问题后才做类型擦除。所以当我们试图往声明为Integer的list中添加字符串时,会无法编译通过。

类型检查是针对引用的,哪个变量是一个引用,用这个引用调用泛型方法,就会针对这个引用调用的方法进行类型检查,而无关它真正引用的对象。

5.2 自动类型转换问题

由于泛型擦除,所有的泛型类型都被替换成原始类型,但我们在实际使用中,get方法返回的数据并不需要做强制转换,就是我们传入的泛型参数类型所对应的类型数据。

还是拿Storage类来测试一下:

public class testGenerics{
    private static Storage<Integer> storage;

    public static void main(String[] args) {
        storage = new Storage<>();
        storage.setValue(1);
        System.out.print(storage.getValue().getClass());
    }
}
复制代码

其输出结果为:class java.lang.Integer,可以看出,它确实是自动从Object转换为传入的Integer类,我们看一下其字节码: 泛型-类型转换

在调用getValue方法返回的确实是Object对象,之后编译器还增加了checkcast指令,做类型转换检查,强转成Integer。所以泛型的类型转换是由编译器增加checkcast指令完成的。

5.3 类型擦除与多态冲突

前面的泛型类Storage,我们定义一个子类继承它。在子类中,覆盖了父类的两个方法,super调用父类方法,把父类泛型类型限定为String,则父类两个方法的参数都为String类型。

从@Override标签也可以看出,这么覆盖父类方法确实是没有问题的。

public class Save extends Storage<String> {
    
    @Override
    public void setValue(String v) {
        // TODO Auto-generated method stub
        super.setValue(v);
    }

    @Override
    public String getValue() {
        // TODO Auto-generated method stub
        return super.getValue();
    }
}
复制代码

但是,在类型擦除后,父类的泛型类型全部变成原始类型Object,于是父类编译之后就变成:

public class Storage {
    Object value;
    public Object getValue() {
        return value;
    }
    public void setValue(Object v) {
        value = v;
    }
}
复制代码

可以看到,在setValue方法中,父类的类型为Object,而子类的类型为String,参数类型不同,不能说是重写覆盖而应称为重载了。

所以类型擦除后,重写实现多态的本意没有达到,子类重写变成了重载,类型擦除和多态产生了冲突。

那么,这个冲突又是如何解决的?我们通过javap查看Save类的字节码:它由两个方法变成了四个方法,多出了两个Object为参数和返回值的set和get方法。 泛型-Save

从这两个编译器生成的set和get方法不难看出,这两个方法才是真正重写了父类的set和get方法,当调用Save类的set和get方法时,实际上调用的是这两个编译器生成的方法,再由其调用我们自己重写的方法。

泛型-Save.set&get

编译器生成的这两个方法也叫桥方法,桥方法才是真正重写了父类的方法,我们自己的重写只是一种假象。通过自动生成的中间桥接方法,解决了类型擦除和多态之间的冲突。

5.4 运行时类型检查异常--instanceof

比如ArrayList<String> list = new ArrayList<>();,由于类型擦除,ArrayList<String>只剩下原始类型,所以在运行时是不允许通过if(list instanceof ArrayList<String>)这种形式进行类型查询的。

java只限定了这种类型查询方式:if(list instanceof ArrayList<?>)

5.5 泛型和异常捕获

5.5.1 不能抛出也不能捕获泛型类对象

泛型类不允许扩展Throwable接口,如下的类定义是无法通过编译的:

public class GenericException<T> extends Exception {
    ....
}
复制代码

因为异常都是在运行时抛出和捕获的,如果允许上面GenericException泛型类的定义,就会有如下的异常捕获:

try {
    ...
} catch(GenericException<Integer> e) {
    ...
} catch(GenericException<String> e) {
    ...
}
复制代码

经类型擦除,保留原始类型后,捕获的异常均变成GenericException<Object> e,即捕获了两个一样的异常,这是不允许的,无法通过编译的。

5.5.2 不能在catch子句中使用泛型变量

java禁止在catch语句中使用泛型变量,如下写法是无法编译通过的。

public static <T extends Throwable> void test(Class<T> t) {
    try {
        ...
    } catch(T e) {//编译不通过
        ...
    }
}
复制代码

如果允许catch泛型变量,那么就会允许如下定义:

public static <T extends Throwable> void test(Class<T> t) {
    try {
        ...
    } catch(T e) {//编译不通过
        ...
    } catch(IndexOutOfBounds e) {
        ...
    }
}
复制代码

异常捕获的原则是子类在前,父类在后;假设这里使用的T类型为ArrayIndexOutofBounds,那么从表面上看这里的捕获顺序是对的(IndexOutOfBounds是ArrayIndexOutofBounds的父类);但在编译后ArrayIndexOutofBounds还是会替换成Throwable,此时就违背了异常捕获的顺序原则。所以为避免这种情况,java禁止在catch语句中使用泛型变量。

但在异常声明中可以使用泛型变量,下面的写法是合法的:

public static <T extends Throwable> void test(T t) {
    try {
        ...
    } catch(Throwable e) {
        t.initCause(e);
        throw t;
    }
}
复制代码

5.6 不能创建泛型类型数组

java不允许创建参数化类型数组,如下写法是无法编译通过的,

Storage<Integer>[] storages = new Storage<Integer>[10];//编译不通过
复制代码

假设可以编译通过,那么擦除后,storages的类型变成Storage[],由于数组是协变的,它可以转化成一个Object[]。

Object[] objs = storages;
复制代码

但有时错误使用数组的协变特性会带来安全隐患,比如如下的两个实例:

objs[0] = "abc";//编译通过,但由于数组可以记住自己存储的元素类型,运行时会抛ArrayStoreException

objs[0] = new Storage<String>();//虽然通过了数组的存储类型检测
int n = storages[0].getValue();//但执行到这里也会发生类型转换错误
复制代码

所以为避免参数化类型数组所带来的各种安全隐患,java不允许创建参数化类型数组,如果有这方面的需求,可以通过ArrayList<Storage<Integer>>来代替数组。

5.6 其他

  • 泛型类型变量不能是基本数据类型。

  • 泛型中参数化类型无继承关系:如ArrayList<String> list = new ArrayList<Object>();会导致无法编译通过。我们上面提到,类型检查是针对引用的,当我们使用list.get()取值时,返回的都应是String对象,而后面的list实际存放的是Object对象,就会导致类型转换异常。所以为避免这种错误,是不允许这种引用传递的。

  • 泛型类型不能实例化,a = new T();是不允许的,因为new 无法为不确定的类型分配内存空间;也不能创建一个泛型数组,但是可以用反射构造泛型对象和数组,利用反射,调用newInstance。

    public static <T> void add(Box<T> box, Class<T> clazz) {
        // 因为T是在运行时通过反射才能知道是什么类型
        try {
            T item = clazz.newInstance();   // 通过反射使用字节码
        } catch(Exception e) {
            ...
        }
        box.add(item);
    }
    复制代码
  • 不能使用泛型创建与父类方法名重名的方法。

    比如定义一个equals方法,经类型擦除后,就变成equals(Object value),与Object的equals方法冲突。

    public boolean equals(T value) {
        return null;
    }
    复制代码
  • 要支持擦除的转化,需要强制一个类或者类型变量不能同时成为两个接口的子类,而这两个子类是同一接口的不同参数化。

    比如,父类和子类都实现了Comparable接口,子类又继承了父类,导致PairChild类同时实现了Comparable和Comparable接口,这是同一接口的不同参数化实现。

    public class Pair implements Comparable<Pair> { ... }
    public class PairChild extends Pair implements Comparable<PairChild> { ... }// 编译报错
    复制代码
  • 泛型类中的静态方法和静态变量不可以使用泛型类所声明的泛型类型参数,因为泛型参数是在实例化时指定,而静态方法和变量不属于对象。

参考:泛型擦除为何运行时仍可以通过反射获取到具体的泛型类型泛型擦除带来的问题泛型基础

免责声明:文章版权归原作者所有,其内容与观点不代表Unitimes立场,亦不构成任何投资意见或建议。

信息安全

500

相关文章推荐

未登录头像

暂无评论