Bootstrap

Java 中的泛型以及协变和逆变(PECS法则)

前言:知人者智,自知者明;胜人者力,自胜者强。   ——《道德经》

一、概述

Java 中的泛型

  在 Java 中,其实所谓的泛型就是类型的参数化。如果方法有入参,那么这些入参面前往往会有类型,这个类型就是修饰参数所用。假如我们在创建类型的时候,也为其指定了参数,这个参数又是个类型,这种我们就称为泛型。

    public void addList(Collection<T> list) {//T表示可传入任何类型表示
       //TODO
    }

使用泛型能够像传递参数一样传递类型,同时保证运行时的类型安全。类型的传递能满足各种类型的调用,类型安全是指编译器在编译期间会对泛型信息进行检查,只有符合规范才能通过编译,可以有效避免了运行时异常。这就是使用 Object 相比(所有类型都可以使用基类 Object 表示)泛型的优势所在。

	//未使用泛型
    Map map = new HashMap();
    map.put("key", 10);
    Integer i = (Integer) map.get("key"); //正确,返回的就是Integer类型
    //String str = (String) map.get("key"); //错误,运行时会报异常,map存放的值实际是Integer类型,不是String类型

	//使用泛型
    Map<String, Integer> strMap = new HashMap<>();
    strMap.put("key", 10);
    Integer i2 = strMap.get("key"); //正确,不用考虑类型转换
    //String str2 = strMap.get("key"); //错误,编译不通过,strMap的值只能是Integer类型,其他类型会报错

Java 中支持类泛型,也支持方法泛型:

	//类泛型
    public class Dragon<T> {//创建类的时候,通过<T>为其指定了类型参数T
        public void share(T t) {
            //TODO
        }
    }
	//方法泛型化
    public class Tiger {
        public <T> void invite(T t) {//声明方法的时候,为其指定了类型参数T
            //TODO
        }
    }

上面的 T 可以传入任何类型表示,相当于一个入参,只不过这个入参是个类型而已。以确定下面使用的参数是具体那种类型。

泛型的限制

了解泛型的用法后,我们直接使用 JDK 提供的泛型库来演示下泛型的限制:

	List<String> strings = new ArrayList<>();
	List<Object> objects = strings;//报错,List<String>不是List<Object>的子类

error:(IntelliJ says)

Incompatible types.Required:List<java.lang.Object> Found:List<java.lang.String>

下面一步步推导,为什么出现上面的错误,以及背后的原因。

按道理来说,String 是 Object 的子类,一个集合中的元素都是 String 类型,该集合应该能被放到 Object 类型的集合中,然而 Java 却不允许我们这样做,为什么呢?

	List<String> strings = new ArrayList<>();
	List<Object> objects = strings;//假如允许这样赋值
	
	objects.add(10);//将一个数值放入 objects 中
	String string = (String) objects.get(0);//报错,运行时会抛出异常,不能整数转换为字符串

假如 List<Object> objects = strings; 成立,那么 objects.add(10) 也是成立的,但是 集合 objects 中取出的元素会被强转为 String 类型,实际上该元素是 Integer 类型的,这样在运行时就会报错。这就是 Java 不允许我们这样做的原因,为了保证运行时安全性,同时说明泛型不是可变的。

    List<String> strings = new ArrayList<>();
    List<Object> objects = new ArrayList<>();
    objects.addAll(strings);//正确

上面最后一行的写法是正确的,将 List<String> 通过 addAll() 的方法添加到 List<Object> 中,按照上面的说法,这是不可能的。我们自定义一个例子来看看:

    //定义一个泛型接口
    interface IList<T> {
        void addAll(IList<T> list);//定义addAll()方法,用于添加集合
    }
    
    //MyList提供IList的默认实现
    class MyList<T> implements IList<T> {
        @Override
        public void addAll(IList<T> list) {}
    }

	//调用
	MyList<String> stringMyList = new MyList<>();
    MyList<Object> objectMyList = new MyList<>();
    //objectMyList.addAll(stringMyList); //编译错误

上面的最后一行代码居然报错了,依然是提示了类型冲突,那么为什么 Java 中 List 的 addAll() 可以,为什么呢?我们来看看 List 中 addAll() 的源码:

boolean addAll(Collection<? extends E> items);

可以看到 addAll() 中的泛型实际上是 <? extends E> 这个类型,而不是 <E> ,这就引出了Java 中的通配符 ? 和界限。

二、协变和逆变

看下面一段代码:

    Number num = new Integer(10); // compile success
    ArrayList<Number> list = new ArrayList<Integer>(); // compile error

error:(IntelliJ says)

Incompatible types.Required:List<java.lang.Number> Found:List<java.lang.Integer>

为什么 Number 对象可以有 Integer 对象实例化,而 ArrayList<Number> 不能由 ArrayList<Integer> 实例化?要解决这些问题,我们需要了解 Java 中的协变和逆变以及泛型的通配符用法。

说逆变协变之前先来了解一下 Liskov替换原则 (Liskov Substitution Principle, LSP)

Liskov替换原则

LSP由Barbara Liskov于1987年提出,其定义如下:

所有引用基类(父类)的地方必须透明地使用其子类对象。

LSP包含一下四层含义:

  • 子类完全拥有父类的方法,且具体子类必须实现父类的抽象方法;
  • 子类中可以增加自己的方法;
  • 当子类覆盖或者实现父类方法时,方法的形参要比父类方法的更为宽松;
  • 当子类覆盖或者实现父类方法时,方法的返回值要比父类的更严格。

前面的两层含义比较好理解,后面的两层含义后面会讲解到。

类型转换

协变和逆变主要用来描述类型转换后的继承关系,如果 A、B 表示参数类型,f(⋅) 表示类型转换,≤ 表示继承关系(比如 A ≤ B 表示 A 是 B派生出来的子类)

  • 当 f(⋅) 是协变时,如果 A ≤ B,则 f(A) ≤ f(B);
  • 当 f(⋅) 是逆变时,如果 A ≤ B,则 f(B) ≤ f(A);
  • 当 f(⋅) 是不变时, A ≤ B 时上面两个式子均不成立,即 f(A) ≤ f(B) 相护之间没有继承关系。

(1)泛型

简单来说,Integer 是 Number 派生出来的子类,如果是协变,ArrayList<Integer>ArrayList<Number> 的子类型;如果是逆变,ArrayList<Intege >ArrayList<Number> 的父类型;如果是不变的,则 ArrayList<Integer>ArrayList<Number> 两者相护没有继承关系。

上面的例子中 ArrayList<Number> list = new ArrayList<Integer>() 编译报错,说明泛型是不变的。

(2)数组

数组是协变的,如下:

Number[] numbers = new Integer[0];

(3)方法

    static Number method(Number num) {
        return 1;
    }
	
	//调用
    Object result1 = method(new Integer(1));  // compile success
    Number result2 = method(new Integer(1));  // compile success
    Number result3 = method(new Object());    // compile error 传入参数必须为Number或其子类型
    Integer result4 = method(new Integer(0)); // compile error 返回值类型必须为Number或其超类型

调用方法 result = method(number);,根据Liskov替换原则,传入形参 number 的应为方法 method(Number num) 形参的Number 类型或者是Number的子类型,返回结果的类型 result 应为 Number method(Number num) 返回值类型 Number 类型或者 Number 的超类型。

    class Base {
        Number method(Number n) { ... }
    }

    class Child extends Base {
        @Override
        Number method(Number n) { ... }
    }

在 Java1.4中,子类覆盖(Override)父类方法时,形参和返回值类型必须保持与父类的一致。

    class Child extends Base {
        @Override
        Integer method(Number n) { ... }
    }

从 Java1.5开始,子类覆盖(Override)父类方法时,允许协变返回更为具体的类型。

三、泛型中的通配符

Java 中的泛型是不变的,如果需要实现逆变和协变,怎么办呢?这时就需要用到 通配符 ? 了。

Java 的通配符有两大类:

  • 子类限定通配符:<? extends E>
  • 父类限定通配符: <? super E>

通配符方式不同于普通的类型变量声明,它不能进行类型推断,实际上就限制了数据的写入和读取。那么需要明确的告诉编译器数据的上界和下界,否则会报错。

实现泛型协变和逆变主要是 extends 和 super 关键字,例如:

< ? extends T>实现泛型协变

ArrayList<? extends Number> list = new ArrayList<Integer>();

<? super T>实现泛型逆变

ArrayList<? super Number> list2 = new ArrayList<Object>();

这里我们以下面的几个类的 super 和 extend 关系举个例子说明:

Student 是 Teacher 的子类,但是 List<Student> 不是 List<Teacher> 的子类,那么有没有办法使他们兼容,答案是有的,那就是协变和逆变。

Java 中所有类的顶级父类是 Object,同时可以认为 null 所有类的子类。

3.1 泛型协变 < ? extends T>

通配符 ? 的类型在 null 和 T 之间,编译器不能确定放入的是什么类型,null 可以转换为任意类型,也就是说,编译器不知道类型下界是什么,只清楚类型上界。如图:

类型的上界是 T ,参数化类型是 T 或 T 的子类。

public class Test {
    private static class Person { }

    private static class Teacher extends Person { }

    private static class Student extends Teacher { }

    public static void main(String[] args) {
        List<? extends Teacher> list = new ArrayList<>();

        //不能加人任何元素
        list.add(new Person()); // compile error
        list.add(new Teacher()); // compile error
        list.add(new Student()); // compile error

        Teacher teacher = teachers.get(0);// compile success
    }
}

可以看到 List<? extends Teacher> 在添加 Person、Teacher、Student 都发生了编译错误,我们来看看 add() 方法的实现:

boolean add(E e)
存入数据:

在调用 add() 方法的时候,泛型 e 自动变成了<? extends Teacher>,其表示 list 所持有的类型为 Teacher 或 Teacher 的子类,但是并不知道这个类具体是什么类型,只好阻止向其中加人任何子类。为了类型安全,不能往使用了 <? extends E> 的数据结构中写入任何值(null除外)。

读取数据:

但是,由于编译器总是知道他是 Teacher 或 Teacher 的子类型,因此可以从中取出 Teacher 对象;

Teacher teacher = teachers.get(0);

对于<? extends T>来说,参数类型 ? 表示的是 T 以及 T 的子类型。如果允许我们向容器中添加元素,那么无法确定子类具体是什么类型,这样取出元素的时候就可能报类型转换异常,为了运行时安全,Java 禁止了元素写入。

为了能调用 add() 方法,可以使用 super 关键字实现,也就是逆变。

3.2 泛型逆变<? super T>

通配符 ? 的类型在 T 和 Object 之间,编译器很清楚放入类型的下界是 T,但是不知道上界具体是什么。
在这里插入图片描述
表示类型的下界限是 T,参数化类型可以是 T 或者 T 的超类。

public class Test {
    private static class Person { }

    private static class Teacher extends Person { }

    private static class Student extends Teacher { }

    public static void main(String[] args) {
        List<? super Teacher> list = new ArrayList<>();

        //Teacher 及其子类可以看做成 Teacher,从而添加成功
        list.add(new Person()); // compile error
        list.add(new Teacher()); // compile success
        list.add(new Student()); // compile success

        Object object = teachers.get(0);// compile success
    }
}
存入数据:

其中 <? super T> 表示所持有的类型为 Teacher 或 Teacher 的超类。其中 Teacher 和 Student 必定为这某一类型的子类,因为编译器会自动向上转型,Teacher 以及子类可被认为是 Teacher 类型,所以添加 Teacher 以及子类元素成功;但是 Person 是 Teacher 的超类,由于编译器并不知道 List 的内容究竟是 Teacher 的哪个超类,因此不允许加入任何的超类型。

读取数据:

编译器不知道这个超类具体是什么类,只能返回 Object 对象,因为 Object 在 Java 中是任何类的祖先。

Object object = teachers.get(0);

对于<? super T>来说,参数类型 ? 表示的是 T 或者 T 的超类。如果是 T 的子类那么也是 T 超类的子类,所以将元素添加到容器是允许的,因为取出来一定符合 T 或者 T 的超类型。但是如果是 T 的超类型是不允许向容器添加元素的,因为无法确定 T 的超类具体是什么类型,取出元素的时候可能会引起类型转换错误。

从上面的例子可以看出,extends 确定了泛型的上界,super 确定了泛型的下界。

3.3 PECS原则

那么问题来了,究竟什么时候调用 extends 什么时候用 super ?

《Effective Java》给出了答案:

PECS 全称为 Producer-Extends-Consumer-Super。

这就是 PECS 原则,即描述了子类限定通配符和父类限定通配符的使用原则。

这里以类 Stack 为例:

//代码已简化
public class Stack<E> extends Vector<E> {

    public Stack() {}

    public E push(E item)

    public E pop()

	//遍历获取src中的元素
    public void pushAll(ArrayList<E> src) {
        for (E e : src)//获取
            push(e);
    }

    //将Stack中的元素依次取出add到dst中
    public void popAll(ArrayList<E> dst) {
        while (!isEmpty())
            dst.add(pop());//添加
    }
}

(1)要实现 pushAll(Iterable<E> src) 方法,将 src 的元素逐一存入类 Stack 中:

    Stack<Number> stack = new Stack<>();
    stack.pushAll(new ArrayList<Float>()); // compile error,提示类型冲突
    stack.pushAll(new ArrayList<Integer>()); // compile error,提示类型冲突

上面一个实例化 Stack<Number> 的对象 stack,所以类中的泛型 E 表示 Number。src 有 ArrayList<Float>ArrayList<Integer>,在调用 pushAll() 方法时提示类型冲突错误,因为 Java 中的泛型是不可变的,ArrayList<Float>ArrayList<Integer> 都不是 ArrayList<Number> src 的子类,因此需要使用 extends :

  public void pushAll(ArrayList<? extends E> src) {//作为E生成器的参数的通配符类型
        for (E e : src)//获取
            push(e);
    }

泛型协变后,ArrayList<Float>ArrayList<Integer> 都是 ArrayList<Number> src 的子类,编译成功。

(2)要实现 popAll(ArrayList<E> dst) 方法,将类 Stack 中的元素依次取出 add() 到 dst 集合中,如果不用通配符实现:

    Stack<Number> stack = new Stack<>();
    stack.popAll(new ArrayList<Object>()); // compile error,提示类型冲突

传入的集合 dst 为 ArrayList<Object>类型,调用 popAll() 方法时会提示类型冲突错误,因为 ArrayList<Object> 不是 ArrayList<Number> 的超类,所以需要使用super:

  public void popAll(ArrayList<? super E> dst) {//作为E使用者的参数的通配符类型
        while (!isEmpty())
            dst.add(pop());//添加
    }

泛型逆变后,ArrayList<Object>ArrayList<Number> 的超类,编译成功。

上面的例子类 Stack 中,在调用 pushAll() 方法时 src 产生 E 实例(生产者场景),在调用 popAll() 方法时 dst 消耗 E 实例(消费者场景),PECS被称为Get and Put Principle(取放原则)。

其实 java.util.Collections 的 copy() 方法(JDK1.7)完美地诠释了 PECS:

    public static <T> void copy(List<? super T> dest, List<? extends T> src) {
        int srcSize = src.size();
        if (srcSize > dest.size())
            throw new IndexOutOfBoundsException("Source does not fit in dest");

        if (srcSize < COPY_THRESHOLD ||
            (src instanceof RandomAccess && dest instanceof RandomAccess)) {
            for (int i=0; i<srcSize; i++)
                dest.set(i, src.get(i));
        } else {
            ListIterator<? super T> di=dest.listIterator();
            ListIterator<? extends T> si=src.listIterator();
            for (int i=0; i<srcSize; i++) {
                di.next();
                di.set(si.next());
            }
        }
    }
PECS原则总结:
  • <? extends E>子类限定通配符:从泛型类读取类型 T 的数据,并且不能写入,用于生产者环境(Producer);
  • <? super E>父类限定通配符: 从集合中写入类型 T 的数据,并且不需要读取,用于消费者场景(Consumer);
  • 如果既要存数据又要取数据,那么通配符无法满足需求。

注意:如果使用生产者对象,例如 List<? extends Foo>,你不允许再这个对象上 add() 或者 set() ,但并不意味着这个对象是不可变的,例如:没有什么可以阻止你调用 clear() 来从列表中删除所有元素,因为 clear() 根本不接受任何参数。通配符(或其他类型的型变)唯一能保证的是类型安全性。不变性则完全是另外一个回事。

点关注,不迷路


好了各位,以上就是这篇文章的全部内容了,能看到这里的人呀,都是人才

我是suming,感谢各位的支持和认可,您的点赞、评论、收藏【一键三连】就是我创作的最大动力,我们下篇文章见!

如果本篇博客有任何错误,请批评指教,不胜感激 !

要想成为一个优秀的安卓开发者,这里有必须要掌握的知识架构,一步一步朝着自己的梦想前进!Keep Moving!

;