Metode overload atau jembatan terlarang di Jawa


Sebagian besar wawancara saya tentang posisi teknis memiliki tugas di mana kandidat perlu mengimplementasikan 2 antarmuka yang sangat mirip di kelas yang sama:


Terapkan kedua antarmuka dalam satu kelas, jika memungkinkan. Jelaskan mengapa ini mungkin atau tidak.


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

Dari seorang penerjemah: Artikel ini tidak mendorong Anda untuk mengajukan pertanyaan yang sama dalam sebuah wawancara. Tetapi jika Anda ingin sepenuhnya siap ketika pertanyaan ini diajukan kepada Anda, selamat datang di kucing.


Kadang-kadang, pelamar yang tidak begitu yakin dengan jawabannya, lebih memilih untuk menyelesaikan daripada masalah ini dengan kondisi berikut (kemudian, dalam hal apa pun, saya meminta Anda untuk menyelesaikannya):


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

Memang, tugas kedua tampaknya jauh lebih sederhana, dan sebagian besar kandidat menjawab bahwa tidak mungkin untuk memasukkan kedua metode dalam kelas yang sama, karena tanda tangan Sm(int) dan Vm(int) sama, sedangkan jenis nilai pengembaliannya berbeda. Dan ini benar sekali.


Namun, terkadang saya mengajukan pertanyaan lain terkait dengan topik ini:


Apakah menurut Anda masuk akal untuk memungkinkan implementasi metode dengan tanda tangan yang sama tetapi jenis yang berbeda di kelas yang sama? Misalnya, dalam beberapa bahasa hipotetis berdasarkan JVM, atau setidaknya di tingkat JVM?


Ini adalah pertanyaan yang jawabannya ambigu. Tetapi, terlepas dari kenyataan bahwa saya tidak mengharapkan jawaban untuk itu, jawaban yang benar ada. Seseorang yang sering berurusan dengan API refleksi, memanipulasi bytecode atau terbiasa dengan spesifikasi JVM bisa menjawabnya.


Tanda tangan metode Java dan pegangan metode JVM


Tanda tangan metode Java (yaitu nama metode dan tipe parameter) hanya digunakan oleh kompiler Java pada waktu kompilasi. Pada gilirannya, JVM memisahkan metode di kelas menggunakan nama metode yang tidak memenuhi syarat (yaitu, hanya nama metode) dan metode yang menangani , yaitu, daftar parameter deskriptor dan satu deskriptor kembali.


Sebagai contoh, jika kita ingin memanggil metode String m(int i) langsung di kelas foo.Bar , bytecode berikut diperlukan:


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

dan untuk void m(int i) berikut ini:


 INVOKEVIRTUAL foo/Bar.m (I)V 

Dengan demikian, JVM cukup nyaman dengan String m(int i) dan void m(int i) di kelas yang sama. Yang diperlukan hanyalah menghasilkan bytecode yang sesuai.


Kung fu dengan bytecode


Kami memiliki antarmuka S dan V, sekarang kami akan membuat kelas SV yang mencakup kedua antarmuka. Di Jawa, jika diizinkan, tampilannya akan seperti ini:


 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; } } 

Untuk menghasilkan bytecode, kami menggunakan pustaka Objectweb ASM , pustaka tingkat rendah yang cukup untuk mendapatkan gambaran tentang bytecode JVM.


Kode sumber lengkap diunggah ke GitHub, di sini saya akan memberikan dan menjelaskan hanya bagian yang paling penting.


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

Mari kita mulai dengan membuat ClassWriter untuk menghasilkan bytecode.


Sekarang kita akan mendeklarasikan kelas yang mencakup antarmuka S dan V.


Meskipun kode pseudo-java referensi kami untuk SV tidak memiliki konstruktor, kami masih perlu membuat kode untuk itu. Jika kita tidak menggambarkan konstruktor di Jawa, kompiler secara implisit menghasilkan konstruktor kosong.


Di tubuh metode, kita mulai dengan mendapatkan bidang System.out dari tipe java.io.PrintStream dan menambahkannya ke tumpukan operan. Kemudian kita memuat konstanta ( String atau void ) ke stack dan memanggil perintah println dalam variabel yang dihasilkan dengan konstanta string sebagai argumen.


Akhirnya, untuk String m(int i) menambahkan konstanta dari tipe referensi dengan nilai null ke stack dan menggunakan return tipe yang sesuai, mis. ARETURN , untuk mengembalikan nilai ke inisiator pemanggilan metode. Untuk void m(int i) Anda perlu menggunakan RETURN diketik hanya untuk kembali ke pemrakarsa pemanggilan metode tanpa mengembalikan nilai. Untuk memastikan bytecode benar (yang saya lakukan terus-menerus, mengoreksi kesalahan berkali-kali), kami menulis kelas yang dihasilkan ke disk.


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

dan gunakan jad (decompiler Java) untuk menerjemahkan bytecode kembali ke kode sumber 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"); } } 

Tidak buruk menurut saya.


Menggunakan kelas yang dihasilkan


Dekompilasi jad berhasil pada dasarnya tidak menjamin apa pun bagi kita. Utilitas jad hanya memperingatkan Anda untuk masalah umum dalam bytecode, dari ukuran frame ke ketidakcocokan variabel lokal atau pernyataan pengembalian yang hilang.


Untuk menggunakan kelas yang dihasilkan saat runtime, kita perlu memuatnya entah bagaimana ke JVM dan kemudian instantiate.


Mari kita laksanakan AsmClassLoader kita sendiri. Ini hanya pembungkus berguna untuk 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); } } 

Sekarang gunakan pemuat kelas ini dan instantiate kelas:


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

Karena kelas kami dibuat pada saat runtime, kami tidak dapat menggunakannya dalam kode sumber. Tapi kita bisa menggunakan tipenya untuk mengimplementasikan antarmuka. Panggilan tanpa refleksi dapat dilakukan seperti ini:


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

Saat mengeksekusi kode, kami mendapatkan output berikut:


 String void 

Untuk beberapa orang, kesimpulan ini mungkin tampak tidak terduga: kita merujuk pada metode yang sama (dari sudut pandang Java) di kelas, tetapi hasilnya berbeda tergantung pada antarmuka yang kita bawa objek. Menakjubkan, bukan?


Semuanya akan menjadi jelas jika kita memperhitungkan bytecode yang mendasarinya. Untuk panggilan kami, kompiler menghasilkan pernyataan INVOKEINTERFACE, dan metode pegangan tidak berasal dari kelas, tetapi dari antarmuka.


Dengan demikian, panggilan pertama yang kita dapatkan:


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

dan yang kedua:


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

Objek tempat kita melakukan panggilan dapat diperoleh dari stack. Ini adalah kekuatan polimorfisme yang melekat di Jawa.


Namanya adalah metode jembatan


Seseorang akan bertanya: "Jadi apa gunanya semua ini? Apakah akan pernah berguna?"


Intinya adalah kita menggunakan hal yang sama (secara implisit) saat menulis kode Java biasa. Misalnya, tipe kovarian, generik, dan akses ke bidang pribadi dari kelas dalam diimplementasikan menggunakan keajaiban bytecode yang sama.


Lihatlah antarmuka ini:


 public interface ZeroProvider { Number getZero(); } 

dan implementasinya dengan kembalinya tipe kovarian:


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

Sekarang mari kita pikirkan tentang kode ini:


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

Untuk iz.getZero() , kompiler panggilan akan menghasilkan INVOKEVIRTUAL dengan metode pegangan ()Ljava/lang/Integer; , sedangkan untuk zp.getZero() akan menghasilkan INVOKEINTERFACE dengan deskriptor metode ()Ljava/lang/Number; . Kita sudah tahu bahwa JVM mengirimkan panggilan objek menggunakan deskriptor nama dan metode. Karena deskriptor berbeda, 2 panggilan ini tidak dapat dialihkan ke metode yang sama dalam instance IntegerZero .


Bahkan, kompiler menghasilkan metode tambahan yang bertindak sebagai jembatan antara metode nyata yang ditentukan dalam kelas dan metode yang digunakan saat memanggil melalui antarmuka. Karenanya namanya adalah metode jembatan. Jika ini dimungkinkan di Jawa, kode terakhir akan terlihat seperti ini:


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

Kata penutup


Bahasa pemrograman Java dan mesin virtual Java bukanlah hal yang sama: meskipun mereka memiliki kata yang sama dalam namanya dan Java adalah bahasa utama untuk JVM, kemampuan dan keterbatasan mereka jauh dari selalu sama. Mengetahui JVM membantu Anda lebih memahami Java atau bahasa berbasis JVM lainnya, tetapi di sisi lain, mengenal Java dan sejarahnya membantu memahami keputusan tertentu dalam desain JVM.


Dari penerjemah


Masalah kompatibilitas cepat atau lambat mulai mengkhawatirkan pengembang mana pun. Artikel asli menyentuh pada masalah penting dari perilaku implisit kompiler Java dan efek sihirnya pada aplikasi, yang kami, sebagai pengembang kerangka Platform CUBA, sangat peduli, ini secara langsung mempengaruhi kompatibilitas perpustakaan. Baru-baru ini, kami berbicara tentang kompatibilitas dalam aplikasi kehidupan nyata di JUG di Yekaterinburg dalam laporan "API tidak berubah di persimpangan - bagaimana membangun API yang stabil", video pertemuan dapat ditemukan di sini.


Source: https://habr.com/ru/post/id426419/


All Articles