Java基础系列二:Java泛型

时间:2022-05-13 14:37:36

该系列博文会告诉你如何从入门到进阶,一步步地学习Java基础知识,并上手进行实战,接着了解每个Java知识点背后的实现原理,更完整地了解整个Java技术体系,形成自己的知识框架。

一、泛型概述

1、定义:

所谓泛型,就是允许在定义类、接口、方法时使用类型形参,这个类型形参(或叫泛型)将在声明变量、创建对象、调用方法时动态地指定(即传入实际的类型参数,也可称为类型实参)。Java5改写了集合框架中的全部接口和类,为这些接口、类增加了泛型支持,从而可以在声明集合变量、创建集合对象时传入类型实参。

2、泛型初体验:一个被举了无数次的栗子

1
2
3
4
5
6
7
8
List arrayList = new ArrayList();
arrayList.add("aaaa");
arrayList.add(100);
 
for(int i = 0; i< arrayList.size();i++){
    String item = (String)arrayList.get(i);
    Log.d("泛型测试","item = " + item);
}

  

运行上述代码,我们可以在控制台看到这样的错误信息:

1
java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String

  

ArrayList可以存放任意类型,例子中添加了一个String类型,添加了一个Integer类型,再使用时都以String的方式使用,因此程序崩溃了。为了解决类似这样的问题(在编译阶段就可以解决),泛型应运而生。

3、泛型的特性:

先思考如下一段代码:

1
2
3
List<String> sList=new ArrayList<String>();
List<Integer> iList=new ArrayList<Integer>();
System.out.println(sList.getClass()==iList.getClass());  

先不要看结果,自己思考一下。

结果:

1
true

  

我们看到输出的结果为true,通过上面的例子可以证明,在编译之后程序会采取去泛型化的措施。也就是说Java中的泛型,只在编译阶段有效。在编译过程中,正确检验泛型结果后,会将泛型的相关信息擦出,并且在对象进入和离开方法的边界处添加类型检查和类型转换的方法。也就是说,泛型信息不会进入到运行时阶段。(泛型的这一特性在下述文字中会有详解介绍)

小结:泛型类型在逻辑上看以看成是多个不同的类型,实际上都是相同的基本类型。

二、泛型的使用

泛型有三种使用方式,分别为:泛型类、泛型接口、泛型方法

1、泛型类:

泛型类型用于类的定义中,被称为泛型类。通过泛型可以完成对一组类的操作对外开放相同的接口。最典型的就是各种容器类,如:List、Set、Map。

泛型的基本写法:

1
2
3
4
5
6
class 类名称 <泛型标识:可以随便写任意标识号,标识指定的泛型的类型>{
  private 泛型标识 /*(成员变量类型)*/ var;
  .....
 
  }
}

  

看不懂?看不懂就对了,下面我们来看一个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Apple<T> {
     
    //使用T类型定义变量
    private T info;
    public Apple() {}
     
    //下面方法使用T类型定义构造器
    public Apple(T info){
        this.info=info;
    }
 
    public T getInfo() {
        return info;
    }
 
    public void setInfo(T info) {
        this.info = info;
    }
     
    public static void main(String[] args) {
        //由于传给T形参的是String,所以构造器参数只能是String
        Apple<String> apple=new Apple<String>("苹果");
        System.out.println(apple.getInfo());
         
        //由于传给T形参的是Double,所以构造器参数只能是Double
        Apple<Double> apple2=new Apple<Double>(5.56);
        System.out.println(apple2.getInfo());
         
    }
     
 
}

  

这里的T可以写成任意符合,常用的有如下几个符合:

  • ?:表示不确定的 java 类型
  • T (type): 表示具体的一个java类型
  • K V (key value): 分别代表java键值中的Key Value
  • E (element) :代表Element

2、泛型接口:

泛型接口与泛型类的定义及使用基本相同。下面是Java5改写后的List接口,Map接口的代码片段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface List<E>{
    //在该接口中,E可以作为类型使用
    //下面方法可以使用E作为参数类型
    void add(E x);
    Iterator<E> iterator();
}
 
 
//定义该接口时指定了两个泛型形参,其参数名为K,V
public interface Map<K,V>{
    //在该接口中K,V完全可以作为类型使用
    Set<K> keySet()
    V put(K key,V value);
}

  

下面我们来看一个泛型案例:

定义一个泛型接口:

1
2
3
4
//定义一个泛型接口
public interface Generator<T> {
    public T next();
}

  

现在有一个类实现了这个泛型接口,类的代码如下:

1
2
3
4
5
6
class FruitGenerator<T> implements Generator<T>{
    @Override
    public T next() {
        return null;
    }
}

  

我们看到了这个类中也使用了泛型,并未传入实际的参数

未传入泛型实参时,与泛型类的定义相同,在声明类的时候,需将泛型的声明也一起加到类中,即:class FruitGenerator<T> implements Generator<T>

如果不声明泛型,如:class FruitGenerator implements Generator<T>,编译器会报错:"Unknown class"

1
2
3
4
5
6
7
8
9
10
public class FruitGenerator implements Generator<String> {
 
    private String[] fruits = new String[]{"Apple""Banana""Pear"};
 
    @Override
    public String next() {
        Random rand = new Random();
        return fruits[rand.nextInt(3)];
    }
}

  

这段代码也是实现了Generator接口,不同的是传入了实际的类型String

传入泛型实参时:定义一个生产器实现这个接口,虽然我们只创建了一个泛型接口Generator<T>但是我们可以为T传入无数个实参,形成无数种类型的Generator接口。在实现类实现泛型接口时,如已将泛型类型传入实参类型,则所有使用泛型的地方都要替换成传入的实参类型即:Generator<T>,public T next();中的的T都要替换成传入的String类型。

3、泛型通配符:

为什么要使用泛型通配符:

正如前面讲的,当使用一个泛型类时(包括声明变量和创建对象两种情况),都应该为这个泛型类传入一个类型实参。如果没有传入类型实际参数,编译器就会提出泛型警告。假设现在需要定义一个方法,该方法里有一个集合形参,集合形参的元素类型是不确定的,那应该怎样定义呢?

考虑如下代码:

1
2
3
4
5
public void test(List c) {
    for(int i=0;i<c.size;i++) {
        System.out.println(c.get(i));
    }
}

  

上面程序当然没有问题:这是一段最普通的遍历List集合的代码。问题是上面程序中List是一个有泛型声明的接口,此处使用List 接口时没有传入实际类型参数,这将引起泛型警告。为此,考虑为List 接口传入实际的类型参数——因为List集合里的元素类型是不确定的

泛型通配符的使用:

为了表示各种泛型List的父类,可以使用类型通配符,类型通配符是一个问号(?),将一个问号作为类型实参传给List集合,写作:List<?>(意思是元素类型未知的List)。这个问号(?)被称为通配符,它的元素类型可以匹配任何类型。可以将上面方法改写为如下形式:

1
2
3
4
5
public void test(List<?> c) {
    for(int i=0;i<c.size;i++) {
        System.out.println(c.get(i));
    }
}

  

这样就不会出现警告了,但这种带通配符的List仅表示它是各种泛型List的父类,并不能将其他元素加入到其中,例如将String放入其中

List<?> c=new ArrayList<String>();//这段代码将报错

因为程序无法确定c集合中元素的类型,所以不能向其中添加对象。根据前面的List<E>接口定义的代码可以发现:add0方法有类型参数E作为集合的元素类型,所以传给add的参数必须是E类的对象或者其子类的对象。但因为在该例中不知道E是什么类型,所以程序无法将任何对象“丢进”该集合。唯一的例外是null,它是所有引用类型的实例。

设置类型通配符的上限:

现在想实现一个简单的绘图程序,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public abstract class Shape{
    public abstract void draw(Canvas c);
}
 
//定义Shape的子类Circle
public class Circle extends Shape{
    //实现画图方法,以打印字符串来模拟画图方法实现
    public void draw(Canvas c)
    {
        System.out.println("在画布"+c+"上画一个圆");
    }
}
 
//定义Shape的子类Rectangle
public class Rectangle extends Shape{
    //实现画图方法,以打印字符串来模拟画图方法实现
    public void draw(Canvas c)
    {
        System.out.print1n("把一个矩形画在画布"+c+"上");
    }
}

  

上面定义了三个形状类,其中Shape是一个抽象父类,该抽象父类有两个子类:Circle和Rectangle。接下来定义一个Canvas类,该画布类可以画数量不等的形状(Shape子类的对象),那应该如何定义这个Canvas类呢?考虑如下的Canvas实现类。

1
2
3
4
5
//同时在画布上绘制多个形状
public void drawAll(List<Shape>shapes) {
    for(Shape s:shapes)
        s.draw(this);
}

  

注意上面的drawAll()方法的形参类型是List<Shape>,而List<Circle>并不是List<Shape>的子类型,因此,下面代码将引起编译错误。

1
2
3
4
List<Circle> circleList=new ArrayList<Circle>();
Canvas c=new Canvas();
//不能把List<Circle>当成List<Shape>使用,所以下面代码引起编译错误
c.drawAll(circleList); 

  

这时我们可以通过通配符的上限来解决这个问题:

1
List<? extends Shape>

  

List<? extends Shape>是受限制通配符的例子,此处的问号(?)代表一个未知的类型,就像前面看到的通配符一样。但是此处的这个未知类型一定是Shape的子类型(也可以是Shape本身),因此可以把Shape称为这个通配符的上限(upper bound)。

设置类型通配符的下限:

除可以指定通配符的上限之外,Java也允许指定通配符的下限,通配符的下限用<?super类型>的方式来指定,通配符下限的作用与通配符上限的作用恰好相反。

指定通配符的下限就是为了支持类型型变。比如Foo是Bar的子类,当程序需要一个A<? super Bar>变量时,程序可以将A<Foo>、A<Object>赋值给A<? super Bar>类型的变量,这种型变方式被称为逆变。

对于逆变的泛型集合来说,编译器只知道集合元素是下限的父类型,但具体是哪种父类型则不确定。因此,这种逆变的泛型集合能向其中添加元素(因为实际赋值的集合元素总是逆变声明的父类),从集合中取元素时只能被当成Object类型处理(编译器无法确定取出的到底是哪个父类的对象)。

4、泛型方法:

前面介绍了在定义类、接口时可以使用泛型形参,在该类的方法定义和成员变量定义、接口的方法定义中,这些泛型形参可被当成普通类型来用。在另外一些情况下,定义类、接口时没有使用泛型形参,但定义方法时想自己定义泛型形参,这也是可以的,Java5还提供了对泛型方法的支持。

(1)、泛型方法的基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
 * 泛型方法的基本介绍
 * @param tClass 传入的泛型实参
 * @return T 返回值为T类型
 * 说明:
 *     1)public 与 返回值中间<T>非常重要,可以理解为声明此方法为泛型方法。
 *     2)只有声明了<T>的方法才是泛型方法,泛型类中的使用了泛型的成员方法并不是泛型方法。
 *     3)<T>表明该方法将使用泛型类型T,此时才可以在方法中使用泛型类型T。
 *     4)与泛型类的定义一样,此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型。
 */
    public <T> T genericMethod(Class<T> tClass)throws InstantiationException ,
      IllegalAccessException{
            T instance = tClass.newInstance();
            return instance;
    }
 
Object obj = genericMethod(Class.forName("com.test.test"));

  

(2)、类中的泛型方法:

泛型方法可以出现杂任何地方和任何场景中使用。但是有一种情况是非常特殊的,当泛型方法出现在泛型类中时,我们再通过一个例子看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//注意泛型类先写类名再写泛型,泛型方法先写泛型再写方法名
//类中声明的泛型在成员和方法中可用
class A <T, E>{
    {
        T t1 ;
    }
    A (T t){
        this.t = t;
    }
    T t;
 
    public void test1() {
        System.out.println(this.t);
    }
 
    public void test2(T t,E e) {
        System.out.println(t);
        System.out.println(e);
    }
}
@Test
public void run () {
    A <Integer,String > a = new A<>(1);
    a.test1();
    a.test2(2,"ds");
//        1
//        2
//        ds
}
 
static class B <T>{
    T t;
    public void go () {
        System.out.println(t);
    }
}

  

(3)、泛型方法和可变参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class 泛型和可变参数 {
    @Test
    public void test () {
        printMsg("dasd",1,"dasd",2.0,false);
        print("dasdas","dasdas""aa");
    }
    //普通可变参数只能适配一种类型
    public void print(String ... args) {
        for(String t : args){
            System.out.println(t);
        }
    }
    //泛型的可变参数可以匹配所有类型的参数
    public <T> void printMsg( T... args){
        for(T t : args){
            System.out.println(t);
        }
    }
        //打印结果:
    //dasd
    //1
    //dasd
    //2.0
    //false
 
}

  

(4)、静态方法与泛型

静态方法有一种情况需要注意一下,那就是在类中的静态方法使用泛型:静态方法无法访问类上定义的泛型;如果静态方法操作的引用数据类型不确定的时候,必须要将泛型定义在方法上。

即:如果静态方法要使用泛型的话,必须将静态方法也定义成泛型方法 。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class StaticGenerator<T> {
    ....
    ....
    /**
     * 如果在类中定义使用泛型的静态方法,需要添加额外的泛型声明(将这个方法定义成泛型方法)
     * 即使静态方法要使用泛型类中已经声明过的泛型也不可以。
     * 如:public static void show(T t){..},此时编译器会提示错误信息:
          "StaticGenerator cannot be refrenced from static context"
     */
    public static <T> void show(T t){
 
    }
}

  

总结:

泛型方法能使方法独立于类而产生变化,以下是一个基本的指导原则:

无论何时,如果你能做到,你就该尽量使用泛型方法。也就是说,如果使用泛型方法将整个类泛型化,那么就应该使用泛型方法。另外对于一个static的方法而已,无法访问泛型类型的参数。所以如果static方法要使用泛型能力,就必须使其成为泛型方法。

三、泛型的类型擦除:

1、什么是类型擦除:

还记得我们在文章开始介绍的代码吗?我们现在再来看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Test {
 
    public static void main(String[] args) {
 
        ArrayList<String> list1 = new ArrayList<String>();
        list1.add("abc");
 
        ArrayList<Integer> list2 = new ArrayList<Integer>();
        list2.add(123);
 
        System.out.println(list1.getClass() == list2.getClass());
    }
 
}

  

在这个例子中,我们定义了两个ArrayList数组,不过一个是ArrayList<String>泛型类型的,只能存储字符串;一个是ArrayList<Integer>泛型类型的,只能存储整数,最后,我们通过list1对象和list2对象的getClass()方法获取他们的类的信息,最后发现结果为true。这就是java的泛型擦除。

下面我们再来看一个例子加深一下理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Test001 {
     
    public static void main(String[] args) throws Exception {
        ArrayList<Integer> list=new ArrayList<Integer>();
        list.add(1);
         
        list.getClass().getMethod("add",Object.class).invoke(list, "asd");
        for(int i=0;i<list.size();i++) {
            System.out.println(list.get(i));
        }
    }
 
}

  

上面这段代码首先创建了一个ArrayList,泛型类型实例化为Integer对象,如果我们直接调用add()方法,那么只能添加Integer类型的值,但是现在我们利用反射,发现可以往里面加入String类型的值,这也说明了java的泛型擦除。

定义:

Java在编译期间,所有的泛型信息都会被擦掉,这就是泛型擦除。

正确理解泛型概念的首要前提是理解类型擦除。Java的泛型基本上都是在编译器这个层次上实现的,在生成的字节码中是不包含泛型中的类型信息的,使用泛型的时候加上类型参数,在编译器编译的时候会去掉,这个过程成为类型擦除。

2、类型擦除后保留的原始类型

原始类型 就是擦除去了泛型信息,最后在字节码中的类型变量的真正类型,无论何时定义一个泛型,相应的原始类型都会被自动提供,类型变量擦除,并使用其限定类型(无限定的变量用Object)替换。

例1、

1
2
3
4
5
6
7
8
9
class Pair<T> { 
    private T value; 
    public T getValue() { 
        return value; 
    
    public void setValue(T  value) { 
        this.value = value; 
    
}

  

Pair的原始类型为:Object

1
2
3
4
5
6
7
8
9
class Pair { 
    private Object value; 
    public Object getValue() { 
        return value; 
    
    public void setValue(Object  value) { 
        this.value = value; 
    
}

  

因为在Pair<T>中,T 是一个无限定的类型变量,所以用Object替换,其结果就是一个普通的类,如同泛型加入Java语言之前的已经实现的样子。在程序中可以包含不同类型的Pair,如Pair<String>Pair<Integer>,但是擦除类型后他们的就成为原始的Pair类型了,原始类型都是Object

如果类型变量有限定,那么原始类型就用第一个边界的类型变量类替换。

例如:

1
public class Pair<T extends Comparable> {}

  

此时的原始类型就不再是Object,而是Comparable了。

在调用泛型方法时,可以指定泛型,也可以不指定泛型。

  • 在不指定泛型的情况下,泛型变量的类型为该方法中的几种类型的同一父类的最小级,直到Object
  • 在指定泛型的情况下,该方法的几种类型必须是该泛型的实例的类型或者其子类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Test { 
    public static void main(String[] args) { 
 
        /**不指定泛型的时候*/ 
        int i = Test.add(12); //这两个参数都是Integer,所以T为Integer类型 
        Number f = Test.add(11.2); //这两个参数一个是Integer,以风格是Float,所以取同一父类的最小级,为Number 
        Object o = Test.add(1"asd"); //这两个参数一个是Integer,以风格是Float,所以取同一父类的最小级,为Object 
 
        /**指定泛型的时候*/ 
        int a = Test.<Integer>add(12); //指定了Integer,所以只能为Integer类型或者其子类 
        int b = Test.<Integer>add(12.2); //编译错误,指定了Integer,不能为Float 
        Number c = Test.<Number>add(12.2); //指定为Number,所以可以为Integer和Float 
    
 
    //这是一个简单的泛型方法 
    public static <T> T add(T x,T y){ 
        return y; 
    
}

  

其实在泛型类中,不指定泛型的时候,也差不多,只不过这个时候的泛型为Object,就比如ArrayList中,如果不指定泛型,那么这个ArrayList可以存储任意的对象。

Object泛型:

1
2
3
4
5
6
public static void main(String[] args) { 
    ArrayList list = new ArrayList(); 
    list.add(1); 
    list.add("121"); 
    list.add(new Date()); 

  

四、常见的泛型面试题:

  1. Java中的泛型是什么 ? 使用泛型的好处是什么?

这是在各种Java泛型面试中,一开场你就会被问到的问题中的一个,主要集中在初级和中级面试中。那些拥有Java1.4或更早版本的开发背景的人 都知道,在集合中存储对象并在使用前进行类型转换是多么的不方便。泛型防止了那种情况的发生。它提供了编译期的类型安全,确保你只能把正确类型的对象放入 集合中,避免了在运行时出现ClassCastException。

  1. Java的泛型是如何工作的 ? 什么是类型擦除 ?

这是一道更好的泛型面试题。泛型是通过类型擦除来实现的,编译器在编译时擦除了所有类型相关的信息,所以在运行时不存在任何类型相关的信息。例如 List在运行时仅用一个List来表示。这样做的目的,是确保能和Java 5之前的版本开发二进制类库进行兼容。你无法在运行时访问到类型参数,因为编译器已经把泛型类型转换成了原始类型。根据你对这个泛型问题的回答情况,你会 得到一些后续提问,比如为什么泛型是由类型擦除来实现的或者给你展示一些会导致编译器出错的错误泛型代码。请阅读我的Java中泛型是如何工作的来了解更 多信息。

  1. 什么是泛型中的限定通配符和非限定通配符 ?

这是另一个非常流行的Java泛型面试题。限定通配符对类型进行了限制。有两种限定通配符,一种是它通过确保类型必须是T的子类来设定类型的上界,另一种是它通过确保类型必须是T的父类来设定类型的下界。泛型类型必须用限定内的类型来进行初始化,否则会导致编译错误。另一方面表 示了非限定通配符,因为<?>可以用任意类型来替代。更多信息请参阅我的文章泛型中限定通配符和非限定通配符之间的区别。

  1. List<? extends T>和List <? super T>之间有什么区别 ?

这和上一个面试题有联系,有时面试官会用这个问题来评估你对泛型的理解,而不是直接问你什么是限定通配符和非限定通配符。这两个List的声明都是 限定通配符的例子,List<? extends T>可以接受任何继承自T的类型的List,而List<? super T>可以接受任何T的父类构成的List。例如List<? extends Number>可以接受List<Integer>或List<Float>。

  1. 如何编写一个泛型方法,让它能接受泛型参数并返回泛型类型?

编写泛型方法并不困难,你需要用泛型类型来替代原始类型,比如使用T, E or K,V等被广泛认可的类型占位符。泛型方法的例子请参阅Java集合类框架。最简单的情况下,一个泛型方法可能会像这样:

public V put(K key, V value) {

return cache.put(key, value);

}

  1. Java中如何使用泛型编写带有参数的类?

这是上一道面试题的延伸。面试官可能会要求你用泛型编写一个类型安全的类,而不是编写一个泛型方法。关键仍然是使用泛型类型来代替原始类型,而且要使用JDK中采用的标准占位符。

  1. 编写一段泛型程序来实现LRU缓存?

对于喜欢Java编程的人来说这相当于是一次练习。给你个提示,LinkedHashMap可以用来实现固定大小的LRU缓存,当LRU缓存已经满 了的时候,它会把最老的键值对移出缓存。LinkedHashMap提供了一个称为removeEldestEntry()的方法,该方法会被put() 和putAll()调用来删除最老的键值对。当然,如果你已经编写了一个可运行的JUnit测试,你也可以随意编写你自己的实现代码。

  1.  你可以把List<String>传递给一个接受List<Object>参数的方法吗?

对任何一个不太熟悉泛型的人来说,这个Java泛型题目看起来令人疑惑,因为乍看起来String是一种Object,所以 List<String>应当可以用在需要List<Object>的地方,但是事实并非如此。真这样做的话会导致编译错误。如 果你再深一步考虑,你会发现Java这样做是有意义的,因为List<Object>可以存储任何类型的对象包括String, Integer等等,而List<String>却只能用来存储Strings。

List<Object> objectList;

List<String> stringList;

objectList = stringList; //compilation error incompatible types

  1. Array中可以用泛型吗?

这可能是Java泛型面试题中最简单的一个了,当然前提是你要知道Array事实上并不支持泛型,这也是为什么Joshua Bloch在Effective Java一书中建议使用List来代替Array,因为List可以提供编译期的类型安全保证,而Array却不能。

  1. 如何阻止Java中的类型未检查的警告?

如果你把泛型和原始类型混合起来使用,例如下列代码,Java 5的javac编译器会产生类型未检查的警告,例如

List rawList = new ArrayList()

注意: Hello.java使用了未检查或称为不安全的操作;

这种警告可以使用@SuppressWarnings(“unchecked”)注解来屏蔽。