الأصناف الآباء
يمكنك اختيار أصدقائك لكنك لا تستطيع اختيار عائلتك، فهم يظلّون من دمك سواء اعترفتَ بهم أم لا، ويجعلونك تبدو ساذجاً جداً حين لا تعترف بذلك.
هاربر لي، قتل العصفور
هذا هو الفصل الأخير الذي نضيف فيه وظيفة جديدة إلى آلتنا الافتراضية. لقد حشرنا من.language Lox كاملةً تقريباً فيها بالفعل. كل ما بقي هو وراثة الدوال واستدعاء دوال الصنف الأب. ولدينا فصل آخر بعد هذا، لكنه لا يقدّم أي سلوك جديد. إنه فقط يجعل الأشياء الموجودة أسرع. اصمد حتى نهاية هذا الفصل، وستكون لديك تنفيذ كامل لـ Lox.
سيذكّرك بعض مادة هذا الفصل بـ jlox. فنحن نحلّ استدعاءات super بالطريقة نفسها إلى حدٍّ كبير، لكن من منظور آلية clox الأكثر تعقيداً لتخزين الحالة على المكدّس. لكن لدينا هذه المرة طريقة مختلفة تماماً وأسرع بكثير للتعامل مع استدعاءات الدوال الموروثة.
وراثة الدوال
سنبدأ بوراثة الدوال لأنها الجزء الأبسط. ولِتُذكّر ذاكرتك، تبدو صياغة الوراثة في Lox هكذا:
class Doughnut {
cook() {
print "Dunk in the fryer.";
}
}
class Cruller < Doughnut {
finish() {
print "Glaze with icing.";
}
}
هنا، يرث صنف Cruller من Doughnut، وبالتالي ترث نسخ Cruller دالة cook(). ولا
أعرف لماذا أطيل في هذا. أنت تعرف كيف تعمل الوراثة. لنبدأ بتصريف الصياغة الجديدة.
^code compile-superclass (2 before, 1 after)
بعد أن نصرّف اسم الصنف، إذا كان الرمز التالي هو <، فقد وجدنا بند صنف أب. عندئذٍ
نستهلك رمز مُعرِّف الصنف الأب، ثم نستدعي variable(). تأخذ تلك الدالة الرمز الذي
استهلكناه للتوّ، وتعامله كإشارة إلى متغيّر، وتُصدر شيفرة لتحميل قيمة المتغيّر. بمعنى
آخر، هي تبحث عن الصنف الأب بالاسم وتدفعه على المكدّس.
بعد ذلك، نستدعي namedVariable() لتحميل الصنف الفرعي الوارث onto المكدّس، يليها
تعليمة OP_INHERIT. تربط تلك التعليمة الصنف أب بالصنف الفرعي الجديد. وفي الفصل
الأخير، عرّفنا تعليمة OP_METHOD لتعديل كائن صنف قائم بإضافة دالة إلى جدول
دواله. هذا مشابه -- فتعليمة OP_INHERIT تأخذ صنفاً قائماً وتُطبّق عليه أثر
الوراثة.
في المثال السابق، حين يمرّ المصرّف في هذا الجزء من الصياغة:
class Cruller < Doughnut {
تكون النتيجة هي شيفرة البايت هذه:
قبل أن ننفّذ تعليمة OP_INHERIT الجديدة، لدينا حالة حدّية يجب اكتشافها.
^code inherit-self (1 before, 1 after)
لا يمكن لصنف أن يكون صنفه الأب. Unless ما لم يكن لديك وصول إلى فيزيائي نووي مهووس ونواة ديلورين معدّلة تعديلاً جوهرياً، فلا يمكنك أن ترث من نفسك.
تنفيذ الوراثة
الآن إلى التعليمة الجديدة.
^code inherit-op (1 before, 1 after)
لا معاملات علينا القلق بشأنها. فالقيمتان اللتان نحتاجهما -- الصنف الأب والصنف الفرعي -- موجودتان كلتاهما على المكدّس. وهذا يعني أن التفكيك سهل.
^code disassemble-inherit (1 before, 1 after)
المفسّر هو مسرح الأحداث.
^code interpret-inherit (1 before, 1 after)
من قمة المكدّس نزولاً لدينا الصنف الفرعي ثم الصنف الأب. نأخذ كليهما ثم نقوم بجزء «الوراثة». وهنا يأخذ clox مساراً مختلفاً عن jlox. في مفسّرنا الأول، كان كل صنف فرعي يخزّن مرجعاً إلى صنفه الأب. وعند الوصول إلى دالة، إن لم نجد الدالة في جدول دوال الصنف الفرعي، كنا نتعاود عبر سلسلة الوراثة نافذين جدول دوال كل سلف حتى نجدها.
مثلاً، استدعاء cook() على نسخة من Cruller يرسل jlox في هذه الرحلة:
هذا قدر كبير من العمل يُؤدّى في وقت استدعاء الدالة. إنه بطيء، والأسوأ أن كلما كانت الدالة الموروثة أبعد في سلسلة الأسلاف، ازداد البطء. ليست قصة أداء حميدة.
النهج الجديد أسرع بكثير. فحين يُعلن الصنف الفرعي، فإننا ننسخ كل دوال الصنف الموروث نزلاً إلى جدول دوال الصنف الفرعي نفسه. لاحقاً، عند استدعاء دالة، ستُوجد أي دالة موروثة من صنف أب مباشرةً في جدول دوال الصنف الفرعي نفسه. لا يلزم أي عمل زمني إضافي للوراثة على الإطلاق. فعند إعلان الصنف يكون العمل قد أُنجز. وهذا يعني أن استدعاءات الدوال الموروثة سريعة تماماً كاستدعاءات الدوال العادية -- بحث واحد في جدول تجزئة.
سمعتُ أحياناً بهذه التقنية مسمّاة «الوراثة بالنسخ النازل». إنها بسيطة وسريعة، لكن، مثل معظم تحسينات الأداء، لا يمكنك استخدامها إلا في قيود معيّنة. فهي تعمل في Lox لأن أصناف Lox مغلقة. فبعد انتهاء تنفيذ إعلان الصنف، لا يمكن لمجموعة دوال ذلك الصنف أن تتغيّر أبداً.
في لغات مثل Ruby وPython وJavaScript، من الممكن أن تفتح صنفاً قائماً وتدسّ بداخله دوالاً جديدة أو حتى تزيل منها. وكان ذلك سيكسر تحسيننا، لأن تلك التعديلات إن وقعت على صنف أب بعد تنفيذ إعلان الصنف الفرعي، فلن يلتقط الصنف الفرعي تلك التغييرات. وكسر ذلك توقّعات المستخدم بأن تعكس الوراثة دائماً الحالة الراهنة للصنف الأب.
ولحسن حظّنا (لكن ليس لحسن حظّ المستخدمين الذين يحبّون هذه الميزة، على ما أظن)، لا تسمح Lox لك بترقيع القرود ولا بلكز البط، لذا يمكننا تطبيق هذا التحسين بأمان.
وماذا عن تجاوزات الدوال؟ أليس نسخ دوال الصنف الأب إلى جدول دوال الصنف الفرعي
سيتعارض مع دوال الصنف الفرعي نفسها؟ لحسن الحظ، لا. فنحن نُصدر OP_INHERIT بعد
تعليمة OP_CLASS التي تنشئ الصنف الفرعي، لكن قبل تصريف أي إعلانات دوال وتعليمات
OP_METHOD. فعند النقطة التي ننسخ فيها دوال الصنف الأب نازلاً، يكون جدول دوال
الصنف الفرعي فارغاً. وأي دوال يتجاوزها الصنف الفرعي ستكتب فوق تلك المدخلات
الموروثة في الجدول.
الأصناف الآباء غير الصالحة
تنفيذنا بسيط وسريع، وهذا تماماً ما أحبّه في شيفرة آلتي الافتراضية. لكنه ليس متيناً. فلا شيء يمنع المستخدم من أن يرث من كائن ليس صنفاً إطلاقاً:
var NotClass = "So not a class";
class OhNo < NotClass {}
من الواضح أن لا مبرمج يحترم نفسه سيكتب ذلك، لكن علينا أن نحرس ضد مستخدمي Lox المحتملين الذين لا يحترمون أنفسهم. فحصّ زمني بسيط يعالج ذلك.
^code inherit-non-class (1 before, 1 after)
إذا لم تكن القيمة التي حمّلناها من المُعرِّف في بند الصنف الأب هي ObjClass،
فإننا نبلّغ عن خطأ زمني لنُعلم المستخدم بما نظنّه فيه وبشيفرته.
تخزين الأصناف الآباء
هل لاحظتَ أنه حين أضفنا وراثة الدوال، لم نضف في الواقع أي مرجع من صنف فرعي إلى صنفه الأب؟ فبعد نسخ الدوال الموروثة، ننسى الصنف الأب تماماً. لا نحتاج إلى الإبقاء على مقبض له للصنف الأب، لذا لا نفعل.
لكن ذلك لن يكفي لدعم استدعاءات super. وبما أن الصنف الفرعي قد يتجاوز دالة الصنف الأب، فنحتاج إلى القدرة على الوصول إلى جداول دوال الأصناف الآباء. وقبل أن نصل إلى تلك الآلية، أريد أن أُذكّرك بكيفية حلّ استدعاءات super ساكناً.
في أيام jlox الهادئة، عرضتُ لك هذا المثال الحرِج لشرح طريقة توجيه استدعاءات super:
class A {
method() {
print "A method";
}
}
class B < A {
method() {
print "B method";
}
test() {
super.method();
}
}
class C < B {}
C().test();
داخل جسم دالة test()، يكون this نسخة من C. لو حُلّت استدعاءات super نسبةً إلى
الصنف الأب للـمُستقبِل، لبحثنا في الصنف الأب لـ C وهو B. لكن استدعاءات super تُحلّ
نسبةً إلى الصنف أب للصنف المحيط حيث يقع الاستدعاء. وفي حالتنا هذه، نحن في دالة
test() الخاصة بـ B، فالصنف الأب هو A، وينبغي أن يطبع البرنامج "A method".
وهذا يعني أن استدعاءات super لا تُحلّ ديناميكياً بناءً على النسخة الزمنية. فالصنف
الأب المستخدَم للبحث عن الدالة خاصية ساكنة -- عملياً معجمية -- لمكان وقوع
الاستدعاء. وحين أضفنا الوراثة إلى jlox، استفادنا من تلك السمة الساكنة بتخزين الصنف
الأب في بنية Environment نفسها التي استخدمناها لكل النطاقات المعجمية. وكأن المفسّر
رأى البرنامج أعلاه هكذا:
class A {
method() {
print "A method";
}
}
var Bs_super = A;
class B < A {
method() {
print "B method";
}
test() {
runtimeSuperCall(Bs_super, "method");
}
}
var Cs_super = B;
class C < B {}
C().test();
لكل صنف فرعي متغيّر مخفي يخزّن مرجعاً إلى صنفه الأب. وكلما احتجنا إلى تنفيذ استدعاء super، نصل إلى الصنف الأب من ذلك المتغيّر ونطلب من النظام الزمني أن يبدأ البحث عن الدوال هناك.
سنسلك المسار نفسه مع clox. والفرق هو أننا بدلاً من صنف Environment المخصّص في كومة
الذاكرة (heap) عند jlox، لدينا مكدّس القيم ونظام القيم الأعلى (upvalues) الخاصين بآلة
شيفرة البايت. والآلية مختلفة قليلاً، لكن الأثر الكلّي هو نفسه.
متغيّر محلّي للصنف الأب
مصرّفنا يُصدر أصلاً شيفرة لتحميل الصنف الأب على المكدّس. بدلاً من ترك تلك الخانة كمؤقّت، ننشئ نطاقاً جديداً ونجعلها متغيّراً محلّياً.
^code superclass-variable (2 before, 2 after)
إنشاء نطاق معجمي جديد يضمن أنه إن أعلنّا صنفين في النطاق نفسه، يكون لكل منهما خانة محلّية مختلفة لتخزين صنفه الأب. وبما أننا نسمي هذا المتغيّر دائماً "super"، لو لم ننشئ نطاقاً لكل صنف فرعي لتعارضت المتغيّرات.
ونسمي المتغيّر "super" للسبب نفسه الذي نستخدم به "this" كاسم للمتغيّر المحلّي
المخفي الذي تُحلّ إليه تعبيرات this: فـ"super" كلمة محجوزة، وهذا يضمن أن المتغيّر
المخفي للمصرّف لن يتعارض مع متغيّر يعرّفه المستخدم.
الفرق هو أن عند تصريف تعبيرات this، لدينا بالصدفة رمز جاهز مُعرِّفه
(lexeme) هو "this". ونحن لسنا محظوظين هنا. بدلاً من ذلك، نضيف دالة مساعدة صغيرة
لإنشاء رمز اصطناعي لسلسلة ثابتة معطاة.
^code synthetic-token
بما أننا فتحنا نطاقاً محلّياً لمتغيّر الصنف الأب، نحتاج إلى إغلاقه.
^code end-superclass-scope (1 before, 2 after)
نُخرج النطاق من المكدّس ونتخلّص من متغيّر "super" بعد تصريف جسم الصنف ودوالّه. وبذلك يكون المتغيّر متاحاً في كل دوال الصنف الفرعي. إنها تحسينية غير مجدية إلى حدٍّ ما، لكننا ننشئ النطاق فقط إذا كان هناك بند صنف أب. وبالتالي نحتاج إلى إغلاق النطاق فقط إذا كان هناك بند.
لتتبّع ذلك، يمكننا أن نُصرّح عن متغيّر محلّي صغير في classDeclaration(). لكن دوالاً
أخرى في المصرّف ستحتاج قريباً إلى معرفة ما إذا كان الصنف المحيط صنفاً فرعياً أم
لا. لذا من الأفضل أن نقدّم لأنفسنا المستقبل معروفاً ونخزّن هذه الحقيقة كحقل في
ClassCompiler الآن.
^code has-superclass (2 before, 1 after)
حين نهيّئ ClassCompiler لأول مرة، نفترض أنه ليس صنفاً فرعياً.
^code init-has-superclass (1 before, 1 after)
ثم، إن رأينا بند صنف أب، نعرف أننا نصرّف صنفاً فرعياً.
^code set-has-superclass (1 before, 1 after)
تمنحنا هذه الآلية وسيلة في الزمن التشغيلي للوصول إلى كائن الصنف أب للصنف الفرعي المحيط من داخل أي دالة من دوال الصنف الفرعي -- ببساطة أن نُصدر شيفرة لتحميل المتغيّر المسمّى "super". ذلك المتغيّر متغيّر محلّي خارج جسم الدالة، لكن دعمنا الحالي للقيم الأعلى (upvalues) يتيح للآلة الافتراضية أن تلتقط ذلك المتغيّر المحلي داخل جسم الدالة أو حتى داخل دوال متداخلة داخل تلك الدالة.
استدعاءات Super
مع وجود هذا الدعم الزمني، نحن مستعدّون لتنفيذ استدعاءات super. وكالمعتاد، نمضي من
الأمام إلى الخلف، بادئين بالصياغة الجديدة. استدعاء super يبدأ
— كما هو بديهي — بالكلمة المفتاحية super.
^code table-super (1 before, 1 after)
حين يصير محلّل التعبيرات على رمز super، تنتقل التحكّم إلى دالة تحليل جديدة تبدأ
هكذا:
^code super
هذا مختلف تماماً عن الطريقة التي صرّفنا بها تعبيرات this. فبخلاف this، رمز
super ليس تعبيراً مستقلاً. بدلاً من ذلك، النقطة واسم
الدالة اللاحقَان له جزءان لا ينفصلان من الصياغة. لكن قائمة المعاملات المحاطة بأقواس
منفصلة. وكما في الوصول العادي إلى الدوال، تدعم Lox الحصول على مرجع إلى دالة صنف
أب كإغلاق دون استدعائها:
class A {
method() {
print "A";
}
}
class B < A {
method() {
var closure = super.method;
closure(); // Prints "A".
}
}
بمعنى آخر، لا تملك Lox تعبيرات استدعاء super فعلياً، بل تعبيرات وصول super
(access)، ويمكنك اختيار استدعائها فوراً إن أردت. لذا حين يصطدم المصرّف برمز
super، نستهلك رمز . اللاحق ثم نبحث عن اسم دالة. ولأن المتغيّرات ديناميكية، لذا نستخدم
identifierConstant() لأخذ مُعرِّف (lexeme) رمز اسم الدالة وتخزينه في جدول
الثوابت تماماً كما نفعل لتعبيرات الوصول إلى الخصائص.
إليك ما يفعله المصرّف بعد استهلاك تلك الرموز:
^code super-get (1 before, 1 after)
ولكي نصل إلى دالة صنف أب على النسخة الحالية، يحتاج النظام الزمني إلى المُستقبِل
وإلى الصنف أب لصنف الدالة المحيطة. ينتج استدعاء namedVariable() الأول شيفرة
للبحث عن المُستقبِل الحالي المخزَّن في المتغيّر المخفي "this" ودفعه على المكدّس.
وينتج استدعاء namedVariable() الثاني شيفرة للبحث عن الصنف الأب من متغيّره
"super" والدفعه فوقه.
أخيراً، نُصدر تعليمة OP_GET_SUPER جديدة مع معامل فهرس جدول الثوابت لاسم الدالة.
هذا كثير لتضعه في ذهنك. وكي يكون ملموساً، تأمّل برنامج المثال التالي:
class Doughnut {
cook() {
print "Dunk in the fryer.";
this.finish("sprinkles");
}
finish(ingredient) {
print "Finish with " + ingredient;
}
}
class Cruller < Doughnut {
finish(ingredient) {
// No sprinkles, always icing.
super.finish("icing");
}
}
تبدو شيفرة البايت المُصدَرة لتعبير super.finish("icing") وتعمل كالتالي:
تمنح التعليمات الثلاث الأولى النظام الزمني الوصول إلى القطع الثلاث من المعلومات التي يحتاجها لتنفيذ الوصول إلى super:
-
التعليمة الأولى تحمّل النسخة على المكدّس.
-
التعليمة الثانية تحمّل الصنف الأب الذي تُحلّ عنده الدالة.
-
ثم تُشفّر تعليمة
OP_GET_SUPERالجديدة اسم الدالة المطلوب الوصول إليها كمعامل.
التعليمات المتبقّية هي شيفرة البايت المعتادة لتقييم قائمة معاملات واستدعاء دالة.
نحن على وشك تنفيذ تعليمة OP_GET_SUPER الجديدة في المفسّر. لكن قبل ذلك، لدى
المصرّف بعض الأخطاء التي مسؤوليته الإبلاغ عنها.
^code super-errors (1 before, 1 after)
استدعاء super ذو معنى فقط داخل جسم دالة (أو داخل دالة متداخلة داخل دالة)، وفقط
داخل دالة صنف له صنف أب. ونكتشف كلتا الحالتين باستخدام قيمة currentClass. إن
كانت NULL أو تشير إلى صنف بلا صنف أب، فإننا نبلّغ عن تلك الأخطاء.
تنفيذ عمليات الوصول إلى super
بافتراض أن المستخدم لم يضع تعبير super في مكان غير مسموح به، تنتقل شيفرته من
المصرّف إلى النظام الزمني. ولدينا الآن تعليمة جديدة.
^code get-super-op (1 before, 1 after)
نُفكّكها مثل رموز التشغيل (opcodes) الأخرى التي تأخذ معامل فهرس جدول ثوابت.
^code disassemble-get-super (1 before, 1 after)
قد تتوقّع شيئاً أصعب، لكن تفسير التعليمة الجديدة يشبه تنفيذ وصول عادي إلى خاصية.
^code interpret-get-super (1 before, 1 after)
وكما في الخصائص، نقرأ اسم الدالة من جدول الثوابت. ثم نمرّره إلى bindMethod()
والذي يبحث عن الدالة في جدول دوال الصنف المعطى وينشئ ObjBoundMethod ليربط الإغلاق
الناتج بالنسخة الحالية.
الفرق الجوهري هو أيّ صنف نمرّره إلى bindMethod().
مع الوصول العادي إلى خاصية، نستخدم صنف الـ ObjInstance نفسه، وهو ما يمنحنا التوجيه
الديناميكي الذي نريده. أما في استدعاء super، فنحن لا نستخدم صنف النسخة. بدلاً من
ذلك، نستخدم الصنف الأب المحلول ساكناً الخاص بالصنف الحاوي، والذي تضمن المصرّف
بسهولة أنه جالس على قمة المكدّس في انتظارنا.
نُخرج ذلك الصنف الأب من المكدّس ونمرّره إلى bindMethod()، الذي يتخطّى بشكل صحيح
أي دوال تتجاوزها في أي من الأصناف الفرعية بين ذلك الصنف الأب وصنف النسخة نفسه. كما
أنه يتضمّن بشكل صحيح أي دوال ورثها الصنف الأب من أي من أصنافه آباء.
بقية السلوك هو نفسه. فإخراج الصنف الأب يترك النسخة على قمة المكدّس. وعندما تنجح
bindMethod()، فإنها تُخرج النسخة وتدفع الدالة المربوطة الجديدة. وإلا فإنها تبلّغ
عن خطأ زمني وتُعيد false. وفي تلك الحالة، نُجهض المفسّر.
استدعاءات super أسرع
لدينا الآن وصول إلى دوال الصنف الأب. وبما أن الكائن المُعاد هو ObjBoundMethod يمكنك استدعاؤه بعد ذلك، فإن لدينا استدعاءات super تعمل أيضاً. تماماً كما في الفصل الأخير، وصلنا إلى نقطة تمتلك فيها آلتنا الافتراضية الدلالات الكاملة الصحيحة.
لكن، أيضاً تماماً كما في الفصل الأخير، إنها بطيئة إلى حدٍّ كبير. فنحن مرةً أخرى
نخصّص ObjBoundMethod على الكومة لكل استدعاء super، رغم أن التعليمة التالية في أغلب
الأحيان هي OP_CALL التي تفكّ تلك الدالة المربوطة فوراً وتستدعيها ثم تتخلّص منها.
بل إن هذا أكثر احتمالاً في استدعاءات super منها في استدعاءات الدوال العادية. فعلى
الأقل في استدعاءات الدوال هناك احتمال أن يكون المستخدم استدعى فعلاً دالة مخزَّنة في
حقل. أما مع استدعاءات super، فأنت دائماً تبحث عن دالة. السؤال الوحيد هو هل
تستدعيها فوراً أم لا.
يستطيع المصرّف غالباً أن يجيب عن هذا السؤال بنفسه إن رأى قوساً أيسر بعد اسم دالة
الصنف الأب، لذا سنقوم بتنفيذ التحسين نفسه الذي فعلناه لاستدعاءات الدوال. أخرج
سطرَي الشيفرة اللذين يحمّلان الصنف الأب ويصدران OP_GET_SUPER، واستبدلهما بهذا:
^code super-invoke (1 before, 1 after)
الآن قبل أن نصدر أي شيء، نبحث عن قائمة معاملات محاطة بأقواس. إن وجدنا واحدة، نصرّفها.
ثم نحمّل الصنف الأب. وبعد ذلك، نُصدر تعليمة OP_SUPER_INVOKE جديدة. تجمع هذه
التعليمة الخارقة سلوك OP_GET_SUPER مع
OP_CALL، لذا فهي تأخذ معاملين: فهرس جدول الثوابت لاسم الدالة المطلوب البحث
عنها، وعدد المعاملات التي يجب تمريرها إليها.
وإلا، إن لم نجد (، فإننا نواصل تصريف التعبير كوصول super كما فعلنا من قبل ونُصدر
OP_GET_SUPER.
ونزل مع مجرى التصريف، أول محطة لنا هي تعليمة جديدة.
^code super-invoke-op (1 before, 1 after)
وبعدها مباشرة، دعم تفكيكها.
^code disassemble-super-invoke (1 before, 1 after)
تعليمة استدعاء super لها المجموعة نفسها من المعاملات التي لدى OP_INVOKE، لذا نعيد
استخدام نفس الدالة المساعدة لتفكيكها. وأخيراً، يقودنا المجرى إلى المفسّر.
^code interpret-super-invoke (2 before, 1 after)
هذه الكتلة الصغيرة من الشيفرة هي في الأساس تنفيذنا لـ OP_INVOKE مخلوطةً بجرّة
من OP_GET_SUPER. لكن هناك بعض الفروق في كيفية تنظيم المكدّس. فمع استدعاء super
غير المحسَّن، يُخرَج الصنف الأب ويُستبدل بـ ObjBoundMethod الخاص بالدالة المحلول
عنها قبل تنفيذ معاملات الاستدعاء. وهذا يضمن أن يكون الوقت الذي تُنفَّذ فيه
OP_CALL قد كانت فيه الدالة المربوطة تحت قائمة المعاملات، حيث يتوقّعها النظام
الزمني في استدعاء الإغلاق.
مع تعليماتنا المحسَّنة، تتنقّل الأمور قليلاً:
الآن صار حلّ دالة الصنف الأب جزءاً من الاستدعاء، لذا تحتاج المعاملات أن تكون على المكدّس بالفعل عند النقطة التي نبحث فيها عن الدالة. وهذا يعني أن كائن الصنف الأب موجود فوق المعاملات.
عدا ذلك، السلوك تقريباً نفس سلوك OP_GET_SUPER يليها OP_CALL. أولاً، نستخرج
معاملي اسم الدالة وعدد المعاملات. ثم نُخرج الصنف الأب من قمة المكدّس حتى نتمكن من
البحث عن الدالة في جدول دواله. وهذا يترك المكدّس مهيّأً تماماً لطلب استدعاء دالة.
نمرّر الصنف الأب واسم الدالة وعدد المعاملات إلى دالة invokeFromClass() القائمة
لديّ. وتبحث تلك الدالة عن الدالة المعطاة على الصنف المعطى وتحاول إنشاء استدعاء لها
بالعدد المطلوب من الوسائط. إن لم يُعثر على دالة، فإنها تُعيد false، ونخرج من
المفسّر. وإلا، فتدفع invokeFromClass() إطار استدعاء (CallFrame) جديداً على مكدّس
الاستدعاءات الخاص بإغلاق الدالة. وهذا يُبطل مؤشر CallFrame المخزَّن مؤقتاً في
المفسّر، لذا نُحدّث frame.
آلة افتراضية كاملة
انظر إلى ما أنشأناه. وفق عدّي، كتبنا نحو 2,500 سطر من لغة C نظيفة والمباشرة إلى حدٍّ كبير. ذلك البرنامج الصغير يحوي تنفيذاً كاملاً للغة Lox -- العالية المستوى إلى حدٍّ كبير! -- مع جدول أولويات كامل مليء بأنواع التعبيرات ومجموعة من جمل التحكّم في المسار. ونفّذنا المتغيّرات، والدوال، والإغلاق، والأصناف، والحقول، والدوال، والوراثة.
والأكثر إبهاراً، أن تنفيذنا قابل للنقل إلى أي منصة فيها مصرّف C، وسريع بما يكفي للاستخدام الإنتاجي في العالم الحقيقي. لدينا مصرّف شيفرة بايت مرور واحد، ومفسّر آلة افتراضية مُحكم لِمجموعة التعليمات الداخلي لدينا، وتمثيلات كائنات مُدمجة، ومكدّس لتخزين المتغيّرات دون تخصيص ذاكرة، وجامع قمامة دقيق.
إن ذهبت وتقرّص في تنفيذات Lua أو Python أو Ruby، فسيُفاجئك كم يشبه منها الكثير الآن. لقد رفعتَ بالفعل مستوى معرفتك بكيفية عمل لغات البرمجة، وهذا بدوره يمنحك فهماً أعمق للبرمجة نفسها. إنه أشبه بأنك كنت سائق سيارات سباق، والآن تستطيع أن تفتح غطاء المحرك وتُصلحه أيضاً.
يمكنك التوقّف هنا إن أردت. فنسختا Lox اللتان بين يديك كاملتان ومليئتان بالميزات. لقد بنيت السيارة ويمكنك قيادتها إلى حيثما شئت الآن. لكن إن كنت تبحث عن متعةٍ أخرى في الضبط والتحسين للحصول على أداء أعلى المضمار، فهناك فصلٌ آخر. لا نضيف أي قدرات جديدة، لكننا نُدخل تحسينين كلاسيكيين لنستخرج أداءً أكثر. إن بدا ذلك ممتعاً، واصل القراءة...
التحديات
-
أحد مبادئ البرمجة الموجّهة للكائنات هو أن الصنف ينبغي أن يضمن أن الكائنات الجديدة في حالة صالحة. وفي Lox، يعني ذلك تعريف مُهيّئ يملأ حقول النسخة. والوراثة تعقّد الثوابت (invariants) لأن النسخة يجب أن تكون في حالة صالحة بحسب كل الأصناف الموجودة في سلسلة وراثة الكائن.
الجزء السهل هو تذكّر استدعاء
super.init()في دالةinit()لكل صنف فرعي. والجزء الأصعب هو الحقول. فلا شيء يمنع صنفين في سلسلة الوراثة من أن يدّعيا بالخطأ اسم الحقل نفسه. وعندما يحدث هذا، سيتداخلان على حقول كل منهما، وقد يتركانك مع نسخة في حالة معطوبة.لو كانت Lox لغتك، فكيف كنت ستعالج هذا إن كنت ستعالجه أصلاً؟ إن أردت تغيير اللغة، نفّذ تغييرك.
-
تحسين الوراثة بالنسخ النازل صالح فقط لأن Lox لا تسمح لك بتعديل دوال صنف بعد إعلانه. وهذا يعني أننا لا نضطر للقلق بشأن الدوال المنسوخة في الصنف الفرعي تخرج عن المزامنة مع التغييرات اللاحقة على الصنف الأب.
أما اللغات الأخرى مثل Ruby، فتسمح فعلاً بتعديل الأصناف بعد إعلانها. كيف تدعم تنفيذات اللغات من هذا النوع تعديل الأصناف مع إبقاء حلّ الدوال فعّالاً؟
-
في فصل jlox عن الوراثة، كان لدينا تحدٍّ لتنفيذ نهج لغة BETA في تجاوز الدوال. حلّ ذلك التحدي مرةً أخرى، لكن هذه المرة في clox. إليك وصف التحدي السابق:
في Lox، كما في معظم لغات البرمجة الموجّهة للكائنات الأخرى، نبدأ عند البحث عن دالة من أسفل تسلسل الأصناف ونصعد تدريجياً -- فدالة الصنف الفرعي مقدَّمة على دالة الصنف الأب. وللوصول إلى دالة الصنف الأب من داخل دالة تتجاوزها، تستخدم
super.وتتتّخذ لغة BETA نهجاً معاكساً. فحين تستدعي دالة، تبدأ من أعلى تسلسل الأصناف وتعمل نازلاً. فدالة الصنف الأب تغلب على دالة الصنف الفرعي. وللوصول إلى دالة الصنف الفرعي، يمكن لدالة الصنف الأب أن تستدعي
inner، وهو شيء يشبه عكسsuper. فهو يتسلسل إلى الدالة التالية في التسلسل النازل.تتحكّم دالة الصنف الأب في متى وأين يُسمح للصنف الفرعي بأن يُدقّق سلوكه. فإذا لم تستدعِ دالة الصنف الأب
innerإطلاقاً، فلا يملك الصنف الفرعي أي وسيلة لتجاوز سلوك الصنف الأب أو تعديله.أخرج سلوك التجاوز و
superالحالي في Lox، واستبدله بدلالات BETA. باختصار:-
عند استدعاء دالة على صنف، دالة الأعلى في سلسلة وراثة الصنف هي المقدَّمة.
-
داخل جسم دالة، يبحث استدعاء
innerعن دالة بالاسم نفسه في أقرب صنف فرعي على امتداد سلسلة الوراثة بين الصنف الذي يحتويinnerوصنفthis. إن لم تكن هناك دالة مطابقة، فلا يفعل استدعاءinnerشيئاً.
مثلاً:
class Doughnut { cook() { print "Fry until golden brown."; inner(); print "Place in a nice box."; } } class BostonCream < Doughnut { cook() { print "Pipe full of custard and coat with chocolate."; } } BostonCream().cook();ينبغي أن يطبع هذا:
Fry until golden brown. Pipe full of custard and coat with chocolate. Place in a nice box.بما أن clox لا يتعلق بتنفيذ Lox فحسب، بل بتنفيذه بأداء جيد، فحاول هذه المرة حلّ التحدي بعين تراعي الكفاءة.
-