Java中的范型使用 扫清盲点

概述

泛型,即“参数化类型”。一提到参数,最熟悉的就是定义方法时有形参,然后调用此方法时传递实参。那么参数化类型怎么理解呢?顾名思义,就是将类型由原来的具体的类型参数化,类似于方法中的变量参数,此时类型也定义成参数形式(可以称之为类型形参),然后在使用/调用时传入具体的类型(类型实参)。泛型的本质是为了参数化类型(在不创建新的类型的情况下,通过泛型指定的不同类型来控制形参具体限制的类型)。也就是说在泛型使用过程中,操作的数据类型被指定为一个参数,这种参数类型可以用在类、接口和方法中,分别被称为泛型类、泛型接口、泛型方法。使用范型可以减少大量的强制类型转换,在编译期检查类型,减少出错的可能。

范型的使用

1、范型类

泛型类型用于类的定义中,被称为泛型类。通过泛型可以完成对一组类的操作对外开放相同的接口

class 类名称 <泛型标识:可以随便写任意标识号,标识指定的泛型的类型>{
  private 泛型标识  var; 
  .....

  }
}

2、范型接口

范型接口和范型类使用方法类似,唯一要注意的是在实现该接口时范型的书写

//定义一个泛型接口
public interface Container<T> {
    public T get();
}

实现该接口时,当不传入范型实参时,需要在该实现类声明范型参数

public class ContainerImpl implements Container<T>{
    //未声明时,编译器会报错:"Unknown class"
    @Override
    public T get() {
        return null;
    }
}
//声明范型参数时

public class ContainerImpl implements Container<String>{
    @Override
    public String get() {
        return null;
    }
}

传入范型实参时

public class ContainerImpl<T> implements Container<T>{
    @Override
    public T get() {
        return null;
    }
}

3、范型方法

泛型类,是在实例化类的时候指明泛型的具体类型;泛型方法,是在调用方法的时候指明泛型的具体类型 。在方法中使用类上定义的范型,我们暂且把它归为第一类,范型类中

public class StaticMethodr<T> {
    ....
    ....
    /**
     * 静态方法中先于类的初始化(new,而非类加载等初始化),因此无法使用类中定义的范型,使用时会报错
     * "StaticGenerator cannot be refrenced from static context"
     */
    public static <E> void get(E t){

    }
}

范型通配符

  • 常用的 T,E,K,V
    所有的字母都代表一种特定类型的参数类型,由时间传入的 范型实参决定,但是通常来说大家遵循如下约定:

    • T (type) 表示具体的一个java类型
    • K V (key value) 分别代表java键值中的Key Value
    • E (element) 代表Element
  • ?无界通配符
    ?是通配符,泛指所有类型,一般使用它的原因是它可以指向多种范型实参的对象,如下

    SuperClass<?> sup = new SuperClass<String>("");
    sup = new SuperClass<People>(new People());
    sup = new SuperClass<Animal>(new Animal())
    
  • 上界通配符 < ? extends E>
    指T类型或T的子类型

  • 下界通配符 < ? super E>
    指T类型或T的夫类型

泛型中的PECS原则

使用泛型的过程中,经常出现一种很别扭的情况,比如我们有水果类,和它的派生类苹果

class Fruit {}
class Apple extends Fruit {}

然后我们有一个盘子,里面可以放各种东西

class Plate<T>{
  private T item;
  public Plate(T t){item=t;}
  public void set(T t){item=t;}
  public T get(){return item;}
}

现在家里来客人了,我需要把苹果放入水果盘子

Plate<Fruit> p=new Plate<Apple>(new Apple());

但实际上会出现编译错误,error: incompatible types: Plate cannot be converted to Plate,因为在编译器看来:

  • 苹果 IS-A 水果
  • 装苹果的盘子 NOT-IS-A 装水果的盘子
    所以即使容器内的所盛放的东西之间有继承关系也不能通用,那么我们可以使用边界通配符<? extends T>和<? super T>,是水果盘子和苹果盘子可以通用

上界通配符

Plate<? extends Fruit>

那么此时我们的盘子可以放下水果以及它的一切派生类,或者说我们的果盘可以放所有的水果。但是这样的范型会给它带来一个副作用:只能取不能放

Plate<? extends Fruit> p=new Plate<Apple>(new Apple());
    
//不能存入任何元素
p.set(new Fruit());    //Error
p.set(new Apple());    //Error

//读取出来的东西只能存放在Fruit或它的基类里。
Fruit newFruit1=p.get();
Object newFruit2=p.get();
Apple newFruit3=p.get();    //Error

编译器只知道是这个盘子可以放水果的派生类,但是不知道具体的类型,可能是苹果也有可能是香蕉,所以没法进行存放,但是可以进行读取,因为盘子里的东西肯定是一个水果类型的。事实上,编译器在看到后面用Plate赋值以后,盘子里没有被标上有“苹果”。而是标上一个占位符:CAP#1,来表示捕获一个Fruit或Fruit的子类,具体是什么类不知道,代号CAP#1。然后无论是想往里插入Apple或者Meat或者Fruit编译器都不知道能不能和这个CAP#1匹配,所以就都不允许。

下界通配符

Plate<? super Fruit>

表达的就是相反的概念:一个能放水果以及一切是水果基类的盘子。那么它有什么用处呢?他可以解决刚才上界通配符所带来的弊端

Plate<? super Fruit> p=new Plate<Fruit>(new Fruit());

//存入元素正常
p.set(new Fruit());
p.set(new Apple());

//读取出来的东西只能存放在Object类里。
Apple newFruit3=p.get();    //Error
Fruit newFruit1=p.get();    //Error
Object newFruit2=p.get();

虽然我们的通配符表示水果的基类,但是存放内容是只能存放水果的派生类,这是因为编译器无法知道具体的类到底是水果的哪个基类,但是可以肯定可以存放比最小粒度水果还要小的派生类,可以把它强行提升为水果类,然后再提升为我们所要存放的那个特定的水果的基类。当然他也有一个弊端就是,只能存放,无法读取,因为无法判断它到底是那个类。

PECS原则

所以我们得出了一个结论:PECS(Producer Extends Consumer Super)原则:

  • 频繁往外读取内容的,适合用上界Extends。
  • 经常往里插入的,适合用下界Super。
    如果扩展到参数类型中,可以总结为:如果参数化类型表示一个生产者,就使用<? extends T>;如果它表示一个消费者,就使用<? super T>,例如:
    //这个是Function中的一个默认方法,Function<T, R>把T类型转换为R类型,然后再调用andThen方法可以把R类型转换为V类型
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
      Objects.requireNonNull(after);
      return (T t) -> after.apply(apply(t));
  }

猜你喜欢

转载自blog.csdn.net/qqxx6661/article/details/121914268