احصل على يديك متسخين باستخدام Scala JVM Bytecode
نشرت: 2022-03-11استمرت لغة Scala في اكتساب شعبية على مدار السنوات العديدة الماضية ، وذلك بفضل مزيجها الممتاز من مبادئ تطوير البرامج الوظيفية والموجهة للكائنات ، وتنفيذها على رأس Java Virtual Machine (JVM).
على الرغم من أن Scala يترجم إلى Java bytecode ، إلا أنه مصمم لتحسين العديد من أوجه القصور الملحوظة في لغة Java. من خلال تقديم دعم برمجة وظيفي كامل ، يحتوي بناء جملة Scala الأساسي على العديد من الهياكل الضمنية التي يجب أن يتم بناؤها بشكل صريح بواسطة مبرمجي Java ، وبعضها ينطوي على تعقيد كبير.
يتطلب إنشاء لغة تترجم إلى Java bytecode فهماً عميقاً للأعمال الداخلية لجهاز Java Virtual Machine. لتقدير ما أنجزه مطورو Scala ، من الضروري الذهاب تحت الغطاء ، واستكشاف كيف يتم تفسير شفرة مصدر Scala بواسطة المترجم لإنتاج كود JVM ثنائي فعال وفعال.
دعونا نلقي نظرة على كيفية تنفيذ كل هذه الأشياء.
المتطلبات الأساسية
تتطلب قراءة هذه المقالة بعض الفهم الأساسي لرمز Java Virtual Machine bytecode. يمكن الحصول على مواصفات الجهاز الظاهري الكاملة من وثائق Oracle الرسمية. قراءة المواصفات بأكملها ليست مهمة لفهم هذه المقالة ، لذلك ، للحصول على مقدمة سريعة للأساسيات ، قمت بإعداد دليل قصير في أسفل المقالة.
هناك حاجة إلى الأداة المساعدة لتفكيك Java bytecode لإعادة إنتاج الأمثلة الواردة أدناه ، والمضي قدمًا في مزيد من التحقيق. توفر Java Development Kit أداة سطر الأوامر الخاصة بها ، javap
، والتي سنستخدمها هنا. يتم تضمين عرض توضيحي سريع لكيفية عمل javap
في الدليل الموجود في الأسفل.
وبالطبع ، يعد التثبيت العملي لمترجم Scala ضروريًا للقراء الذين يرغبون في متابعة الأمثلة. تمت كتابة هذه المقالة باستخدام Scala 2.11.7. قد تنتج إصدارات مختلفة من Scala رمزًا ثانويًا مختلفًا قليلاً.
الحاصلون الافتراضيون والمواقفون
على الرغم من أن اصطلاح Java يوفر دائمًا أساليب getter و setter للسمات العامة ، فإن مبرمجي Java مطالبون بكتابتها بأنفسهم ، على الرغم من حقيقة أن نمط كل منها لم يتغير منذ عقود. على النقيض من ذلك ، يوفر Scala محددات ومحددات افتراضية.
لنلق نظرة على المثال التالي:
class Person(val name:String) { }
دعونا نلقي نظرة داخل Person
الفصل. إذا قمنا بتجميع هذا الملف باستخدام scalac
، فإن تشغيل $ javap -p Person.class
يعطينا:
Compiled from "Person.scala" public class Person { private final java.lang.String name; // field public java.lang.String name(); // getter method public Person(java.lang.String); // constructor }
يمكننا أن نرى أنه لكل حقل في فئة Scala ، يتم إنشاء حقل وطريقة الحصول عليه. الحقل خاص ونهائي ، بينما الطريقة عامة.
إذا استبدلنا val
بـ var
في مصدر Person
وأعدنا التحويل البرمجي ، فسيتم إسقاط المعدل final
للحقل ، وتتم إضافة طريقة setter أيضًا:
Compiled from "Person.scala" public class Person { private java.lang.String name; // field public java.lang.String name(); // getter method public void name_$eq(java.lang.String); // setter method public Person(java.lang.String); // constructor }
إذا تم تحديد أي val
أو var
داخل جسم الفئة ، فسيتم إنشاء طرق المجال الخاص والمدخل المقابل ، وتهيئتهما بشكل مناسب عند إنشاء المثيل.
لاحظ أن مثل هذا التنفيذ لحقول مستوى val
وحقول var
يعني أنه إذا تم استخدام بعض المتغيرات على مستوى الفئة لتخزين القيم الوسيطة ، ولم يتم الوصول إليها مباشرة من قبل المبرمج ، فإن تهيئة كل حقل سيضيف طريقة أو طريقتين إلى بصمة الطبقة. إن إضافة مُعدِّل private
لمثل هذه الحقول لا يعني أنه سيتم إسقاط الموصلات المقابلة. سوف يصبحون فقط خاصين.
تعريفات المتغيرات والوظائف
لنفترض أن لدينا طريقة ، m()
، وأنشئ ثلاثة مراجع مختلفة على غرار Scala لهذه الوظيفة:
class Person(val name:String) { def m(): Int = { // ... return 0 } val m1 = m var m2 = m def m3 = m }
كيف يتم تكوين كل من هذه الإشارات إلى m
؟ متى يتم إعدام m
في كل حالة؟ دعنا نلقي نظرة على الرمز الثانوي الناتج. يُظهر الإخراج التالي نتائج javap -v Person.class
(مع حذف الكثير من المخرجات الزائدة):
Constant pool: #22 = Fieldref #2.#21 // Person.m1:I #24 = Fieldref #2.#23 // Person.m2:I #30 = Methodref #2.#29 // Person.m:()I #35 = Methodref #4.#34 // java/lang/Object."<init>":()V // ... public int m(); Code: // other methods refer to this method // ... public int m1(); Code: // get the value of field m1 and return it 0: aload_0 1: getfield #22 // Field m1:I 4: ireturn public int m2(); Code: // get the value of field m2 and return it 0: aload_0 1: getfield #24 // Field m2:I 4: ireturn public void m2_$eq(int); Code: // get the value of this method's input argument 0: aload_0 1: iload_1 // write it to the field m2 and return 2: putfield #24 // Field m2:I 5: return public int m3(); Code: // execute the instance method m(), and return 0: aload_0 1: invokevirtual #30 // Method m:()I 4: ireturn public Person(java.lang.String); Code: // instance constructor ... // execute the instance method m(), and write the result to field m1 9: aload_0 10: aload_0 11: invokevirtual #30 // Method m:()I 14: putfield #22 // Field m1:I // execute the instance method m(), and write the result to field m2 17: aload_0 18: aload_0 19: invokevirtual #30 // Method m:()I 22: putfield #24 // Field m2:I 25: return
في التجمع الثابت ، نرى أن الإشارة إلى الطريقة m()
مخزنة في الفهرس #30
. في كود المُنشئ ، نرى أنه يتم استدعاء هذه الطريقة مرتين أثناء التهيئة ، مع ظهور التعليمات invokevirtual #30
أولاً عند إزاحة البايت 11 ، ثم عند الإزاحة 19. يتبع الاستدعاء الأول التعليمة putfield #22
التي تعين نتيجة هذه الطريقة للحقل m1
، المشار إليه بواسطة الفهرس #22
في التجمع الثابت. الاستدعاء الثاني يتبعه نفس النمط ، هذه المرة يعين القيمة للحقل m2
، مفهرس عند #24
في التجمع الثابت.
بعبارة أخرى ، فإن تعيين طريقة إلى متغير محدد باستخدام val
أو var
يعين فقط نتيجة الطريقة إلى ذلك المتغير. يمكننا أن نرى أن الطريقتين m1()
و m2()
اللتين تم إنشاؤهما هما مجرد حروف لهذه المتغيرات. في حالة var m2
، نرى أيضًا أنه تم إنشاء setter m2_$eq(int)
، والتي تتصرف تمامًا مثل أي واضع آخر ، حيث تقوم بالكتابة فوق القيمة الموجودة في الحقل.
ومع ذلك ، فإن استخدام الكلمة الأساسية def
يعطي نتيجة مختلفة. بدلاً من إحضار قيمة حقل لإرجاعها ، تتضمن الطريقة m3()
أيضًا التعليمات invokevirtual #30
. أي أنه في كل مرة يتم استدعاء هذه الطريقة ، فإنها تستدعي m()
، وتعيد نتيجة هذه الطريقة.
لذلك ، كما نرى ، يوفر Scala ثلاث طرق للعمل مع حقول الفصل ، ويمكن تحديدها بسهولة عبر الكلمات الرئيسية val
و var
و def
. في Java ، سيتعين علينا تنفيذ المحددات والمعرفات الضرورية بشكل صريح ، وستكون هذه الشفرة المعيارية المكتوبة يدويًا أقل تعبيرًا وأكثر عرضة للخطأ.
قيم كسولة
يتم إنتاج كود أكثر تعقيدًا عند الإعلان عن قيمة كسولة. افترض أننا أضفنا الحقل التالي إلى الفئة المحددة مسبقًا:
lazy val m4 = m
سيؤدي تشغيل javap -p -v Person.class
الآن إلى إظهار ما يلي:
Constant pool: #20 = Fieldref #2.#19 // Person.bitmap$0:Z #23 = Methodref #2.#22 // Person.m:()I #25 = Fieldref #2.#24 // Person.m4:I #31 = Fieldref #27.#30 // scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; #48 = Methodref #2.#47 // Person.m4$lzycompute:()I // ... private volatile boolean bitmap$0; private int m4$lzycompute(); Code: // lock the thread 0: aload_0 1: dup 2: astore_1 3: monitorenter // check the flag for whether this field has already been set 4: aload_0 5: getfield #20 // Field bitmap$0:Z // if it has, skip to position 24 (unlock the thread and return) 8: ifne 24 // if it hasn't, execute the method m() 11: aload_0 12: aload_0 13: invokevirtual #23 // Method m:()I // write the method to the field m4 16: putfield #25 // Field m4:I // set the flag indicating the field has been set 19: aload_0 20: iconst_1 21: putfield #20 // Field bitmap$0:Z // unlock the thread 24: getstatic #31 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 27: pop 28: aload_1 29: monitorexit // get the value of field m4 and return it 30: aload_0 31: getfield #25 // Field m4:I 34: ireturn // ... public int m4(); Code: // check the flag for whether this field has already been set 0: aload_0 1: getfield #20 // Field bitmap$0:Z // if it hasn't, skip to position 14 (invoke lazy method and return) 4: ifeq 14 // if it has, get the value of field m4, then skip to position 18 (return) 7: aload_0 8: getfield #25 // Field m4:I 11: goto 18 // execute the method m4$lzycompute() to set the field 14: aload_0 15: invokespecial #48 // Method m4$lzycompute:()I // return 18: ireturn
في هذه الحالة ، لا يتم حساب قيمة الحقل m4
حتى يتم الاحتياج إليه. الطريقة الخاصة والخاصة m4$lzycompute()
يتم إنتاجها لحساب القيمة البطيئة ، bitmap$0
لتتبع حالتها. تتحقق الطريقة m4()
مما إذا كانت قيمة هذا الحقل هي 0 ، مما يشير إلى أن m4
لم تتم تهيئته بعد ، وفي هذه الحالة يتم استدعاء m4$lzycompute()
، وملء m4
وإرجاع قيمته. تقوم هذه الطريقة الخاصة أيضًا بتعيين قيمة bitmap$0
إلى 1 ، بحيث في المرة التالية التي يتم فيها استدعاء m4()
، ستتخطى استدعاء طريقة التهيئة ، وبدلاً من ذلك تقوم ببساطة بإرجاع قيمة m4
.
تم تصميم bytecode Scala الذي ينتج هنا ليكون مؤشر الترابط آمنًا وفعالًا. لكي تكون مؤشر الترابط آمنًا ، تستخدم طريقة الحساب monitorexit
زوج من التعليمات الخاصة بمركز monitorenter
/ جهاز العرض. تظل الطريقة فعالة نظرًا لأن الحمل الزائد في الأداء لهذه المزامنة يحدث فقط في القراءة الأولى للقيمة البطيئة.
هناك حاجة إلى بت واحد فقط للإشارة إلى حالة القيمة البطيئة. لذلك إذا لم يكن هناك أكثر من 32 قيمة كسولة ، فيمكن لحقل int واحد تتبعها جميعًا. إذا تم تحديد أكثر من قيمة كسولة في الكود المصدري ، فسيتم تعديل الرمز الثانوي أعلاه بواسطة المترجم لتنفيذ قناع بت لهذا الغرض.
مرة أخرى ، يتيح لنا Scala الاستفادة بسهولة من نوع معين من السلوك الذي يجب تنفيذه بشكل صريح في Java ، مما يوفر الجهد ويقلل من مخاطر الأخطاء المطبعية.
الوظيفة كقيمة
الآن دعنا نلقي نظرة على كود مصدر Scala التالي:
class Printer(val output: String => Unit) { } object Hello { def main(arg: Array[String]) { val printer = new Printer( s => println(s) ); printer.output("Hello"); } }
تحتوي فئة Printer
على حقل واحد ، output
، بالنوع String => Unit
: وظيفة تأخذ String
وتعيد كائنًا من النوع Unit
(على غرار void
في Java). في الطريقة الرئيسية ، نقوم بإنشاء أحد هذه الكائنات ، ونخصص هذا الحقل ليكون وظيفة مجهولة تطبع سلسلة معينة.
يؤدي تجميع هذا الرمز إلى إنشاء أربعة ملفات فئة:
Hello.class
عبارة عن فئة مجمعة تستدعي طريقتها الرئيسية ببساطة Hello$.main()
:
public final class Hello // ... public static void main(java.lang.String[]); Code: 0: getstatic #16 // Field Hello$.MODULE$:LHello$; 3: aload_0 4: invokevirtual #18 // Method Hello$.main:([Ljava/lang/String;)V 7: return
تحتوي فئة Hello$.class
المخفية على التنفيذ الحقيقي للطريقة الرئيسية. لإلقاء نظرة على الرمز الثانوي الخاص به ، تأكد من هروب $
بشكل صحيح وفقًا لقواعد غلاف الأوامر ، لتجنب تفسيره كحرف خاص:
public final class Hello$ // ... public void main(java.lang.String[]); Code: // initialize Printer and anonymous function 0: new #16 // class Printer 3: dup 4: new #18 // class Hello$$anonfun$1 7: dup 8: invokespecial #19 // Method Hello$$anonfun$1."<init>":()V 11: invokespecial #22 // Method Printer."<init>":(Lscala/Function1;)V 14: astore_2 // load the anonymous function onto the stack 15: aload_2 16: invokevirtual #26 // Method Printer.output:()Lscala/Function1; // execute the anonymous function, passing the string "Hello" 19: ldc #28 // String Hello 21: invokeinterface #34, 2 // InterfaceMethod scala/Function1.apply:(Ljava/lang/Object;)Ljava/lang/Object; // return 26: pop 27: return
الطريقة تخلق Printer
. ثم يقوم بإنشاء Hello$$anonfun$1
، والذي يحتوي على وظيفتنا المجهولة s => println(s)
. Printer
مهيأة مع هذا الكائن كحقل output
. يتم تحميل هذا الحقل بعد ذلك في المكدس ، ويتم تنفيذه باستخدام المعامل "Hello"
.
دعنا نلقي نظرة على فئة الوظيفة المجهولة ، فئة Hello$$anonfun$1.class
، أدناه. يمكننا أن نرى أنه يوسع Function1
Scala1 (مثل AbstractFunction1
) من خلال تنفيذ طريقة apply()
. في الواقع ، يُنشئ طريقتان apply()
، إحداهما تغلف الأخرى ، والتي تقوم معًا بفحص النوع (في هذه الحالة ، أن الإدخال عبارة عن String
) ، وتنفذ الوظيفة المجهولة (طباعة الإدخال باستخدام println()
).
public final class Hello$$anonfun$1 extends scala.runtime.AbstractFunction1<java.lang.String, scala.runtime.BoxedUnit> implements scala.Serializable // ... // Takes an argument of type String. Invoked second. public final void apply(java.lang.String); Code: // execute Scala's built-in method println(), passing the input argument 0: getstatic #25 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: aload_1 4: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 7: return // Takes an argument of type Object. Invoked first. public final java.lang.Object apply(java.lang.Object); Code: 0: aload_0 // check that the input argument is a String (throws exception if not) 1: aload_1 2: checkcast #36 // class java/lang/String // invoke the method apply( String ), passing the input argument 5: invokevirtual #38 // Method apply:(Ljava/lang/String;)V // return the void type 8: getstatic #44 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 11: areturn
إذا نظرنا إلى الوراء في طريقة Hello$.main()
أعلاه ، يمكننا أن نرى أنه في الإزاحة 21 ، يتم تشغيل تنفيذ الوظيفة المجهولة عن طريق استدعاء أسلوب apply( Object )
الخاص بها.
أخيرًا ، من أجل الاكتمال ، دعنا نلقي نظرة على الرمز الثانوي لفئة Printer.class
:
public class Printer // ... // field private final scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output; // field getter public scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output(); Code: 0: aload_0 1: getfield #14 // Field output:Lscala/Function1; 4: areturn // constructor public Printer(scala.Function1<java.lang.String, scala.runtime.BoxedUnit>); Code: 0: aload_0 1: aload_1 2: putfield #14 // Field output:Lscala/Function1; 5: aload_0 6: invokespecial #21 // Method java/lang/Object."<init>":()V 9: return
يمكننا أن نرى أن الوظيفة المجهولة هنا تعامل تمامًا مثل أي متغير val
. يتم تخزينه في output
حقل الفصل ، ويتم إنشاء output()
. الاختلاف الوحيد هو أن هذا المتغير يجب أن يقوم الآن بتطبيق scala interface scala.Function1
(الذي يقوم به AbstractFunction1
).
لذلك ، فإن تكلفة ميزة Scala الأنيقة هذه هي فئات المرافق الأساسية ، التي تم إنشاؤها لتمثيل وتنفيذ وظيفة مجهولة واحدة يمكن استخدامها كقيمة. يجب أن تأخذ في الاعتبار عدد هذه الوظائف ، بالإضافة إلى تفاصيل تنفيذ VM الخاص بك ، لمعرفة ما يعنيه تطبيقك الخاص.
سمات سكالا
تشبه سمات Scala الواجهات في Java. تحدد السمة التالية توقيعين للطريقة ، وتوفر تطبيقًا افتراضيًا للتوقيع الثاني. دعونا نرى كيف يتم تنفيذه:
trait Similarity { def isSimilar(x: Any): Boolean def isNotSimilar(x: Any): Boolean = !isSimilar(x) }
يتم إنتاج كيانين: "مماثل" ، الواجهة التي تعلن عن كلا الطريقتين ، والصنف التركيبي ، " Similarity.class
Similarity$class.class
، مما يوفر التنفيذ الافتراضي:
public interface Similarity { public abstract boolean isSimilar(java.lang.Object); public abstract boolean isNotSimilar(java.lang.Object); }
public abstract class Similarity$class public static boolean isNotSimilar(Similarity, java.lang.Object); Code: 0: aload_0 // execute the instance method isSimilar() 1: aload_1 2: invokeinterface #13, 2 // InterfaceMethod Similarity.isSimilar:(Ljava/lang/Object;)Z // if the returned value is 0, skip to position 14 (return with value 1) 7: ifeq 14 // otherwise, return with value 0 10: iconst_0 11: goto 15 // return the value 1 14: iconst_1 15: ireturn public static void $init$(Similarity); Code: 0: return
عندما تنفذ فئة هذه السمة وتستدعي الطريقة isNotSimilar
، يقوم مترجم Scala بإنشاء تعليمات invokestatic
لاستدعاء الطريقة الثابتة التي توفرها الفئة المصاحبة.
يمكن إنشاء تعدد الأشكال المعقدة وهياكل الوراثة من السمات. على سبيل المثال ، قد تتجاوز السمات المتعددة ، بالإضافة إلى فئة التنفيذ ، طريقة لها نفس التوقيع ، super.methodName()
لتمرير التحكم إلى السمة التالية. عندما يواجه مترجم Scala مثل هذه المكالمات ، فإنه:
- تحدد السمة الدقيقة التي تفترضها هذه المكالمة.
- يحدد اسم الفئة المصاحبة التي توفر طريقة ثابتة للرمز الثانوي معرّف للسمة.
- ينتج التعليمات
invokestatic
الضرورية.
وبالتالي يمكننا أن نرى أن المفهوم القوي للسمات يتم تنفيذه على مستوى JVM بطريقة لا تؤدي إلى زيادة كبيرة ، وقد يستمتع مبرمجو Scala بهذه الميزة دون القلق من أنها ستكون باهظة الثمن في وقت التشغيل.

الفردي
يوفر Scala تعريفًا صريحًا للفئات الفردية باستخدام object
الكلمة الأساسية. دعونا ننظر في الفصل الفردي التالي:
object Config { val home_dir = "/home/user" }
ينتج المترجم ملفي فئة:
Config.class
بسيط جدًا:
public final class Config public static java.lang.String home_dir(); Code: // execute the method Config$.home_dir() 0: getstatic #16 // Field Config$.MODULE$:LConfig$; 3: invokevirtual #18 // Method Config$.home_dir:()Ljava/lang/String; 6: areturn
هذا مجرد مصمم لفئة Config$
الاصطناعية التي تضم وظائف الفردي. يؤدي فحص هذه الفئة باستخدام javap -p -c
إلى إنتاج الرمز الثانوي التالي:
public final class Config$ public static final Config$ MODULE$; // a public reference to the singleton object private final java.lang.String home_dir; // static initializer public static {}; Code: 0: new #2 // class Config$ 3: invokespecial #12 // Method "<init>":()V 6: return public java.lang.String home_dir(); Code: // get the value of field home_dir and return it 0: aload_0 1: getfield #17 // Field home_dir:Ljava/lang/String; 4: areturn private Config$(); Code: // initialize the object 0: aload_0 1: invokespecial #19 // Method java/lang/Object."<init>":()V // expose a public reference to this object in the synthetic variable MODULE$ 4: aload_0 5: putstatic #21 // Field MODULE$:LConfig$; // load the value "/home/user" and write it to the field home_dir 8: aload_0 9: ldc #23 // String /home/user 11: putfield #17 // Field home_dir:Ljava/lang/String; 14: return
وتتكون مما يلي:
- المتغير التركيبي
MODULE$
، والذي من خلاله تصل الكائنات الأخرى إلى هذا الكائن المفرد. - المُهيئ الثابت
{}
(المعروف أيضًا باسم<clinit>
، مُهيئ الفئة) والطريقة الخاصةConfig$
، تُستخدم لتهيئةMODULE$
وتعيين حقولها على القيم الافتراضية - طريقة getter للحقل الثابت
home_dir
. في هذه الحالة ، إنها طريقة واحدة فقط. إذا كان المفرد يحتوي على المزيد من الحقول ، فسيكون لديه المزيد من الحاصل ، بالإضافة إلى أدوات ضبط الحقول القابلة للتغيير.
المفرد هو نمط تصميم شائع ومفيد. لا توفر لغة Java طريقة مباشرة لتحديدها على مستوى اللغة ؛ بدلاً من ذلك ، تقع على عاتق المطور مسؤولية تنفيذه في Java source. من ناحية أخرى ، يوفر Scala طريقة واضحة وملائمة للإعلان عن مفردة صراحة باستخدام الكلمة الأساسية object
. كما نرى تحت الغطاء ، يتم تنفيذه بطريقة طبيعية وبأسعار معقولة.
خاتمة
لقد رأينا الآن كيف يقوم Scala بتجميع العديد من ميزات البرمجة الضمنية والوظيفية في هياكل Java bytecode المتطورة. من خلال هذه اللمحة في الأعمال الداخلية لـ Scala ، يمكننا الحصول على تقدير أعمق لقوة Scala ، مما يساعدنا في الحصول على أقصى استفادة من هذه اللغة القوية.
لدينا الآن أيضًا الأدوات لاستكشاف اللغة بأنفسنا. هناك العديد من الميزات المفيدة في بناء جملة Scala التي لم يتم تناولها في هذه المقالة ، مثل فئات الحالة ، والكاري ، وقائمة الفهم. أنا أشجعك على التحقيق في تنفيذ سكالا لهذه الهياكل بنفسك ، حتى تتمكن من تعلم كيف تكون نينجا سكالا من المستوى التالي!
آلة جافا الافتراضية: دورة مكثفة
تمامًا مثل مترجم Java ، يقوم مترجم Scala بتحويل شفرة المصدر إلى ملفات .class
. ، والتي تحتوي على Java bytecode ليتم تنفيذها بواسطة Java Virtual Machine. من أجل فهم كيفية اختلاف اللغتين تحت الغطاء ، من الضروري فهم النظام الذي تستهدفه كلتا اللغتين. هنا ، نقدم نظرة عامة موجزة عن بعض العناصر الرئيسية في بنية Java Virtual Machine ، وهيكل ملف الفئة ، وأساسيات المُجمِّع.
لاحظ أن هذا الدليل سيغطي فقط الحد الأدنى لتمكين المتابعة مع المقالة أعلاه. على الرغم من عدم مناقشة العديد من المكونات الرئيسية لـ JVM هنا ، يمكن العثور على التفاصيل الكاملة في المستندات الرسمية هنا.
فك ملفات الفصل الدراسي باستخدام
javap
تجمع ثابت
جداول الحقول والطريقة
JVM بايت كود
طريقة المكالمات ومكدس المكالمات
التنفيذ على Operand Stack
المتغيرات المحلية
العودة للقمة
فك ملفات الفصل الدراسي باستخدام javap
يتم شحن Java مع الأداة المساعدة لسطر الأوامر javap
، والتي تقوم بفك تجميع ملفات .class
إلى نموذج يمكن قراءته بواسطة الإنسان. نظرًا لأن كلا من ملفات فئة Scala و Java تستهدف نفس JVM ، يمكن استخدام javap
لفحص ملفات الفئة التي تم تجميعها بواسطة Scala.
لنقم بتجميع الكود المصدري التالي:
// RegularPolygon.scala class RegularPolygon( val numSides: Int ) { def getPerimeter( sideLength: Double ): Double = { println( "Calculating perimeter..." ) return sideLength * this.numSides } }
سيؤدي تجميع هذا باستخدام scalac RegularPolygon.scala
إلى إنتاج RegularPolygon.class
. إذا قمنا بتشغيل javap RegularPolygon.class
ما يلي:
$ javap RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }
هذا تحليل بسيط للغاية لملف الفصل الذي يعرض ببساطة أسماء وأنواع أعضاء الفصل العام. ستشمل إضافة الخيار -p
الأعضاء الخاصين:
$ javap -p RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }
هذا لا يزال ليس الكثير من المعلومات. لمعرفة كيفية تنفيذ الأساليب في Java bytecode ، دعنا نضيف الخيار -c
:
$ javap -p -c RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); Code: 0: aload_0 1: getfield #13 // Field numSides:I 4: ireturn public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn public RegularPolygon(int); Code: 0: aload_0 1: iload_1 2: putfield #13 // Field numSides:I 5: aload_0 6: invokespecial #38 // Method java/lang/Object."<init>":()V 9: return }
هذا أكثر إثارة للاهتمام. ومع ذلك ، للحصول على القصة كاملة حقًا ، يجب أن نستخدم الخيار -v
أو -verbose
، كما هو الحال في javap -p -v RegularPolygon.class
:
هنا نرى أخيرًا ما هو موجود بالفعل في ملف الفصل. ماذا يعني كل هذا؟ دعنا نلقي نظرة على بعض أهم الأجزاء.
تجمع ثابت
تتضمن دورة تطوير تطبيقات C ++ مراحل التجميع والربط. تتخطى دورة تطوير Java مرحلة الربط الصريح لأن الارتباط يحدث في وقت التشغيل. يجب أن يدعم ملف الفصل الدراسي ربط وقت التشغيل هذا. هذا يعني أنه عندما تشير التعليمات البرمجية المصدر إلى أي حقل أو طريقة ، يجب أن تحتفظ الشفرة الثانوية الناتجة بالمراجع ذات الصلة في شكل رمزي ، وتكون جاهزة للتراجع عنها بمجرد تحميل التطبيق في الذاكرة ويمكن حل العناوين الفعلية بواسطة رابط وقت التشغيل. يجب أن يحتوي هذا النموذج الرمزي على:
- اسم الفصل
- اسم الحقل أو الطريقة
- اكتب المعلومات
تتضمن مواصفات تنسيق ملف الفئة قسمًا من الملف يسمى التجمع الثابت ، وهو جدول يضم جميع المراجع التي يحتاجها الرابط. يحتوي على مداخل من أنواع مختلفة.
// ... Constant pool: #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #3 = Utf8 java/lang/Object #4 = Class #3 // java/lang/Object // ...
البايت الأول لكل إدخال هو علامة رقمية تشير إلى نوع الإدخال. توفر وحدات البايت المتبقية معلومات حول قيمة الإدخال. يعتمد عدد البايتات وقواعد تفسيرها على النوع الذي يشير إليه البايت الأول.
على سبيل المثال ، قد تحتوي فئة Java التي تستخدم عددًا صحيحًا ثابتًا 365
على إدخال مجمع ثابت مع الرمز الثانوي التالي:
x03 00 00 01 6D
يحدد البايت الأول ، x03
، نوع الإدخال ، CONSTANT_Integer
. هذا يخبر الرابط أن الأربعة بايت التالية تحتوي على قيمة العدد الصحيح. (لاحظ أن 365 في النظام الست عشري هو x16D
). إذا كان هذا هو الإدخال الرابع عشر في التجمع الثابت ، javap -v
على النحو التالي:
#14 = Integer 365
تتكون العديد من الأنواع الثابتة من إشارات إلى أنواع ثابتة أكثر "بدائية" في مكان آخر في التجمع الثابت. على سبيل المثال ، يحتوي رمز المثال الخاص بنا على العبارة:
println( "Calculating perimeter..." )
سيؤدي استخدام ثابت السلسلة إلى إدخالين في التجمع الثابت: إدخال واحد من النوع CONSTANT_String
، وإدخال آخر من النوع CONSTANT_Utf8
. يحتوي إدخال النوع Constant_UTF8
على تمثيل UTF8 الفعلي لقيمة السلسلة. يحتوي إدخال النوع CONSTANT_String
على مرجع إلى الإدخال CONSTANT_Utf8
:
#24 = Utf8 Calculating perimeter... #25 = String #24 // Calculating perimeter...
يعد هذا التعقيد ضروريًا نظرًا لوجود أنواع أخرى من إدخالات التجمع الثابت التي تشير إلى إدخالات من النوع Utf8
وليست إدخالات من النوع String
. على سبيل المثال ، أي مرجع إلى سمة فئة سينتج نوع CONSTANT_Fieldref
، والذي يحتوي على سلسلة من المراجع إلى اسم الفئة واسم السمة ونوع السمة:
#1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #9 = Utf8 numSides #10 = Utf8 I #12 = NameAndType #9:#10 // numSides:I #13 = Fieldref #2.#12 // RegularPolygon.numSides:I
لمزيد من التفاصيل حول التجمع الثابت ، راجع وثائق JVM.
جداول الحقول والطريقة
يحتوي ملف الفصل على جدول حقل يحتوي على معلومات حول كل حقل (أي سمة) محدد في الفصل. هذه مراجع لإدخالات المجموعة الثابتة التي تصف اسم الحقل ونوعه بالإضافة إلى إشارات التحكم في الوصول والبيانات الأخرى ذات الصلة.
يوجد جدول طريقة مماثل في ملف الفصل. ومع ذلك ، بالإضافة إلى معلومات الاسم والنوع ، لكل طريقة غير مجردة ، فإنها تحتوي على تعليمات الرمز الثانوي الفعلية التي سيتم تنفيذها بواسطة JVM ، بالإضافة إلى هياكل البيانات المستخدمة بواسطة إطار مكدس الطريقة ، الموصوف أدناه.
JVM بايت كود
يستخدم JVM مجموعة التعليمات الداخلية الخاصة به لتنفيذ التعليمات البرمجية المترجمة. تشغيل javap
مع الخيار -c
يتضمن عمليات تنفيذ الطريقة المترجمة في المخرجات. إذا فحصنا ملف RegularPolygon.class
بهذه الطريقة ، فسنرى الإخراج التالي getPerimeter()
الخاصة بنا:
public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn
قد يبدو الرمز الثانوي الفعلي على النحو التالي:
xB2 00 17 x12 19 xB6 00 1D x27 ...
تبدأ كل تعليمات برمز تشغيل أحادي البايت يحدد تعليمات JVM ، متبوعًا بصفر أو أكثر من معاملات التعليمات التي سيتم تشغيلها ، اعتمادًا على تنسيق التعليمات المحددة. تكون هذه عادةً إما قيمًا ثابتة أو مراجع في التجمع الثابت. javap
مفيد يترجم الرمز الثانوي إلى نموذج يمكن قراءته بواسطة الإنسان يعرض:
- الإزاحة ، أو موضع البايت الأول للتعليمات داخل الكود.
- الاسم الذي يقرأه الإنسان ، أو ذاكري ، للتعليمات.
- قيمة المعامل إن وجدت.
المعاملات التي يتم عرضها بعلامة الجنيه ، مثل #23
، هي إشارات إلى الإدخالات في التجمع الثابت. كما نرى ، ينتج javap
أيضًا تعليقات مفيدة في الإخراج ، مع تحديد ما يتم الرجوع إليه بالضبط من التجمع.
سنناقش بعض الإرشادات الشائعة أدناه. للحصول على معلومات مفصلة حول مجموعة تعليمات JVM الكاملة ، راجع الوثائق.
طريقة المكالمات ومكدس المكالمات
يجب أن يكون كل استدعاء طريقة قادرًا على العمل مع سياقه الخاص ، والذي يتضمن أشياء مثل المتغيرات المعلنة محليًا ، أو الوسائط التي تم تمريرها إلى الطريقة. معًا ، يشكل هؤلاء إطارًا مكدسًا . عند استدعاء طريقة ، يتم إنشاء إطار جديد ووضعه أعلى مكدس الاستدعاءات . عندما تعود الطريقة ، تتم إزالة الإطار الحالي من مكدس الاستدعاءات ويتم التخلص منه ، ويتم استعادة الإطار الذي كان ساري المفعول قبل استدعاء الطريقة.
يشتمل إطار المكدس على عدد قليل من الهياكل المميزة. اثنان مهمان هما حزمة المعامل وجدول المتغيرات المحلية ، والتي ستتم مناقشتها لاحقًا.
التنفيذ على Operand Stack
تعمل العديد من تعليمات JVM على مكدس معاملات الإطار الخاص بهم. بدلاً من تحديد معامل ثابت بشكل صريح في الرمز الثانوي ، تأخذ هذه التعليمات بدلاً من ذلك القيم الموجودة في الجزء العلوي من مكدس المعامل كمدخلات. عادة ، يتم إزالة هذه القيم من المكدس في العملية. تضع بعض الإرشادات أيضًا قيمًا جديدة أعلى المكدس. بهذه الطريقة ، يمكن دمج تعليمات JVM لإجراء عمليات معقدة. على سبيل المثال ، التعبير:
sideLength * this.numSides
يتم تجميعها إلى ما يلي في طريقة getPerimeter()
بنا:
8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul
- تقوم التعليمة الأولى ،
dload_1
، بدفع مرجع الكائن من الفتحة 1 لجدول المتغير المحلي (الذي تمت مناقشته لاحقًا) إلى حزمة المعامل. في هذه الحالة ، هذه هي وسيطة الأسلوبsideLength
. - التعليمة التالية ،aload_0
، تدفع مرجع الكائن في الفتحة 0 من جدول المتغير المحلي إلى حزمة المعامل. في الممارسة العملية ، هذا هو دائمًا ما يشير إلىthis
، الفصل الحالي. - يقوم هذا بإعداد المكدس للمكالمة التالية ،
invokevirtual #31
، والتي تنفذ طريقة المثيلnumSides()
. ينبثقinvokevirtual
المعامل العلوي (الإشارة إلىthis
) من المكدس لتحديد من أي فئة يجب أن يستدعي الطريقة. بمجرد عودة الطريقة ، يتم دفع نتيجتها إلى المكدس. - في هذه الحالة ، تكون القيمة التي تم إرجاعها (
numSides
) بتنسيق عدد صحيح. يجب تحويلها إلى تنسيق مزدوج للفاصلة العائمة لمضاعفتها بقيمة مزدوجة أخرى. تقوم التعليماتi2d
قيمة العدد الصحيح من المكدس ، وتحويلها إلى تنسيق النقطة العائمة ، وتدفعها مرة أخرى إلى المكدس. - عند هذه النقطة ، يحتوي المكدس على نتيجة الفاصلة العائمة لـ
this.numSides
في الأعلى ، متبوعة بقيمة وسيطةsideLength
التي تم تمريرها إلى الطريقة. ينبثقdmul
هاتين القيمتين العلويتين من المكدس ، ويقوم بضرب الفاصلة العائمة عليهما ، ويدفع النتيجة إلى المكدس.
عندما يتم استدعاء طريقة ، يتم إنشاء مكدس معاملات جديد كجزء من إطار المكدس ، حيث سيتم تنفيذ العمليات. We must be careful with terminology here: the word “stack” may refer to the call stack , the stack of frames providing context for method execution, or to a particular frame's operand stack , upon which JVM instructions operate.
Local Variables
Each stack frame keeps a table of local variables . This typically includes a reference to this
object, any arguments that were passed when the method was called, and any local variables declared within the method body. Running javap
with the -v
option will include information about how each method's stack frame should be set up, including its local variable table:
public double getPerimeter(double); // ... Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... // ... LocalVariableTable: Start Length Slot Name Signature 0 16 0 this LRegularPolygon; 0 16 1 sideLength D
In this example, there are two local variables. The variable in slot 0 is named this
, with the type RegularPolygon
. This is the reference to the method's own class. The variable in slot 1 is named sideLength
, with the type D
(indicating a double). This is the argument that is passed to our getPerimeter()
method.
Instructions such as iload_1
, fstore_2
, or aload [n]
, transfer different types of local variables between the operand stack and the local variable table. Since the first item in the table is usually the reference to this
, the instruction aload_0
is commonly seen in any method that operates on its own class.
This concludes our walkthrough of JVM basics.