计算机程序的思维逻辑 (37) - 泛型 (下) - 细节和局限性

35节介绍了泛型的基本概念和原理,上节介绍了泛型中的通配符,本节来介绍泛型中的一些细节和局限性。

这些局限性主要与Java的实现机制有关,Java中,泛型是通过类型擦除来实现的,类型参数在编译时会被替换为Object,运行时Java虚拟机不知道泛型这回事,这带来了很多局限性,其中有的部分是比较容易理解的,有的则是非常违反直觉的。

一项技术,往往只有理解了其局限性,我们才算是真正理解了它,才能更好的应用它。

下面,我们将从以下几个方面来介绍这些细节和局限性:

  • 使用泛型类、方法和接口
  • 定义泛型类、方法和接口
  • 泛型与数组

使用泛型类、方法和接口

在使用泛型类、方法和接口时,有一些值得注意的地方,比如:

  • 基本类型不能用于实例化类型参数
  • 运行时类型信息不适用于泛型
  • 类型擦除可能会引发一些冲突

我们逐个来看下。

基本类型不能用于实例化类型参数

Java中,因为类型参数会被替换为Object,所以Java泛型中不能使用基本数据类型,也就是说,类似下面写法是不合法的:

Pair<int> minmax = new Pair<int>(1,100);

解决方法就是使用基本类型对应的包装类。

运行时类型信息不适用于泛型

在介绍继承的实现原理时,我们提到,在内存中,每个类都有一份类型信息,而每个对象也都保存着其对应类型信息的引用。关于运行时信息,后续文章我们会进一步详细介绍,这里简要说明一下。

在Java中,这个类型信息也是一个对象,它的类型为Class,Class本身也是一个泛型类,每个类的类型对象可以通过<类名>.class的方式引用,比如String.class,Integer.class。

这个类型对象也可以通过对象的getClass()方法获得,比如:

Class<?> cls = "hello".getClass();

这个类型对象只有一份,与泛型无关,所以Java不支持类似如下写法:

Pair<Integer>.class

一个泛型对象的getClass方法的返回值与原始类型对象也是相同的,比如说,下面代码的输出都是true:

Pair<Integer> p1 = new Pair<Integer>(1,100);
Pair<String> p2 = new Pair<String>("hello","world");
System.out.println(Pair.class==p1.getClass());
System.out.println(Pair.class==p2.getClass());

第16节,我们介绍过instanceof关键字,instanceof后面是接口或类名,instanceof是运行时判断,也与泛型无关,所以,Java也不支持类似如下写法:

if(p1 instanceof Pair<Integer>)

不过,Java支持这么写:

if(p1 instanceof Pair<?>)

类型擦除可能会引发一些冲突

由于类型擦除,可能会引发一些编译冲突,这些冲突初看上去并不容易理解,我们通过一些例子看一下。

上节我们介绍过一个例子,有两个类Base和Child,Base的声明为:

class Base implements Comparable<Base>

Child的声明为:

class Child extends Base

Child没有专门实现Comparable接口,上节我们说Base类已经有了比较所需的全部信息,所以Child没有必要实现,可是如果Child希望自定义这个比较方法呢?直觉上,可以这样修改Child类:

class Child extends Base implements Comparable<Child>{
    @Override
    public int compareTo(Child o) {

    }
    //...
}

遗憾的是,Java编译器会提示错误,Comparable接口不能被实现两次,且两次实现的类型参数还不同,一次是Comparable<Base>,一次是Comparable<Child>。为什么不允许呢?因为类型擦除后,实际上只能有一个。

那Child有什么办法修改比较方法呢?只能是重写Base类的实现,如下所示:

class Child extends Base {
    @Override
    public int compareTo(Base o) {
        if(!(o instanceof Child)){
            throw new IllegalArgumentException();
        }
        Child c = (Child)o;
        //...
        return 0;
    }
    //...
}

还有,你可能认为可以这么定义重载方法:

public static void test(DynamicArray<Integer> intArr)
public static void test(DynamicArray<String> strArr)

虽然参数都是DynamicArray,但实例化类型不同,一个是DynamicArray<Integer>,另一个是DynamicArray<String>,同样,遗憾的是,Java不允许这种写法,理由同样是,类型擦除后,它们的声明是一样的。

定义泛型类、方法和接口

在定义泛型类、方法和接口时,也有一些需要注意的地方,比如:

  • 不能通过类型参数创建对象
  • 泛型类类型参数不能用于静态变量和方法
  • 了解多个类型限定的语法

我们逐个来看下。

不能通过类型参数创建对象

不能通过类型参数创建对象,比如,T是类型参数,下面写法都是非法的:

T elm = new T();
T[] arr = new T[10];

为什么非法呢?因为如果允许,那你以为创建的就是对应类型的对象,但由于类型擦除,Java只能创建Object类型的对象,而无法创建T类型的对象,容易引起误解,所以Java干脆禁止这么做。

那如果确实希望根据类型创建对象呢?需要设计API接受类型对象,即Class对象,并使用Java中的反射机制,后续文章我们再详细介绍反射,这里简要说明一下,如果类型有默认构造方法,可以调用Class的newInstance方法构建对象,类似这样:

public static <T> T create(Class<T> type){
    try {
        return type.newInstance();
    } catch (Exception e) {
        return null;
    }
}

比如:

Date date = create(Date.class);
StringBuilder sb = create(StringBuilder.class);

泛型类类型参数不能用于静态变量和方法

对于泛型类声明的类型参数,可以在实例变量和方法中使用,但在静态变量和静态方法中是不能使用的。类似下面这种写法是非法的:

public class Singleton<T> {

    private static T instance;

    public synchronized static T getInstance(){
        if(instance==null){
             // 创建实例
        }
        return instance;
    }
}    

如果合法的话,那么对于每种实例化类型,都需要有一个对应的静态变量和方法。但由于类型擦除,Singleton类型只有一份,静态变量和方法都是类型的属性,且与类型参数无关,所以不能使用泛型类类型参数。

不过,对于静态方法,它可以是泛型方法,可以声明自己的类型参数,这个参数与泛型类的类型参数是没有关系的。

了解多个类型限定的语法

之前介绍类型参数限定的时候,我们介绍,上界可以为某个类、某个接口或者其他类型参数,但上界都是只有一个,Java中还支持多个上界,多个上界之间以&分隔,类似这样:

T extends Base & Comparable & Serializable

Base为上界类,Comparable和Serializable为上界接口,如果有上界类,类应该放在第一个,类型擦除时,会用第一个上界替换。

泛型与数组

泛型与数组的关系稍微复杂一些,我们单独讨论一下。

为什么不能创建泛型数组?

引入泛型后,一个令人惊讶的事实是,你不能创建泛型数组。比如说,我们可能想这样创建一个Pair的泛型数组,以表示随机一节中介绍的奖励面额和权重。

Pair<Object,Integer>[] options = new Pair<Object,Integer>[]{
        new Pair("1元",7),
        new Pair("2元", 2),
        new Pair("10元", 1)
};

Java会提示编译错误,不能创建泛型数组。这是为什么呢?我们先来进一步理解一下数组。

前面我们解释过,类型参数之间有继承关系的容器之间是没有关系的,比如,一个DynamicArray<Integer>对象不能赋值给一个DynamicArray<Number>变量。不过,数组是可以的,看代码:

Integer[] ints = new Integer[10];
Number[] numbers = ints;
Object[] objs = ints;

后面两种赋值都是允许的。数组为什么可以呢?数组是Java直接支持的概念,它知道数组元素的实际类型,它知道Object和Number都是Integer的父类型,所以这个操作是允许的。

虽然Java允许这种转换,但如果使用不当,可能会引起运行时异常,比如:

Integer[] ints = new Integer[10];
Object[] objs = ints;
objs[0] = "hello";

编译是没有问题的,运行时会抛出ArrayStoreException,因为Java知道实际的类型是Integer,所以写入String会抛出异常。

理解了数组的这个行为,我们再来看泛型数组。如果Java允许创建泛型数组,则会发生非常严重的问题,我们看看具体会发生什么:

Pair<Object,Integer>[] options = new Pair<Object,Integer>[3];
Object[] objs = options;
objs[0] = new Pair<Double,String>(12.34,"hello");

如果可以创建泛型数组options,那它就可以赋值给其他类型的数组objs,而最后一行明显错误的赋值操作,则既不会引起编译错误,也不会触发运行时异常,因为Pair<Double,String>的运行时类型是Pair,和objs的运行时类型Pair[]是匹配的。但我们知道,它的实际类型是不匹配的,在程序的其他地方,当把objs[0]当做Pair<Object,Integer>进行处理的时候,一定会触发异常。

也就是说,如果允许创建泛型数组,那就可能会有上面这种错误操作,它既不会引起编译错误,也不会立即触发运行时异常,却相当于埋下了一颗炸弹,不定什么时候爆发,为避免这种情况,Java干脆就禁止创建泛型数组。

如何存放泛型对象?

但,现实需要能够存放泛型对象的容器啊,怎么办呢?可以使用原始类型的数组,比如:

Pair[] options = new Pair[]{
      new Pair<String,Integer>("1元",7),
      new Pair<String,Integer>("2元", 2),
      new Pair<String,Integer>("10元", 1)};

更好的选择是,使用后续章节介绍的泛型容器。目前,可以使用我们自己实现的DynamicArray,比如:

DynamicArray<Pair<String,Integer>> options = new DynamicArray<>();
options.add(new Pair<String,Integer>("1元",7));
options.add(new Pair<String,Integer>("2元",2));
options.add(new Pair<String,Integer>("10元",1));

DynamicArray内部的数组为Object类型,一些操作插入了强制类型转换,外部接口是类型安全的,对数组的访问都是内部代码,可以避免误用和类型异常。

如何转换容器为数组?

有时,我们希望转换泛型容器为一个数组,比如说,对于DynamicArray,我们可能希望它有这么一个方法:

public E[] toArray()

而我们希望可以这么用:

DynamicArray<Integer> ints = new DynamicArray<Integer>();
ints.add(100);
ints.add(34);
Integer[] arr = ints.toArray();

先使用动态容器收集一些数据,然后转换为一个固定数组,这也是一个常见合理的需求,怎么来实现这个toArray方法呢?

可能想先这样:

E[] arr = new E[size];

遗憾的是,如之前所述,这是不合法的。Java运行时根本不知道E是什么,也就无法做到创建E类型的数组。

另一种想法是这样:

public E[] toArray(){
    Object[] copy = new Object[size];
    System.arraycopy(elementData, 0, copy, 0, size);
    return (E[])copy;
}

或者使用之前介绍的Arrays方法:

public E[] toArray(){
    return (E[])Arrays.copyOf(elementData, size);
}

结果都是一样的,没有编译错误了,但运行时,会抛出ClassCastException异常,原因是,Object类型的数组不能转换为Integer类型的数组。

那怎么办呢?可以利用Java中的运行时类型信息和反射机制,这些概念我们后续章节再介绍。这里,我们简要介绍下。

Java必须在运行时知道你要转换成的数组类型,类型可以作为参数传递给toArray方法,比如:

public E[] toArray(Class<E> type){
    Object copy = Array.newInstance(type, size);
    System.arraycopy(elementData, 0, copy, 0, size);
    return (E[])copy;
}

Class<E>表示要转换成的数组类型信息,有了这个类型信息,Array类的newInstance方法就可以创建出真正类型的数组对象。

调用toArray方法时,需要传递需要的类型,比如,可以这样:

Integer[] arr = ints.toArray(Integer.class);

泛型与数组小结

我们来稍微总结下泛型与数组的关系:

  • Java不支持创建泛型数组。
  • 如果要存放泛型对象,可以使用原始类型的数组,或者使用泛型容器。
  • 泛型容器内部使用Object数组,如果要转换泛型容器为对应类型的数组,需要使用反射。

小结

本节介绍了泛型的一些细节和局限性,这些局限性主要是由于Java泛型的实现机制引起的,这些局限性包括,不能使用基本类型,没有运行时类型信息,类型擦除会引发一些冲突,不能通过类型参数创建对象,不能用于静态变量等,我们还单独讨论了泛型与数组的关系。

我们需要理解这些局限性,但,幸运的是,一般并不需要特别去记忆,因为用错的时候,Java开发环境和编译器会提示你,当被提示时,你需要能够理解,并可以从容应对。

至此,关于泛型的介绍就结束了,泛型是Java容器类的基础,理解了泛型,接下来,就让我们开始探索Java中的容器类。

----------------

未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),从入门到高级,深入浅出,老马和你一起探索Java编程及计算机技术的本质。用心写作,原创文章,保留所有版权。

时间: 2024-08-03 11:29:47

计算机程序的思维逻辑 (37) - 泛型 (下) - 细节和局限性的相关文章

计算机程序的思维逻辑 (25) - 异常 (下)

上节我们介绍了异常的基本概念和异常类,本节我们进一步介绍对异常的处理,我们先来看Java语言对异常处理的支持,然后探讨在实际中到底应该如何处理异常. 异常处理 catch匹配 上节简单介绍了使用try/catch捕获异常,其中catch只有一条,其实,catch还可以有多条,每条对应一个异常类型,比如说: try{ //可能触发异常的代码 }catch(NumberFormatException e){ System.out.println("not valid number"); }

计算机程序的思维逻辑 (36) - 泛型 (中) - 解析通配符

上节我们介绍了泛型的基本概念和原理,本节继续讨论泛型,主要讨论泛型中的通配符概念.通配符有着令人费解和混淆的语法,但通配符大量应用于Java容器类中,它到底是什么?本节,让我们逐步来解析. 更简洁的参数类型限定 在上节最后,我们提到一个例子,为了将Integer对象添加到Number容器中,我们的类型参数使用了其他类型参数作为上界,代码是: public <T extends E> void addAll(DynamicArray<T> c) { for(int i=0; i<

计算机程序的思维逻辑 (16) - 继承的细节【转】

上节我们介绍了继承和多态的基本概念,基本概念是比较简单的,子类继承父类,自动拥有父类的属性和行为,并可扩展属性和行为,同时,可重写父类的方法以修改行为. 但继承和多态概念还有一些相关的细节,本节就来探讨这些细节,具体包括: 构造方法 重名与静态绑定 重载和重写 父子类型转换 继承访问权限 (protected) 可见性重写 防止继承 (final) 下面我们逐个来解释. 构造方法 super 上节我们说过,子类可以通过super(...)调用父类的构造方法,如果子类没有通过super(...)调

计算机程序的思维逻辑 (16) - 继承的细节

上节我们介绍了继承和多态的基本概念,基本概念是比较简单的,子类继承父类,自动拥有父类的属性和行为,并可扩展属性和行为,同时,可重写父类的方法以修改行为. 但继承和多态概念还有一些相关的细节,本节就来探讨这些细节,具体包括: 构造方法 重名与静态绑定 重载和重写 父子类型转换 继承访问权限 (protected) 可见性重写 防止继承 (final) 下面我们逐个来解释. 构造方法 super 上节我们说过,子类可以通过super(...)调用父类的构造方法,如果子类没有通过super(...)调

计算机程序的思维逻辑 (90) - 正则表达式 (下 - 剖析常见表达式)

?88节介绍了正则表达式的语法,上节介绍了正则表达式相关的Java API,本节来讨论和分析一些常用的正则表达式,具体包括: 邮编 电话号码,包括手机号码和固定电话号码 日期和时间 身份证 IP地址 URL Email地址 中文字符 对于同一个目的,正则表达式往往有多种写法,大多没有唯一正确的写法,本节的写法主要是示例.此外,写一个正则表达式,匹配希望匹配的内容往往比较容易,但让它不匹配不希望匹配的内容,则往往比较困难,也就是说,保证精确性经常是很难的,不过,很多时候,我们也没有必要写完全精确的

计算机程序的思维逻辑 (23) - 枚举的本质

前面系列,我们介绍了Java中表示和操作数据的基本数据类型.类和接口,本节探讨Java中的枚举类型. 所谓枚举,是一种特殊的数据,它的取值是有限的,可以枚举出来的,比如说一年就是有四季.一周有七天,虽然使用类也可以处理这种数据,但枚举类型更为简洁.安全和方便. 下面我们就来介绍枚举的使用,同时介绍其实现原理. 基础 基本用法 定义和使用基本的枚举是比较简单的,我们来看个例子,为表示衣服的尺寸,我们定义一个枚举类型Size,包括三个尺寸,小/中/大,代码如下: public enum Size {

计算机程序的思维逻辑 (21) - 内部类的本质

内部类 之前我们所说的类都对应于一个独立的Java源文件,但一个类还可以放在另一个类的内部,称之为内部类,相对而言,包含它的类称之为外部类. 为什么要放到别的类内部呢?一般而言,内部类与包含它的外部类有比较密切的关系,而与其他类关系不大,定义在类内部,可以实现对外部完全隐藏,可以有更好的封装性,代码实现上也往往更为简洁. 不过,内部类只是Java编译器的概念,对于Java虚拟机而言,它是不知道内部类这回事的, 每个内部类最后都会被编译为一个独立的类,生成一个独立的字节码文件. 也就是说,每个内部

计算机程序的思维逻辑 (22) - 代码的组织机制

使用任何语言进行编程都有一个类似的问题,那就是如何组织代码,具体来说,如何避免命名冲突?如何合理组织各种源文件?如何使用第三方库?各种代码和依赖库如何编译连接为一个完整的程序? 本节就来讨论Java中的解决机制,具体包括包.jar包.程序的编译与连接,从包开始. 包的概念 使用任何语言进行编程都有一个相同的问题,就是命名冲突,程序一般不全是一个人写的,会调用系统提供的代码.第三方库中的代码.项目中其他人写的代码等,不同的人就不同的目的可能定义同样的类名/接口名,Java中解决这个问题的方法就是包

计算机程序的思维逻辑 (28) - 剖析包装类 (下)

本节探讨Character类,它的基本用法我们在包装类第一节已经介绍了,本节不再赘述.Character类除了封装了一个char外,还有什么可介绍的呢?它有很多静态方法,封装了Unicode字符级别的各种操作,是Java文本处理的基础,注意不是char级别,Unicode字符并不等同于char,本节详细介绍这些方法以及相关的Unicode知识. 在介绍这些方法之前,我们需要回顾一下字符在Java中的表示方法,我们在第六节.第七节.第八节介绍过编码.Unicode.char等知识,我们先简要回顾一