In most of my interviews for technical positions there is a task in which a candidate needs to implement 2 very similar interfaces in one class:
Implement both interfaces with the same class, if possible. Explain why this is possible or not.
interface WithPrimitiveInt { void m(int i); } interface WithInteger { void m(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):
interface S { String m(int i); } interface V { void m(int i); }
Indeed, the second task seems to be much simpler, and most candidates answer that it is impossible to include both methods in the same class, because the signatures Sm(int)
and Vm(int)
same, while the type of the return value is 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. It could be answered by a person who often deals with reflection APIs, manipulates bytecode or is familiar with the JVM specification.
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 the method descriptor , that is, the list of descriptor parameters and one return descriptor.
For example, if we want to call the String m(int i)
method directly on the class foo.Bar
, we need the following bytecode:
INVOKEVIRTUAL foo/Bar.m (I)Ljava/lang/String;
and for void 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.
We have interfaces S and V, now we will create class SV which includes both interfaces. In Java, if it were allowed, it should look like this:
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; } }
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 a ClassWriter
to 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 the methods, we start by getting the System.out
field with the type java.io.PrintStream
and adding it to the operand stack. Then we load a constant ( String
or void
) onto the stack and call the println
command in the resulting out
variable with a string constant as an argument.
Finally, for String m(int i)
add a constant of reference type with a value of null
to the stack and use the return
operator of the appropriate type, that is, ARETURN
, to return the value to the initiator of the method call. For void m(int i)
you should use an untyped RETURN
only to return to the initiator of the method call without returning a 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 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"); } }
In my opinion, not bad.
Successful jad
decompilation essentially does not guarantee us anything. The jad
utility only notifies about major problems in the bytecode, such as frame size, 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 handy wrapper for 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); } }
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:
String void
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 will become 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/Sm (I)Ljava/lang/String;
and at the second:
INVOKEINTERFACE edio/java/experiments/Vm (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.
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:
public interface ZeroProvider { Number getZero(); }
and its implementation with the return of the covariant type:
public class IntegerZero implements ZeroProvider { public Integer getZero() { return 0; } }
Now let's think about this code:
IntegerZero iz = new IntegerZero(); iz.getZero(); ZeroProvider zp = iz; zp.getZero();
For iz.getZero()
, the call compiler will generate INVOKEVIRTUAL
with the ()Ljava/lang/Integer;
descriptor method ()Ljava/lang/Integer;
, while for zp.getZero()
it will generate INVOKEINTERFACE with a handle to the ()Ljava/lang/Number;
method ()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 directed to the same method in an IntegerZero
instance.
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:
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(); } }
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. JVM knowledge 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.
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 worried about quite strongly - this directly affects the compatibility of libraries. More 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 here.
Source: https://habr.com/ru/post/426419/
All Articles