专业的JAVA编程教程与资源

网站首页 > java教程 正文

Java修炼终极指南:42. 举例说明擦除与重载

temp10 2024-11-01 13:41:29 java教程 13 ℃ 0 评论


在通过一个示例来讨论它们之前,让我们先分别快速了解一下擦除和重载。

Java修炼终极指南:42. 举例说明擦除与重载

擦除概述

Java在编译时使用类型擦除来强制类型约束和与旧字节码的向后兼容性。基本上,在编译时,所有的类型参数都被替换为Object(任何泛型都必须可以转换为Object)或类型边界(extends或super)。接下来,在运行时,编译器擦除的类型将被我们的类型替换。类型擦除的一个常见情况涉及泛型。

泛型类型的擦除

实际上,编译器将无界类型(如E、T、U等)用有界Object替换。这通过以下类型擦除的类示例来强制类型安全:

public class ImmutableStack<E> implements Stack<E> {  
  private final E head;  
  private final Stack<E> tail;  
  // ...


编译器应用类型擦除将E替换为Object:

public class ImmutableStack<Object> implements Stack<Object> {  
  private final Object head;  
  private final Stack<Object> tail;  
  // ...


如果E参数被绑定,则编译器使用第一个绑定类。例如,在类`class Node<T extends Comparable<T>> {...}`中,编译器会将T替换为Comparable。同样,在类`class Computation<T extends Number> {...}`中,T的所有出现都将被编译器替换为上限Number。

检查以下情况,这是方法类型擦除的经典案例:

public static <T, R extends T> List<T> listOf(T t, R r) {  
        
  List<T> list = new ArrayList<>();  
        
  list.add(t);  
  list.add(r);        
        
  return list;  
}  
// 使用这个方法  
List<Object> list = listOf(1, "one");


这是如何工作的?当我们调用`listOf(1, "one")`时,我们实际上是将两种不同的类型传递给了泛型参数T和R。编译器的类型擦除将T替换为Object。这样,我们就可以在ArrayList中插入不同的类型,代码运行得很好。

擦除和桥接方法

桥接方法是编译器为覆盖边缘情况而创建的。具体来说,当编译器遇到参数化接口的实现或参数化类的扩展时,它可能需要生成一个桥接方法(也称为合成方法),作为类型擦除阶段的一部分。例如,考虑以下参数化类:

public class Puzzle<E> {  
  public E piece;  
  public Puzzle(E piece) {  
    this.piece = piece;  
  }  
  public void setPiece(E piece) {         
    this.piece = piece;  
  }  
}


以及这个类的扩展:

public class FunPuzzle extends Puzzle<String> {  
  public FunPuzzle(String piece) {  
    super(piece);  
  }  
  @Override  
  public void setPiece(String piece) {         
    super.setPiece(piece);  
  }  
}


类型擦除将`Puzzle.setPiece(E)`修改为`Puzzle.setPiece(Object)`。这意味着`FunPuzzle.setPiece(String)`方法并没有重写`Puzzle.setPiece(Object)`方法。由于方法的签名不兼容,编译器必须通过桥接(合成)方法来适应泛型类型的多态性,以确保子类型按预期工作。让我们在代码中突出显示这个方法:

/* Decompiler 8ms, total 3470ms, lines 18 */  
package modern.challenge;  
public class FunPuzzle extends Puzzle<String> {  
   public FunPuzzle(String piece) {  
      super(piece);  
   }  
   public void setPiece(String piece) {  
      super.setPiece(piece);  
   }  
   // $FF: synthetic method  
   // $FF: bridge method  
   public void setPiece(Object var1) {  
      this.setPiece((String)var1);  
   }  
}


现在,每当你在堆栈跟踪中看到桥接方法时,你就知道它是什么以及为什么在那里。

类型擦除和堆污染

你是否见过未检查的警告?我确定你见过!这是所有Java开发人员都会遇到的问题之一。它们可能在编译时作为类型检查的结果出现,或在运行时作为类型转换或方法调用的结果出现。在这两种情况下,我们谈论的是编译器无法验证涉及某些参数化类型的操作的正确性。并非每个未检查的警告都是危险的,但确实存在我们必须考虑和处理的情况。

堆污染的一个特例是,当某个类型的参数化变量指向一个不是该类型的对象时,我们就容易遇到导致堆污染的代码。涉及varargs参数的方法的一个很好的候选者是这样的场景。检查以下代码:

public static <T> void listOf(List<T> list, T... ts) {  
  list.addAll(Arrays.asList(ts));     
}


listOf()的声明将导致此警告:可能的堆污染来自参数化varargs类型T。那么这里发生了什么?故事从编译器将形式T...参数替换为数组开始。在应用类型擦除后,T...参数变为T[],并最终变为Object[]。因此,我们为可能的堆污染打开了一扇门。但是,我们的代码只是将Object[]的元素添加到了List<Object>中,所以我们处于安全区域。

换句话说,如果你知道varargs方法的主体不容易生成特定异常(例如,ClassCastException)或在不适当的操作中使用varargs参数,那么我们可以指示编译器抑制这些警告。我们可以通过@SafeVarargs注解来实现这一点,如下所示:

@SafeVarargs  
public static <T> void listOf(List<T> list, T... ts) { … }


@SafeVarargs是一个提示,表明被注解的方法将仅在适当的操作中使用varargs形式参数。更常见但不太推荐的是使用@SuppressWarnings({"unchecked", "varargs"}),它只是简单地抑制此类警告,而不声明varargs形式参数不会在不适当的操作中使用。

现在,让我们来处理这段代码:

public static void main(String[] args) {  
  List<Integer> ints = new ArrayList<>();  
  Main.listOf(ints, 1, 2, 3);  
         
  Main.listsOfYeak(ints);  
}  
  
public static void listsOfYeak(List<Integer>... lists) {  
         
  Object[] listsAsArray = lists;      
     
  listsAsArray[0] = Arrays.asList(4, 5, 6);         
  Integer someInt = lists[0].get(0);    
     
  listsAsArray[0] = Arrays.asList("a", "b", "c");     
  Integer someIntYeak = lists[0].get(0); // ClassCastException  
}


这次,类型擦除将List<Integer>...转换为List[],这是Object[]的子类型。这允许我们进行赋值:`Object[] listsAsArray = lists;`。但是,请查看最后两行代码,其中我们创建了一个List<String>并将其存储在`listsAsArray[0]`中。在最后一行,我们尝试从`lists[0]`访问第一个Integer,这显然会导致ClassCastException。这是使用varargs的不当操作,因此不建议在这种情况下使用@SafeVarargs。我们应该认真对待以下警告:

// unchecked generic array creation for varargs parameter  
// of type java.util.List<java.lang.Integer>[]  
Main.listsOfYeak(ints);  
// Possible heap pollution from parameterized vararg  
// type java.util.List<java.lang.Integer>  
public static void listsOfYeak(List<Integer>... lists) { … }


现在,你已经熟悉了类型擦除,让我们简要地介绍一下多态重载。

多态重载概述

由于重载(也称为“即席”多态性)是面向对象编程(OOP)的核心概念,我相信你对Java方法重载很熟悉,所以我不会坚持这个概念的基本理论。

我也知道有些人不同意重载是一种多态形式,但那是另一个不会在这里讨论的话题。我们将更加实际,并跳入一系列旨在突出重载某些有趣方面的测验。更具体地说,我们将讨论类型优势。让我们处理第一个测验(wordie是一个最初为空的字符串):

static void kaboom(byte b) { wordie += "a";}    
static void kaboom(short s) { wordie += "b";}    
kaboom(1);


会发生什么?如果你回答编译器会指出找不到适合kaboom(1)的方法,那么你是对的。编译器寻找一个接受整数参数的方法,kaboom(int)。好的,这很简单!下一个:

static void kaboom(byte b) { wordie += "a";}    
static void kaboom(short s) { wordie += "b";}   
static void kaboom(long l) { wordie += "d";}     
static void kaboom(Integer i) { wordie += "i";}    
kaboom(1);


我们知道前两个kaboom()是无用的。那么kaboom(long)和kaboom(Integer)呢?你是对的,将调用kaboom(long)。如果我们删除kaboom(long),则调用kaboom(Integer)。

在原始类型重载中,编译器首先寻找一对一的匹配。如果这种尝试失败,编译器将寻找接受比当前域更广泛的原始域的重载版本(例如,对于int,它寻找int、long、float或double)。如果这也失败,编译器将检查接受装箱类型(Integer、Float等)的重载。

以下面的情况为例:

static void kaboom(Integer i) { wordie += "i";}    
static void kaboom(Long l) { wordie += "j";}    
kaboom(1);


这次,wordie将会是"i"。调用了kaboom(Integer),因为不存在kaboom(int/long/float/double)。如果我们有一个kaboom(double),则该方法将比kaboom(Integer)具有更高的优先级。

在装箱类型重载中,编译器首先寻找一对一的匹配。如果失败,编译器将不会考虑任何接受比当前域更广泛的装箱类型的重载版本(当然,更狭窄的域也被忽略)。它查找Number作为所有装箱类型的超类。如果找不到Number,编译器将向上遍历层次结构,直到达到java.lang.Object,这是道路的尽头。

现在让我们稍微复杂一点:

static void kaboom(Object... ov) { wordie += "o";}    
static void kaboom(Number n) { wordie += "p";}    
static void kaboom(Number... nv) { wordie += "q";}      
kaboom(1);


这次,哪个方法将被调用?你可能会想到kaboom(Number),对吧?至少,我的简单逻辑让我认为这是一个常识性的选择。而且它是正确的!如果我们移除kaboom(Number),编译器将调用varargs方法kaboom(Number...)。这是有道理的,因为kaboom(1)使用了一个参数,所以kaboom(Number)应该比kaboom(Number...)具有更高的优先级。但是,如果我们调用kaboom(1,2,3),逻辑就会反转,因为kaboom(Number)不再代表这次调用的有效重载,而kaboom(Number...)是正确的选择。

但是,这种逻辑之所以适用,是因为Number是所有装箱类(Integer、Double、Float等)的超类。现在考虑以下情况:

static void kaboom(Object... ov) { wordie += "o";}    
static void kaboom(File... fv) { wordie += "s";}    
kaboom(1);


这次,编译器将“绕过”kaboom(File...)并调用kaboom(Object...)。基于相同的逻辑,调用kaboom(1, 2, 3)也会调用kaboom(Object...),因为没有kaboom(Number...)。

在重载中,如果调用具有单个参数,则具有单个参数的方法比其varargs对应项具有更高的优先级。另一方面,如果调用具有多个相同类型的参数,则调用varargs方法,因为具有单个参数的方法不再适用。当调用具有单个参数但只有varargs重载可用时,将调用该方法。

这导致了以下示例:

static void kaboom(Number... nv) { wordie += "q";}    
static void kaboom(File... fv) { wordie += "s";}    
kaboom();


这次,kaboom()没有参数,编译器无法找到唯一匹配项。这意味着对kaboom()的引用是模糊的,因为两个方法都匹配(modern.challenge.Main中的kaboom(java.lang.Number...)和modern.challenge.Main中的kaboom(java.io.File...))。

在捆绑的代码中,你可以进一步探索多态重载并测试你的知识。此外,尝试挑战自己,并在等式中引入泛型。

擦除与重载

现在,基于之前的经验,检查以下代码:

void print(List<A> listOfA) {  
  System.out.println("Printing A: " + listOfA);  
}  
    
void print(List<B> listofB) {  
  System.out.println("Printing B: " + listofB);  
}


会发生什么?这是一个擦除和重载冲突的情况。类型擦除将List<A>替换为List<Object>,并将List<B>替换为List<Object>。因此,重载是不可能的,我们会得到一个错误,如“名称冲突:print(java.util.List<modern.challenge.B>)和print(java.util.List<modern.challenge.A>)具有相同的擦除。”

为了解决这个问题,我们可以向这两个方法中的一个添加一个虚拟参数:

void print(List<A> listOfA, Void... v) {  
  System.out.println("Printing A: " + listOfA);  
}


现在,我们可以对这两个方法进行相同的调用:

new Main().print(List.of(new A(), new A()));  
new Main().print(List.of(new B(), new B())); // 注意:这里实际上会编译错误,因为第二个print方法已被修改


但是请注意,上面的第二个调用实际上会导致编译错误,因为我们修改了第二个print方法以接受一个额外的Void...参数。通常,为了处理类型擦除导致的重载问题,你会通过改变方法名称、添加不同类型的参数或使用其他技术来区分它们。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表