浅析Java 泛型

时间:2023-12-27 11:40:49

泛型是JavaSE5引入的一个新概念,但是这个概念在编程语言中却是很普遍的一个概念。下面,根据以下内容,我们总结下在Java中使用泛型。

  1. 泛型使用的意义
  2. 什么是泛型
  3. 泛型类
  4. 泛型方法
  5. 泛型接口
  6. 泛型擦除
  7. 通配符

泛型使用的意义

一份好程序的一个特点就是这个程序是否具有通用性。Java 使用了多态的机制,让我们可以把方法参数类型设置为基类,而调用方法时却可以接受该基类和其子类,让我们编写代码更加通用。后来因为Java单继承受限太多的原因,我们可以把方法的参数设定为接口,直接面向接口编程。程序的通用性又提高了不少。

但现在我们希望方法接受的参数是不具体的类型,也就是说他不是一个具体的类或者接口,如果能这样做的话,我们的代码通用性将大大加强,一份代码可以根据不同的需求,在实际使用的时候使用不同的类。这个听起来是不是很熟悉,我们在编写普通方法的时候,就是根据传入的参数不同,而实现不同的功能。比如一个拥有输出学生信息的方法,输入不同的名字,会有不同的信息结果。那么我们也可以把类型信息作为参数。

具体而言就是,定义的时候,把类型做一个参数,使用的时候才将这个参数用具体的类型来代替。而实现这种需求就是靠泛型的机制。而这个也是泛型引入的原因之一:创造一份更加通用的代码

泛型引入还有一个原因,就是为容器类提供更加安全的机制。比如在早期泛型还没有引入到Java的时代,容器类如ArrayList是接受任何一个对象的,因为任何对象放进来之后都会向上转型成Object,而在取出的时候就会向下转型成期待的类型。比如我们定义了一个ArrayList 他的名字是teachers,但是由于没有任何检查机制,我们不仅可以把teacher对象放进去,同样,我们也可以放入student对象。这在没有泛型的时候,是被允许的。但我们定义ArrayList的名字为teachers就是希望这个ArrayList里面任何一个对象都能做老师才能做的事情,然而当我们取出的对象为student的时候,程序就会报错。因为student无法向下转型成teacher类型。这种编译的时候没有检查的机制实在是恼火,所以Java在SE5的时候推出了泛型,让我们在编译的时候就能确保这种事情不会发生。

但是对于一个合格的Java程序员而言,犯这种错误的机会又会有多少呢,我们有多大的可能在已经看到这个名字为teachers的时候还会把student对象放进去?所以,泛型的出现为了防止这种情况的发生只是一个原因,我认为第二个理由并不是Java推出泛型的主要原因,而主要的原因还是为了编写更加通用的代码。

什么是泛型

泛型是什么呢?泛型的字面意思就是泛化的类型,也就是说是适用于很多的类型。上面说到了泛型实现了参数化类型的概念,即这个类型是一个参数,我们在编写代码的时候用<>括住类型参数,一般如下:

<T>

这个T就是代表类型参数,在加入了<T>以后,我们就可以用T来泛指类型做各种事情。下面,我们分别说一说<T>都可以用在哪些地方。

泛型类

我们需要在一个类中,使用参数化类型,因为我们需要这个类中的成员变量的类型不是一个具体的类,而是在使用这个类的时候,才指定他。这个很容易理解,我们的ArrayList<User> users 就是在使用的时候才把ArrayList类里面的成员变量类型指定为User。

在语法上,我们需要使用泛型类的时候,一般这样去定义它:

public class MyList<T>{
private T t;
}

在类名后面加入<T>,声明一个类型参数,这个T也是整个类中都可以用的,如果不仅仅声明一个类型参数,则可以用<T,K,······,V>来声明多个。

我们看一个完整的例子,来了解泛型类的使用。

public class OneList<T> {
protected T t; public T getT() {
return t;
} public void setT(T t) {
this.t = t;
}
}

这个就是一个简单的泛型类的编写,我们在类上声明一个类型参数T,然后在这个类中T就是一种类型,我们声明了T类型的变量t,然后get方法返回T类型的变量,而set方法则传入一个T类型的变量。我们看一下这个类怎么用。

public class Test {
public static void main(String[] args) {
OneList<Integer> oneList = new OneList<>();
oneList.setT(1);
System.out.println(oneList.getT());
}
}

在具体测试的时候,我们给这个泛型类指定了一个具体的类型Integer。然后我们set的时候就只能set这个类型的变量。如果我们set一个String类型的变量,在编译的时候就会报错。

Error:(10, 22) java: 不兼容的类型: java.lang.String无法转换为java.lang.Integer

接下来,我们编写twoList这个类,同样这个类也用到泛型,而且这个类是继承OneList的

public class TwoList<T,W> extends OneList<Integer> {
private W w; public TwoList(Integer a, W w) {
super(a);
this.w = w;
} public W getW() {
return w;
} public void setW(W w) {
this.w = w;
} public void print(){
System.out.println(getW() + " " + super.getT());
}
}

我们看一下这个类,我们继承父类的时候具体指定了这个父类的类型参数为Integer,是因为我们需要在显式调用父类方法的时候,需要知道父类的类型参数,比如这个方法 super.getT()

当然也可以什么都不加,不过默认是Object。甚至我们可以用子类的泛型。比如下面这么写:

public class TwoList<T,W> extends OneList<T>

这是因为,TwoList已经声明了两个类型参数T,W,所以OneList就可以直接用这个类型参数。OneList的T就是TwoList<T,W>中的T,下面类中的代码,凡是用到T的部分,或者返回类型为T的部分都是TwoList<T,W>中的T。

在接下来,我们说一些在泛型类类型信息覆盖的问题。

我们编写一个ThreeList类,在里面有一个内部类同样使用了泛型。

public class ThreeList<T> {
public T t; public T getT() {
return t;
} public void setT(T t) {
this.t = t;
} public class Node<T>{
public T t; public T getT() {
return ThreeList.this.getT();
} public void setT(T t) {
this.t = t;
}
}
}

在内部类Node的getT()的方法中,这样写是报错的,错误的原因,如下图:

浅析Java 泛型

这是因为我们在内部类Node中又声明了一个类型参数为T,这个T与外部的T虽然是一样的名字,但是确实不同的类型参数,所以内部类里面用到的T都是Node声明的T而不是外部类的T。

泛型方法

泛型的出现,可以让我们编写更灵活的代码,让类的适用性更强,但有时,我们不需要在对类使用泛型,因为我们可能只把泛型用在了类中的一个方法上面。这时,我们可以对使用泛型方法。对一个方法应用泛型和对一个类应用泛型两者并没有什么关系。泛型方法的一般写法如下:

public staitc/final <T> T getEverything(T[] t){
//doSomething
}

首先,泛型的声明要放在public static final 等后面,但要在返回值前面。这样声明之后,方法中就可以使用类型参数了。比如上面例子的方法,返回值,参数信息都是类型参数。

我们看下完整的例子:

public class OneList<T> {
protected T t; public T getT() {
return t;
} public void setT(T t) {
this.t = t;
} public <S> void getEverything(S[] ses) {
for (S s: ses) {
System.out.println(s);
}
}
}

我们看一个完整的例子:

public class MyList {

    public static <T> void getEvetything(T[] numbers){
for (T t: numbers) {
System.out.print(t + " ");
}
}
}

我们看一下这个方法怎么使用。

public class Test {
public static void main(String[] args) {
MyList.getEvetything(new Integer[]{1,2,3,4,5,6});
}
}

泛型方法是不需要像泛型类一样,需要明确指定类型参数是什么。编译器会自动进行类型推断。

此外,还有一种情况,是必须使用泛型方法的,那就是static修饰的方法,如果要用到泛型类中的泛型,那么必须使用泛型方法。因为类还没有被实例化。但注意这个时候泛型方法中声明的类型参数和泛型类声明的类型参数完全是两个东西,虽然可能他们的名字都是T,但确实不同的所指。

泛型接口

我们除了对类,和方法使用泛型以外,我们还可以对接口使用泛型,即所谓的泛型接口。常见的泛型接口的用法,是用在生成器当中。接口+泛型,让我们可以设计更加通用的代码。泛型接口的写法与泛型类时一样的,所以就不加多说了。我们直接看例子。

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

这个泛型接口,用来返回下一个值,但是返回的类型,需要实现了这个方法的类来指定。接下来,我们编写一个NumberGenerator来实现这个接口。

public class NumberGenerator<T> implements Generator<T>{

    private T[] numbers;
private int i = 0; public NumberGenerator(T [] numbers){
this.numbers = numbers;
} @Override
public T next() {
return numbers[i++];
}
}

这个用法和上面泛型类继承是一致的,我们把接口的类型参数指定成实现了这个接口类的类型信息,这样我们可以在使用这个泛型类的时候,在指定类型参数是什么。

我们看看是如何调用使用这个泛型类的

 public static void main(String[] args) {
NumberGenerator<Integer> numberGenerator = new NumberGenerator<>(new Integer[]{10,20,30,40,50,60});
for(int i = 0 ; i < 6;i++) {
System.out.println(numberGenerator.next());
}
}

这样,我们的next方法就可以根据类型参数的不同,返回不同类型的值,不需要去更改类和接口。我们的代码就更加的通用。

至此,我们的泛型基本用法就说完了,我们接下来说一下Java泛型的独特的地方以及其他更复杂的用法。

泛型擦除

Java 泛型机制是比较特殊的一种泛型机制的实现。他是采用擦除的形式来实现泛型。

Java 采用擦除的原因

所谓擦除的机制实现泛型指的是Java的泛型是在编译器这个层次实现的。在生成的字节码中是不包含泛型中的类型信息的。换句话说,我们编写了ArrayList<Integer> list,和ArrayList list这两个类,编译之后都会生成ArrayList.class这一个类文件。我们单单从类文件上是看不出这两个类的不同的,因为他们就是一个类文件。这就是擦除,擦除了我们使用泛型的证据。这样也导致了一些比较困难的事情,比如我们在运行期是没办法得到泛型的信息的。我们没法得知ArrayList<Integer> list中,list是Integer约束的。

Java 之所以采用泛型擦除来实现泛型其实也是逼不得已的一件事情。我们上文说了Java是在SE5的时候才引入的泛型,这也导致了在JavaSE5以前有很多已经在生产环境运行了很久的代码了。比如我们有如下的早期代码:

ArrayList things = new ArrayList();
things.add(Integer.valueOf(1));
things.add("1");

Java 要保证容器类引入泛型之后,这样的代码还能够运行。一般有两种思路:

  1. 需要泛型化的类型,以前有的保持不变,然后平行的加一套泛型化版本的新类型。
  2. 直接把已有的类型泛型化,让所有需要泛型化的已有类型都原地泛型化,不添加任何平行于已有类型的泛型版本。

    第二的思路的意思是将以前的List这种需要泛型化的类,全部变成List这种形式。

而Java设计有一个规则就是向后兼容,指的是早期的版本的代码是可以在后期的Java环境中完美运行的。而高版本的Java代码放入低版本的环境一般会失败。所以Java必须保证低版本的没有泛型的代码也可以在高版本用。

如果这些老版本的代码在新版本的Java中,为了使用Java的新功能而必须做大量源码层修改,那么新功能则会大受影响。

所以现在Java就只剩下一条路了,原地将所有需要泛型的类全部转换成泛型。我们的ArrayList就变成了ArrayList<E>这样的形式,为了使以前使用ArrayList的代码在新版本中还能继续使用,所以采用了唯一的解决办法 擦除

擦除的一些特点

我们先通过一个例子来验证我们上面所说的内容。还是我们上面的OneList这个泛型类。

 OneList<Integer> IList = new OneList<>();
OneList<String> SList = new OneList<>();
System.out.println(IList.getClass() == (SList.getClass()));

结果显而易见是true。因为擦除的存在,使得这两个泛型类都被擦除为原始类型(raw type)

原始类型(raw type)指的是擦除了泛型信息,最后在字节码中的类型变量的真正类型。类型变量被擦除的原则是没有使用限定类型的则用Object替换,使用限定类型的则用限定类型替换。

我们OneList的原始类型的代码如下:OneList

public class OneList{
protected Object t; public Object getT() {
return t;
} public void setT(Object t) {
this.t = t;
} }

这样的原始类型是因为我们没有对泛型T进行限定,所以默认用的Object。这样默认用Object,是有一些弊端的。

比如,我们在A类中有一个方法f(), 我们在编写泛型的时候oneList的时候,是没法在泛型类里面调用f()方法,虽然你知道的这个oneList的泛型要绑定A类。系统只会调用Object拥有的方法。如果要满足这种需求就要引入边界这个概念,对泛型T进行限定。

这里我们用extends 关键字,如果我们要对oneList泛型类添加一个边界,我们可以用如下的语法:

public class oneList<T extends A>{
}

这样,编译器在进行泛型擦除的时候就会擦除到边界A,而不会擦除到Object,这样我们就可以在泛型类中调用A类的方法。直接看例子:

public class A {
public void f(){
System.out.println("A is a");
}
}
public class OneList<T extends A> {
protected T t; public OneList(T t){
this.t = t;
}
public void printf() {
t.f();
}
public void setT(T t) {
this.t = t;
} public static<T> void getEverything(T[] ses) {
for (T t: ses) {
System.out.println(t);
} }
}

这样,我们就可以直接在泛型类中调用A类的方法。同时,我们在使用泛型类的时候,绑定的泛型也必须是A类或者A类的子类。

这里的A类就是泛型的边界。如果泛型有很多边界,则擦除的时候则会擦除到第一个边界,比如<T extends A & B & C> 这时只会擦除到A类。这种通过extends限定就称为泛型的上限。他的最明显的好处就是按照自己的边界类型调用方法。

通配符

上面说的内容都是在声明泛型的时候,下面的内容则是针对使用泛型的时候。我们根据上面的代码看下面的一个例子。

public class Test {
public static void main(String[] args) {
List<A> OList = new ArrayList<B>()
}
}

这样的写法,编辑器是会报错的。错误的原因是泛型并不具有继承。即使B是A的子类,但是在泛型上List<A>List<B>并不等价。

为了实现这种向上转型,我们需要一个新知识:通配符 ?

比如上面的例子,我们可以用通配符来改写,变成了如下的代码:

public class Test {
public static void main(String[] args) {
List<?> OList = new ArrayList<B>()
}
}

这里的?如果不加限制,默认指的任何类型,当然我们也可以给这个通配符添加限制。同样使用到extends

代码改写后如下:

public class Test {
public static void main(String[] args) {
List<? extends A> OList = new ArrayList<B>()
}
}

这里extends 限定的意义是叫泛型或者通配符的上界。表示类型参数可以是指定类型或者指定类型的子类。这样List<B> 就是List<?>的子类,就可以实现向上转型。这样是有弊端的。如果我们调用Olist的add方法,我们会发现我们无法往OList中添加任何一个值,即使这个对象是new B()得到,或者是new A()。这是因为编译器知道OList容器接受一个类型A的子类型,但是我们不知道这个子类型究竟是什么,所以为了保证类型安全,我们是不能往这个类型里面添加任何元素的,即使是A。从一个角度来看,我们知道这个容器里面放入的是A的子类型,也就是我们通过这个容器取出的数据都是可以调用A的方法的,得到的数据都是一个A类型的实例。下面这种写法是成功的。

public class Test {
public static void main(String[] args) {
List<? extends B> OList;
ArrayList<B> BList = new ArrayList<>();
BList.add(new B());
OList = BList;
OList.get(0).f();
}
}

使用通配符的容器则代表了一个确切的类型参数的容器。,我们可以把这个类型参数的容器用到定义方法上面。比如下面的例子。

public class Test<E>{
public static void main(String[] args) {
List<? extends B> OList;
ArrayList<B> BList = new ArrayList<>();
BList.add(new B());
OList = BList; new Test<A>().getFirstElement(OList).f();
} public E getFirstElement(List<? extends E> list) {
return list.get(0);
}
}

在getFirstElement方法中,我们接受一个E类型或者其子类的容器,我们知道这个容器中取出的元素一定是E类型或者其子类。当我们给Test绑定泛型参数的时候,我们就可以根据泛型参数调用其相应的方法。

上面的代码,如果我们用泛型方法也是可以做到的。其代码如下:

public class Test<E>{
public static void main(String[] args) {
List<? extends B> OList;
ArrayList<B> BList = new ArrayList<>();
BList.add(new B());
OList = BList; new Test<A>().getFirstElement(OList).f();
} public <T extends E > E getFirstElement(List<T> list) {
return list.get(0);
}
}

这两者都是可以达到同样的效果。

我们回到之前泛型通配符的问题上来,因为使用了通配符。我们无法向其中加入任何元素,只能读取元素。如果我们一定要加入元素,我们要怎么办?这个时候,需要使用通配符的 下界。? super T表示类型参数是T或者T的父类。这样,我们就可以向容器中加入元素。比如下面的代码:

public class Test {
public class Test{
public static void main(String[] args) {
List<? super A> OList = new ArrayList<>();
OList.add(new B());
}

? super A 表示类型是A或者A的父类。根据向上转型一定安全的原则,我们的类型参数为A的容器中加入其子类B一定是成功的,因为B可以向上转型成A,但是这个面临和上面一样的问题,我们无法加入任何A的父类,比如Object对象。

然而在引用的时候,我们可以把OList引用到一个Object类型的ArrayList中。

List<? super A> OList = new ArrayList<Object>();

就和下面代码的引用会成功一样。

List<? extends A> Olist = new ArrayList<A>();

第一个通配符下界表明Olist是具有任何是A父类的列表。

第二个通配符上界表明OList是具有任何是A子类的列表。

自然,我们把一个符合要求的列表引用给他是成功。但是他们在读取和写入数据是不同的。拥有下界的列表表明我们可以把A类或者子类的数据写入到这个列表中,但是我们无法调用任何A类的方法,因为编译器只知道返回的是A的父类或者A,但是具体是哪一个,他不知道,自然也无法调用除Object以外的任何方法。拥有上界的列表表明我们可以从列表中读取数据,因为数据一定是A类或者A的子类。但是由于无法确定具体是哪一个类型,我们自然也无法向其加入任何类型。

最后,总结一下,如果我们要读取数据,则用拥有上界的通配符,如果我们要写入数据,则用拥有下界的通配符。既读又写则不要用通配符。