阅读 163

重学泛型-一看就懂系列

前言

不知道读者们平时使用泛型多不多,自认为对泛型了解多少呢?本文笔者带你重学一下泛型,不只从语法的角度,尽可能从本质的角度上去理解它,并用实例代码去解释,主要内容如下:

  • 泛型的声明和实例化
  • extends 和 super 的使用
  • 泛型的协变和逆变
  • 泛型的类型擦除
  • 泛型方法和类型推断
  • 泛型的嵌套和重复
  • Kotlin泛型

如果你对以上有不是很清楚的知识点,建议看完以下内容,相信你会有所收获!

泛型的创建

泛型的声明和实例化

假设有个商店,它只卖某种类型的商品,这时候我们就可以通过声明一个泛型类型来限制它。

public interface Shop<T> {
    void sale(T item);
}
复制代码

如上面代码所示,声明的泛型T就指代商店出售的商品类型,这一步可称其为泛型的声明。

继续这个场景栗子:

假设有个水果店只卖水果,那么就可以这么写

class Fruit {}

class FruitShop implements Shop<Fruit>{
    @Override
    public void sale(Fruit item) {

    }
}
复制代码

这个时候我们Shop接口的泛型T就被确定了,是个水果类型。这一步可称其为泛型实例化。

继续这个场景栗子:

假设这个水果店比较专一,每天只卖一种水果。

由于上面FruitShop类的泛型已经被实例化了,这个商店可以卖所有类型的水果。要想实现这个需求,就需要把FruitShop类也声明一个泛型。这个泛型的要求该店只售卖水果,并且每天可更换水果品种

public interface Fruit {}
public class FruitShop<F extends Fruit> implements Shop<F>{
    @Override
    public void sale(F item) {
    }
}

//今日只卖苹果
public class Apple implements Fruit {}
new FruitShop<Apple>()
复制代码

可以看到用到extends关键字,它可以限制边界,把泛型F的上边界限制成了Fruit。这样泛型F的实例化就只允许是Fruit接口的实现类或者继承它接口。

不知道你有没有这样的疑惑 ,Fruit是个接口,F在实例化的时候都是具体的类,而extends不是表示继承嘛,实现类怎么可以继承接口?

extends 和 super 的使用

这个extends在泛型中的使用和我们平常在类关系中使用的不太一样,所以不能用继承的角度去理解它,在泛型中它起到的是限制边界的作用。

我们经常在集合中使用泛型,用此来举例子 ,先看下面这行代码有没有问题:

ArrayList<Fruit> fruits = new ArrayList<Apple>();
复制代码

直觉上看这行代码没有问题,我要一堆水果,你给我一堆苹果,没毛病。

但这一行代码放在编译器上直接飘红,编译器会告诉你 我要的是 ArrayList <Fruit>你不能给我ArrayList<Apple>,也就是说声明和实例化的泛型类型必须一样。这是为啥呀???

我们可以从写代码的角度看,假设这么写编译器不报错的话,就意味着如果我们不小心把橘子加入到这个水果集合里,却也没有任何错误提醒。它就只能在运行的时候抛出错误。(当然,根本原因并不是这样,而是泛型擦除的特性,这个后面详细说)

fruits.add(new Orange());  //加一个橘子到实例化成苹果的集合,编译器它不出错
复制代码

但实际上我们经常会遇到这样的需求,我们需要把多种不同水果,放到这个水果集合里。我们可以按照编译器提示,把声明和实例化的泛型写成一致的,都用Fruit

 ArrayList<Fruit> fruits2 = new ArrayList<Fruit>();
 fruits.add(new Orange());
 fruits.add(new Apple());
复制代码

这么写在这种场景下完全没问题,但是声明和实例化的泛型一致就必须一致吗?从直观感觉上看,如果这两个泛型有父子关系,不一样的写法也没有问题啊,很容易理解。

其实是可以的,我们可以借助extends来限制上边界,下面写就没有问题了,可以解除限制泛型一致的问题:

ArrayList<? extends Fruit> fruits = new ArrayList<Apple>();
fruits.add(new Orange());  //报错,提示加入集合的元素类型不对
复制代码

先加入一个橘子到实例化的苹果集合里,发现提示报错,很友好。这样就可以避免误写代码。

那么现在加一个苹果到集合里:

fruits.add(new Apple());  //报错了?
复制代码

报错信息:\ Required type: capture of ? extends Fruit\ Provided:Apple

what?为什么报错了,我是苹果,自己人啊,都不给加,编译器出问题了吧,傻了?

其实在这里 extends的作用是一种约定,就是说如果你用了 extends,虽然解除了声明和实例化需泛型一致的限制,但同时也添加了一种限制。

这个限制就是:为了避免类型的乱入,就不让你往里面加入元素。

ArrayList<? extends Fruit> fruits = new ArrayList<Apple>(); //1.可能是 Apple类型
ArrayList<? extends Fruit> fruits = new ArrayList<Orange>(); //2. 可能是 Orange类型
复制代码

在编译期,编译器只确定容器可存储类型是? extends Fruit 类型,也就是Fruit或其子类。如上面代码所示,当你加入元素时,容器有可能是Apple类型。也有可能是Orange类型的。它们都属于 ? extends Fruit,既然不能被确定那就都不给加了,不让你写入数据。

既然写入数据不能,那我读取数据可行不?像这样:

Fruit getFruit =  fruits3.get(0);
复制代码

这样就没有问题,其实还比较好理解,因为上边界已经被限制了,数据肯定属于Fruit或其子类数据。所以获取Fruit肯定是没问题的。当然如果要获取具体子类对象,就需要强转了。

在所举例子的这种场景下,用<? extends Fruit>这种方式就很鸡肋,不是很合适,它的使用场景更多是作为方法的参数来使用,并且只要去获取数据

举个栗子:

假设每个水果都有个名字(硬凑一个奇奇怪怪的假设),需要提供一个方法去打印所有水果的名字

因为你不知道调用这个方法的人会想要什么类型的水果名字,你还不能直接用Fruit,因为那样只能这样使用:

//获取某种水果集合里它们的名字
void getFruitName(ArrayList<Fruit> fruits){
  for (Fruit fruit: fruits){
     System.out.println("name is " +  fruit.getName());
  }
}
//只能水果集合
ArrayList<Fruit> fruits = new ArrayList<Fruit>();
getFruitName(fruits);
复制代码

这样就强制了调用者必须使用Fruit集合,而这时使用<? extends Fruit>就可以解除限制,可以传递具体的实例化对象集合,比如苹果集合:

//获取某种水果集合里它们的名字
void getFruitName(ArrayList<? extends Fruit> fruits){
    for (Fruit fruit: fruits){
        System.out.println("name is " +  fruit.getName());
    }
}
//苹果集合
ArrayList<Apple> appList= new ArrayList<Apple>()
getFruitName(appList);
复制代码

所以针对 ? extends有它适合的使用场景

既然上边界可以被限制,那么下边界能不能被限制呢?

也是可以的,用super关键字,和extends方式使用一样,但它和extends的限制刚好相反,它是可以写入数据,但只能读取部分数据(只允许存放到Object对象)。

关于super这里不细说了,贴下代码,如果上面的内容你看懂了,下面的代码还是很容易理解的:

ArrayList<? super GreenApple> appleList = new ArrayList<Fruit>();
apples.add(new GreenApple());  //可以正常添加,GreenApple 是 Apple 子类
apples.add(new Apple());       //可以正常添加

GreenApple greenApple = appleList.get(0); //get失败
Fruit fruit = appleList.get(0);           //get失败
Object object = appleList.get(0);         //get成功
复制代码

因为下界规定了元素的最小粒度的下限,实际上是放松了容器元素的类型控制,所以只要符合类型条件都可以写入,但是读取就受到了限制,因为不能确定具体类型了,所以只能用Object来抽象的存储,但这样就失去了具体类型信息,很大程度上也就失去了使用的意义。

如果还存有疑惑,建议看下这篇文章:

Java 泛型 <? super T> 中 super 怎么 理解?与 extends 有何不同?-知乎

泛型的协变和逆变

这里再提两个概念: 泛型的协变和逆变

对于协变和逆变的解释是这亚子的:

协变和逆变都是术语,前者指能够使用比原始指定的派生类型的派生程度更大(更具体的)的类型,后者指能够使用比原始指定的派生类型的派生程度更小(不太具体的)的类型

具体到刚刚的例子,协变就是这样:

ArrayList<? extends Fruit> fruits = new ArrayList<Apple>()
复制代码

协变派生的类型比原始类型Fruit更加具体,是个更明确的水果

逆变就是这样:

ArrayList<? super GreenApple> appleList = new ArrayList<Fruit>()
复制代码

逆变派生的类型比原始类型GreenApple就更加宽泛,可能是个水果,可能是个食物,可是是个东西...

泛型的类型擦除

上面提到的声明和实例化需泛型一致的限制,我们还可以通过下面这样的代码解除

 ArrayList<Fruit> fruits4 = (ArrayList) new ArrayList<Apple>();
 fruits4.add(new Apple());   //可以添加苹果
 fruits4.add(new Orange());  //添加橘子也没有问题
复制代码

发现通过强转就可以解除。关键是还可以添加非苹果类型的水果。这本质上是因为泛型的类型擦除

泛型类型擦除是理解泛型必须理解的概念,概念很简单

Java中的泛型基本上都是在编译器这个层次来实现的。使用泛型时加上的类型参数信息,只存在编译阶段,会在运行阶段去掉。这个过程就称为类型擦除。

简单点说就是在运行阶段,泛型类型就被消除了。比如上面的代码消除掉泛型后等价于

 ArrayList fruits5 = (ArrayList) new ArrayList();
复制代码

这行代码我们就很好理解了,它就变成了一个不受限制的普通容器,你想往里面加什么都可以。

为什么需要泛型类型擦除呢?

  1. 主要是因为兼容性,因为泛型是JDK 5 中引入的一个新特性,如果没有泛型擦除,就会有两种不同的类型,比如 List<String>List。假设只用新版本的JVM,可以定义规则,把两者视为不同的类型,但对于旧版本的JVM就无法辨识这两种类型,编译就会出现问题。所以为了兼容旧版本JVM,就只能通过泛型擦除将其变成一种类型。
  2. 另外还有一个原因是因为性能,如果泛型不擦除,就意味着要增加泛型化后的类来支持,内存就会受到影响。

那么问题来了,我们经常会通过反射去拿泛型的信息,那既然刚刚说泛型类型在运行时被擦除,为什么我们却可以通过反射去拿到泛型的类型,不矛盾?

其实是因为这些信息在编译成字节码的时候被保留下来了,口说无凭,证据哪里来,看字节码文件:

还是用上面那个商店的例子:

public class Shop<T> {
    void sale(T item){}
}

Shop shop = new Shop<Fruit>();
复制代码

按之前的说法泛型类型擦除后,就变成了这样:

public class Shop {
    void sale(Object item){}
}
复制代码

可最终结果真的是这样吗,我们接下来验证一下是否如此,反编译一下编译后的字节码:

{
  com.example.androidpromoteroad.generic.shop.Shop();
    descriptor: ()V
    flags:
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 8: 0

  void sale(T);                         //1. 可以看到`T`还在
    descriptor: (Ljava/lang/Object;)V   //2. 这里是对`T`的类型描述Object
    flags:
    Code:
      stack=0, locals=2, args_size=2
         0: return
      LineNumberTable:
        line 11: 0
    Signature: #11      // (TT;)V
}
复制代码

可以看出来,类型擦除的类型信息(1处),在字节码阶段还是存在的。descriptor这一行可以看到最终的结果是Object,验证我们的猜想是正确的。

泛型方法和类型推断

假设一个水果商店有交换物品的功能,你给他一个物品,它会返回一个物品(具体由外面决定),我们就可以这么实现

public class FruitShop implements Shop{
    Object changeItem(Object item){
        //假设一个object对象
        Object newItem = new Object();
        return newItem;
    }
}
复制代码

若想用苹果换橘子就可以这么写

//调用泛型方法
FruitShop changeShop = new FruitShop();
Orange orange2 = (Orange) changeShop.changeItem(new Apple());
复制代码

这么写完全是可以的,但是这样的问题在于,虽然用Object来避免对象类型的具体化,但实际使用时需要强转,这就需要写很多这样的强转样板代码。

这时我们可以用泛型方法来解决(ps:为什么要啰嗦上面一段不直接介绍泛型方法,目的是为了让读者们想清楚为什么需要用这个,而不仅仅学它的语法),这么写泛型方法:

<T,E> T change(E item) {
    //省略具体实现
}
复制代码

这么去调用

 Apple apple = new Apple();
 Orange orange = changeShop.<Orange,Apple>change(apple);
复制代码

因为泛型的类型推断功能,它会根据实例化的类型,自动推断出声明的泛型类型。

因此我们还可以省略<Orange,Apple>,这么写:

Orange orange = changeShop.change(apple);
复制代码

反之也是可以的,如果你声明了泛型类型,实例化的泛型类型如果和声明一样就可以省略。例如:

FruitShop<Fruit> changeShop = new FruitShop<Fruit>();
简写成 ==>
FruitShop<Fruit> changeShop = new FruitShop();
复制代码

泛型方法一般只和方法有关联,就是说它的泛型参数类型,和在类上的泛型参数没有关系。

泛型的嵌套和重复

泛型的重复

创建一个商店集合,并加上泛型T,这T就表示泛型的重复

class ShopList<T> extends ArrayList<T> {
     void sale(T item){}
}
复制代码

当然这个概念本身并不重要,两个泛型T自身也不重要,我们要关注的点是两处T各自发挥作用的地方,比如 ShopList<T>Tsale() 方法中表示出售的T物品,而ArrayList<T>T,表示只能存储或操作T元素

所以这里想表达的是,它们重复的关系并不重要,更多的是要关注它们所发挥作用的那个类内部使用它的地方到底干了啥。

泛型的嵌套

泛型的嵌套,顾名思义,泛型类型里面嵌套一个泛型类型

比如上面的商店集合是一种只卖苹果的水果商店,就可以这么写

class ShopList<T extends List<FruitShop<Apple>>> extends ArrayList<T> {
  void sale(T item){}  
}

复制代码

仔细看ShopList的泛型类型,变成了<T extends List<FruitShop<Apple>>>,它是一个"经典的"泛型嵌套类型。这个嵌套是可以无穷尽的,不过实际场景我们很少会嵌套很多层。

Kotlin泛型

在你不了解Java泛型的时候,去看Kotlin泛型,会很难理解。这也是为什么要花前面这么长的篇幅来介绍Java泛型,而不是直接介绍。不过在你看完上面的内容,再看Kotlin泛型,就非常容易理解了。

基本使用和Java泛型一样,这里主要介绍一下Kotlin中如何使用泛型的协变和逆变

依然用上述使用的商店,改写成Kotlin语法

class KotlinShop<T> {
    fun sale(): T {
        return null as T //此处不合理,纯属为了方便编译通过
    }
    fun buy(item: T) {
    }
}
复制代码
  • 协变和可以借助out 替换 java中的 ? extends
  • 逆变可以借助in 替换 java中的 ? super

像这样:

val outShop: KotlinShop<out KotlinApple> = KotlinShop()  //协变
val inShop: KotlinShop<in KotlinApple> = KotlinShop()    //逆变
复制代码

那么逆变和协变在kotlin中是否有和java泛型一样的特性呢

  • 协变只能读不能写入?
  • 逆变只能写入部分读?
val outSale = outShop.sale()  // 返回 KotlinApple
val inSale = inShop.sale()    //  返回 Any
outShop.buy(KotlinApple()) //编译报错
inShop.buy(KotlinApple())   //编译通过
复制代码

从上面的例子可以看出来,确实是和java泛型一样的。

后语

笔者定下了一个重学Android知识的博客计划,目的从更深入,更全的层次去重新学习那些一知半解且非常重要的知识点,本篇博客是重学系列的第一篇,之后的内容就慢慢更吧,立个flage:至少月更一篇。 立flag的目的如果不是为了打脸,都将毫无意义[手动狗头]