Overloading that is prohibited or bridge methods in Java

Original author: Dmytro Kostiuchenko
  • Transfer

In most of my interviews for technical positions there is a task in which a candidate needs to implement 2 very similar interfaces in the same class:


Implement both interfaces with the same class, if possible. Explain why this is possible or not.


interfaceWithPrimitiveInt{
  voidm(int i);
}
interfaceWithInteger{
  voidm(Integer i);
}

From the translator: This article does not encourage you to ask the same questions in an interview. But if you want to be fully armed when this question is asked you, then welcome under cat.


Sometimes applicants who are not very sure of the answer prefer to solve instead of this task with the following condition (later, in any case, I ask it to be solved):


interfaceS{
  String m(int i);
}
interfaceV{
  voidm(int i);
}

Indeed, the second problem seems to be much easier, and the majority of candidates answer, that the inclusion of both methods in the same class is not possible, because of the signature S.m(int)and V.m(int)the same, while the return type - different. And this is absolutely true.


However, sometimes I ask another question related to this topic:


Do you think it makes sense to allow the implementation of methods with the same signature but different types in the same class? For example, in a certain hypothetical language based on JVM or at least at the level of JVM?


This is a question whose answer is ambiguous. But, despite the fact that I do not expect an answer to it, the correct answer exists. A person who often deals with the reflection API, manipulates bytecode or is familiar with the JVM specification, could respond to it.


Java method signature and JVM method handle


The Java method signature (i.e. method name and parameter types) is used only by the Java compiler at compile time. In turn, the JVM separates the methods in the class using the unqualified method name (that is, just the method name) and method descriptor , that is, the list of descriptor parameters and one return descriptor.


For example, if we want to call a method String m(int i)directly on a class foo.Bar, the following bytecode is needed:


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

and for void the m(int i)following:


INVOKEVIRTUAL foo/Bar.m (I)V

Thus, the JVM feels quite comfortable with String m(int i)and void m(int i)in the same class. All that is needed is to generate the corresponding bytecode.


Kung Fu bytecode


We have interfaces S and V, now we will create a class SV, which includes both interfaces. In Java, if it were allowed, it should look like this:


publicclassSVimplementsS, V{
  publicvoidm(int i){
    System.out.println("void m(int i)");
  }
  public String m(int i){
    System.out.println("String m(int i)");
    returnnull;
  }
}

To generate bytecode, we use the Objectweb ASM library , a low-level enough library to get an idea of ​​the JVM bytecode.


The full source code is uploaded to GitHub, but here I will give and explain only the most important fragments.


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();

Start by creating ClassWriterto generate bytecode.


Now we will declare a class that includes interfaces S and V.


Although our reference pseudo-java code for SV has no constructors, we still need to generate code for it. If we do not describe constructors in Java, the compiler implicitly generates an empty constructor.


In the body of methods, we start by getting a field System.outwith a type java.io.PrintStreamand adding it to the operand stack. Then we load a constant ( Stringor void) onto the stack and call the command printlnin the resulting variable outwith a string constant as an argument.


Finally, String m(int i)add a constant of reference type with a value to the stack nulland use the operator of the returnappropriate type, that is ARETURN, to return the value to the initiator of the method call. For void m(int i)it is necessary to use untyped RETURNonly in order to return to the initiator of the method call without returning the value. To make sure that the bytecode is correct (which I do all the time, repeatedly correcting errors), we write the generated class to disk.


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

and use jad(Java decompiler) to translate the bytecode back into Java source code:


$ jad -p /tmp/SV.class
The classfileversionis 51.0 (only 45.3, 46.0 and 47.0 aresupported)
// DecompiledbyJadv1.5.8e. Copyright 2001 PavelKouznetsov.
// Jadhomepage: http://www.geocities.com/kpdus/jad.html
// Decompileroptions: packimports(3) 
packageedio.java.experiments;
import java.io.PrintStream;
// Referenced classes of package edio.java.experiments://            S, VpublicclassSVimplementsS, V{
    publicSV(){
    }
    public String m(int i){
        System.out.println("String");
        returnnull;
    }
    publicvoidm(int i){
        System.out.println("void");
    }
}

In my opinion, not bad.


Using the generated class


Successful decompiling jaddoes not guarantee us anything. The utility jadnotifies only about the main problems in the byte-code, from such as the size of the frame, to the inconsistency of local variables or a missing return statement.


To use the generated class at runtime, we need to somehow load it into the JVM and then create an instance of it.


Let's implement our own AsmClassLoader. This is just a convenient wrapper for ClassLoader.defineClass:


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

Now use this class loader and create an instance of the class:


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

Since our class is generated at runtime, we cannot use it in the source code. But we can bring its type to the implemented interfaces. A challenge without reflection can be done like this:


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

When executing the code, we get the following output:


Stringvoid

Someone would find such a conclusion unexpected: we turn to the same (from the Java point of view) method in the class, but the results differ depending on the interface to which we led the object. Stunning, right?


Everything becomes clear if we take into account the underlying bytecode. For our call, the compiler generates the INVOKEINTERFACE statement, and the method handle does not come from the class, but from the interface.


Thus, the first call we get:


INVOKEINTERFACE edio/java/experiments/S.m (I)Ljava/lang/String;

and at the second:


INVOKEINTERFACE edio/java/experiments/V.m (I)V

The object on which we made the call can be obtained from the stack. This is the power of polymorphism inherent in Java.


His name is the bridge method


Someone will ask: "So what's the point of all this? Will it ever come in handy?"


The point is that we use the same thing (implicitly) when writing regular Java code. For example, covariant return types, generics, and access to private fields from internal classes are implemented using the same bytecode magic .


Take a look at this interface:


publicinterfaceZeroProvider{
  Number getZero();
}

and its implementation with the return of the covariant type:


publicclassIntegerZeroimplementsZeroProvider{
  public Integer getZero(){
    return0;
  }
}

Now let's think about this code:


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

For the iz.getZero()compiler, the call will generate INVOKEVIRTUALwith the method descriptor ()Ljava/lang/Integer;, while for zp.getZero()it it will generate INVOKEINTERFACE with the method descriptor ()Ljava/lang/Number;. We already know that the JVM dispatches an object call using its name and method handle. Since the descriptors are different, these 2 calls cannot be sent to the same method in the instance IntegerZero.


In essence, the compiler generates an additional method that acts as a bridge between the actual method specified in the class and the method used when calling through the interface. Hence the name - the bridge method. If this were possible in Java, the final code would look like this:


publicclassIntegerZeroimplementsZeroProvider{
  public Integer getZero(){
    return0;
  }
  // This is a synthetic bridge method, which is present only in bytecode.// Java compiler wouldn't permit it.public Number getZero(){
    returnthis.getZero();
  }
}

Afterword


The Java programming language and the Java virtual machine are not the same thing: although they have a common word in their name and Java is the main language for the JVM, their capabilities and limitations are not always the same. Knowing JVM helps you better understand Java or any other JVM-based language, but, on the other hand, knowledge of Java and its history helps you understand certain solutions in JVM design.


From the translator


Compatibility issues sooner or later begin to worry any developer. The original article touched upon the important question of the implicit behavior of the Java compiler and the impact of its magic on applications, which we, as developers of the CUBA Platform framework, are concerned about quite strongly - this directly affects the compatibility of libraries. Most recently, we talked about compatibility in real-world applications at JUG in Yekaterinburg in the report “APIs at the ferry do not change - how to build a stable API,” a video of the meeting can be found at the link.



Also popular now: