您当前的位置:首页 > 计算机 > 编程开发 > Java

Java泛型编程入门教程(非常详细)

时间:10-16来源:作者:点击数:

Java 在 JDK 1.5 中引入泛型这一新特性,泛型的本质是参数化类型,也就是说,可以把数据类型指定为一个参数,这个参数类型可以用在类、接口和方法的创建中。

泛型在 Java 语言的 Collection 中大量地被使用,例如 List 允许被插入任意类型的对象,在程序中可以声明 List<Integer>、List<String> 等更多的类型。

泛型的引入为程序员带来了很多编程的好处,具体而言,有以下两个方面的内容:

  • 简单安全。一方面,由于在编译时会进行类型检查,因此提高了安全性,另一方面,在编译阶段就可以把错误报出来,从而减轻了程序员的调试工作量。
  • 性能的提升。以容器为例,在没有泛型的时候,由于容器返回的类型都是 Object 类型,因此需要根据实际情况将返回值强制转换为期望的类型。在引入泛型以后,由于容器中存储的类型在声明的时候可以确定,因此对容器的操作不需要进行类型转换,这样做的好处是一方面增强了代码的可读性,降低了程序出错的可能性,另一方面也提高了程序运行的效率。

Java泛型基本概念

【示例1】在 JDK 1.5 之前的版本中,Java 没有办法显式地指定容器中存储的类型,在没有注释或者文档说明的情况下,很容易出现运行时错误。Java 代码如下:

ArrayList list=new ArrayList();
list.add(0);
list.add(1);
list.add('2');
list.add(3);

//输出list内容
System.out.println(list);

//遍历输出list内容
for(int i=0,len=list.size();i<len; i++){
    Integer object=(Integer)list.get(i);
    System.out.println(object);
}

运行结果:

[0, 1, 2, 3]
0
1
Exception in thread "main" java.lang.ClassCastException: java.lang.Character cannot be cast to java.lang.Integer
    at com.company.Test.main(Test.java:18)

从上面的运行结果可以看出,在直接输出 list 的时候,int 类型的 1 和 char 类型的 2 是看不出区别的,一旦忽略了类型的差别,当在代码中强制转换为 Integer 类型使用的时候,就抛出了强制类型转化异常。泛型正是为了解决这种问题而诞生的。

泛型是一种编程范式(Programming Paradigm),是为了效率和重用性产生的。由 Alexander Stepanov(C++ 标准库主要设计师)和 David Musser(伦斯勒理工学院计算机科学名誉教授)首次提出,自实现之日起,它就成为了 ANSI/ISO C++ 的重要标准之一。

泛型的本质是一个参数化的类型,那么,什么是参数化?

其实,参数是一个外部变量。对于一个方法,其参数都是从外部传入的,那么,参数的类型是否也作为一个参数,在运行时决定呢?答案是肯定的,泛型就可以做到这一点。示例代码如下所示:

List<String> list = new ArrayList<String>();
list.add(l);

上述代码中,在第 2 行处,会抛出如下的编译期错误:

The method add(int, String) in the type List<String>is not applicable for the arguments (int)

之所以会出现以上这样的现象,是因为 list 在声明时定义了 String 为自己需要的类型,而由于 1 是一个整型数,因此会出现类型不匹配的问题。

在上面的例子中,以下几种添加方式都是合法的:

list.add("字符串");
list.add(new String());
String str="字符串";
list.add(str);

由此可见,泛型的出现是非常有必要的。具体而言,它主要提供了如下几个方面的功能:

  • 避免代码中的强制类型转换。
  • 限定类型。在编译时提供一个额外的类型检查,避免错误的值被存入容器。
  • 实现一些特别的编程技巧。例如:提供一个方法用于拷贝对象,在不提供额外方法参数的情况下,使返回值类型和方法参数类型保持一致。

Java泛型的分类

根据泛型使用方式的不同,可以把泛型分为泛型接口、泛型类和泛型方法。它们的定义如下所示:

1) 泛型接口

在接口定义的接口名后加上<泛型参数名>,就定义了一个泛型接口,该泛型参数名的作用域存在于接口定义和整个接口主体内。

2) 泛型类

在类定义的类名后加上<泛型参数名>,就定义了一个泛型类,该泛型参数名的作用域存在于类定义和整个类主体内。

3) 方法类

在方法的返回值之前加上<泛型参数名>,就定义了一个泛型方法,该泛型参数名的作用域包括方法返回值、方法参数、方法异常以及整个方法主体。

【示例2】下面通过一个例子来分别介绍这几种泛型的定义方法,Java 代码如下:

/*在普通的接口后加上<泛型参数名>即可以定义泛型接口 */
interface GenericInterface<T> {
}

/*
**在类定义后加上<泛型参数名>即可定义一个泛型类,
**注意后面这个GenericInterface<T>,这里是使用类的泛型参数,而非定义。
*/

class GenericClass<T> implements GenericInterface<T> {

    /*在返回值前定义了泛型参数的方法,就是泛型方法。*/
    public <K, E extends Exception> K genericMethod(K param) throws E {
        java.util.List<K> list = new ArrayList<K>();
        K k = null;
        return null;
    }
}

【示例2】中,class GenericClass<T> implements GenericInterface<T> 中有两个地方使用了 <T>,它们是同一个概念吗?

为了回答这个问题,下面给出几个基本概念,通过对这些基本概念的理解,将可以解决大部分类似的泛型问题。

  1. 类(接口)的泛型定义位置紧跟在类(接口)定义之后,可以替代该类(接口)定义内部的任意类型。在该类(接口)被声明时,确定泛型参数。
  2. 方法的泛型定义位置在修饰符之后,返回值之前,可以替代该方法中使用的任意类型,包括返回值、参数以及局部变量。在该方法被调用时,确定泛型参数,一般来说,是通过方法参数来确定的泛型参数。
  3. <>的出现有两种情况,一是定义泛型,二是使用某个类/接口来具象化泛型。

根据上面介绍的几个基本概念,再来分析 class GenericClass<T>implemenets GenericInterface<T> 这句代码就比较好理解了。

上例中,由于 class GenericClass 是类的定义,那么第一个 <T> 就构成了泛型参数的定义,而接口 GenericInterface 是定义在别处的,因为该代码是对此接口的引用,所以,第二个 <T> 是使用泛型 T 来规范 GenericInterface。

引申:如果泛型方法是没有形参的,那么是否还有其他方法来指定类型参数?

答案:有方法指定,但是这个语法并不常见,实现代码如下所示:

GenericClass<String> gc=new GenericClass<String>();
gc.<String>genericMethod(null);

上面出现了一个非常特别的代码形式,gc.genericMethod(null)中间多出了一个<String>,它的作用是为genericMethod方法进行泛型参数定义。

Java有界泛型

有界泛型有三个非常重要的关键字:extendssuper。以下将分别对它们进行分析。

1)通配符泛型

? 表示通配符类型,用于表达任意类型,注意,它指代的是“某一个任意类型”,但并不是 Object。

【示例3】示例代码如下所示:

class Parent {
}

class Sub1 extends Parent {
}

class Sub2 extends Parent {
}

class WildcardSample<T> {

    T obj;
    void test() {
        WildcardSample<Parent> sample1 = new WildcardSample<Parent>();
        //编译错误
        WildcardSample<Parent> sample2 = new WildcardSample<Sub1>();

        //正常编译
        WildcardSample<?> sample3 = new WildcardSample<Parent>();
        WildcardSample<?> sample4 = new WildcardSample<Sub1>();
        WildcardSample<?> sample5 = new WildcardSample<Sub2>();
        
        sample1.obj = new Sub1();
        //编译错误
        sample3.obj = new Sub1();
    }
}

以上代码体现了通配符的作用。针对以上代码,分析如下:

  • 由于 sample2 的声明中使用了 Parent 作为泛型参数,因此它不能指向使用 Sub1 作为泛型参数的实例。因为编译器处理泛型时严格地按照定义来执行,Sub1 虽然是 Parent 的子类,但它毕竟不是 Parent;
  • 当 sample3~5 声明里使用?作为泛型参数的时候,可以指向任意 WildcardSample 实例;
  • sample1.obj 可以指向 Sub1 实例,这是因为 obj 被认为是 Parent,而 Sub1 是 Parent 的子类,满足向上转型;
  • sample3.obj 不能指向 Sub1 实例,因为 sample3.obj 的类型是,这个通配符表示“某个类型”而并不是 Object,所以,Sub1 并不是的子类,抛出编译期错误。例如:类型?从理论上讲,可以去表示 Sub2,如果把 Sub1 的对象赋给它,那么显然是不合理的;
  • 虽然有如此多的限制,但唯一可以确定的是可以使用 Object 类型来读取 sample3.obj,毕竟无论通配符是什么类型,Object 一定是它的父类。因此在这种情况下,这种通配符主要的作用是读而不是写,即可以读取 Object 类型。

引申:设想如果 sample3.obj=new Sub1() 可以编译通过,那么事实上期望的 sample3 类型是 WildcardSample<Object>,这样的话,通配符就失去意义了。而在实际应用中,这并不只是失去意义这样简单的事,还会引起执行异常。

下面给出例子来帮助理解:

WildcardSample<Parent> sample1= new WildcardSample<Parent>();
samplel.obj = new Parent();

WildcardSample<?> extSample = sample1;
//原本应当被限定为Parent类型,这里使用了 String 类型,必须抛出异常。
extSample.obj = new String();
2)上界

extends在泛型里不是继承,而是定义上界的意思,例如 T extends UpperBound,UpperBound 为泛型 T 的上界,也就是说,T 必须为 UpperBound 或者它的子类。

【示例3】泛型上界可以用于定义以及声明代码处,在不同的位置使用的时候,它的作用与使用方法都有所不同,Java 代码如下所示:

/*有上界的泛型类*/
class ExtendSample<T extends Parent> {

    T obj;
    /*有上界的泛型方法*/
    <K extends Subl> T extendMethod(K param) {
        return this.obj;
    }
}

public class Generic3_1_2_b {

    public static void main(String[] args) {

        ExtendSample<Parent> sample1 = new ExtendSample<Parent>();
        ExtendSample<Sub1> sample2 = new ExtendSample<Sub1>();

        ExtendSample<? extends Parent> sample3 = new ExtendSample<Sub1>();
        ExtendSample<? extends Sub1> sample4;

        sample4 = new ExtendSample<Sub2>(); // 编译错误
        ExtendSample<? extends Number> sample5; // 编译错误

        sample1.obj = new Sub1();
        sample3.obj = new Parent(); //编译错误
    }
}

以上这个例子中使用了一个具备上界的泛型方法和一个具备上界的泛型类,它们体现了 extends 在泛型中的应用:

  • 在方法、接口或类的泛型定义时,需要使用泛型参数名(例如T或者K)。
  • 在声明位置使用泛型参数时,需要使用通配符,意义是“用来指定类的上界(该类或其子类)”。

即使加上了上界,使用通配符来定义的对象,也是只能读,不能写。

例如 B 和 C 都是 A 的子类,对于一个声明的列表 List<? extends A>,唯一可以确定的是这个列表中一定存储的是 A 或者它的子类,也就是说,可以从这个列表中读取类型 A 的对象,但是无法向列表中写入任何类型。之所以不能写入 A,是因为列表中有可能存储的是 B 类型,之所以不能写入 B,是因为列表中有可能存储的是 C 类型。

3)下界

super 关键字用于定义泛型的下界。例如 T super LowerBound,LowerBound 为泛型 T 的下界,也就是说,T 必须为 LowerBound 或者它的父类。

【示例4】泛型下界只能应用于声明代码处,表示泛型参数一定是指定类或其父类。Java 代码如下:

class SuperSample<T> {
    T obj;
}

public class Generic3_1_2_c {

    public static void main(String[] args) {

        SuperSample<? super Parent> sample1 = new SuperSample<Parent>();
        //编译错误,因为只能存放Parent或它的父类
        SuperSample<? super Parent> sample2 = new SuperSample<Sub1>();
        SuperSample<? super Sub1> sample3 = new SuperSample<Parent>();

        sample1.obj = new Sub1();
        sample1.obj = new Sub2();
        sample1.obj = new Parent();

        sample3.obj = new Sub1();
        sample3.obj = new Sub2();  //编译错误
        sample3.obj = new Parent();  // 编译错误
    }
}

示例分析:

  • sample1.obj 一定是 Parent 或者 Parent 的父类,那么 Sub1/Sub2/Parent 都能满足向上转型,也就是说,Sub1 或 Sub2 的对象可以赋值给sample1.obj。因为可以确定的是 sample1.obj 一定是 Sub1 与 Sub2 父类。Parent 的对象也可以赋值给 sample1.obj,因为 sample1.obj 一定是 Parent 或它的父类。
  • sample3.obj 一定是 Sub1 或者 Sub1 的父类,因为 Parent 和 Sub2 无法完全满足条件,所以抛出了异常。例如 sample3.obj 完全有可能是 Sub1 类型,在这种情况下,显然 Parent 或 Sub2 的对象都不能赋值给 sample3.obj。

引申:在上面的例子里,sample1.obj 是什么类型?

答案:? extends Parent,也就是说,没有类型。

复杂的Java泛型

复杂的泛型也是由简单的泛型组合起来的,对于复杂泛型,需要掌握下面几个概念:

  • 多个泛型参数定义由逗号隔开,例如<T,K>。
  • 同一个泛型参数如果有多个上界,那么各个上界之间用符号&连接。
  • 多个上界类型里最多只能有一个类,其他必须为接口,如果上界里有类,那么必须放置在第一位。

结合以上的知识,可以灵活地组合出复杂的泛型声明来。参考以下代码:

class A { }
class B extends A { }
class C extends B { }

/*这是一个泛型类*/
class ComplexGeneric<T extends A, K extends B & Serializable & Cloneable> {...}

通过上面代码可以看出,ComplextGeneric 类具备两个泛型参数 <T,K>,其中,T 具备上界 A,换言之,T 一定是 A 或者其子类;K 具备三个上界,分别为类 B、接口 Serializable 和 Cloneable,换言之,K 一定是 B 或者其子类,并且实现了 Serializable 和 Cloneable。

事实上,复杂的泛型为更规范更精确的设计提供了可能性。

引申:运行时,泛型会被处理为上界类型。也就是说,ComplextGeneric 在其内部用到泛型 T 的时候,反射会把它当成 A 类来处理(需要注意的是,在字节码里,还是当作 Object 处理),那么,反射用到泛型 K 的时候呢?答案是会把它当成上界定义的第一个上界处理,在当前例子是,也就是 B 这个类。

那么知道了这个有什么实际意义呢?设想有一个方法 <T extends A>void method(T t),如果需要反射获取它,那么必须同时知道方法名和参数类型。这时候,使用 Object 是找不到它的,只能通过 A 类来获取。

Java泛型使用建议

泛型在 Java 开发和设计中占据了非常重要的地位,如何正确高效地使用泛型显得尤为重要。

下面通过介绍一些使用泛型时的建议,来加深对泛型的理解:

1)泛型类型只能是类类型,不能是基本数据类型,如果要使用基本数据类型作为泛型,那么应当使用其对应的包装类。例如,如果期望在 List 中存放整型变量,那么因为 int 是基本类型,所以不能使用 List<int>,应该使用 int 的包装类 Integer,所以正确的使用方法为 List<Integer>。

当然,泛型不支持基本数据类型,试图使用基本数据类型作为泛型的时候必须转化为包装类,这点是 Java 泛型设计之初的缺陷。

2)当使用到集合的时候,尽量使用泛型集合来替代非泛型集合。

一般来说,软件的开发期和维护期时间占比是符合二八定律的,维护期的时长能超出开发期数倍。使用了泛型的集合,在开发时,很多 IDE 工具\编译环境会提供泛型泛型警告,来辅助开发者去确定合适的类型,从而可以提高代码的可读性,并且在编译期就可以避免一些严重的 BUG。

3)不要使用常见类名(尤其是 String 这种属于 java.lang 的类)作为泛型名,使用的话会造成编译器无法区分开类和泛型问题的发送,并且不会抛出异常。

方便获取更多学习、工作、生活信息请关注本站微信公众号城东书院 微信服务号城东书院 微信订阅号
推荐内容
相关内容
栏目更新
栏目热门