• Java数组类型协变性、泛型类型的不变性


    Java数组类型协变性、泛型类型的不变性

    主要参考:(https://www.cnblogs.com/tjxing/p/10419993.html)

    变性是OOP语言不变的大坑,Java的数组协变就是其中的一口老坑。
    解释数组协变之前,先明确三个相关的概念,协变、不变和逆变。

    一、协变、不变、逆变

    假设,我为一家餐馆写了这样一段代码

    class Soup<T> {
        public void add(T t) {}
    }
    
    class Vegetable {}
    
    class Carrot extends Vegetable {}
    

    有一个范型类Soup,表示用食材T做的汤,它的方法add(T t)表示向汤中添加食材T。类Vegetable表示蔬菜,类Carrot表示胡萝卜。当然,Carrot是Vegetable的子类。

    那么问题来了,Soup和Soup之间是什么关系呢?

    第一反应,Soup应该是Soup的子类,因为胡萝卜汤显然是一种蔬菜汤。如果真是这样,那就看看下面的代码。其中Tomato表示西红柿,是Vegetable的另一个子类

    Soup<Vegetable> soup = new Soup<Carrot>();
    soup.add(new Tomato());
    

    第一句没问题,Soup是Soup的子类,所以可以将Soup的实例赋给变量soup。第二句也没问题,因为soup声明为Soup类型,它的add方法接收一个Vegetable类型的参数,而Tomato是Vegetable,类型正确。

    但是,两句放在一起却有了问题。soup的实际类型是Soup,而我们给它的add方法传递了一个Tomato的实例!换言之,我们在用西红柿做胡萝卜汤,肯定做不出来。所以,把Soup视为Soup的子类在逻辑上虽然是通顺的,在使用过程中却是有缺陷的。

    那么,Soup和Soup究竟应该是什么关系呢?不同的语言有不同的理解和实现。总结起来,有三种情况。

    (1)如果Soup是Soup的子类,则称泛型Soup是协变的
    (2)如果Soup和Soup是无关的两个类,则称泛型Soup是不变的
    (3)如果Soup是Soup的父类,则称泛型Soup是逆变的。(逆变偶有使用)

    理解了协变、不变和逆变的概念,再看Java的实现。
    Java的一般泛型是不变的,也就是说Soup和Soup是毫无关系的两个类,不能将一个类的实例赋值给另一个类的变量。所以,上面那段用西红柿做胡萝卜汤的代码,其实根本无法通过编译。

    二、数组协变

    Java中,数组是基本类型,不是泛型,不存在Array这样的东西。但它和泛型很像,都是用另一个类型构建的类型。所以,数组也是要考虑变性的。

    与泛型的不变性不同,Java的数组是协变的。也就是说,Carrot[]是Vegetable[]的子类。而上一节中的例子已经表明,协变有时会引发问题。比如下面这段代码

    Vegetable[] vegetables = new Carrot[10];
    vegetables[0] = new Tomato(); // 运行期错误
    

    因为数组是协变的,编译器允许把Carrot[10]赋值给Vegetable[]类型的变量,所以这段代码可以顺利通过编译。但实际上vegetables只能接收Carrot类型的元素。只有在运行期,JVM真的试图往一堆胡萝卜中插入一个西红柿的时候,才发现大事不好。所以,上面的代码在运行期会抛出一个java.lang.ArrayStoreException类型的异常。

    数组类型的协变性,是Java的著名历史包袱之一。使用数组时,千万要小心!

    三、泛型不变

    如果把例子中的数组替换为List,情况就不同了。就像这样

    ArrayList<Vegetable> vegetables = new ArrayList<Carrot>(); // 编译期错误
    vegetables.add(new Tomato());
    

    ArrayList是一个泛型类,它是不变的。所以,ArrayList和ArrayList之间并无继承关系,这段代码在编译期就会报错。
    两段代码虽然都会报错,但通常情况下,编译期错误总比运行期错误好处理一些。

    四、让泛型实现协变

    泛型是不变的,但某些场景里我们还是希望它能协变起来。比如,有一个天天喝蔬菜汤减肥的小姐姐

    class Girl {
        public void drink(Soup<Vegetable> soup) {}
    }
    

    我们希望drink方法可以接受各种不同的蔬菜汤,包括Soup和Soup。但受到不变性的限制,它们无法作为drink的参数。
    要实现这一点,应该采用一种类似于协变性的写法

    public void drink(Soup<? extends Vegetable> soup) {}
    

    意思是,参数soup的类型是泛型类Soup,而T必须是Vegetable的子类(也包括Vegetable本类)。这时,小姐姐终于可以愉快地喝上胡萝卜汤和西红柿汤了。

    但是,这种方法有一个限制。编译器只知道泛型参数是Vegetable的子类,却不知道它具体是什么。所以,所有非null的泛型类型参数均被视为不安全的。说起来很拗口,其实很简单。直接上代码

    public void drink(Soup<? extends Vegetable> soup) {
        soup.add(new Tomato()); // 错误
        soup.add(null); // 正确
    }
    

    方法内的第一句会在编译期报错。因为编译器只知道add方法的参数是Vegetable的子类,却不知道它具体是Carrot、Tomato、或者其他的什么类型。这时,传递一个具体类型的实例一律被视为不安全的。即使soup真的是Soup类型也不行,因为soup的具体类型信息是在运行期才能知道的,编译期并不知道。
    但是方法内的第二句是正确的。因为参数是null,它可以是任何合法的类型,编译器认为它是安全的。

    五、让泛型实现逆变

    同样,也有一种类似于逆变的方法

    public void drink(Soup<? super Vegetable> soup) {}
    

    这时,Soup中的T必须是Vegetable的父类。这种情况就不存在上面的限制了,下面的代码毫无问题

    public void drink(Soup<? super Vegetable> soup) {
        soup.add(new Tomato());
    }
    

    Tomato是Vegetable的子类,自然也是Vegetable父类的子类。所以,编译期就可以确定类型是安全的。

  • 相关阅读:
    C++ 项目和资源导引
    C++ 类再探
    C++ 语句函数再探
    leetcode-174. Dungeon Game 地下城游戏
    34. Find First and Last Position of Element in Sorted Array + 二分
    leetcode-27. Remove Element删除元素
    git 使用入门
    MySQL数据库的启动与停止
    C++类型转换
    C++ 获取对象类型
  • 原文地址:https://www.cnblogs.com/JaxYoun/p/13129981.html
Copyright © 2020-2023  润新知