Java中禁止的重载或桥接方法


我在技术职位上的大多数面试都有一项任务,即应聘者需要在同一班上实现2个非常相似的界面:


如果可能,在一个类中实现两个接口。 解释为什么这是可能的。


interface WithPrimitiveInt { void m(int i); } interface WithInteger { void m(Integer i); } 

来自翻译:本文不鼓励您在面试中提出相同的问题。 但是,如果您想在提出这个问题时做好充分准备,那么欢迎您。


有时,对答案不太确定的申请人更喜欢以以下条件解决此问题(无论如何,我要求您解决此问题):


 interface S { String m(int i); } interface V { void m(int i); } 

实际上,第二项任务似乎要简单得多,大多数候选人都回答说不可能将两个方法都包含在同一个类中,因为签名Sm(int)Vm(int)相同,而返回值的类型却不同。 这是绝对正确的。


但是,有时我会问另一个与此主题相关的问题:


您认为允许在同一类中实现具有相同签名但具有不同类型的方法是否有意义? 例如,使用某种基于JVM的假设语言还是至少在JVM级别?


这个问题的答案是模棱两可的。 但是,尽管我不期望对此有任何答案,但仍然存在正确的答案。 经常使用反射API,操纵字节码或熟悉JVM规范的人可以回答它。


Java方法签名和JVM方法句柄


Java方法签名(即方法名称和参数类型)仅在编译时由Java编译器使用。 反过来,JVM使用非限定的方法名称 (即方法名称)和方法handle (即,描述符参数列表和一个返回描述符)来分隔类中的方法


例如,如果我们想直接在foo.Bar类上调用String m(int i)方法,则需要以下字节码:


 INVOKEVIRTUAL foo/Bar.m (I)Ljava/lang/String; 

对于无效的m(int i)以下内容:


 INVOKEVIRTUAL foo/Bar.m (I)V 

因此,JVM对同一类中的String m(int i)void m(int i)非常满意。 所需要做的就是生成相应的字节码。


功夫与字节码


我们有S和V接口,现在我们将创建一个包含两个接口的SV类。 在Java中,如果允许,则应如下所示:


 public class SV implements S, V { public void m(int i) { System.out.println("void m(int i)"); } public String m(int i) { System.out.println("String m(int i)"); return null; } } 

为了生成字节码,我们使用Objectweb ASM库 ,这是一个足以了解字节码JVM的底层库。


完整的源代码已上传到GitHub,在这里我将仅给出和解释最重要的片段。


 ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES); // package edio.java.experiments // public class SV implements S, V cw.visit(V1_7, ACC_PUBLIC, "edio/java/experiments/SV", null, "java/lang/Object", new String[]{ "edio/java/experiments/S", "edio/java/experiments/V" }); // constructor MethodVisitor constructor = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null); constructor.visitCode(); constructor.visitVarInsn(Opcodes.ALOAD, 0); constructor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V"); constructor.visitInsn(Opcodes.RETURN); constructor.visitMaxs(1, 1); constructor.visitEnd(); // public String m(int i) MethodVisitor mString = cw.visitMethod(ACC_PUBLIC, "m", "(I)Ljava/lang/String;", null, null); mString.visitCode(); mString.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mString.visitLdcInsn("String"); mString.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V"); mString.visitInsn(Opcodes.ACONST_NULL); mString.visitInsn(Opcodes.ARETURN); mString.visitMaxs(2, 2); mString.visitEnd(); // public void m(int i) MethodVisitor mVoid = cw.visitMethod(ACC_PUBLIC, "m", "(I)V", null, null); mVoid.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mVoid.visitLdcInsn("void"); mVoid.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V"); mVoid.visitInsn(Opcodes.RETURN); mVoid.visitMaxs(2, 2); mVoid.visitEnd(); cw.visitEnd(); 

让我们开始创建一个ClassWriter来生成字节码。


现在,我们将声明一个包含接口S和V的类。


尽管我们的SV参考伪Java代码没有构造函数,但仍需要为其生成代码。 如果我们不使用Java描述构造函数,则编译器会隐式生成一个空的构造函数。


在方法的主体中,我们首先获取类型为java.io.PrintStreamSystem.out字段,并将其添加到操作数堆栈中。 然后,将常量( Stringvoid )加载到堆栈上,并在out变量中以字符串常量作为参数调用println命令。


最后,对于String m(int i)将引用类型的常量(其值为null到堆栈中,并使用对应类型的returnARETURN将值返回给方法调用的发起者。 对于void m(int i)您只需要使用无类型的RETURN即可返回到方法调用的发起者,而无需返回任何值。 为了确保字节码正确(我经常这样做,多次纠正错误),我们将生成的类写入磁盘。


 Files.write(new File("/tmp/SV.class").toPath(), cw.toByteArray()); 

并使用jad (Java反编译器)将字节码转换回Java源代码:


 $ jad -p /tmp/SV.class The class file version is 51.0 (only 45.3, 46.0 and 47.0 are supported) // Decompiled by Jad v1.5.8e. Copyright 2001 Pavel Kouznetsov. // Jad home page: http://www.geocities.com/kpdus/jad.html // Decompiler options: packimports(3) package edio.java.experiments; import java.io.PrintStream; // Referenced classes of package edio.java.experiments: // S, V public class SV implements S, V { public SV() { } public String m(int i) { System.out.println("String"); return null; } public void m(int i) { System.out.println("void"); } } 

我认为还不错。


使用生成的类


jad成功反编译本质上对我们没有任何保证。 jad实用程序仅向您警告字节码中的常见问题,从帧大小到局部变量不匹配或缺少返回语句。


要在运行时使用生成的类,我们需要以某种方式将其加载到JVM中,然后实例化它。


让我们实现自己的AsmClassLoader 。 这只是ClassLoader.defineClass一个方便包装ClassLoader.defineClass


 public class AsmClassLoader extends ClassLoader { public Class defineAsmClass(String name, ClassWriter classWriter) { byte[] bytes = classWriter.toByteArray(); return defineClass(name, bytes, 0, bytes.length); } } 

现在使用此类加载器并实例化该类:


 ClassWriter cw = SVGenerator.generateClass(); AsmClassLoader classLoader = new AsmClassLoader(); Class<?> generatedClazz = classLoader.defineAsmClass(SVGenerator.SV_FQCN, cw); Object o = generatedClazz.newInstance(); 

由于我们的类是在运行时生成的,因此我们无法在源代码中使用它。 但是我们可以将其类型转换为已实现的接口。 可以这样进行无反射的呼叫:


 ((S)o).m(1); ((V)o).m(1); 

执行代码时,我们得到以下输出:


 String void 

在某些情况下,这个结论似乎是出乎意料的:我们在类中引用了相同的方法(从Java的角度来看),但是结果取决于将对象带到的接口而有所不同。 令人惊叹吧?


如果我们考虑底层字节码,一切将变得清晰。 对于我们的调用,编译器生成一个INVOKEINTERFACE语句,并且方法句柄不是来自类,而是来自接口。


因此,我们得到的第一个电话是:


 INVOKEINTERFACE edio/java/experiments/Sm (I)Ljava/lang/String; 

在第二个中:


 INVOKEINTERFACE edio/java/experiments/Vm (I)V 

可以从堆栈中获取进行调用的对象。 这就是Java固有的多态性的力量。


他叫桥法


有人会问:“那么这一切的意义何在?它会派上用场吗?”


关键是在编写常规Java代码时,我们(隐式)使用了相同的东西。 例如,协变返回类型,泛型以及从内部类对私有字段的访问是使用相同字节码魔术实现的


看一下这个界面:


 public interface ZeroProvider { Number getZero(); } 

及其实现,并返回协变量类型:


 public class IntegerZero implements ZeroProvider { public Integer getZero() { return 0; } } 

现在让我们考虑以下代码:


 IntegerZero iz = new IntegerZero(); iz.getZero(); ZeroProvider zp = iz; zp.getZero(); 

对于iz.getZero() ,调用编译器将使用handle方法()Ljava/lang/Integer;生成INVOKEVIRTUAL ()Ljava/lang/Integer; ,而对于zp.getZero() ,它将使用方法描述符()Ljava/lang/Number;生成INVOKEINTERFACE ()Ljava/lang/Number; 。 我们已经知道JVM使用名称和方法描述符调度对象调用。 由于描述符不同,因此这两个调用不能路由到IntegerZero实例中的相同方法。


实际上,编译器会生成一个附加方法,该方法充当类中指定的实际方法与通过接口调用时使用的方法之间的桥梁。 因此,该名称是桥接方法。 如果在Java中可行,则最终代码如下所示:


 public class IntegerZero implements ZeroProvider { public Integer getZero() { return 0; } // This is a synthetic bridge method, which is present only in bytecode. // Java compiler wouldn't permit it. public Number getZero() { return this.getZero(); } } 

后记


Java编程语言和Java虚拟机不是一回事:尽管它们的名称具有相同的词,并且Java是JVM的主要语言,但是它们的功能和局限性并非始终相同。 了解JVM可以帮助您更好地理解Java或任何其他基于JVM的语言,但是,另一方面,了解Java及其历史有助于了解JVM设计中的某些决策。


来自翻译


兼容性问题迟早会开始让任何开发人员担心。 原始文章谈到了Java编译器的隐式行为及其魔术对应用程序的影响这一重要问题,作为CUBA Platform框架的开发人员,我们非常关心Java编译器的隐含行为,这直接影响了库的兼容性。 最近,我们在叶卡捷琳堡JUG的报告中谈到了“现实应用中的兼容性”,报告“ API不会在交叉口发生变化-如何构建稳定的API”,可以在此处找到会议视频。


Source: https://habr.com/ru/post/zh-CN426419/


All Articles