Java中泛型应用和原理解析(全面总结)

本文深入探讨Java中的泛型,解释为何需要泛型以提高代码复用性和编译时检查。介绍了泛型类、泛型接口、泛型方法的使用,以及泛型边界限制和通配符的应用。详细阐述了泛型的实现原理,包括类型擦除、桥接方法和编译器的类型转换检查。最后,讨论了泛型数组的限制及其原因,提出了PECS原则以指导更好的泛型使用。
摘要由CSDN通过智能技术生成

今天我们来聊一聊Java中的泛型

1. 为什么需要泛型

【例子1】 例如你设计了一个计算两个整数的和的函数,代码如下:

public int sum(int a, int b){
	return a+b;
}

但是这个函数只能计算两个整数类型的值,如果想要计算两个long类型,或者float类型的相似功能,就需要重写函数来实现,但是这些函数只是参数类型不同,逻辑都是一样的。这样就会出现大量的重复逻辑的代码,使得代码的复用性很低。
如果能让参数的类型信息也参数化,在调用的时候传递具体的类型过去,这样就可以实现代码复用了。

【例子2】 例如你设计了一个集合类Collection,在编码的时候,需要创建一个集合对象专门用来存字符串,伪代码如下:

public void foo(){
	Collection c = new Collection();
	c.add("hello");
	c.add("world");
	c.add("java");
	//......

	//循环遍历
	for(Object obj: c){
		System.out.println((String)obj);
	}
}

因为你指定集合c是专门用来存字符串的,所以在遍历的时候,就会毫不犹豫的把obj直接向下转型为String类型。但是如果在代码中有一行代码往集合c中添加了一个非String类型的元素,此时编译器不会报错的(因为Collection作为集合,可以添加任何类型),只有在运行时,才会报类转换的异常。这样的程序是很容易出现bug的,而且还不好定位。
如果在写代码的时候,就能标注集合c是专门用来存放某种类型的对象的,并且如果向集合中添加了不兼容的类型,编译器就会检查到,这样就可以尽早地发现程序的错误。

1.1. 泛型的作用

为了让代码复用性更高,减少bug,JDK1.5引入了泛型新特性,它本质上就是参数化类型,即代表数据类型的变量

通过上面两个例子我们可以看出,泛型至少有两个作用:

  • 提高代码复用性
  • 进行编译时期检查

2. 泛型的使用

泛型的使用主要有三个方面:

  • 泛型类
  • 泛型方法
  • 泛型接口

2.1. 泛型类

我们看一下JDK(1.8.0_121)中ArrayList的代码:

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    private static final long serialVersionUID = 8683452581122892189L;

    /**
     * Default initial capacity.
     */
    private static final int DEFAULT_CAPACITY = 10;

    /**
     * Shared empty array instance used for empty instances.
     */
    private static final Object[] EMPTY_ELEMENTDATA = {};
	
	//... ....
}

代码用尖括号括起来的E,即泛型,可以理解为一种类型变量,它代表某种数据类型。这个类型变量,可以应用到类的方法中,字段的定义中,例如add方法:

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

当然,泛型声明可以不止一个,例如HashMap,此处不再赘述。当实例化一个泛型类时,可以指定泛型的具体类型。

ArrayList<String> stringList = new ArrayList<String>();
stringList.add(1);//编译期报错,类型不兼容
//也可以不指定泛型的具体类型,一般来说,不指定时,泛型功能就不起作用,就用Object类型作为具体类型

泛型的具体类型只能是引用类型,不能是原始类型

2.2. 泛型接口

泛型接口的定义和泛型类相似,但是在定义接口的实现类时,需要注意:
一般一个类实现一个泛型接口时,需要指定泛型接口中的具体类型。例如一个简单的泛型接口定义如下:

public interface Foo<T> {
    T function(T t);
}

现在创建一个类FooStringImpl,实现那个接口,此时可以指定泛型接口中的具体类型:

public class FooStringImpl implements Foo<String>{
    @Override
    public String function(String s) {
        return null;
    }
}

也可以不指定具体类型,仍然使用泛型,这样实现接口的类就是泛型类了,此时需要在类的声明中加上泛型:

public class FooStringImpl<T> implements Foo<T>{

    @Override
    public T function(T t) {
        return null;
    }
}

当然,也可以不使用泛型:

public class FooStringImpl implements Foo{

    @Override
    public Object function(Object o) {
        return null;
    }
}

只不过,此时编译器会发出警告使用了原生的Foo类型

2.3. 泛型方法

并不是使用了泛型的方法就叫做泛型方法。在泛型类中,类中的某个成员方法使用了类声明中的泛型,但是它不是泛型方法。泛型方法有具体的格式。
泛型类中的泛型是类在实例化的时候,才会指定具体类型;而泛型方法是在调用方法的时候,才会指定具体类型。
关于泛型方法的声明,我们看一下JDK中Arrays类的copyOf方法:

public static <T> T[] copyOf(T[] original, int newLength) {
    return (T[]) copyOf(original, newLength, original.getClass());
}
  1. 方法的返回值类型之前带有尖括号括住的类型参数的方法,才是泛型方法,例如上面的<T>,当然可以声明的泛型不止一个,跟类声明的泛型一样的;
  2. 只有在尖括号中声明的泛型,在方法的返回值,参数列表中才可以使用;
  3. 实例泛型方法也可以结合泛型类来使用,也可以使用泛型类中声明的泛型,当方法中声明泛型和类中声明的泛型重名时,实际使用的还是方法中声明的泛型;
  4. 静态泛型方法不能使用泛型类中声明的泛型,因为静态方法的创建先于实例的创建。

2.4. 泛型边界限制和通配符

有时候我们定义泛型类的时候,这个类不一定对所有的类型适用,例如我们定义一个类,这个类中的逻辑只对Number类型的数据适用,那么我们在声明泛型的时候,就可以指定泛型的范围,编译器会对传入的实际类型进行编译期检查。
例如,只允许Number类及其子类的实际类型传入:

public class FooGeneric<T extends Number>{
}
  1. 实际传入的类型必须是Number及其子类
  2. extends 后面还可以跟接口,实际传入的类型可以是接口类型及其实现类类型
  3. 当实例化FooGeneric时不指定具体泛型的类型,那它默认就是Number类型

例如在某个非泛型方法中,如果我们的参数中使用了泛型,例如:

public void function(FooGeneric<Number> fg){
	//... ...
}

如果我们不知道参数中的类型是什么,或者什么类型都可以,我们改怎么指定类型?使用通配符,如下

public void function(FooGeneric<?> fg){
	//... ...
}

这里的?不是一个类型变量,而是一个具体的类型,只不过我们不知道它是什么类型,用一个?占位而已。同理,如果我们知道了泛型的范围,例如必须是某个类型及其子类,某个类型及其父类等,可以这样限定。

//上边界限定
public void function(FooGeneric<? extends Number> fg){
	//... ...
}

//下边界限定
public void function1(FooGeneric<? super Number> fg){
	//... ...
}

注意,不同的泛型之间类型不兼容。
例如定义了一个public void function(FooGeneric<Object> fg)的方法,实际传入一个FooGeneric<String>类型的参数,虽然String是Object的子类,但是代码不会通过编译,要想指定泛型的范围,请使用通配符。

其实编译器禁止这种使用方式也好理解,例如你定义了一个盘子类,这个类是泛型类,它可以盛装任何东西,然后你定义一个水果类(Fruit),定义一个苹果类(Apple),然后你写了一个方法,需要传入一个装水果盘子的实例,但是你传递了一个装苹果的盘子进去,这合理吗?
虽然苹果是水果,但是装苹果的盘子,并不能装任何水果,它只能装苹果,因此装苹果的盘子就不是装水果的盘子了,所以为了类型安全,编译器禁止这种使用。

我们可以使用?来指定泛型的范围,?表示未知,或者说范围内的类型都可以,但是?并不代表Object,例如FooGeneric<?>和FooGeneric\不是一回事,准确的说FooGeneric<?>包含了FooGeneric<Object>

<T> 和 <?>的区别, 表示一个确定的类型,T只是一个类型变量而已,使用的时候,会用具体的类型,替代T;而?表示未知的类型,啥类型都行。

2.4. 泛型通配符的缺点

例如,定义了这么一个泛型类:

public class Generic<T>{

    private T data;

    public void setData(T t){
        this.data = t;
    }

    public T getData(){
        return data;
    }

}

但是当我们写下如下代码的时候,就会编译不通过:

public class GenericTest{
    public static void main(String[] args) {
        Generic<? extends Number> numberGeneric = new Generic<>();
        //下面的四个语句都会编译报错,因为通配符代表Number及其子类,但是具体哪个类型是未知的,所以报错
        numberGeneric.setData(1);
        numberGeneric.setData(1L);
        numberGeneric.setData(1.2f);
        numberGeneric.setData(1.2);
    }
}

当我们使用get方法时,却是可以的,但是返回值的类型,都是宽泛的Number类型了,即丢失了数据的类型信息

public class GenericTest{
    public static void main(String[] args) {
        Generic<? extends Number> numberGeneric = new Generic<>();
        //获取的数据的类型,都是Number及其子类的类型,但是都可以使用Number类型来接收
        Number data = numberGeneric.getData();
		//如果需要转换成具体的子类类型,需要强制类型转换
		Integer idata = (Integer) numberGeneric.getData();
    }
}

当我们限定下边界时,情况就不一样了,例如:

public class GenericTest{
    public static void main(String[] args) {
            Generic<? super Number> numberGeneric = new Generic<>();
            //因为? super Number代表Number及其父类的某个类型,因此Number及其子类都可以隐式向上转型为Number,因此下面的四个语句都没有问题
            numberGeneric.setData(1);
            numberGeneric.setData(1L);
            numberGeneric.setData(1.2);
            numberGeneric.setData(1.2f);
			//但是在获取的时候,因为泛型指定了Number及其父类的某个类型,但是具体是哪个是未知的,因此只能使用Object类型来接收了,如果用具体类型来接收,需要强制类型抓换。
            Object data = numberGeneric.getData();
            Integer dataInteger = (Integer) numberGeneric.getData();
    }
}

使用?通配符的效果是,extends和super的结合起来的效果。

2.5. PECS原则

  1. extends 主要是限定上界,适合从泛型类中获取泛型数据,可以限定返回值的类型
  2. super 主要是限定下界,适合向泛型类中传递参数的类型限定,不适合获取泛型数据

Producer Extends 生产者使用Extends来确定上界,往里面放东西来生产

Consumer Super 消费者使用Super来确定下界,往外取东西来消费

3. 泛型的实现原理

Java的泛型是编译期泛型,是依靠Java的前端编译器来实现的。

3.1. 类型擦除

Java编译器根据泛型的各个规则,完成了编译时期的类型安全检查工作以后,在生成JVM字节码的时候,会把泛型信息去掉,因此在字节码中是没有泛型信息的,当然在JVM执行的时候,也不会识别到泛型信息。
例如:

public class Generic<T> {

    private T data;

    public void setData(T t) {
        this.data = t;
    }

    public T getData() {
        return data;
    }

    public static void main(String[] args) {
        final Generic<Number> numberGeneric = new Generic<>();
        numberGeneric.setData(1);
    }

}

把这个类通过javap反编译,得到如下结果,我们看main方法中调用的setData方法:
在这里插入图片描述
既然在运行时期没有泛型信息,因此并没有针对不同泛型的Class对象,因此下面的代码将打印true

final Generic<String> stringGeneric = new Generic<>();
final Generic<Integer> integerGeneric = new Generic<>();

//具有不同泛型的Generic类型,在运行时,只有一个Class对象,即Generic.class
System.out.println(stringGeneric.getClass() == integerGeneric.getClass());

instanceof 操作符是在运行时判断一个对象是不是某种类型,因为运行时没有泛型信息,因此下面的操作是非法的 :

final Generic<String> stringGeneric = new Generic<>();
if(stringGeneric instanceof Generic<String>){
	//...
}

同理在异常处理中,catch语句捕获的异常类型不能带有泛型

类型擦除的过程就是:

  1. 把泛型声明去掉,去掉类似于<T>等内容;
  2. 把使用泛型的地方使用具体类型代替,如果泛型没有指定边界,就使用Object类型代替,如果指定了边界,则使用边界类型来代替,例如add(E e)方法被改为add(Object e)等;
  3. 可能需要生成桥接方法。

3.2. 桥接方法

生成桥接方法一般存在于类实现接口,或者泛型类之间的继承中,主要是存在方法重写中。看下面的例子:
例如java中的Comparable接口,这是个泛型接口,如果我们自己的类实现这个接口,需要实现其compareTo方法,代码如下:

public class MyComparable implements Comparable<String>{

    @Override
    public int compareTo(String o) {
        return 0;
    }
}

大家看出这个类的问题了吗?
在运行时,由于类型擦除的原因,Comparable接口中的public int compareTo(T o);被替换为public int compareTo(Object o);,但是MyComparable中的public int compareTo(String o)方法其实是没有重写接口中的方法的(不符合方法重写的规则),因此编译器就会生成一个桥接方法,重写了public int compareTo(Object o);方法,然后把参数进行类型转换,调用泛型类中的方法。反编译MyComparable得到如下结果:
在这里插入图片描述

3.3. 编译器进行类型转换检查

把下面的代码进行反编译:

public class Generic<T> {

    private T data;

    public void setData(T t) {
        this.data = t;
    }

    public T getData() {
        return data;
    }

    public static void main(String[] args) {
        final Generic<Number> numberGeneric = new Generic<>();
        numberGeneric.setData(1);
        final Number data = numberGeneric.getData();
    }

}

得到如下结果,
在这里插入图片描述
其实,Java中泛型的实现,是编译器根据类型擦除的规则把相关的类型进行擦除,然后在必要的时候,添加一些桥接方法或者类型转换的代码,实现类型的安全。

3.4. 泛型数组

在java中创建具体的泛型类型的数组是不允许的,例如如下代码:

List<String>[] strings = new ArrayList<String>[10];

编译会报错,使用某个范围的泛型约束也不行。只能创建不知道具体类型的数组,

List<?>[] strings = new ArrayList<?>[10];
//或者不使用泛型
List<?>[] strings = new ArrayList[10];

为什么呢?
请看下面一段代码:

List<String>[] lsa = new List<String>[10]; // Not really allowed.    
Object o = lsa;    
Object[] oa = (Object[]) o;    
List<Integer> li = new ArrayList<Integer>();    
li.add(new Integer(3));    
oa[1] = li; // Unsound, but passes run time store check    
String s = lsa[1].get(0); // Run-time error: ClassCastException.

如果可以声明一个确定类型的泛型数组的话,由于类型擦除的缘故,在运行时,数组lsa中存放的数据类型就是List类型,具体是List<String>还是List<Integer>,JVM并不关心,因此上面的代码会通过编译,但是在运行期间,因为编译器会为做类型转换,因此程序就会出错。这样的代码是不安全的。而使用通配符时,获取数据的时候,因为不知道是什么类型,编译器就会通知程序员进行强制类型转换,减少了代码出错的几率。

最后

如果觉得本文不错,建议一键三连,给个支持😂。

  • 8
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 7
    评论
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值