معظم المقابلات التي أجريتها حول المواقف الفنية لديها مهمة يحتاج فيها المرشح إلى تنفيذ واجهات متشابهتين جدًا في فصل واحد:
قم بتطبيق كلتا الواجهتين في فئة واحدة ، إن أمكن. اشرح لماذا هذا ممكن أم لا.
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؟
هذا سؤال الإجابة عليه غامضة. ولكن ، على الرغم من أنني لا أتوقع إجابة عليه ، فإن الإجابة الصحيحة موجودة. يمكن للشخص الذي يتعامل في كثير من الأحيان مع واجهة برمجة التطبيقات الخاصة بالانعكاس ، أو يعالج الرمز الثانوي أو على دراية بمواصفات JVM ، يمكنه الإجابة عليه.
توقيع طريقة جافا ومعالجة أسلوب JVM
يتم استخدام توقيع طريقة Java (أي اسم الأسلوب وأنواع المعلمات) فقط بواسطة مترجم Java في وقت الترجمة. في المقابل ، يفصل JVM الطرق في الفئة باستخدام اسم أسلوب غير مؤهل (أي اسم طريقة فقط) ومقبض أسلوب ، أي قائمة معلمات الواصف وواصف إرجاع واحد.
على سبيل المثال ، إذا أردنا استدعاء طريقة String m(int i)
مباشرة على فئة foo.Bar
، foo.Bar
التالي:
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 ، وهي مكتبة منخفضة المستوى بما يكفي للحصول على فكرة عن JBM بايت كود.
يتم تحميل شفرة المصدر الكاملة إلى GitHub ، وهنا سأقدم وأشرح الأجزاء الأكثر أهمية فقط.
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
لنبدأ بإنشاء ClassWriter
لإنشاء رمز ClassWriter
.
الآن سنعلن عن فئة تتضمن واجهات S و V.
على الرغم من أن كود جافا الزائف المرجعي الخاص بـ SV لا يحتوي على مُنشئين ، إلا أننا ما زلنا بحاجة إلى إنشاء رمز له. إذا لم نقم بتوصيف المُنشئين في Java ، فإن المترجم يقوم ضمنيًا بإنشاء مُنشئ فارغ.
في نص الطرق ، نبدأ بالحصول على حقل System.out
من نوع java.io.PrintStream
وإضافته إلى حزمة المعامل. ثم نقوم بتحميل الثابت ( String
or void
) على المكدس واستدعاء الأمر println
في المتغير الناتج الناتج بسلسلة ثابتة كوسيطة.
أخيرًا ، بالنسبة إلى String m(int i)
نضيف ثابت النوع المرجعي بالقيمة null
ARETURN
المكدس ونستخدم return
النوع المقابل ، أي ARETURN
، لإرجاع القيمة إلى بادئ استدعاء الأسلوب. بالنسبة إلى void m(int i)
تحتاج إلى استخدام RETURN
المكتوب فقط للعودة إلى بادئ استدعاء الأسلوب دون إرجاع قيمة. للتأكد من صحة الرمز الثانوي (الذي أفعله باستمرار ، وتصحيح الأخطاء عدة مرات) ، نكتب الفئة التي تم إنشاؤها على القرص.
Files.write(new File("/tmp/SV.class").toPath(), cw.toByteArray());
واستخدم jad
(Java decompiler) لترجمة رمز البايت مرة أخرى إلى شفرة مصدر 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;
ليس سيئا في رأيي.
باستخدام الفئة المولدة
فك التجميع الناجح jad
لا يضمن لنا أي شيء. تنبهك الأداة المساعدة jad
فقط إلى المشاكل الشائعة في البايت كود ، من حجم الإطار إلى عدم التطابق المتغير المحلي أو عبارات الإرجاع المفقودة.
لاستخدام الفئة التي تم إنشاؤها في وقت التشغيل ، نحتاج إلى تحميلها بطريقة أو بأخرى في JVM ومن ثم نسخها.
دعونا ننفذ AsmClassLoader
الخاصة AsmClassLoader
. هذا مجرد غلاف مفيد لـ 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
بالنسبة للبعض ، قد يبدو هذا الاستنتاج غير متوقع: نشير إلى نفس الطريقة (من وجهة نظر جافا) في الفصل ، لكن النتائج تختلف اعتمادًا على الواجهة التي أحضرنا إليها الكائن. مذهل ، أليس كذلك؟
سيصبح كل شيء واضحًا إذا أخذنا في الاعتبار الرمز الثانوي الأساسي. لدعوتنا ، ينشئ المحول البرمجي عبارة INVOKEINTERFACE ، ولا يأتي مقبض الطريقة من الفئة ، ولكن من الواجهة.
وبالتالي ، فإن المكالمة الأولى نحصل عليها:
INVOKEINTERFACE edio/java/experiments/Sm (I)Ljava/lang/String
وفي الثانية:
INVOKEINTERFACE edio/java/experiments/Vm (I)V
يمكن الحصول على الكائن الذي قمنا بإجراء المكالمة عليه من المكدس. هذه هي قوة تعدد الأشكال الكامنة في جافا.
اسمه هو طريقة الجسر
سوف يسأل أحدهم: "ما الفائدة من كل هذا؟ هل ستكون مفيدة في أي وقت؟"
النقطة هي أننا نستخدم نفس الشيء (ضمنيًا) عند كتابة كود 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()
، سيقوم المحول البرمجي INVOKEVIRTUAL
بإنشاء INVOKEVIRTUAL
باستخدام طريقة المقبض ()Ljava/lang/Integer;
، بينما بالنسبة لـ zp.getZero()
، سيتم إنشاء INVOKEINTERFACE باستخدام واصف الأسلوب ()Ljava/lang/Number;
. نحن نعلم بالفعل أن JVM يرسل استدعاء كائن باستخدام الاسم وطريقة الوصف. نظرًا لاختلاف الواصفات ، لا يمكن توجيه هاتين المكالمتين إلى نفس الطريقة في مثيل IntegerZero
.
في الواقع ، يولد المترجم طريقة إضافية تعمل كجسر بين الطريقة الحقيقية المحددة في الفئة والطريقة المستخدمة عند الاتصال من خلال الواجهة. ومن هنا الاسم طريقة الجسر. إذا كان ذلك ممكنًا في Java ، فسيبدو الرمز النهائي كما يلي:
public class IntegerZero implements ZeroProvider { public Integer getZero() { return 0; }
خاتمة
لغة برمجة Java وجهاز Java الافتراضي ليستا نفس الشيء: على الرغم من أن لديهم كلمة مشتركة في الاسم و Java هي اللغة الرئيسية لـ JVM ، فإن قدراتهم وحدودهم ليست بعيدة عن نفسها دائمًا. تساعدك معرفة JVM على فهم Java أو أي لغة أخرى تعتمد على JVM بشكل أفضل ، ولكن من ناحية أخرى ، تساعد معرفة Java وتاريخها على فهم قرارات معينة في تصميم JVM.
من المترجم
تبدأ مشكلات التوافق عاجلاً أم آجلاً في القلق على أي مطور. تطرق المقال الأصلي إلى القضية المهمة للسلوك الضمني لمترجم جافا وتأثير سحره على التطبيقات ، والتي نهتم بها كمطورين لإطار منصة CUBA كثيرًا ، وهذا يؤثر بشكل مباشر على توافق المكتبات. في الآونة الأخيرة ، تحدثنا عن التوافق في التطبيقات الحقيقية في JUG في يكاترينبورغ في تقرير "APIs لا تتغير في العبارة - كيفية بناء API ثابت" ، يمكن العثور على فيديو الاجتماع هنا.