JAVA核心技术卷解读-深入理解泛型编程的本质与特点
JAVA核心技术卷解读-深入理解泛型编程的本质与特点
Java 泛型是一种在编译时期进行类型检查和类型安全的机制,它允许在定义类、接口和方法时使用参数化类型。通过泛型,可以编写更加通用和类型安全的代码,减少了代码重复和类型转换的需求。Java 泛型的基本概念包括以下几点:
-
类型参数(Type Parameters):使用尖括号(<>)来声明泛型类型,例如:
List<T>
,其中T
是类型参数,表示可以是任意的引用类型。 -
泛型类(Generic Class):使用了类型参数的类称为泛型类,例如:
List<T>
,T
可以是任意的引用类型。 -
泛型接口(Generic Interface):类似泛型类,但是应用于接口的情况。
-
泛型方法(Generic Method):在方法中使用类型参数的方法称为泛型方法,例如:
<T> T getObject(Class<T> clazz)
。 -
类型通配符(Wildcard):使用
?
表示的通配符,用于表示未知类型,例如:List<?>
,表示元素类型未知的列表。
Java 泛型的作用主要包括以下几点:
-
类型安全(Type Safety):泛型可以在编译时期发现类型错误,避免了在运行时出现类型转换错误的可能性。
-
代码重用(Code Reusability):通过泛型可以编写更加通用的代码,减少了重复的代码量。
-
更好的性能(Better Performance):避免了类型转换带来的性能损失,提高了程序的执行效率。
-
更清晰的代码(Clearer Code):使用泛型可以使代码更加清晰易懂,减少了类型转换的混乱和错误。
自定义泛型类实现
class GreetingPinter<T>{
private T content;
public GreetingPinter(T content) {
this.content = content;
}
public void greetPrint(){
System.out.println("Hello!, " + this.content);
}
}
public class GenericsClass {
public static void main(String[] args) {
GreetingPinter<String> greetString = new GreetingPinter<String>("World");
greetString.greetPrint();
GreetingPinter<Integer> greetInteger = new GreetingPinter<Integer>(2333);
greetInteger.greetPrint();
}
}
在这个例子中,T
是一个泛型参数,它在 GreetingPrinter
类中被用作类型参数。在 Java 中,泛型参数用尖括号(<>
)括起来,放在类名后面,用于指定类中某些部分的类型。在这里,T
就是一个泛型参数,可以被替换为任何引用类型。使用了泛型参数的类自动成为泛型类。需要注意的是,泛型参数T仍然视为对象拥有,不能在静态字段中进行使用
在 GreetingPrinter
类中,T
被用作实例变量 content
的类型。这意味着,在创建 GreetingPrinter
对象时,可以指定 T
的具体类型,然后在类中使用这个类型来定义实例变量。在 main
方法中的两个示例中,分别创建了 GreetingPrinter<String>
和 GreetingPrinter<Integer>
对象,分别指定了 T
的类型为 String
和 Integer
。
通过使用泛型,可以使GreetingPinter
类更加灵活和通用,可以用不同的类型实例化GreetingPinter
类来处理不同类型的数据。
泛型参数T是可以自定义的,我们可以定义多个泛型参数来接收不同类型:
class ContentPrinter<T, K>{
private T content;
private K symbol;
public ContentPrinter(T content, K symbol) {
this.content = content;
this.symbol = symbol;
}
public void print(){
System.out.println("Symbol: " + this.symbol);
System.out.println("Content: " + this.content);
}
}
自定义泛型方法实现
注意泛型符号
T
的位置即可,与上面类似的
public class GenericsClass {
public static <T> void greetPrint(T content){
System.out.println("Hello!, " + content);
}
public static void main(String[] args) {
greetPrint("World");
greetPrint(123456);
}
}
泛型自动推断简写
在 Java 7 及以上版本中,可以使用泛型的自动推断简写(Diamond Operator)来简化泛型对象的创建过程。泛型自动推断简写使用了尖括号 <>
,但在尖括号中不需要明确指定泛型类型,编译器会根据上下文推断出泛型类型。这样就可以避免重复书写泛型类型,提高了代码的简洁性和可读性。
public class GenericsClass {
public static void main(String[] args) {
GreetingPinter<String> greetString = new GreetingPinter<>("World");
greetString.greetPrint();
GreetingPinter<Integer> greetInteger = new GreetingPinter<>(2333);
greetInteger.greetPrint();
}
}
定义泛型时划定上界限定
class BaseClass {
public int id = 233;
public int getId() { return id;}
}
class FirstClass extends BaseClass {}
class SecondClass extends FirstClass {}
class GenericsTest<T extends BaseClass> {
public T obj;
public GenericsTest(T obj) { this.obj = obj; }
public int getId() { return obj.getId(); }
}
public class GenericsClass {
public static void main(String[] args) {
SecondClass secondObj = new SecondClass();
GenericsTest<SecondClass> obj = new GenericsTest<>(secondObj);
System.out.println(obj.getId());
}
}
上述代码在定义泛型类时,用extends
关键字定义了一个泛型类 GenericsTest<T extends BaseClass>
,它接受一个泛型参数 T
,要求 T
必须是 BaseClass
或其子类。并且这样做还有一个好处就是允许我们在泛型类中的泛型参数可以获得其上界类型的方法,例如上述中的getId
。并且在指定上界时,我们还可以指定其实现的接口类型:
class MyClass<T extends BaseClass & Interface1 & Interface2> {
...
}
需要注意的是,继承的类有且只有一个(单继承原则),并且类名要写在最前面,接口或多个接口写在后面,并且将类与各个接口以&
分开,如上述例子中表示继承了BaseClass
类并同时实现了Interface1
,Interface2
接口的类
使用泛型划定上下界限定
这里的划定上下界指的是在使用泛型时划定上下界,要进行这种划定,我们需要借助通配符?
来使得我们支持多种类型的参数,不过遗憾的是,Java目前不支持同时划分上下界:
- 划定泛型上界,控制泛型参数为指定类型或其子类
import java.util.ArrayList;
import java.util.List;
public class GenericsClass {
public static void main(String[] args) {
List<? extends BaseClass> list = new ArrayList<>();
}
}
- 划定泛型下界,控制泛型参数为指定类型或其父类
import java.util.ArrayList;
import java.util.List;
public class GenericsClass {
public static void main(String[] args) {
List<? super MyChildClass> list = new ArrayList<>();
}
}
深入泛型-类型擦除规则
类型擦除(Type Erasure)是Java中泛型的一种实现方式。在编译期间,Java的泛型信息会被擦除,即泛型类型参数会被替换为它们的上界(对于通配符类型)或者是Object(对于未指定上界的类型参数)。类型擦除是为了保持Java泛型的向后兼容性。在Java 5引入泛型之前,所有的集合都是处理Object
类型的,为了让旧代码继续运行,Java设计了类型擦除的机制。同样也基于此,泛型参数并不能实例化为基础类型,只能实例化为对应包装类,原因是这些基本类型无法转化为Object(注意,这个地方并不会自动装箱)
public class Box<T> {
private T value;
public void setValue(T value) { this.value = value; }
public T getValue() { return value; }
}
在编译时,编译器会将泛型信息擦除,使得上述代码实际上相当于以下代码:
public class Box {
private Object value;
public void setValue(Object value) { this.value = value; }
public Object getValue() { return value; }
}
这样做的好处是可以让使用泛型的代码在编译时获得类型检查,而在运行时则不需要保留泛型的信息。但也因为类型擦除,有些泛型相关的操作在运行时是无法完成的,比如获取泛型类型的具体信息。我们可以运行以下代码来验证猜想:
package package2;
class GenericClass<T> {
void method(T input) {
System.out.println("Generic method: " + input);
}
}
public class Main {
public static void main(String[] args) {
GenericClass<String> object1 = new GenericClass<>();
System.out.println(object1.getClass());
System.out.println(object1 instanceof GenericClass<String>);
System.out.println(object1 instanceof GenericClass<String>);
System.out.println(object1 instanceof Object);
}
}
得到输出:
class package2.GenericClass
true
true
true
进程已结束,退出代码为 0
类型擦除后的兼容性处理
在未指定限定名时将会擦除为Object,在多重限定名下的类型擦除时,只会保留第一个限定名,考虑以下代码:
import java.io.Serializable;
class Interval<T extends Serializable & Comparable<T>> {
private T start;
private T end;
public Interval(T start, T end) {
this.start = start;
this.end = end;
}
public T getStart() { return start; }
public T getEnd() { return end; }
public boolean contains(T value) {
return start.compareTo(value) <= 0 && end.compareTo(value) >= 0;
}
@Override
public String toString() {
return "[" + start + ", " + end + "]";
}
}
在进行类型擦除后,将会只保留有Serializable
限定名,那么将会引出一个问题,在使用compareTo
方法时,要求必须是实现了Comparable
接口的类型,而擦除了类型的T仅仅保留了Serializable
,似乎并不符合要求,编译器在此的处理方式是在必要时插入强制类型装换(无限定名时则强制转换Object到目标类型):
public boolean contains(T);
Code:
0: aload_0
1: getfield #7 // Field start:Ljava/io/Serializable;
4: checkcast #16 // class java/lang/Comparable
7: aload_1
8: invokeinterface #18, 2 // InterfaceMethod java/lang/Comparable.compareTo:(Ljava/lang/Object;)I
13: ifgt 36
16: aload_0
17: getfield #13 // Field end:Ljava/io/Serializable;
20: checkcast #16 // class java/lang/Comparable
23: aload_1
24: invokeinterface #18, 2 // InterfaceMethod java/lang/Comparable.compareTo:(Ljava/lang/Object;)I
29: iflt 36
32: iconst_1
33: goto 37
36: iconst_0
37: ireturn
我们直接读取字节码,发现在使用compareTo
方法时JVM插入了强制类型转换:
4: checkcast #16 // class java/lang/Comparable
20: checkcast #16 // class java/lang/Comparable
后置标签接口以提高效率
在Java核心技术卷中,有一句旁注:为了提高效率,应该将标签 (tagging)接口(即没有方法的接口)放在边界限定列表的末尾。
看了许多帖子都没聊到点上,全是抄的核心技术卷的原话,没有任何深入的思考,那我们就来进行详谈:
首先,标签接口(Tagging Interface)是一种特殊的接口,它没有任何方法声明,仅用于标记类的类型。标签接口通常用于指示一个类具有某种特定的性质或能力,而不需要对类的方法做出任何要求。在 Java 中,标签接口是指那些没有任何方法的接口,例如 Serializable
和 Cloneable
接口就是标签接口的典型例子。通过实现这些接口,类可以表明自己具有序列化或克隆的能力,而无需实现任何方法。
其次,这里的效率我们需要明确其定义,统计了网上的资料谈到的点:
-
提高语义分析器的执行效率节省类型检查时间,但是我觉得这一说法是错误的,因为作为一门强类型语言来讲,类型检测一定是要完全符合要求的,就算顺序不同有差异,那在完全检测完类型的速度差异上也是微乎其微的,完全不值得专门关注
-
作为一种典型的编程范式,提升代码维护与审核的效率,这个倒是挺有道理,无论对错,出发点都是有理有据的
-
提高编译器的运行效率和节省程序运行开销,这一点国内的主流论坛是没有提到的,它来自于StackOverFlow,这也是我所认为的正确答案,从重要性和根据都是令人信服的
补充一则趣闻:有营销号搬取了一则推文提到:Java泛型的类型转换是从左到右依次转换的,即以下代码在转换兼容时是这样的:
class MyClass<T extends A & B & C>
转换到C的过程是
A -> B -> C
,而不是直接强制转换为C,想想也知道前辈们不会设计出这么垃圾的转换机制来浪费系统资源
接下来细谈实现,既然要后置标签,我们直接后置上述原来的例子中的Serializable
来看看字节码不久知道了吗:
public boolean contains(T);
Code:
0: aload_0
1: getfield #7 // Field start:Ljava/lang/Comparable;
4: aload_1
5: invokeinterface #16, 2 // InterfaceMethod java/lang/Comparable.compareTo:(Ljava/lang/Object;)I
10: ifgt 30
13: aload_0
14: getfield #13 // Field end:Ljava/lang/Comparable;
17: aload_1
18: invokeinterface #16, 2 // InterfaceMethod java/lang/Comparable.compareTo:(Ljava/lang/Object;)I
23: iflt 30
26: iconst_1
27: goto 31
30: iconst_0
31: ireturn
我们会发现,很明显JVM没有执行强制转换操作,原因是因为擦除后我们的类型是第一个限定名。所谓的效率提升就提升在,我们实际使用的非标签接口在作为第一限定名使用时,无需额外插入强制转换指令,如果说使用了标签接口作为第一限定名,那么我们,每次使用非标签接口的方法都要插入转换指令,对于一个大型并发程序来说,这可能是灾难性的。
泛型方法擦除后的桥方法
在泛型方法中也会进行类型擦除,我们考虑以下代码:
class GenericClass<T> {
void method(T input) {
System.out.println("Generic method: " + input);
}
}
class SubClass extends GenericClass<String> {
@Override
void method(String input) {
System.out.println("Subclass method: " + input);
}
}
在普通的Java类型中,我们会根据形参类型来判断方法是否被进行了重写,但是在泛型类中的情况是不一样的,我们反编译字节码来看看情况:
Compiled from "Main.java"
class package2.SubClass extends package2.GenericClass<java.lang.String> {
package2.SubClass();
void method(java.lang.String);
void method(java.lang.Object);
}
即在上述代码中,经过类型擦除后,我们的子类将会获得两个方法:
void method(String input)
void method(Object input)
很明显这是两个不同的方法,因为其参数签名不同(详见下文JVM批注),这里就会引发一个问题,在多态调用时执行方法将出现冲突
GenericClass<String> genericClass = new SubClass();
genericClass.method("Hello");
这里就不得不提到继承的方法调用规则了,在普通继承中,父类类型的引用只能调用父类类型拥有的方法(父类型变量只保留有自身的方法签名,与实际类型的方法相独立),而无法调用子类拓展的方法,即在多态实现时调用实际类型的与父类型签名相同方法。在泛型继承中,父类型遵循同样原则,即在多态实现时,调用的方法签名实质上是void method(Object input)
,而编译器在实现多态时为了确保实际类型相匹配,将void method(Object input)
转化成了桥方法,读取字节码,我们将发现以下内容:
class SubClass extends GenericClass<java.lang.String> {
SubClass();
Code:
0: aload_0
1: invokespecial #1 // Method GenericClass."<init>":()V
4: return
void method(java.lang.String);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: aload_1
4: invokedynamic #13, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
9: invokevirtual #17 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: return
void method(java.lang.Object);
Code:
0: aload_0
1: aload_1
2: checkcast #23 // class java/lang/String
5: invokevirtual #25 // Method method:(Ljava/lang/String;)V
8: return
}
编译器修改了继承而来的类型擦除方法,并插入强制类型转换后调用子类的方法实现,这样使得SubClass
自父类继承而来的方法变为如下状态:
void method(Object input){
method( (String) input )
}
这里需要注意的是,在JVM中界定两个方法实质上是由返回类型与参数类型共同决定的,我们考虑以下代码:
class GenericClass<T> { T method() { return (T)"233"; } } class SubClass extends GenericClass<String> { String method() { return "input"; } }
当出现桥方法转化时,这种和重载规则不一致的设计就会保证我们的多态正确实现:
java.lang.String method(); Code: 0: ldc #7 // String input 2: areturn java.lang.Object method(); Code: 0: aload_0 1: invokevirtual #9 // Method method:()Ljava/lang/String; 4: areturn
不过,桥方法也有可能存在冲突,例如下面的两个方法,这通常出现在泛型类重写其他类继承而来的方法时出现:
boolean equals(T) boolean equals(Object)
=======================================================================================
泛型规范说明还引用了另外一个原则:“为了支持擦除转换,我们要施加一个限制:倘若两个接口类型是同一接口的不同参数化,一个类或类型变量就不能同时作为这两个接口类型的子类。” 例如,下述代码是非法的:
class Employee implements Comparable<Employee> { . . .} class Manager extends Employee implements Comparable<Manager> { . . . } // ERROR
from:Java核心技术卷1 p345
深入泛型-类型安全风险
谈到了类型擦除,就不得不提到类型安全了,类型安全是指编程语言在程序编译时或程序运行时对于类型不匹配的错误捕获。基于前文提到的类型擦除问题,我们的JVM是无法在运行时获取到泛型类型的元数据的,泛型的类型信息在运行时是不可用的。自然对于泛型的类型检查也就只能基于编译时检测,我们可以写一段简陋的代码来验证(这种情况通常出现在健壮性不高的程序中):
import java.util.ArrayList;
import java.util.List;
public class GenericsClass {
public static void main(String[] args) {
List<Object> list = new ArrayList<>();
list.add(12345);
list.add("2333");
String listElement = (String) list.get(0);
}
}
可以看到编译通过,但是运行时将会抛出异常,IDEA将会提示:
Exception in thread "main" java.lang.ClassCastException:
java.lang.Integer cannot be cast to java.lang.String
at GenericsClass.main(GenericsClass.java:9)
泛类型数组实例化风险
尤其注意,Java不允许直接创建泛型数组(不会通过编译),原因是在类型擦除后Pair<String>[] table = new Pair<String>[10];
实质上在类型擦除后是下面这玩意(假如可以通过编译的话):
Pair[] table = new Pair[10];
根据泛型的编译时检查,下面的代码是可以通过检测的:
table[0] = new Pair<String>[10];
table[1] = new Pair<Integer>[10];
但是为了保证数组的类型安全性,Java 运行时会检测数组中每一个元素的类型是否一致。即数组中的所有元素必须是相同类型的。当你创如果发现不匹配的元素类型,就会抛出 ArrayStoreException
异常。上面的代码中Pair<String>
与Pair<Integer>
实质类型不一致,所以会引发错误。
并且创建实例化泛型数组还会出现一个问题,它将破坏数组的安全性策略。在 Java 中,数组会记住自己的类型意味着在运行时,每个数组实例都知道自己的元素类型是什么。例如我创建一个 A
类型的数组,然后将其转换为 Object
类型数组时,数组仍然会记住它最初的类型A
。这是因为在 Java 中,数组的类型在创建时就已经确定了,无法在运行时改变。因此,即使你将 A
类型数组转换为 Object
类型数组,数组本身仍然知道它最初是一个 A
类型数组**。这种转换实际上只是将数组的引用类型转换为 Object[]
,但数组本身的元素类型并没有改变**。我们可以将 A
类型数组中的元素放入 Object
类型数组中,但是在取出元素时,需要进行类型转换,否则会在编译时或运行时产生类型错误,以下代码是不能够通过编译的,因为不符合规定,但是在上述假如允许创建泛型数组的情况下,就可以突破此限制:
Object[] objects = new Object[3];
objects[0] = "Hello World";
可变参数的堆污染风险
按照惯例,先写一段抽象代码来体现体现风险:
package package2;
import java.util.Arrays;
class GenericClass<T> {
public T value;
GenericClass(T value){
this.value = value;
}
@Override
public String toString(){
return (String) this.value;
}
}
public class Main {
public static <T> void argsPrinter(int value, T... other){
System.out.println(value + " " + Arrays.toString(other));
}
public static void main(String[] args) {
GenericClass<String> a = new GenericClass<>("123");
GenericClass<String> b = new GenericClass<>("456");
argsPrinter(233, a, b);
}
}
然后我们的IDEA将会给出一个警告:来自形参化 vararg 类型的可能的堆污染
,这里我们先需要知道两个概念:
- vararg 类型:
vararg
是 Java 中的一个关键字,用于表示可变参数(variable arguments)。可变参数允许一个方法接受不定数量的参数。在方法声明中,使用...
表示可变参数,它可以接受零个或多个参数,但是只能放在参数列表的最后一个位置。 - 堆污染:堆污染(Heap Pollution)是指在使用泛型的过程中,出现了类型不一致的情况,导致了类型安全性问题。这通常发生在将一个带有泛型类型参数的对象转换为另一种泛型类型时,或者在使用可变参数时。
上述这样的代码会造成什么问题呢?
在我的代码中,argsPrinter
方法使用了可变参数 (varargs
) 来接收参数。在 Java 中,可变参数是通过数组实现的,而数组是协变的,这意味着如果我传递一个 GenericClass<String>
类型的对象数组给 argsPrinter
方法,它实际上被当做 GenericClass<Object>
类型的对象处理。然后被 argsPrinter
方法擦除后的Object数组引用接收
在 argsPrinter
方法中,虽然可以传递任意类型的参数,但是它们都会被当做 Object
类型来处理。然而,当我们在 GenericClass
类中使用了泛型,并且在 toString
方法中将泛型类型强制转换为 String
类型。这就可能导致堆污染,因为在编译时,类型擦除会将泛型类型擦除为其上界(这里是GenericClass<Object>
),而强制转换可能会导致 ClassCastException
异常。原因是这里JVM为可变参数放宽了策略,用以兼容不定参数方法
为了避免这种问题,我们可以考虑修改 argsPrinter
方法,使其不要将参数当做泛型类型处理,或者在使用泛型时避免强制类型转换。
协变(covariance)和逆变(contravariance)是指在类型系统中,一个类型参数的子类型关系是否保持在参数化类型之间的关系中。
协变:如果类型A是类型B的子类型,那么
SomeType<A>
就是SomeType<B>
的子类型。换句话说,协变保持了子类型关系。逆变:如果类型A是类型B的子类型,那么
SomeType<B>
就是SomeType<A>
的子类型。逆变在一定程度上是反向的,但它仍然保持了某种形式的关系。
泛型参数实例化风险
泛型参数T不允许直接构造其对象,因为类型擦除的原因,下面的代码实质上是构造的其上界类型实例,并且将无法通过编译:
public class Pair <T> {
public T first;
public T second;
public Pair() { first = new T(); second = new T(); }
}
要解决此问题,我们通常有两种解决方案:
- 传入指定的构造器进行构造
我们可以借助Supplier
泛型接口来实现,在Java中,Supplier
接口是java.util.function
包的一部分,它在Java 8中引入,用于支持函数式编程。Supplier
接口是一个函数式接口,有且只有一个方法,它不接受任何参数,但会返回一个对应的类型的结果:
@FunctionalInterface
public interface Supplier<T> {
T get();
}
我们可以借助方法引用来传递构造器返回对应的类型给泛型参数T,使得其可以正常构造
import java.util.function.Supplier;
class Pair<T> {
private T first;
private T second;
public Pair(T first, T second) {
this.first = first;
this.second = second;
}
@Override
public String toString() {
return "Pair{" + "first=" + first + ", second=" + second + '}';
}
}
public class Main {
public static <T> Pair<T> makePair(Supplier<T> constr) {
return new Pair<>(constr.get(), constr.get());
}
public static void main(String[] args) {
Supplier<String> stringSupplier = () -> "Hello";
Pair<String> stringPair = makePair(stringSupplier);
System.out.println(stringPair); // 输出 Pair{first=Hello, second=Hello}
Supplier<Integer> intSupplier = () -> (int) (Math.random() * 100);
Pair<Integer> intPair = makePair(intSupplier);
System.out.println(intPair);
}
}
- 借助反射机制来进行构造
由于泛型擦除,使得我们无法获取运行时的元数据,要使用反射的话需要进行额外的设计,虽然泛型类型被擦除了,但是其实际类型却仍然可以作为参数传递,我们的设计就可以通过获取Class<T>
的字面量来取得实际类型:
public class Main {
public static <T> Pair<T> makePair(Class<T> cl) {
try{
return new Pair<>(cl.getConstructor().newInstance(), cl.getConstructor().newInstance());
}catch (Exception e){
return null;
}
}
public static void main(String[] args) {
Pair<? extends String> op1 = Main.makePair("Hello World".getClass());
Pair<String> op2 = Main.makePair(String.class);
}
}
这里需要注意的就是getClass
返回的是一个Class<? extends String>
类型,需要注意类型匹配
泛型参数数组化风险
还是因为类型擦除导致的一系列安全性问题,泛型参数的数组化也会导致一系列的问题,构造的数组始终是其上界类型,核心技术卷中提到的实现方式则是使用强制类型转换:
注意:在该数组为私有字段时这样的设计才是有效的,当其为Public时,这样的设计必要性不大,因为无法控制访问
public class ArrayList<E>{
private Object[] elements;
@SuppressWarnings("unchecked") public E get(int n) { return (E) elements[n]; }
public void set(int n, E e) { elements[n] = e; } // no cast needed
}
这种方式实现的ArrayList
看起来是比较迷惑的,因为类型擦除后,就是Object
转换为Object
,为什么要这样做呢?
在Java的泛型中,类型擦除会导致编译器将泛型类型擦除为其上界(对于未指定上界的情况,默认为Object
)。因此,泛型类型E
在运行时会被擦除为Object
,但是在编译时,编译器仍然会进行类型检查,确保不会发生类型不匹配的情况。
在提供的例子中,elements
数组是一个Object[]
数组,**但是由于get
方法声明为返回E
类型,因此编译器会在编译时检查确保elements[n]
可以安全地转换为E
类型。**因此,即使在泛型擦除后,编译器仍然可以通过类型检查确保在get
方法中返回的是E
类型的元素,而不是Object
类型。
下面我们再来看看核心技术卷提到的第二种实现方式:
public static <T extends Comparable> T[] minmax(T ... a) {
var result = new Comparable[2]; // array of erased type
...
return (T[]) result; // compiles witl warnirg
}
为什么上面的设计会不适用于此段代码呢?
因为我们指定了一个上界为Comparable
类型,在外部转换为其他类型的时候会出现类型转换兼容问题,导致ClassCastException
,此时由要回到合理构造的问题上来,和上面一样我们也可以使用反射和方法引用来解决此问题,故不进行过多赘述
深入泛型-通配符应用
无类型通配符
除了上述的划定上下界时使用通配符?
,我们还有一种情况使用它,即参数为一个泛型类型时:
import java.util.ArrayList;
import java.util.List;
public class GenericsClass {
public static void print(List<?> list){ System.out.println(list); }
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("12345");
list.add("2333");
print(list);
}
}
那么有的人可能会想,Object是所有类的超类,那我直接使用它作为泛型类型不就好了,即:
public static void print(List<Object> list){ System.out.println(list); }
为什么要复用?
来徒增用法呢?但是非常遗憾的是,使用Object类型的泛型是不被Java所支持的,Java不会认为List<String>
是List<Object>
的子类型,这主要是因为Java泛型的设计目标是保持向后兼容性和类型安全性。作为一门强类型语言,灵活性并不是重点。即JAVA并不支持泛型的协变与逆变。
在Java中,数组是协变的,这意味着如果String
是Object
的子类型,那么String[]
就是Object[]
的子类型。但是,泛型在Java中并非如此,因为泛型没有内建的协变或逆变支持。但是,可以通过使用通配符和通配符边界来实现类似的效果。例如,List<? extends Number>
表示一个元素类型为Number
或其子类型的列表,这就是一种协变的表达方式。
复合通配符
除了上述提到的上界,下界,无类类型通配符,我们还可以同时复合上界与下界通配符,当出现类图是以下情况时(这里防止超长,就只写个extends了):
ClassA --extends--> ClassB --extends--> ClassC --extends--> GenericOther
我们要处理ClassA类型数据就可以使用以下的通配符复合:
<T extends GenericOther<? super T>>
当我们传入ClassA
作为T
时,先划定上界为其最终父类或接口GenericOther
,即T extends GenericOther
,然后划定GenericOther
的下界为ClassA
,例如以下对核心技术卷中时间API示例代码的补全:
LocalDate --extends--> ChronoLocalDate --extends--> Comparable<ChronoLocalDate> --extends--> Comparable
import java.time.LocalDate;
public class GenericMinExample {
public static <T extends Comparable<? super T>> T min(T[] a) {
if (a == null || a.length == 0) {
throw new IllegalArgumentException("Array is empty or null");
}
T min = a[0];
for (T element : a) {
if (element.compareTo(min) < 0) {
min = element;
}
}
return min;
}
public static void main(String[] args) {
String[] stringArray = {"apple", "banana", "pear"};
System.out.println("Min string: " + min(stringArray));
LocalDate[] dates = {
LocalDate.of(2023, 5, 10),
LocalDate.of(2023, 1, 10),
LocalDate.of(2023, 12, 10)
};
System.out.println("Min date: " + min(dates));
}
}
深入泛型-异常与泛型
不允许泛型异常处理
在Java中,不能抛出或捕获泛型类的异常是因为Java的泛型在运行时会被类型擦除。类型擦除的机制会将泛型类型替换为其非泛型的上限,通常是 Object
,并在必要时插入类型转换。因此,泛型类型的信息在运行时是不保留的,这导致了一些限制。
- 类型擦除:
- 在编译时,泛型类型
T
被替换为其非泛型的上限。例如,如果T
没有明确的边界,它会被替换为Object
。 - 这意味着在运行时,无法知道
T
实际上是什么类型。
- 在编译时,泛型类型
- 异常的实例化:
- 如果允许抛出泛型类的异常,Java需要在运行时知道具体的异常类型,以便正确地实例化它。
- 由于类型擦除,运行时无法知道
T
的实际类型,因此不能安全地实例化T
类型的异常。
- 异常处理机制:
- 异常处理机制依赖于精确的类型匹配。
- 由于泛型类型在运行时被擦除,
catch
语句中的类型检查会失效,导致潜在的运行时错误和不一致的行为。
利用泛型绕开类型限制
在 Java 中,我们可以利用泛型取消异常检查,通常有以下三种常见的方式:
使用通配符
import java.util.List;
public class WildcardExample {
public static void printList(List<?> list) {
for (Object element : list) {
System.out.println(element);
}
}
public static void main(String[] args) {
List<String> stringList = List.of("Hello", "World");
List<Integer> intList = List.of(1, 2, 3);
printList(stringList);
printList(intList);
}
}
在这个例子中,printList
方法使用通配符 <?>
,因此它可以接受任何类型的 List
,从而取消了类型检查。
使用泛型方法
public class GenericMethodExample {
public static <T> void printArray(T[] array) {
for (T element : array) {
System.out.println(element);
}
}
public static void main(String[] args) {
String[] stringArray = {"Hello", "World"};
Integer[] intArray = {1, 2, 3};
printArray(stringArray);
printArray(intArray);
}
}
在这个例子中,printArray
方法是一个泛型方法,参数类型为 T
。因此,这个方法可以接受任何类型的数组,从而取消了类型检查。
取消检查异常
有时可能会遇到受检查异常(checked exception)的问题。在这种情况下,可以使用泛型方法和反射来处理受检查异常。例如:
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class ExceptionSuppressor {
@SuppressWarnings("unchecked")
public static <T extends Throwable> void sneakyThrow(Throwable t) throws T {
throw (T) t; // Trick the compiler
}
public static void main(String[] args) {
try {
Method method = ExceptionSuppressor.class.getDeclaredMethod("exampleMethod");
method.invoke(null);
} catch (InvocationTargetException e) {
sneakyThrow(e.getCause());
} catch (NoSuchMethodException | IllegalAccessException e) {
e.printStackTrace();
}
}
public static void exampleMethod() throws Exception {
throw new Exception("Checked exception");
}
}
转载自:https://juejin.cn/post/7370164020869873691