Java泛型简介

以下内容基于JDK8
学习泛型首先需要提到的一点:泛型是编译器层面的语法,这点对于我们认知泛型擦除和运行期泛型无法生效有很重要的作用。
首先让我们先来看个例子,其实对于学习而言,我个人比较推崇先用,然后在实践中总结的方法,当然这也可能和我的理解能力不够强有关系,见仁见智吧(事实上一个东西如果我没有吃透即使用了下次也会忘,尴尬~)。
我们先来看看在JDK5之前,创建一个ArrayList实例是怎样的:

ArrayList arrayList = new ArrayList();
arrayList.add("A String");
arrayList.add(new Integer(10));
arrayList.add("Another String");

由上,我们创建了一个底层数组实现的ArrayList实例,并为其添加了两个String类型,一个int包装类Integer类型的元素。到目前为止,一切正常,编译通过。但其实,隐患已经埋下了。
接下来让我们对列表对象进行遍历,看看会发生什么。
arrayList.forEach(o -> System.out.println((String)o));
输出结果如下:
在这里插入图片描述
我们首先打印出了列表中的第一个String元素,然后当我们循环第二次的时候,因为Integer类型的实例不能被强制转换为String类型,因此报错ClassCastException,如果我们希望避免出错,可能需要如下操作:

arrayList.forEach(o -> {
       if (o  instanceof String) {
            System.out.println((String) o);
        }else {
            System.out.println("元素不合法请检查!");
        }
});

结果如下:
在这里插入图片描述
因为如果需要避免出错,我们需要每次对类型进行检查,否则在强制类型转换时,不免会出现类转换异常,但此时已经是运行时异常,那么对于静态语言而言,能够在编译器检查出来的问题,理论上就不应该在运行期才暴露出来,由此,泛型应时而生。

以上的例子我们使用泛型语法再实现一次
在这里插入图片描述
我们可以看到,应用了参数化类型的列表,在我们制定可以存储的类型后,在添加Integer元素时直接编译不通过,并且展示给我们哪一行的元素与指定的参数不符。这样就解决了我们在编译器无法发现类型错误的问题。

对于泛型的list,我们依然可以使用增强for循环进行遍历,这是因为编译器为我们做了类型推测,所以虽然实际上List内部还是存储的Object对象,但是我们无需转型即可使用。

for (String s : arrayList) {
    System.out.println(s);
}

此语法适用于任何 Iterable(即实现了 Iterable 接口)的对象类型。

如果考虑参数化一个类,请考虑是否满足以下条件:
1.一个核心类位于某种包装器的中心:也就是说,类中心的 “东西” 可广泛地应用,它周围的特性(例如属性)是相同的。
2.行为是相同的:无论类中心的 “东西” 是什么,都会执行完全相同的操作。
上面两点是从IBM的专栏里面摘出来的,有些抽象,这点我们后面再谈。

接下来我们会讨论一个话题:List<String>List<Object>的子集么
一般人可能会认为字符串列表当然是一个对象列表

但如果我们将程序这样写,你会发现这会导致一个逻辑问题:

List<String> strings = new ArrayList<>();
List<Object> objects;
objects = strings;

首先我们假设字符串列表是一个对象列表,按照多态的思想,上面的代码自然是没有问题的。接下来我们这样操作:

objects.add(new Object());
String s = strings .get(0); // 我们正尝试将一个Object的实例转换为一个String类型的实例

是的,我们发现了问题,如果字符串列表是一个对象列表的话,我们其实破坏了泛型的作用,无形中扩大了泛型的受检查范围,所以,泛型是不存在父子关系的。用一个现实中的例子来讲,我们需要招聘一批教师,教师是人的子类型,如果我们认定一批教师是一批人(这从语义上讲没问题)我们随便找来一批人,告诉学校,这是我们招聘的一批教师,里面可能有教师,也可能没有,所以这就出现了错误,破坏了泛型的意义。因此在事实上

objects = strings;

这句编译器是会报错的,因为持有的List<Object>引用在添加元素时可能会破坏实际指向的对象列表中泛型对于可入元素类型的限制。

正是因为泛型之间不存在父子关系,所以我们引出了以下问题,也就是通配符参数

首先看以下代码:
在这里插入图片描述
从上面看出,之前没有使用泛型的时候,我们可以传递任意的Collection ,而无须关注集合中存储的到底是String元素,亦或是Integer元素,但是现在我们应用了泛型,由于我们已经得出结论,泛型是不存在父子类关系的,所以当我们试图给一个参数为List<Object>的形参传入一个类型为List<String>的参数时,编译器报错了,从这点上看,似乎泛型的使用让我们变得更加麻烦了。
那么所有集合的超类型是什么?就是Collection< ? >集合,所以,以下代码是合法的
在这里插入图片描述
我们对上面的泛型方法做一下改造:

void printCollection(Collection<?> c) {
    for (Object e : c) {
        System.out.println(e);
    }
}

在这里插入图片描述
正常调用,没有任何问题
看到这里,大家可能会有些迷惑,? 和 Object等价吗,即Collection<?>Collection<Object>有什么区别,如果没有区别,那么上面的突破原类型边界的情况还是会发生。
我们可以语义化去理解通配符代表的含义:

Collection<?> c = new ArrayList<String>();
c.add(new Object()); // 编译时异常

因为我们不知道c的元素类型代表什么,所以我们不能向它添加对象。add()方法接受E类型的参数,即集合的元素类型。当实际类型参数为?时,它表示某个未知类型(说不准都不是java的引用类型呢(开个玩笑))。我们传递给add的任何参数都必须是这个未知类型的子类型。因为我们不知道那是什么类型,所以我们不能传入任何东西。唯一的例外是null,它是每种类型的成员。

另一方面,给定一个List<?>我们可以调用get()并使用结果。结果类型是未知类型,但我们始终知道它是一个对象。

从编译器的角度来看,对于Collection<?> 类型,调用add的时候,需要传入的是 ?类型或者是其子类型,因为未知,所以只要是有具体类型的值都无法加入,即使是Object,这也是编译器检查的核心,严格对应泛型类型。
但是遍历或者获取的时候,元素总归是个对象(毕竟是已经建好的Java列表,里面存储的肯定是Java对象,即Object或其子类的实例),或者说因此我们可以使用Object来表明其类型,这样既突破了泛型参数的局限性,也保护了泛型对于传入对象的检验。
不过,任何事物都不可能完全适配所有情况,虽然使用通配符解决了我们传参时因泛型无父子关系造成的问题,但是我们遍历对象时还是会遇到问题,因为如果我们调用的是其他人编写的API,回传的参数是Object,我们需要对其进行强制类型转换后才能使用,这就让我们必须要去了解该类型到底是何种类型,这与我们使用泛型所希望的初衷不符,那么有没有一种不失灵活,又可以免去强制类型转换恶心语法的办法呢,答案是,有的。
通配符的上下限
设想我们拥有一个形状的抽象类Shape(以下例子来源于Java参考手册)

public abstract class Shape {
    public abstract void draw(Canvas c);
}
/*圆圈类*/
public class Circle extends Shape {
    private int x, y, radius;
    public void draw(Canvas c) {
        ...
    }
}
/*矩形类*/
public class Rectangle extends Shape {
    private int x, y, width, height;
    public void draw(Canvas c) {
        ...
    }
}

这些类都可以被下面的Canvas类画出:

public class Canvas {
    public void draw(Shape s) {
        s.draw(this);
   }
}

如果我们有一个各种形状的列表需要去绘制,那么我们可以这样去写

public void drawAll(List<Shape> shapes) {
    for (Shape s: shapes) {
        s.draw(this);
   }
}

但是之前的问题出现了,我们只能绘制List<Shape>参数的列表,对于List<Circle>,虽然Circle是Shape的子类型,但是集合却并不存在这样的关系,所以此时我们会想到使用万能的List<?>没错,这是可行的,不过需要强制类型转换这种不够优雅的语法参与,为了避免这种多余的转换动作,我们可以使用通配符的限定:

public void drawAll(List<? extends Shape> shapes) {
    ...
}

(List<? extends Shape> 代表的含义是一个未知的类型,但是可以确定的是这个类型是Shape的子类型,由此我们便可以像调用多钟Shape子类型的列表而无需进行强制类型转换,多态的作用在此体现了出来。
但是方便的同时,也是存在代价的,我们看以下例子:

public void addRectangle(List<? extends Shape> shapes) {
    // 编译时异常(受检查异常)
    shapes.add(0, new Rectangle());
}

但由于?无论怎样限定上下限,仍然是未知的,我们所能确定的,只是说矩形是形状的子类型,但是圆形也是形状的子类型,梯形也是形状的子类型,所以我们在添加的时候,?extends Shape有多少种可能对于我们仍然像是薛定谔的猫,因此,我们不能对其进行添加操作,因为我们不知道?到底是什么,其实对于前面的Collection<?>,在我个人的观点里可以看做是Collection<? extends Object> 虽然我们知道这肯定是一个Object,但是我们不知道?到底代表的是什么,所以我们无从添加,或者说如果允许我们添加,那么就会再次出现最初我们遇到的问题(我招老师却给我招了一群普通人),那么泛型的意义也就完全不存在了。

泛型方法

1.什么时候使用泛型方法,什么时候需要使用通配符参数
关于这个问题,网上的答案很多,但是基本上都不是很清晰,个人根据JAVA的官方参考做出一点理解和判断:
两个小例子:
1.与返回值相关,如果我们定义了一个函数需要接受一个集合,但是我们不知道集合元素时什么,此时我们一般的想法是使用通配符,即如下:

public void getList(Collection<?> collection) {
    
}

此时提出了新的要求,要求我们需要返回该集合中的第一个值,此时以上的写法不能满足需求,如果我们要使用通配符参数来实现以上效果,则按照常规写法,应该写成如此:

public ? getTFromList (List<?> list) {

}

可惜,这种语法并不被支持。

此时我们可以使用方法参数来实现,如下:

 public <T> T getTFromList(List<T> list) {
        return list.get(0);
 }

如果我们在通配符方法上非要实现以上效果的话也可以这样:

public  <T>  T  getTfromList(List<? extends T> list) {
    return list.get(0);
}

但很显然这样的话就没有使用通配符的意义了 因为在这里 T 和 ? extends T 其实是等价的,那我们为什么要多写泛型通配符呢, 没有意义。

以下的使用方法泛型其实是没有意义的,首先方法泛型与返回值没有关系,第二,方法泛型只与自己参数有关,而与其他方法的参数无关。

  public <T> void copy(List<? extends T> src) {
   
  }
  它等价于
  public void copy(List<?> src) {
   
  }

第二种使用参数方法的使用场景可以用以下例子来说明:

class Collections {
    public static <T> void copy(List<T> dest, List<? extends T> src) {
    ...
}

在以上的方法里面,虽然方法泛型与返回值没有关系,但是方法两个参数中均使用到了方法泛型,并且两个参数的泛型存在关系,第二个集合的元素必须是第一个参数元素的子类。

以上的方法也可以使用以下代替

 class Collections {
        public static <T, S extends T> void copy(List<T> dest, List<S> src) {
        ...
    }

这样做完全没有任何问题,和上面的表述应该是等价的,不过我们会发现,第一个泛型参数与两个方法参数都有关系,S本身只使用一次,在src类型中,没有其他东西依赖于它。这表明我们可以用通配符替换S。使用通配符比声明显式类型参数更清晰、更简洁,因此应该尽可能首选通配符。个人的观点是能少写就少写,能用通配符完成的任务就不要用泛型方法参数。并且尽量让参数之间的关系可以仅仅通过观察参数列表就可以得出,而不是通过定义多个方法参数。

通配符还有一个优点,即它们可以作为字段、局部变量和数组的类型在方法签名之外使用。这里有一个例子。

static List<List<? extends Shape>> history = new ArrayList<List<? extends Shape>>();

public void drawAll(List<? extends Shape> shapes) {
    history.addLast(shapes);
    for (Shape s: shapes) {
        s.draw(this);
    }
}

最后是几点简明概要的总结:
1.如果遇到需要动态传入的通配符上限,可以考虑使用泛型方法结合通配符实现,前提是返回值与泛型方法参数相关或者方法参数之间存在关系。
2.能使用通配符解决的,不用泛型方法。能看方法参数就能搞清楚参数之间关系的,能使用通配符作为参数定义,就不要额外定义方法参数。
4.一般使用T,S代替方法参数,与类的参数化类型字母不要一致,避免混淆。

与遗留代码进行互操作

因为泛型出现在JDK1.5之后,因此在相当一段时间内,旧有的代码必须与新代码共存,所以此时就涉及到了泛型代码与遗留代码之间的交互问题,为了保障兼容性,Java允许了在调用旧代码时传入泛型集合或泛型参数,但这其实导致了类型安全的初衷变得完全不可控,如果我们需要从旧有的方法中获取一个集合,我们不能保证这其中都是一种元素组成的,或者是具有某种上下限的元素集合,所以我们只能保证我们新添加的代码是可控制的,因此,在调用旧有方法传入泛型集合参数时,编译器会给出警告,这时候我们只能靠分析和经验确保旧有方法的安全性。
以下是简单的示例:在这里插入图片描述
并且我们也可以对返回的集合添加元素,所以实际上,虽然旧有代码传入参数的表现好像Collection<?> 但实际上并不如此,因为Collection<?> 是不可以进行add操作的,但事实上我们都知道这个集合使我们传入的,可以操作,但如果我们将声明的类型变量变成
Collection<?>,警告的确可以消除,尽管如此,也就没什么意义了,如下所示:
在这里插入图片描述
在旧代码中应用新代码也是相同的道理。

擦除和翻译

正如我们在本文开始时说的那样,泛型的实现是借助于编译器层面的擦除,因此事实上,在编译之后的代码中我们并不会发现<>尖括号之间的内容,对于受泛型元素限制的集合元素的操作一般会被替换为上届(通常是Object),其他的应该会被添加强制类型转换(未经验证)。

未完待续…

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值