《Java核心技术卷I》泛型篇笔记(三) 泛型的特性和局限

本文深入探讨了Java泛型的特性,包括类型擦除、运行时类型查询、创建泛型数组的限制等。文章详细阐述了不能实例化类型变量、不能创建参数化类型数组等问题,并提供了利用方法引用和反射的解决方案。此外,还介绍了ArrayList的实现、泛型类静态上下文的限制、异常处理以及桥方法等相关知识点。
摘要由CSDN通过智能技术生成

写在最前:本笔记全程参考《Java核心技术卷I》,添加了一些个人的思考和整理


泛型的特性、限制和局限性

1. 不能用基本数据类型实例化参数

例如,没有ArrayList<int>,或者Pair<double>,而只有ArrayList<Integer>以及Pair<Double>

因为在类型擦除之后,所有的泛型都将转换为限定类型或者Object,但是基本数据类型不能转换为Object

2. 运行时类型查询只适用于原始类型

例如,对于代码if (a intanceof Pair<String>),它只会检查变量a是否为任意类型Pair,而不会检查是否为Pair<String>,存在风险,编译器会报错

当强制类型转换时同样会存在该风险:Pair<String> p = (Pair<String>) a;会出现警告

同样的道理,对于带泛型的数据类型,getClass方法只会返回原始类型:

Pair<String> stringPair = new Pair<>();
Pair<Double> doublePair = new Pair<>();
stringPair.getClass() == doublePair.getClass() // 结果为true,都是Pair.class

3. 不能创建参数化类型的数组

例如:Pair<String>[] table = new Pair<>[10];会报错

原因:

一般来说,数组会记住它的元素类型,如果试图储存其他类型的元素就会出现ArrayStoreException异常。

int[] a = new int[10];
a[0] = "String"; // 报错,出现ArrayStoreException异常

但是,对于泛型类型,擦除会使这种机制无效。类型擦除后,table的类型是Pair[],它会转换为Object[]Object[]可以储存任意类型的元素,但是最后会出现类型错误。参考下面这个例子,Pair<String>数组里可以进去一个类型为Pair<Double>的叛徒。

Pair<String>[] table = new Pair<>[10];
// table在运行时变成了Object[]
table[0] = new Pair<Double>; // 正常来讲会报错,但是因为类型擦除,所以运行时仍然可以赋值成功

// 在这之后,table数组中就出现了一个叛徒,有极大的可能在将来引起异常,是一个潜在的bug

因此,不能创建参数化类型的数组。

需要说明的是,只是不能创建参数化类型的数组,但是还是可以声明类型为Pair<String>[],只是不能用new Pair<String>[10]初始化这个变量。

另外,有个不安全的技巧:可以声明通配类型的泛型数组,然后进行强转:

var table = (Pair<String>[]) new Pair<?>[10];

但是这样子仍然是不安全的,上面的问题依然存在,你仍然可以在Pair<String>数组里存入Pair<Double>,并最终引起冲突、产生异常。

4. Varargs警告

对于可变长度参数:

public static <T> void addAll(Collection<T> coll, T... ts) { ... }

可变长度参数本质上也是一个数组,包含提供的所有实参。

如果这么调用呢?

Collection<Pair<String> table = new Collection<>();
Pair<String> pair1 = new Pair<>();
Pair<String> pair2 = new Pair<>();
addAll(table, pair1, pair2); // Pair<String>类型的可变长度参数

这样子不就创建了一个Pair<String>[]数组了嘛?

好在java对这种情况,规则有所放松,你只会得到一个警告,而不是错误。

忽略警告
  1. 前面提到的@SuppressWarnings("unchecked")注解可以忽略该警告

  2. java7中,在方法上添加@SafeVarargs注解,也可以忽略警告

    注意,该注解只能够用于声明为static或者final、或者在java9后的private方法和构造方法,其他方法都可能被覆盖而使注解失效。

5. 不能实例化类型变量

不能使用类似new T()或者new T(...)的表达式。类型擦除后,T会变成Object,就没什么意义了

public Pair() {
    // 下面会出错
    first = new T(); 
    second = new T();
}

解决方法1:借助方法引用

java8之后,最好的解决办法就是让调用者提供一个构造器表达式(借助方法引用),例如:

Pair<String> p = Pair.makePair(String::new);

makePair方法接收一个自己设计的泛型接口Supplier<T>,这是一个函数式接口,表示一个无参数且返回类型为T的函数:

public static <T> Pair<T> makePair(Supplier<T> constr) {
    return new Pair<>(constr.get(), constr.get());
}

解决方法2:借助反射

比较传统的解决办法是通过反射调用构造器.newInstance()方法来构造泛型对象,但是不能直接使用方法:T.class.getConstrutor().newInstance();,因为T.class是不合法的,它会被擦除成为Object.class

正确做法是:直接提供一个Class对象,借助反射来完成new的操作

public static <T> T getObj(Class cl) {
    return cl.getConstructor().newInstance();
}

值得一提的是,Class类型也是一个泛型,String.class实际上是一个Class<String>的实例(而且是唯一的实例)

6. 不能构造泛型数组

类似于不能实例化泛型字段一样,由于泛型擦除,所以也不能实例化数组。

需要注意的是,任然可以声明泛型数组,代码T[] t;是合法的,但是不允许写成:new T[n];,这样就试图实例化泛型数组了,编译器会报错。

例如,对于下面的代码,由于类型擦除,总是会构造Comparable[2]的数组:

public static <T extends Comparable> T[] minmax(T... a) {
	// 返回值为T[]是可以的
	
    T[] mm = new T[2]; // 错误,类型擦除会使其总是构造Comparable[2]的数组
}

ArrayList的实现 类型擦除+强转

如果数组仅仅作为一个类的私有字段,那么可以将这个数组的元素类型声明为擦除的类型,并使用强制类型转换。如ArrayList可以有如下实现方式:

public class ArrayList<E> {
    private Object[] elements;
    
    @SuppressWarnings
    public E get(int n) {
        return (E) elements[n]; // 在内部强制类型转换,直接返回所需的数据类型
    }
    
    public void set(int n, E e) {
        elements[n] = e; // 不必强制类型转换
    }
}


源码截图:
ArrayList源码

实际上,还有另一种思路:

public class MyArrayList<E> {
    private E[] elements;
    
    public MyArrayList() {
        elements = (E[]) new Object[10]; // 强制类型转换,实际上是一个假象
    }
    
    public E get(int n) {
        return elements[n];
    }
    
    public void set(int n, E e) {
        elements[n] = e;
    }
    
	public static void main(String[] args) {
        MyArrayList<Double> myArrayList = new MyArrayList<>();
        myArrayList.set(1,122.1);
        Double aDouble = myArrayList.get(1);
        System.out.println(aDouble); // 122.1
    }
}

这里的强制类型转换是一个假象,而类型擦除会使其无法察觉。因为E[]最后会擦除为Object[]

解释一下:
(E[]) new Object[10];,乍一看会出现ClassCastException异常(因为E[]的类型要么比Object[]小,要么相同,而java中不能把父类强转为子类),但是因为类型擦除,E[]最后会变成Object[],所以就等价于Object[] elements = (Object[]) new Object[10]不会出现强转异常。

注:《Java核心技术卷I 第11版》p433中,书上的意思好像是,上方第二种代码才是ArrayList源码的实现方式,但是查看Java源代码,发现是第一种思路,源码截图在上面。

附上原书截图:

Java核心技术卷I p433页截图

实际运用存在的问题

我们企图运用上面这个思路,让minmax方法最终能够返回泛型数组(但是并不实例化泛型数组,而是实例化一个父类数组,再强转为指定类型)。尝试一下代码,发现有错误:

public static <T extends Comparable> T[] minmax(T.. a) {
    Comparable result = new Comparable[2];
    // ...
    return (T[]) result; // 出现警告:未检查的强制类型转换(Unchecked cast)
}

当使用下面的方式调用方法时,编译没有问题,但是运行时会出现ClassCastException异常

String[] names = ArrayAlg.minmax("Tom", "Dick", "Harry");

方法返回时Comparable[]强转为String[]时出现异常。这里其实是子类型引用指向父类型对象,违反了多态。(再回顾一下上面的ArrayList,人家是巧妙地借助了类型擦除,而使得强转成功的)

解决方案
1. 通过方法引用

提供一个数组构造器表达式:

String names = ArrayAlg.minmax(String[]::new, "Tom", "Dick", "Harry");
public static <T extends Comparable> T[] minmax(IntFunction<T[]> constr, T... a) {
    // 通过构造器的apply方法实例化指定类型的数组,赋值给泛型
    // 此处传入的是String[]::new,下面代码相当于T[] result = new String[2]; 
    T[] result = constr.apply(2); 
    // ...
    return (T[]) result;
}

回顾一下方法引用,String[]::new相当于lambda表达式中的n -> new String[n]

2. 通过反射
public static <T extends Comparable> T[] minmax(T... a) {
    var result = (T[]) Array.newInstance(a.getClass().getComponentType(), 2);
    // ...
    return result;
}

7. ArrayListt的toArray方法

ArrayList的toArray方法的实现方式:有两种

  1. 直接返回Object[]。源码:

    public Object[] toArray() {
        return Arrays.copyOf(elementData, size);
    }
    
  2. 返回指定的泛型类型数组T[],但是没有类型(因为默认实现是一个Object[],需要传入一个泛型数组。如果传入的数组足够大,那么会使用传入的数组返回。源码:

    @SuppressWarnings("unchecked")
    public <T> T[] toArray(T[] a) {
        if (a.length < size)
            // Make a new array of a's runtime type, but my contents:
            return (T[]) Arrays.copyOf(elementData, size, a.getClass());
        System.arraycopy(elementData, 0, a, 0, size);
        if (a.length > size)
            a[size] = null;
        return a;
    }
    

使用时,如果不清楚具体的数组长度,可以传入一个长度为0的数组,不占用额外空间。

ArrayList<String> list = new ArrayList<>();
list.add("1");
list.add("2");
Object[] objects = list.toArray();
String[] strings = list.toArray(new String[0]);

8. 泛型类的静态上下文不能使用泛型变量

翻译成人话:不能在静态字段或方法中使用泛型变量。如下:

public class Singleton<T> {
    private static T singleIntance; // 报错
    public static T getSingleIntance() {
        if (singleIntance == null) {
            // 初始化singleIntance
        }
        return singleIntance;
    }
}

原因:

如果觉得上面这样可行,那程序员就可以声明一个Singleton<Random>共享一个随机数生成器对象,同时再另外声明一个Singleton<BufferReader>共享一个读取器对象。

但是类型擦除之后,只剩下Singleton类而没有泛型变量,它的字段也只有一个数据类型,不能即是BufferReader又是Random,但又是静态的(多个实例会共享同一个静态资源),这就冲突了。

所以,我们无法做到借助泛型同时实现多个数据类型的静态单例,应该禁止使用带有类型变量的静态字段和方法。

9. 不能抛出或捕获泛型类的实例

实际上,泛型类扩展Throwable甚至是不合法的:

public class Probleam<T> extends Exception {} // 错误

错误提示为:Generic class may not extend ‘java.lang.Throwable’。

may not意为不允许。同样的用法在英语四级中也出现过。英语四级中题干有一处为:you may not use any of the words in the blank more than once. “请用任意词填空,每词只能用一次”

但是允许使用Throwable或者其他异常类来限定泛型:

public static <T extends Throwable> void doWork(Class<T> t) {
	try {
		System.out.println(t);
	} catch (Exception e) {
		e.printStackTrace();
	}
}

catch子句中不能使用类型变量,例如下面的代码将不能通过编译:

public static <T extends Throwable> void doWork(Class<T> t) {
	try {
		System.out.println(t);
	} catch (T e) {  // 错误。Cannot catch type parameters
		e.printStackTrace();
	}
}

可以取消对检查型异常的检查

java异常处理机制的一个基本原则是,必须为所有检查型异常提供一个处理器。不过利用泛型可以取消(避免)这个机制:

@SuppressWarnings("unchecked")
static <T extends Throwable> void throwAs(Throwable t) throws T {
    throw (T) t;
}

假设这个方法包含在Task接口中,如果有一个检查型异常e,可以调用按一下方式将异常转换为非检查型异常并抛出:

try {
    ...
} catch (Throwable t) {
    Task.<RuntimeException>throwAs(t); // 转换成非检查型异常
}

这有什么意义呢?举例来说,在正常情况下,必须捕获一个Runnablerun方法中的所有检查型异常,如果想要向上抛出,就必须先把这些异常“包装”到非检查型异常中。我们这里明没有这种“包装”,只是抛出异常,“哄骗”编译器,让它相信这不是一个检查型异常。

一个简单的测试:

public class Problem implements Runnable{

	@SuppressWarnings("unchecked")
	static <T extends Throwable> void throwAs(Throwable t) throws T {
        // java.io.FileNotFoundException: D:\read.txt (系统找不到指定的文件。)
        System.out.println(t);
        throw (T) t;
	}

	@Override
	public void run() {
        try {
            // 提供一个不存在的文件路径
            BufferedReader reader = new BufferedReader(new FileReader("D://read.txt"));
        } catch (Throwable t) {
            // 转换成非检查型异常
            Problem.<RuntimeException>throwAs(t);
        }
	}

	public static void main(String[] args) {
        // 不用捕获异常
        Thread thread = new Thread(new Problem());
        thread.start();
	}
}

10. 注意擦除后的冲突

当泛型类型被擦除后,不能够存在引发冲突的代码。

比如,泛型类Pair<T>添加一个equals方法:public boolean equals(T obj) { ... }
看起来,它与Object的equals方法:public boolean equals(Object obj) { ... }
是两个不同的方法。

但是实际上,前者在泛型擦除之后,T会被擦除为Object,两个方法就变得一模一样,起冲突了。如果有代码调用了Pair<T>equals方法,程序不知道应该执行哪一个方法。

程序会给出错误:‘equals(T)’ in ‘Pair’ clashes with ‘equals(Object)’ in ‘java.lang.Object’; both methods have same erasure, yet neither overrides the other.

一个补救的办法自然就是重新命名引发冲突的方法。

桥方法咋不管用了?

复习一下桥方法

为什么这里没有桥方法呢?我分析了一下发现:

  • 桥方法中,子类的参数类型是确定的,而父类是类型变量。运行时父类的泛型被擦除,参数类型变为Object,因此如果没有桥方法,则程序总是调用父类的方法,会破坏多态性。因此编译器会在父类中添加一个桥方法,其参数为Object,在这个桥方法中再调用子类的方法。
  • 而此处,反了过来:子类的参数类型一个类型变量,是不确定的,反倒是父类的参数是确定的,而且是一个Object!好家伙,我桥方法本来就要添加一个参数为Object的方法,你这已经有了,而且子类也会变成Object!运行时类型擦除后,会出现两个方法签名完全相同的方法,要是再添加一个桥方法,那就是三个了,桥方法还帮倒忙!

所以这一次桥方法帮不了你了,只能禁止这种情况的发生。

不能有同个接口的不同参数化

泛型规范说明还提出了另一个规则:为了支持擦除转换,倘若两个接口类型是同一接口的不同参数化,就不能让一个类或类型变量继承或实现于这两个接口类型。

例如,下面的代码是不合法的:

class Employee implements Comparable<Employee> { ... }
class Manager extends Employee implements Comparable<Manager> { ... }

Manager会实现Comparable<Employee>Comparable<Manager>,这是同个接口的不同参数化。

不允许的原因在于,如果同个接口有不同参数化,会导致合成的桥方法起冲突。实现了Comparale<X>的类会获得一个桥方法:

public int compareTo(Object other) { return compareTo((X) other); }

不能对不同的类型X合成两个这种桥方法,会起冲突

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值