صناعة المفسّرات › النداءات والدوال 21 / 35 السابق التالي

الاستدعاءات والدوال

يمكن حل أيّ مشكلة في علوم الحاسوب بمستوى آخر من التوجيه. باستثناء مشكلة وجود طبقات توجيه كثيرة أكثر من اللازم.

ديفيد ويلر

هذا الفصل وحش. أحاول تقسيم الميزات إلى قطع صغيرة، لكن أحياناً عليك أن تبتلع الـوجبة كاملة. مهمّتنا التالية هي الدوال. يمكننا البدء بإعلانات الدوال فقط، لكن ذلك ليس مفيداً كثيراً حين لا تستطيع استدعاءها. يمكننا فعل الاستدعاءات، لكن لا يوجد شيء نستدعيه. وكل دعم زمن تشغيلي مطلوب في الآلة الافتراضية لدعم كليهما ليس مجزياً إن لم يُربط بأيّ شيء تراه. لذا سنفعل كل ذلك. إنه كثير، لكن سنشعر بالارتياح حين ننتهي.

كائنات الدوال

أكثر التغييرات البنيوية إثارةً للاهتمام في الآلة الافتراضية تدور حول المكدّس. فنحن لدينا بالفعل مكدّس للمتغيّرات المحلّية والقيم المؤقتة، لذا فنحن في منتصف الطريق. لكن ليس لدينا مفهوم لـمكدّس استدعاءات. وقبل أن نُحرز تقدّماً كبيراً، علينا معالجة ذلك. لكن أولاً، لنكتب بعض الشيفرة. أشعر دائماً بتحسّن عندما أبدأ بالحركة. ولا نستطيع أن نفعل الكثير دون وجود شكل ما من التمثيل للدوال، لذا سنبدأ من هناك. فمن منظور الآلة الافتراضية، ما هي الدالة؟

للدالة جسم يمكن تنفيذه، وهذا يعني بعضاً من شيفرة البايت. يمكننا صرّف البرنامج بأكمله وجميع إعلانات دواله إلى Chunk واحدة ضخمة واحدة. ولكل دالة مؤشر إلى أول تعليمة من شيفرتها داخل تلك Chunk.

هكذا تقريباً يعمل التصريف إلى شيفرة آلة أصلية حيث ينتهي بك الأمر إلى كتلة واحدة صلبة من شيفرة الآلة. لكن بالنسبة إلى آلتنا الافتراضية لشيفرة البايت، يمكننا أن نفعل شيئاً أعلى مستوى قليلاً. وأظنّ أن النموذج الأنظف هو منح كل دالة Chunk خاصة بها. وسنحتاج إلى بعض البيانات الوصفية الأخرى أيضاً، لذا لن put ذلك كله في بنية الآن.

^code obj-function (2 before, 2 after)

الدوال من الدرجة الأولى في Lox، لذا تحتاج إلى أن تكون كائنات Lox فعلية. وبذلك يحمل ObjFunction ترويسة Obj نفسها التي تشترك فيها جميع أنواع الكائنات. ويخزّن حقل arity عدد المعاملات التي تتوقّعها الدالة. ثم بالإضافة إلى الـ chunk، نخزّن اسم الدالة. وسيكون ذلك مفيداً للإبلاغ عن أخطاء زمنية مفهومة.

هذه هي المرة الأولى التي تحتاج فيها وحدة "object" إلى الإشارة إلى Chunk، فنحصل على تضمين.

^code object-include-chunk (1 before, 1 after)

وكما فعلنا مع النصوص، نعرّف بعض دوال الوصول حتى يسهل العمل مع دوال Lox في C. شيء من نوع التوجيه للكائنات لرجال فقراء. أولاً، سنعلن دالة C تنشئ دالة Lox جديدة.

^code new-function-h (3 before, 1 after)

والتنفيذ هناك:

^code new-function

نستخدم صديقنا ALLOCATE_OBJ() لتخصيص الذاكرة وتهيئة ترويسة الكائن بحيث تعرف الآلة الافتراضية ما نوع الكائن الذي هو. وبدلاً من تمرير وسائط لتهيئة الدالة كما فعلنا مع ObjString، فإننا نضبط الدالة في حالة فارغة تقريباً -- عدد وسائط صفري، ولا اسم، ولا شيفرة. وسيتملّ ذلك لاحقاً بعد إنشاء الدالة.

وبما لدينا نوع كائن جديد، نحتاج إلى نوع كائن جديد في التعداد.

^code obj-type-function (1 before, 2 after)

وحين ننتهي من كائن دالة، علينا إعادة البتات التي استعارها إلى نظام التشغيل.

^code free-function (1 before, 1 after)

حالة الـ switch هذه مسؤولة عن تحرير ObjFunction نفسه وكذلك أيّ ذاكرة أخرى يملكها. فالدوال تملك الـ chunk الخاصة بها، لذا نستدعي دالة Chunk الشبيهة بالمُدمِّر.

تتيح Lox لك طباعة أيّ كائن، والدوال كائنات من الدرجة الأولى، لذا نحتاج إلى التعامل معها أيضاً.

^code print-function (1 before, 1 after)

وهذا يستدعي:

^code print-function-helper

وبما أن الدالة تعرف اسمها، فلاّها أن تقوله.

وأخيراً، لدينا بضعة ماكروهات لتحويل القيم إلى دوال. أولاً، تأكّد من أن قيمتك هي فعلاً دالة.

^code is-function (2 before, 1 after)

بافتراض أن هذا يُقيَّم إلى true، يمكنك بعد ذلك تحويل القيمة بأمان إلى مؤشر ObjFunction باستخدام هذا:

^code as-function (2 before, 1 after)

بهذا، يعرف نموذج كائناتنا كيفية تمثيل الدوال. أشعر بدفء الآن. أنت مستعدّ لشيء أصعب قليلاً؟

التصريف إلى كائنات دوال

في الوقت الحالي، يفترض مصرّفنا أنه يصرّف دائماً إلى قطعة واحدة واحدة. ومع وجود شيفرة كل دالة في قطع منفصلة، يصبح ذلك أعقد. فعندما يصل المصرّف إلى إعلان دالة، عليه أن يُصدر شيفرة داخل chunk الخاصة بالدالة عند تصريف جسمها. وفي نهاية جسم الدالة، يحتاج المصرّف إلى العودة إلى القطعة التي كان يعمل فيها سابقاً.

هذا للشيفرة داخل أجسام الدوال، لكن ماذا عن الشيفرة التي ليست كذلك؟ فالمستوى «الأعلى» لبرنامج Lox هو أيضاً شيفرة أمرية، ونحتاج إلى قطعة لنصرّفها فيها. يمكننا تبسيط المصرّف والآلة الافتراضية بوضع تلك الشيفرة في المستوى الأعلى داخل دالة معرَّفة تلقائياً أيضاً. وبذلك يكون المصرّف دائماً داخل نوع ما من جسم دالة، وتكون الآلة الافتراضية دائماً تشغّل شيفرة باستدعاء دالة. وكأن البرنامج كله ملفوف داخل دالة main() ضمنية.

قبل أن نصل إلى الدوال التي يعرّفها المستخدم، لنُجرِ إعادة التنظيم هذه لدعم تلك الدالة الضمنية في المستوى الأعلى. وتبدأ ببنية Compiler. فبدلاً من أن يشير مباشرةً إلى Chunk يكتب المصرّف فيها، أصبح لديه مرجع إلى كائن الدالة الجاري بناؤه.

^code function-fields (1 before, 1 after)

ولديه أيضاً تعداد FunctionType صغير. يتيح ذلك للمصرّف أن يميّز بين ما إذا كان يصرّف شيفرة المستوى الأعلى أم جسم دالة. معظم المصرّف لا ي care بهذا -- ولهذا تجريد مفيد -- لكن في موضع أو موضعين يكون التمييز ذا معنى. سنصل إلى أحدهما لاحقاً.

^code function-type-enum

كل موضع في المصرّف كان يكتب إلى Chunk عليه الآن أن يمرّ عبر مؤشر function ذاك. ولحسن الحظ، فإننا نمرّ عبر دالة currentChunk() لتحديدها مباشرةً. فنُعدّل ذلك فقط ويكون بقية المصرّف راضياً. والفصل الذي يعالج القطعة الحالية هو الذي نتحدّث عنه.

^code current-chunk (1 before, 2 after)

القطعة الحالية هي دائماً القطعة التي تملكها الدالة التي نحن في منتصف تصريفها. بعد ذلك، نحتاج فعلاً إلى إنشاء تلك الدالة. في السابق، كانت الآلة الافتراضية تمرّر Chunk إلى المصرّف الذي يملؤها بالشيفرة. أما الآن، فالمصرّف سينشئ دالة ويُعيدها تحتوي على شيفرة المستوى الأعلى المصرَّفة -- وهي كل ما ندعمه حالياً -- لبرنامج المستخدم.

إنشاء الدوال وقت التصريف

نبدأ بتمرير هذا في compile()، وهي نقطة الدخول الرئيسية إلى المصرّف.

^code call-init-compiler (1 before, 2 after)

هناك مجموعة تغييرات في كيفية تهيئة المصرّف. أولاً، نهيّئ حقول Compiler الجديدة.

^code init-compiler (1 after)

ثم نخصص كائن دالة جديداً لنصرّف فيه.

^code init-function (1 before, 1 after)

قد يبدو إنشاء ObjFunction داخل المصرّف غريباً بعض الشيء. فكائن الدالة هو التمثيل الزمني لدالة، لكننا هنا ننشئه وقت التصريف. وطريقة التفكير في ذلك أن الدالة مشابهة لنصّ حرفي أو عدد حرفي. فهي تشكّل جسراً بين عالمي وقت التصريف والزمن التشغيلي. وحين نصل إلى إعلانات الدوال، تكون تلك حرفية فعلاً -- فهي تدوين ينتج قيماً من نوع مدمج. لذا يُنشئ المصرّف كائنات الدوال أثناء التصريف. ثم، في الزمن التشغيلي، تُستدعى ببساطة.

وهنا قطعة شيفرة غريبة أخرى:

^code init-function-slot (1 before, 1 after)

تذكّر أن مصفوفة locals في المصرّف تتتبّع أيّ خانات المكدّس المرتبطة بأيّ متغيّرات محلّية أو قيم مؤقتة. ومن الآن فصاعداً، يدّعي المصرّف ضمنياً خانة المكدّس صفر لاستخدام الآلة الافتراضية الداخلي. ونعطيها اسماً فارغاً حتى لا يستطيع المستخدم كتابة مُعرِّف يشير إليها. وسأشرح سبب ذلك حين يصبح مفيداً.

هذا جانب التهيئة. ونحتاج أيضاً إلى بضعة تغييرات على الطرف الآخر حين ننتهي من تصريف بعض الشيفرة.

^code end-compiler (1 after)

في السابق، حين كانت interpret() تستدعي المصرّف، كانت تمرّر Chunk ليُكتب فيها. والآن بما أن المصرّف ينشئ كائن الدالة بنفسه، فإننا نُعيد تلك الدالة. ونأخذها من المصرّف الحالي هنا:

^code end-function (1 before, 1 after)

ثم نُعيدها إلى compile() هكذا:

^code return-function (1 before, 1 after)

الآن هو وقت مناسب لتعديل آخر في هذه الدالة. فقد أضفنا سابقاً بعض شيفرة تشخيص ليُخرج المفسّر شيفرة البايت المفكّكة كي نتمكن من تصحيح أخطاء المصرّف. وينبغي أن نُصلح ذلك ليبقى يعمل الآن بما أن القطعة المُولَّدة ملفوفة في دالة.

^code disassemble-end (2 before, 2 after)

لاحظ الفحص هنا ممّا إذا كان اسم الدالة هو NULL؟ فالدوال التي يعرّفها المستخدم لها أسماء، لكن الدالة الضمنية التي ننشئها لشيفرة المستوى الأعلى ليس لها اسم، ونحتاج إلى التعامل مع ذلك بأسلوب حسن حتى في شيفرة التشخيص الخاصة بنا. وبمناسبة الحديث عن ذلك:

^code print-script (1 before, 1 after)

لا سبيل للمستخدم إلى الحصول على مرجع إلى دالة المستوى الأعلى ومحاولة طباعتها، لكن شيفرة التشخيص DEBUG_TRACE_EXECUTION التي تطبع المكدّس كاملاً تستطيع ذلك وتفعل.

وصعدنا مستوى إلى compile()، فإننا نعدّل توقيعها.

^code compile-h (2 before, 2 after)

وبدلاً من أخذ قطعة، فإنها تُعيد دالة الآن. وفي التنفيذ:

^code compile-signature (1 after)

وأخيراً نصل إلى شيفرة فعلية. نغيّر نهاية الدالة تماماً إلى هذا:

^code call-end-compiler (4 before, 1 after)

نأخذ كائن الدالة من المصرّف. إن لم تكن هناك أخطاء تصريف، فإننا نُعيده. وإلا فإننا نُبلّغ عن خطأ بإعادة NULL. وبهذه الطريقة، لا تحاول الآلة الافتراضية تنفيذ دالة قد تحوي شيفرة بايت غير صالحة.

في النهاية، سنُحدّث interpret() للتعامل مع التصريح الجديد لـ compile()، لكن لدينا أولاً بعض التغييرات الأخرى لنُجريها.

إطارات الاستدعاء

حان وقت القفزة المفاهيمية الكبيرة. وقبل أن نتمكن من تنفيذ إعلانات الدوال والاستدعاءات، نحتاج إلى تجهيز الآلة الافتراضية للتعامل معها. وهناك مشكلتان رئيسيّتان عليناقلق بشأنهما:

تخصيص المتغيّرات المحلّية

يخصّص المصرّف خانات مكدّس للمتغيّرات المحلّية. فكيف ينبغي أن يعمل ذلك عندما تكون مجموعة المتغيّرات المحلّية في برنامج موزّعة على عدة دوال؟

أحد الخيارات هو إبقاؤها منفصلة تماماً. فتحصل كل دالة على مجموعتها الخاصة من الخانات في مكدّس الآلة الافتراضية التي تملكها للأبد، حتى حين لا تكون الدالة قيد الاستدعاء. ولكل متغيّر محلّي في البرنامج كله قطعة ذاكرة في الآلة الافتراضية يحتفظ بها لنفسه.

صدّق أو لا تصدّق، فقد عملت تطبيقات لغات البرمجة المبكّرة بهذه الطريقة. فأوّل مصرّفات Fortran كانت تخصّص الذاكرة لكل متغيّر ساكناً. والمشكلة الواضحة أن ذلك غير فعّال حقاً. فمعظم الدوال ليست في منتصف استدعاء في أيّ لحظة من الزمن، فالجلوس على ذاكرة غير مستخدمة لأجلها إهدار.

لكن المشكلة الأهمّ هي التعاود. فمع التعاود، يمكنك أن تكون «في» استدعاءات متعددة لنفس الدالة في الوقت نفسه. وكل استدعاء يحتاج إلى ذاكرة خاصة به لمتغيّراته المحلّية. وفي jlox، حللنا ذلك بتخصيص ذاكرة ديناميكياً لبيئة في كل مرة تُستدعى فيها دالة أو يدخل كتلة. وفي clox، لا نريد تلك_native كلفة الأداء في كل استدعاء دالة.

بدلاً من ذلك، يقع حلّنا في مكان ما بين التخصيص الساكن عند Fortran والمقاربة الديناميكية عند jlox. فمكدّس القيم في الآلة الافتراضية يعمل على الملاحظة أن المتغيّرات المحلّية والقيم المؤقتة تتصرّف بأسلوب «آخر داخل أول خارج». ولحسن حظّنا، فإن ذلك ما زال صحيحاً حتى عند إضافة استدعاءات الدوال إلى المزيج. إليك مثالاً:

fun first() {
  var a = 1;
  second();
  var b = 2;
}

fun second() {
  var c = 3;
  var d = 4;
}

first();

مرّ عبر البرنامج وانظر أيّ المتغيّرات في الذاكرة عند كل لحظة من الزمن:

تتبّع تنفيذ البرنامج السابق، مع إظهار مكدّس المتغيّرات في كل خطوة.

ونحن ننتقل في التنفيذ عبر الاستدعاءين، فإن كل متغيّر محلّي يتبع المبدأ القائل إن أيّ متغيّر يُعلن بعده سيُهمَل قبل أن يحتاج المتغيّر الأول. وهذا صحيح حتى عبر الاستدعاءات. فنحن نعرف أننا سننتهي من c وd قبل أن ننتهي من a. ويبدو أننا نستطيع تخصيص المتغيّرات المحلّية على مكدّس القيم في الآلة الافتراضية.

على الأفضل، أن نحدّد أين على المكدّس سيذهب كل متغيّر وقت التصريف. فهذا يُبقي تعليمات شيفرة البايت الخاصة بالعمل مع المتغيّرات بسيطة وسريعة. وفي المثال أعلاه، استطعنا أن نتخيّل_ ذلك بطريقة مباشرة، لكنه لا ينجح دائماً. تأمّل:

fun first() {
  var a = 1;
  second();
  var b = 2;
  second();
}

fun second() {
  var c = 3;
  var d = 4;
}

first();

في الاستدعاء الأول لـ second()، ستذهب c وd إلى الخانتين 1 و2. لكن في الاستدعاء الثاني، نحتاج إلى إفساح مجال لـ b، لذا تحتاج c وd إلى أن تكونا في الخانتين 2 و3. وبالتالي لا يستطيع المصرّف تثبيت خانة دقيقة لكل متغيّر محلّي عبر استدعاءات الدوال. لكن داخل دالة معيّنة، تكون المواضع النسبية لكل متغيّر محلّي ثابتة. فالمتغيّر d يكون دائماً في الخانة التي تلي c مباشرةً. وهذه هي الرؤية المفتاحية.

حين تُستدعى دالة، لا نعرف أين سيكون قمة المكدّس لأنها يمكن أن تُستدعى من سياقات مختلفة. لكن أينما كانت تلك القمة، فإننا نعرف أين ستكون كل متغيّرات الدالة النسبية لتلك النقطة الابتدائية. إذن، كما في مسائل كثيرة، نحلّ مسألة التخصيص لدينا بمستوى من التوجيه.

في بداية كل استدعاء دالة، تسجّل الآلة الافتراضية موضع أول خانة حيث تبدأ متغيّرات تلك الدالة نفسها. وتعليمات العمل مع المتغيّرات المحلّية تصل إليها بفهرس خانة نسبي لذلك الموضع، بدلاً من أن تكون نسبية إلى قاع المكدّس كما هي اليوم. وقت التصريف، نحسب تلك الخانات النسبية. وفي الزمن التشغيلي، نحوّل تلك الخانة النسبية إلى فهرس مكدّس مطلق بإضافة خانة البداية للاستدعاء.

وكأن الدالة تحصل على «نافذة» أو «إطار» داخل المكدّس الأكبر حيث يمكنها تخزين متغيّراتها المحلّية. وموضع إطار الاستدعاء (call frame) يُحدَّد في الزمن التشغيلي، لكن داخل ذلك المنطقة بالنسبة إليها، نعرف أين نجد الأشياء.

المكدّس عند النقطتين اللتين تُستدعى فيهما second()، مع نافذة تعلو كلاً منهما تُظهر زوج خانات المكدّس التي تستخدمها الدالة.

الاسم التاريخي لهذا الموضع المسجَّل حيث تبدأ متغيّرات الدالة هو مؤشّر الإطار (frame pointer) لأنه يشير إلى بداية إطار استدعاء الدالة. وأحياناً تسمع مؤشّر القاعدة (base pointer)، لأنه يشير إلى خانة المكدّس الأساسية التي تعيش فوقها كل متغيّرات الدالة.

هذه هي أول قطعة بيانات نحتاج إلى تتبّعها. في كل مرة نستدعي دالة، تحدّد الآلة الافتراضية أول خانة مكدّس حيث تبدأ متغيّرات تلك الدالة.

عناوين الإرجاع

في الوقت الحالي، تتقدّم الآلة الافتراضية في تدفّق التعليمات بزيادة الحقل ip. والسلوك الوحيد المثير للاهتمام هو حول تعليمات التحكّم في المسار التي تُزيح ip بمقادير أكبر. أما استدعاء دالة فبسيط إلى حدٍّ كبير -- اضبط ip فقط ليشير إلى أول تعليمة في chunk تلك الدالة. لكن ماذا عن حين تنتهي الدالة؟

تحتاج الآلة الافتراضية إلى العودة إلى القطعة التي استُدعيت منها الدالة واستئناف التنفيذ عند التعليمة التالية للاستدعاء مباشرةً. إذن، لكل استدعاء دالة، نحتاج إلى تتبّع أين نقفز إليه حين يكتمل الاستدعاء. ويُسمّى هذا عنوان الإرجاع لأنه عنوان التعليمة التي تعود إليها الآلة الافتراضية بعد الاستدعاء.

ومرةً أخرى، بفضل التعاود، قد يكون هناك عناوين إرجاع متعدّدة لدالة واحدة، لذا فهذا خاصية لكل استدعاء وليس للدالة نفسها.

مكدّس الاستدعاءات

إذن لكل استدعاء دالة حيّ -- كل استدعاء لم يُعِد بعد -- نحتاج إلى تتبّع أين تبدأ متغيّرات تلك الدالة على المكدّس، وأين ينبغي للمُنادي أن يستأنف. وسنضع هذا، مع بعض الأشياء الأخرى، في بنية جديدة.

^code call-frame (1 before, 2 after)

يمثّل CallFrame استدعاء دالة واحداً جارياً. ويشير حقل slots إلى مكدّس قيم الآلة الافتراضية عند أول خانة يمكن لهذه الدالة استخدامها. وقد أعطيته اسماً جمعاً لأن -- بفضل «المؤشرات تشبه المصفوفات نوعاً ما» الغريبة في C -- سنتعامل معه كأنه مصفوفة.

تنفيذ عناوين الإرجاع يختلف قليلاً عمّا وصفته أعلاه. فبدلاً من تخزين عنوان الإرجاع في إطار الدالة المُستدعاة، يخزّن المُنادي قيمة ip الخاصة به. وحون نعود من دالة، ستقفز الآلة الافتراضية إلى ip في CallFrame الخاص بالمُنادي وتستأنف من هناك.

وقد وضعتُ هنا أيضاً مؤشراً إلى الدالة المُستدعاة. وسنستخدمه للبحث عن الثوابت ولبعض الأشياء الأخرى.

في كل مرة تُستدعى دالة، ننشئ واحدة من هذه البنى. يمكننا تخصيصها ديناميكياً على الكومة، لكن ذلك بطيء. واستدعاءات الدوال عملية جوهرية، لذا ينبغي أن تكون أسرع ما يمكن. ولحسن الحظ، يمكننا أن نُجري الملاحظة نفسها التي أجريناها على المتغيّرات: استدعاءات الدوال لها دلالات مكدّس. فإذا استدعت first() الدالة second()، فسيكتمل استدعاء second() قبل أن يكتمل first().

إذن في الآلة الافتراضية، ننشئ مصفوفة من بنى CallFrame هذه مسبقاً ونتعامل معها كمكدّس، كما نفعل مع مصفوفة القيم.

^code frame-array (1 before, 1 after)

هذه المصفوفة تحلّ محل حقلي chunk وip اللذين كنا نستخدمهما مباشرةً في الآلة الافتراضية. الآن لكل CallFrame حقل ip خاص به ومؤشر خاص به إلى ObjFunction الذي ينفّذه. ومن هناك يمكننا الوصول إلى chunk الخاصة بالدالة.

ويخزّن حقل frameCount الجديد في الآلة الافتراضية الارتفاع الحالي لمكدّس CallFrame -- أي عدد استدعاءات الدوال الجارية. ولإبقاء clox بسيطاً، فإن سعة المصفوفة ثابتة. وهذا يعني، كما في تطبيقات اللغات كثيرة، أن هناك أقصى عمق استدعاء يمكننا التعامل معه. وبالنسبة إلى clox، فقد عُرِّف هنا:

^code frame-max (2 before, 2 after)

ونُعيد أيضاً تعريف حجم مكدّس القيم

ب بحيث نتأكّد من توفر خانات مكدّس كثيرة بما يكفي حتى في أشجار استدعاءات عميقة جداً. وحين تبدأ الآلة الافتراضية، يكون مكدّس CallFrame فارغاً.

^code reset-frame-count (1 before, 1 after)

يحتاج ترويسة "vm.h" إلى الوصول إلى ObjFunction، لذا نضيف تضميناً.

^code vm-include-object (2 before, 1 after)

الآن نحن مستعدّون للانتقال إلى ملف تنفيذ الآلة الافتراضية. لدينا بعض العمل الروتيني أمامنا. لقد أخرجنا ip من بنية VM وأدخلناه في CallFrame. وعلينا إصلاح كل سطر شيفرة في الآلة الافتراضية يمسّ ip للتعامل مع ذلك. كما أن التعليمات التي تصل إلى المتغيّرات المحلّية عبر خانة المكدّس تحتاج إلى التحديث لتفعل ذلك بالنسبة إلى حقل slots في CallFrame الحالي.

سنبدأ من الأعلى ونمضي فيها.

^code run (1 before, 1 after)

أولاً، نخزّن CallFrame الحالي في متغيّر محلّي داخل دالة تنفيذ شيفرة البايت الرئيسية. ثم نستبدل ماكروهات الوصول إلى شيفرة البايت بنسخ تصل إلى ip عبر ذلك المتغيّر.

الآن إلى كل تعليمة تحتاج إلى بعض العناية Tender.

^code push-local (2 before, 1 after)

في السابق، كانت OP_GET_LOCAL تقرأ خانة المتغيّر المحلّي المعطاة مباشرةً من مصفوفة مكدّس الآلة الافتراضية، ما كان يعني أنها تفهرس الخانة بدءاً من قاع المكدّس. أما الآن فهي تصل إلى مصفوفة slots للإطار الحالي، ما يعني أنها تصل إلى الخانة المرقّمة المعطاة بالنسبة إلى بداية ذلك الإطار.

ويتصرّف ضبط متغيّر محلّي بالطريقة نفسها.

^code set-local (2 before, 1 after)

كانت تعليمات القفز تعدّل حقل ip في الآلة الافتراضية. أما الآن فتفعل الشيء نفسه لـ ip في الإطار الحالي.

^code jump (2 before, 1 after)

وكذلك القفز الشرطي:

^code jump-if-false (2 before, 1 after)

وتعليمة حلقتنا القافزة إلى الخلف:

^code loop (2 before, 1 after)

لدينا بعض شيفرة التشخيص التي تطبع كل تعليمة أثناء تنفيذها لتساعدنا على تصحيح أخطاء الآلة الافتراضية. تلك تحتاج إلى العمل مع البنية الجديدة أيضاً.

^code trace-execution (1 before, 1 after)

وبدلاً من تمرير حقلي chunk وip من الآلة الافتراضية، فإننا نقرأ الآن من CallFrame الحالي.

تعرف، لم يكن ذلك سيئاً جداً في الحقيقة. معظم التعليمات تستخدم الماكروهات فقط فلم تحتج إلى مساس. بعد ذلك، نرقى مستوى إلى الشيفرة التي تستدعي run().

^code interpret-stub (1 before, 2 after)

نصل أخيراً إلى ربط تغييرات المصرّف السابقة بتغييرات الطرف الخلفي التي أجريناها للتوّ. أولاً، نمرّر شيفرة المصدر إلى المصرّف. وهو يُعيد لنا ObjFunction جديداً يحوي شيفرة المستوى الأعلى المصرَّفة. إن عدنا بـ NULL، فذلك يعني وجود خطأ وقت التصريف وقد أبلغ عنه المصرّف بالفعل. في تلك الحالة، نخرج لأننا لا نستطيع تشغيل أيّ شيء.

وإلا، فإننا نخزّن الدالة على المكدّس ونحضّر CallFrame أولياً لتنفيذ شيفرتها. الآن يمكنك أن ترى لماذا يحجز المصرّف خانة المكدّس صفراً -- فهي تخزّن الدالة المُستدعاة. وفي CallFrame الجديد، نشير إلى الدالة، ونهيّئ ip ليشير إلى بداية شيفرة بايت الدالة، ونضبط نافذتها على المكدّس لتبدأ من قاع مكدّس قيم الآلة الافتراضية تماماً.

هذا يجهّز المفسّر لبدء تنفيذ الشيفرة. وبعد الانتهاء، كانت الآلة الافتراضية تحرّر القطعة المكتوبة بشكل ثابت. والآن بما أن ObjFunction يملك تلك الشيفرة، فلا نحتاج إلى فعل ذلك، لذا فإن نهاية interpret() هي ببساطة:

^code end-interpret (2 before, 1 after)

آخر قطعة شيفرة تشير إلى حقول الآلة الافتراضية القديمة هي runtimeError(). سنعود إليها لاحقاً في الفصل، لكن دعنا نغيّرها إلى هذا الآن:

^code runtime-error-temp (2 before, 1 after)

وبدلاً من قراءة الـ chunk و ip مباشرةً من الآلة الافتراضية، فإنها تسحبهما من CallFrame الأعلى على المكدّس. من المفترض أن يعيد هذا تشغيل الدالة وسلوكها كما كان.

بافتراض أننا أجرينا كل ذلك على نحو صحيح، فقد أعدنا clox إلى حالة قابلة للتشغيل. شغّله وسيفعل... بالضبط ما كان يفعله سابقاً. لم نضف أيّ ميزات جديدة بعد، لذا فهذا مخيب للآمال نوعاً ما. لكن كل البنية التحتية موجودة وجاهزة لنا الآن. فلنستثمرها.

إعلانات الدوال

قبل أن نتمكن من فعل تعبيرات الاستدعاء، نحتاج إلى شيء نستدعيه، لذا لنُجرِ إعلانات الدوال أولاً. وتبدأ fun بكلمة مفتاحية.

^code match-fun (1 before, 1 after)

وهذا ينقل التحكّم إلى هنا:

^code fun-declaration

الدوال قيم من الدرجة الأولى، وإعلان الدالة ببساطة ينشئ واحدة ويخزّنها في متغيّر مُعلن حديثاً. لذا نحلّل الاسم كما نفعل مع أيّ إعلان متغيّر آخر. وإعلان الدالة في المستوى الأعلى سيربط الدالة بمتغيّر عام. وداخل كتلة أو دالة أخرى، يُنشئ إعلان الدالة متغيّراً محلّياً.

وفي فصل سابق، شرحتُ كيف تُعرَّف المتغيّرات على مرحلتين. وهذا يضمن أنك لا تستطيع الوصول إلى قيمة متغيّر داخل مُهيّئ المتغيّر نفسه. لأن ذلك سيكون سيئاً إذ لا تكون للمتغيّر قيمة بعد.

والدوال لا تعاني هذه المشكلة. فالإشارة إلى اسم الدالة داخل جسمها أمر آمن. ولا يمكنك استدعاء الدالة وتنفيذ الجسم إلا بعد أن تُعرَّف تماماً، لذا لن ترى المتغيّر في حالة غير مهيّأة أبداً. ومن الناحية العملية، السماح بهذا مفيد لدعم دوال محلّية متعاودة.

وجعل ذلك يعمل، نعلّم متغيّر إعلان الدالة بأنه «مهيّأ» بمجرد أن نصرّف الاسم، قبل أن نصرّف الجسم. وبذلك يمكن الإشارة إلى الاسم داخل الجسم دون توليد خطأ.

لكننا نحتاج إلى فحص واحد على أيّ حال.

^code check-depth (1 before, 1 after)

في السابق، لم نُستدعِ markInitialized() إلا حين كنّا نعرف بالفعل أننا في نطاق محلّي. الآن، فإن إعلان دالة في المستوى الأعلى سيستدعي هذه الدالة أيضاً. وحين يحدث ذلك، لا يوجد متغيّر محلّي نعلّمه كمهيّأ -- فالدالة مربوطة بمتغيّر عام.

بعد ذلك، نصرّف الدالة نفسها -- قائمة معاملاتها وجسمها الكتلي. ونستخدم لذلك دالة مساعدة منفصلة. وتولّد تلك الدالة المساعدة شيفرة تترك كائن الدالة الناتج على قمة المكدّس. وبعد ذلك، نستدعي defineVariable() لتخزين تلك الدالة مرةً أخرى في المتغيّر الذي أعلنّاه لها.

فصلت شيفرة تصريف المعاملات والجسم لأننا سنعيد استخدامها لاحقاً لتحليل إعلانات الدوال داخل الأصناف. لنبنِها تدريجياً، بدءاً بهذا:

^code compile-function

في الوقت الحالي، لا نهتمّ بالمعاملات. نحلّل زوج أقواس فارغاً يليه الجسم. ويبدأ الجسم بقوس فتح معقوف نحلّله هنا. ثم نستدعي دالتنا القائمة block() التي تعرف كيف تصرّف بقية الكتلة بما في ذلك قوس الإغلاق.

مكدّس من المصرّفات

الأجزاء المثيرة هي شيفرة المصرّف في الأعلى والأسفل. فبنية Compiler تخزّن بيانات مثل أيّ الخانات مملوكة لأيّ متغيّرات محلّية، وكم مستوى من التداخل نحن فيه حالياً، إلخ. كل ذلك خاص بدالة واحدة. لكن الآن يحتاج المقدّمة إلى التعامل مع تصريف عدة دوال متداخلة داخل بعضها.

والحيلة لإدارة ذلك هي إنشاء Compiler منفصل لكل دالة يجري تصريفها. تصريف إعلان دالة، ننشئ Compiler جديداً على مكدّس C ونهيّئه. ويضبط initCompiler() ذلك Compiler ليكون هو الحالي. ثم، ونحن نصرّف الجسم، تكتب كل الدوال التي تُصدر شيفرة بايت إلى القطعة التي يملكها Compiler الجديد.

وبعد أن نصل إلى نهاية جسم الكتلة الخاص بالدالة، نستدعي endCompiler(). وهذا يُنتج كائن الدالة المُصرَّف حديثاً، الذي نخزّنه كثابت في جدول ثوابت الدالة المحيطة. لكن، مهلاً، كيف نعود إلى الدالة المحيطة؟ لقد فقدناها حين كتب initCompiler() فوق مؤشر المصرّف الحالي.

ونُصلح ذلك بمعاملاة سلسلة بنى Compiler المتداخلة كأنها مكدّس. وبخلاف مكدّسي Value وCallFrame في الآلة الافتراضية، لن نستخدم مصفوفة. بدلاً من ذلك، نستخدم قائمة مرتبطة. تشير كل Compiler إلى Compiler الخاصة بالدالة التي تحيط بها، وصولاً كلّ way back إلى Compiler الجذر الخاصة بشيفرة المستوى الأعلى.

^code enclosing-field (2 before, 1 after)

وداخل بنية Compiler، لا يمكننا الإشارة إلى الـ typedef الخاص بـ Compiler لأن ذلك التصريح لم ينتهِ بعد. بدلاً من ذلك، نمنح اسماً للبنية نفسها ونستخدمه كنوع للحقل. C غريبة.

وحين نهيّئ Compiler جديداً، فإننا نلتقط في ذلك المؤشر الـ Compiler الذي لم يعد الحالي.

^code store-enclosing (1 before, 1 after)

ثم حين ينتهي Compiler، فإنها يُخرج نفسه من المكدّس بإعادة المصرّف السابق ليكون هو الحالي.

^code restore-enclosing (2 before, 1 after)

لاحظ أننا لا نحتاج حتى إلى تخصيص بنى Compiler ديناميكياً. فكل واحدة منها تُخزَّن كمتغيّر محلّي على مكدّس C -- إما في compile() أو في function(). وقائمة Compiler المرتبطة تمرّ عبر مكدّس C. والسبب في قدرتنا على الحصول على عدد غير محدود منها هو أن مصرّفنا يستخدم التحليل بال descent التكراري، لذا تنتهي function() إلى استدعاء نفسها تعاودياً حين تكون لديك إعلانات دوال متداخلة.

معاملات الدوال

الدوال ليست مفيدة كثيراً إن لم تستطع تمرير وسائط إليها، لذا لننتقل إلى المعاملات.

^code parameters (1 before, 1 after)

دلالياً، المعامل ليس سوى متغيّر محلّي معرَّف في النطاق المعجمي الأوسع لجسم الدالة. ولنا أن نستخدم الدعم القائم في المصرّف لإعلان المتغيّرات المحلّية المُسمّاة لتحليل المعاملات وصرّفها. وبخلاف المتغيّرات المحلّية التي لها مُهيّئات، لا توجد هنا شيفرة تهيّئ قيمة المعامل. وسنرى كيف تُهيّأ لاحقاً حين ننفّذ تمرير الوسائط في استدعاءات الدوال.

وبمناسبة الحديث عن ذلك، فإننا نلاحظ عدد وسائط الدالة بعدد المعاملات التي نحلّلها. وقطعة البيانات الوصفية الأخرى التي نخزّنها مع الدالة هي اسمها. وحين نصرّف إعلان دالة، نستدعي initCompiler() مباشرةً بعد أن نحلّل اسم الدالة. وهذا يعني أننا نستطيع الإمساك بالاسم عندئذٍ من الرمز السابق.

^code init-function-name (1 before, 2 after)

لاحظ أننا حريصون على إنشاء نسخة من سلسلة الاسم. تذكّر أن المُعرِّف (lexeme) يشير مباشرةً إلى شيفرة المصدر الأصلية. وقد تُحرَّر تلك السلسلة بمجرد انتهاء تصريف الشيفرة. وكائن الدالة الذي ننشئه في المصرّف يعمّر أكثر من المصرّف ويبقى حتى الزمن التشغيلي. لذا يحتاج إلى سلسلة اسم خاصة به مخصَّصة على الكومة يستطيع الاحتفاظ بها.

رائع. الآن يمكننا صرّف إعلانات الدوال، مثل هذه:

fun areWeHavingItYet() {
  print "Yes we are!";
}

print areWeHavingItYet;

لكننا لا نستطيع فعل أيّ شيء مفيد بها بعد.

استدعاءات الدوال

بنهاية هذا القسم، سنبدأ في رؤية بعض السلوكيات المثيرة. والخطوة التالية هي استدعاء الدوال. لا نفكّر عادةً بهذه الطريقة، لكن تعبير استدعاء الدالة هو نوعٌ ما من مشغّل ( ثنائي. لديك تعبير عالي الأولوية على اليسار للشيء المُستدعى -- عادةً مُعرِّف واحد فقط. ثم ( في الوسط، يليها تعبيرات الوسائط مفصولة بفواصل، وأخيراً ) لتلفيف كل شيء في النهاية.

تلك المنظور النحوي الغريب يشرح كيفية ربط الصياغة بجدول التحليل لدينا.

^code infix-left-paren (1 before, 1 after)

وحين يصادف المحلل النحوي قوس فتح بعد تعبير، فإنها يوجّه التنفيذ إلى دالة تحليل جديدة.

^code compile-call

لقد استهلكنا رمز ( بالفعل، لذا نصرّف الوسائط تالياً باستخدام دالة مساعدة منفصلة argumentList(). وتعيد تلك الدالة عدد الوسائط التيصرّفتها. ويُنتج كل تعبير وسيط شيفرة تترك قيمته على المكدّس، تحضيراً لللاستدعاء. وبعد ذلك، نُصدر تعليمة OP_CALL جديدة لاستدعاء الدالة، مستخدماً عدد الوسائط كمعامل.

ونصرّف الوسائط باستخدام هذا الصديق:

^code argument-list

وينبغي أن تبدو تلك الشيفرة مألوفة من jlox. نمضغ الوسائط ما دمنا نجد فواصل بعد كل تعبير. ومتى نفدت، نستهلك قوس الإغلاق الأخير وننتهي.

حسناً، تقريباً. فعن jlox، أضفنا فحصاً وقت التصريف بأنك لا تمرّر أكثر من 255 وسيطاً في الاستدعاء.، قلتُ إن ذلك لأن clox ستحتاج إلى حدّ مشابه. والآن ترى لماذا -- فلأننا نحشر عدد الوسائط في شيفرة البايت كمعامل ببايت واحد، لا يمكننا تجاوز 255. ونحتاج إلى التحقّق من ذلك في هذا المصرّف أيضاً.

^code arg-limit (1 before, 1 after)

هذا هو المقدّمة. فلننتقل إلى الطرف الخلفي، مع توقف سريع في المنتصف لإعلان التعليمة الجديدة.

^code op-call (1 before, 1 after)

ربط الوسائط بالمعاملات

قبل أن نصل إلى التنفيذ، يجدر بنا أن نفكّر في شكل المكدّس عند نقطة الاستدعاء وما نحتاج إلى فعله من هناك. فعند وصولنا إلى تعليمة الاستدعاء، يكون تعبير الدالة المُستدعاة قد نُفِّذ بالفعل، تليه وسائطها. لنفترض أن برنامجنا يبدو هكذا:

fun sum(a, b, c) {
  return a + b + c;
}

print 4 + sum(5, 6, 7);

إن أوقفنا الآلة الافتراضية على تعليمة OP_CALL الخاصة بذلك الاستدعاء لـ sum()، فسيبدو المكدّس هكذا:

المكدّس: 4، fn sum، 5، 6، 7.

تخيّل هذا من منظور sum() نفسها. فحين صرّف المصرّف sum()، خصّص خانة المكدّس صفراً تلقائياً. ثم بعد ذلك، خصّص خانات محلّية للمعاملات a وb وc بالترتيب. ولتنفيذ استدعاء لـ sum()، نحتاج إلى CallFrame مهيّأً مع الدالة المُستدعاة ومنطقة من خانات المكدّس يمكنه استخدامها. ثم نحتاج إلى جمع الوسائط المُمرَّرة إلى الدالة ووضعها في الخانات المقابلة للمعاملات.

وحين تبدأ الآلة الافتراضية بتنفيذ جسم sum()، نريد أن تبدو نافذة المكدّس الخاصة به هكذا:

المكدّس نفسه مع نافذة إطار استدعاء دالة sum() تحيط بـ fn sum و5 و6 و7.

هل تلاحظ أن خانات الوسائط التي يهيّئها المُنادي وخانات المعاملات التي يحتاجها المُستدعى كلاهما بالترتيب الصحيح تماماً؟ ما أيسر هذا! وليست هذه مصادفة. فع حين تحدّثتُ عن أن لكل CallFrame نافذته الخاصة في المكدّس، لم أقل قطّ إن تلك النوافذ يجب أن تكون متباعدة. ولا شيء يمنعنا من تداخلها، مثل هذا:

المكدّس نفسه مع إطار استدعاء المستوى الأعلى يغطّي المكدّس بأكمله ونافذة إطار استدعاء دالة sum() تحيط بـ fn sum و5 و6 و7.

قمة مكدّس المُنادي تحوي الدالة المُستدعاة يليها الوسائط بالترتيب. ونحن نعرف أن المُنادي ليس لديه أيّ خانات أخرى مستخدَمة فوق تلك، لأن أيّ قيم مؤقتة لزمت عند تقييم تعبيرات الوسائط قد تخلّص منها الآن. وقاع مكدّس المُستدعى يتداخل بحيث تصطف خانات المعاملات تماماً مع مواقع قيم الوسائط الموجودة بالفعل.

وهذا يعني أننا لا نحتاج إلى إجراء أيّ عمل لـ«ربط وسيط بمعامل». فلا نسخ قيم بين خانات ولا عبر بيئات. فالوسائط موجودة بالفعل في مواقعها تماماً. يصعب ذلك في الأداء.

حان وقت تنفيذ تعليمة الاستدعاء.

^code interpret-call (1 before, 1 after)

نحتاج إلى معرفة الدالة المُستدعاة وعدد الوسائط المُمرَّرة إليها. فنحصل على الأخير من معامل التعليمة. وهذا يخبرنا أيضاً أين نجد الدالة على المكدّس:after بمضيّنا فوق خانات الوسائط انطلاقاً من قمة المكدّس. ونسلّم تلك البيانات إلى دالة منفصلة callValue(). فإن أعادت false، فذلك يعني أن الاستدعاء سبّب خطأً زمنياً من نوع ما. وحين يحدث ذلك، نُجهض المفسّر.

وإن كانت callValue() ناجحة، فسيكون هناك إطار جديد على مكدّس CallFrame للدالة المُستدعاة. ولدى دالة run() مؤشر مخزَّن مؤقتاً خاص بها إلى الإطار الحالي، لذا نحتاج إلى تحديث ذلك المؤشر.

^code update-frame-after-call (2 before, 1 after)

وبما أن حلقة توجيه شيفرة البايت تقرأ من متغيّر frame ذاك، فحين تنفذ الآلة الافتراضية التعليمة التالية، فإنها ستقرأ ip من CallFrame الخاص بالدالة المُستدعاة حديثاً وتقفز إلى شيفرتها. والعمل لتنفيذ ذلك الاستدعاء يبدأ هنا:

^code call-value

وهنا أمر أكثر من مجرد تهيئة CallFrame جديد. فبما أن Lox مكتوبة النوع ديناميكياً، لا شيء يمنع المستخدم من كتابة شيفرة سيّئة مثل:

var notAFunction = 123;
notAFunction();

إن حدث ذلك، يحتاج النظام الزمني إلى الإبلاغ عن خطأ بأمان وإيقاف التنفيذ. لذا أول ما نفعله هو فحص نوع القيمة التي نحاول استدعائها. إن لم تكن دالة، فإننا نُطلق خطأً. وإلا، فإن الاستدعاء الفعلي يحدث هنا:

^code call

وهذا ببساطة يهيّئ CallFrame التالي على المكدّس. فيه خزّن مؤشراً إلى الدالة المُستدعاة ويجعل ip الخاص بالإطار يشير إلى بداية شيفرة بايت الدالة. وأخيراً، يضبط مؤشر slots ليمنح الإطار نافذته في المكدّس. والحساب هناك يضمن أن الوسائط الموجودة بالفعل على المكدّس تصطف مع معاملات الدالة:

الحساب لاشتقاق frame->slots من stackTop و argCount.

الـ - 1 الغريبة الصغيرة موجودة لمراعاة خانة المكدّس صفر التي حجزها المصرّف لِما سنضيف دوال لاحقاً. وتبدأ المعاملات من الخانة واحد، لذا نجعل النافذة تبدأ خانة واحدة أسبق حتى تصطف مع الوسائط.

وقبل أن ننتقل، لنضف التعليمة الجديدة إلى المُفكّك.

^code disassemble-call (1 before, 1 after)

ورحلة جانبية سريعة أخرى. الآن لدينا دالة ملائمة لبدء CallFrame، فلنستخدمها لإعداد أول إطار لتنفيذ شيفرة المستوى الأعلى.

^code interpret (1 before, 2 after)

حسناً، الآن نعود إلى الاستدعاءات...

فحص الأخطاء الزمنية

تعمل نوافذ المكدّس المتداخلة على أساس الافتراض بأن الاستدعاء يمرّر وسيطاً واحداً بالضبط لكل معامل من معاملات الدالة. لكن، ومرةً أخرى، لأن Lox ليست مكتوبة النوع ساكناً،& يستطيع المستخدم الساذج أن يمرّر عدداً أكبر أو أصغر من الوسائط. وفي Lox، جعلنا ذلك خطأً زمنياً، ونُبلّغ عنه هكذا:

^code check-arity (1 before, 1 after)

أمر مباشر إلى حدٍّ كبير. ولهذا نخزّن عدد وسائط كل دالة داخل ObjFunction الخاصة بها.

وهناك خطأ آخر علينا الإبلاغ عنه ليس له علاقة بتهوين المستخدم بقدر ما له علاقة بنا نحن. فبما أن مصفوفة CallFrame ذات حجم ثابت، نحتاج إلى التأكّد من أن سلسلة استدعاءات عميقة لا تفضحها.

^code check-overflow (2 before, 1 after)

وفي الممارسة، إن وصل برنامج إلى ما يقارب هذا الحدّ، فغالباً ما يكون هناك خلل في شيفرة متناسبة تائهة.

طباعة تتبّعات المكدّس

بينما نحن في موضوع الأخطاء الزمنية، فلنُنفق قليلاً من الوقت لجعلها أكثر نفعاً. فالتوقّف عند خطأ زمني مهمّ لمنع الآلة الافتراضية من الانهيار والاشتعال بطريقة غير محدّدة. لكن الإنهاء المجرّد لا يساعد المستخدم على إصلاح شيفرته التي تسبّبت ذلك الخطأ.

والأداة الكلاسيكية لمساعدة التنقيح في إخفاقات الزمن التشغيلي هي تتبّع المكدّس -- طباعة كل دالة كانت لا تزال تُنفَّذ حين مات البرنامج، وأين كان التنفيذ عند النقطة التي مات فيها. والآن لدينا مكدّس استدعاءات وقد خزّنا لكل دالة اسمها، فيمكننا إظهار ذلك المكدّس كلّه عندما يعبث خطأ زمني بهواء وجود المستخدم. يبدو هكذا:

^code runtime-error-stack (2 before, 2 after)

بعد طباعة رسالة الخطأ نفسها، نمشي على مكدّس الاستدعاءات من الأعلى (الدالة المُستدعاة حديثاً) إلى الأسفل (شيفرة المستوى الأعلى). ولكل إطار، نجد رقم السطر المقابل لـ ip الحالي داخل دالة ذلك الإطار. ثم نطبع رقم السطر مع اسم الدالة.

مثلاً، إن شغّلتَ هذا البرنامج المكسور:

fun a() { b(); }
fun b() { c(); }
fun c() {
  c("too", "many");
}

a();

فهو يطبع:

Expected 0 arguments but got 2.
[line 4] in c()
[line 2] in b()
[line 1] in a()
[line 7] in script

ألا يبدو سيئاً جداً، أليس كذلك؟

الإرجاع من الدوال

نحن نقترب. يمكننا استدعاء الدوال، وستنفّذها الآلة الافتراضية. لكننا لا نستطيع الإرجاع منها بعد. لقد كان لدينا تعليمة OP_RETURN منذ وقت طويل، لكنها كانت تحوي دائماً نوعاً ما من الشيفرة المؤقتة إخراجنا من حلقة شيفرة البايت. وقد حان وقت تنفيذ حقيقي.

^code interpret-return (1 before, 1 after)

حين تُعيد الدالة قيمة، ستكون تلك القيمة على قمة المكدّس. ونحن على وشك التخلّص من نافذة المكدّس كاملةً الخاصة بالدالة المُستدعاة، لذا نسحب قيمة الإرجاع تلك ونتمسك بها. ثم نتخلّص من CallFrame الخاصة بالدالة العائدة. إن كان ذلك آخر CallFrame على الإطلاق، فذلك يعني أنناfinished تنفيذ شيفرة المستوى الأعلى. فالبرنامج كله انتهى، لذا نسحب دالة السكربت الرئيسية من المكدّس ثم نخرج من المفسّر.

وإلا، فإننا نتخلّص من كل الخانات التي كان المُستدعى يستخدمها لمعاملاته ومتغيّراته المحلّية. ويشمل ذلك الخانات نفسها التي استخدمها المُنادي لتمرير الوسائط. والآن وقد انتهى الاستدعاء، لا يحتاجها المُنادي. وهذا يعني أن قمة المكدّس تنتهي تماماً عند بداية نافذة المكدّس للدالة العائدة.

ثم ندفع قيمة الإرجاع مرةً أخرى onto المكدّس في ذلك الموضع الجديد الأقلّ. ثم نحدّث مؤشر دالة run() المخزَّن مؤقتاً إلى الإطار الحالي. تماماً كما حين بدأنا استدعاءً، ففي الدورة التالية لحلقة توجيه شيفرة البايت، ستقرأ الآلة الافتراضية ip من ذلك الإطار، وسيقفز التنفيذ عائداً إلى المُنادي في المكان الذي توقّف فيه تماماً، بعد تعليمة OP_CALL مباشرةً.

كل خطوة من عملية الإرجاع: سحب قيمة الإرجاع، والتخلّص من إطار الاستدعاء، ودفع قيمة الإرجاع.

لاحظ أننا نفترض هنا أن الدالة قد أعادت قيمة فعلاً، لكن الدالة يمكن أن تعود ضمنياً بوصولها إلى نهاية جسمها:

fun noReturn() {
  print "Do stuff";
  // No return here.
}

print noReturn(); // ???

ونحتاج إلى معالجة ذلك على نحو صحيح أيضاً. فاللغة معرَّفة بأن تُعيد nil ضمنياً في تلك الحالة. ولتحقيق ذلك، نضيف هذا:

^code return-nil (1 before, 2 after)

يستدعي المصرّف emitReturn() لكتابة تعليمة OP_RETURN في نهاية جسم الدالة. والآن، قبل ذلك، يُصدر تعليمة لدفع nil على المكدّس. وبذلك، لدينا استدعاءات دوال عاملة! بل أنها تستطيع حتى أن تأخذ معاملات! يكاد يبدو أننا نعرف ما نفعله هنا.

جمل الإرجاع

إن أردتَ دالة تُعيد شيئاً غير nil الضمني، فأنت بحاجة إلى جملة return. لنجعلها تعمل.

^code match-return (1 before, 1 after)

حين يرى المصرّف الكلمة المفتاحية return، فإنه ينتقل إلى هنا:

^code return-statement

تعبير قيمة الإرجاع اختياري، لذا يبحث المحلل النحوي عن رمز فاصلة منقوطة ليعرف ما إذا كانت قيمة قد قُدِّمت. وإن لم تكن هناك قيمة إرجاع، فإن الجملة تُعيد nil ضمنياً. وننفّذ ذلك باستدعاء emitReturn() الذي يُصدر تعليمة OP_NIL. وإلا، فإننا نصرّف تعبير قيمة الإرجاع ونُعيده بتعليم OP_RETURN.

وهذه هي تعليمة OP_RETURN نفسها التي نفّذناها بالفعل -- لا نحتاج إلى أيّ شيفرة زمنية جديدة. وهذا فرق كبير عن jlox. فهناك كنا بحاجة إلى استخدام الاستثناءات لفكّ المكدّس حين تُنفَّذ جملة return. وذلك لأن بإمكانك الإرجاع من أعماق بعض الكتل المتداخلة. ولأن jlox يمشي على AST تعاودياً، كان ذلك يعني وجود كومة من استدعاءات دوال Java كان علينا الهروب منها.

ومصرّف شيفرة البايت لدينا يُسطّح كل ذلك. فنحن نستخدم التحليل بال descent التكراري أثناء التحليل، لكن في الزمن التشغيلي، حلقة توجيه شيفرة البايت في الآلة الافتراضية مسطّحة تماماً. لا يوجد أيّ تعاود على مستوى C على الإطلاق. لذا فإن الإرجاع، حتى من داخل بعض الكتل المتداخلة، بسيط تماماً كالإرجاع من نهاية جسم الدالة.

لكننا لم ننتهِ تماماً. فجملة return الجديدة تتيح لنا خطأ تصريف جديداً نقلق بشأنه. والإرجاعات مفيدة للإرجاع من الدوال، لكن المستوى الأعلى لبرنامج Lox هو أيضاً شيفرة أمرية. فلا ينبغي أن تتمكّن من الإرجاع من هناك.

return "What?!";

لقد حدّدنا أن وجود جملة return خارج أيّ دالة خطأ تصريف، وننفّذ ذلك هكذا:

^code return-from-script (1 before, 1 after)

وهذا أحد الأسباب التي أضفنا بسببها تعداد FunctionType إلى المصرّف.

الدوال الأصلية

آلتنا الافتراضية صارت أكثر قوة. لدينا دوال، واستدعاءات، ومعاملات، وإرجاعات. تعريف دوال كثيرة مختلفة تستدعي بعضها بطرق مثيرة. لكن، في النهاية، لا تستطيع حقاً أن تفعل أيّ شيء. الشيء الوحيد المرئي للمستخدم الذي يمكن لبرنامج Lox فعله، مهما كان تعقيده، هو الطباعة. ولإضافة المزيد من القدرات، نحتاج إلى تعريضها للمستخدم.

تصل تطبيقات لغات البرمجة إلى العالم المادي وتلمسه من خلال الدوال الأصلية. فإذا أردتَ القدرة على كتابة برامج تفحص الوقت، أو تقرأ مدخلات المستخدم، أو تصل إلى نظام الملفات، فنحن بحاجة إلى إضافة دوال أصلية -- قابلة للاستدعاء من Lox لكن منفَّذة في C -- تكشف تلك القدرات.

وعلى مستوى اللغة، فإن Lox مكتملة إلى حدٍّ كبير -- لديها إغلاق، وأصناف، ووراثة، وأشياء أخرى ممتعة. وأحد أسباب إحساسها كلعبة لغوية أنها لا تمالك تقريباً أيّ قدرات أصلية. يمكننا تحويلها إلى لغة حقيقية بإضافة قائمة طويلة منها.

لكن اجتياز كومة من عمليات نظام التشغيل ليس تعليمياً جداً في الواقع. فبمجرد أن ترى كيف تربط قطعة شيفرة C واحدة بـ Lox، يصبح الأمر واضحاً. لكنك تحتاج فعلاً إلى رؤية واحدة، وحتى دالة أصلية واحدة تتطلّب منّا بناء كل الآلات اللازمة للربط بين Lox و C. لذا سنمرّ على ذلك ونُنجز كل العمل الشاقّ. ثم، حين ينتهي ذلك، سنضيف دالة أصلية صغيرة واحدة فقط لنُثبت أنها تعمل.

والسبب في حاجتنا إلى آلات جديدة هو أن الدوال الأصلية، من منظور التنفيذ، تختلف عن دوال Lox. فحين تُستدعى، هي لا تدفع CallFrame، لأنه لا توجد شيفرة بايت لذلك الإطار ليشير إليها. فهي لا تملك قطعة شيفرة بايت. بدلاً من ذلك، فإنها تشير بطريقة ما إلى قطعة من شيفرة C الأصلية.

ونتعامل مع هذا في clox بتعريف الدوال الأصلية كنوع كائن مختلف تماماً.

^code obj-native (1 before, 2 after)

والتمثيل أبسط من ObjFunction -- مجرد ترويسة Obj ومؤشر إلى دالة C التي تنفّذ السلوك الأصلي. وتاخذ الدالة الأصلية عدد الوسائط ومؤشراً إلى أول وسيط على المكدّس. وهي تصل إلى الوسائط عبر ذلك المؤشر. ومتى انتهت، تُعيد قيمة النتيجة.

وكالمعتاد، يحمل نوع الكائن الجديد معه بعض التجهيزات. ولإنشاء ObjNative، نعلن دالة شبيهة بالمُنشئ.

^code new-native-h (1 before, 1 after)

وننفّذ ذلك هكذا:

^code new-native

يأخذ المُنشئ مؤشراً إلى دالة C ليلفّه في ObjNative. وهو يضبط ترويسة الكائن ويخزّن الدالة. أما الترويسة فتحتاج إلى نوع كائن جديد.

^code obj-type-native (2 before, 2 after)

كما تحتاج الآلة الافتراضية أيضاً إلى معرفة كيفية تحرير كائن دالة أصلية.

^code free-native (1 before, 1 after)

ليس هناك much هنا لأن ObjNative لا يملك أيّ ذاكرة إضافية. والقدرة الأخرى التي تدعمها جميع كائنات Lox هي الطباعة.

^code print-native (1 before, 1 after)

ودعماً للكتابة الديناميكية، لدينا ماكرو للتحقّق ممّا إذا كانت قيمة ما دالة أصلية.

^code is-native (1 before, 1 after)

بافتراض أن ذلك يُعيد true، فإن هذا الماكرو يستخرج مؤشر دالة C من قيمة تمثّل دالة أصلية:

^code as-native (1 before, 1 after)

كل هذا العتاد يسمح للآلة الافتراضية بالتعامل مع الدوال الأصلية كأيّ كائن آخر. يمكنك تخزينها في متغيّرات، وتمريرها، والاحتفال بأعياد ميلادها، إلخ. وبالطبع، فالعملية التي نهمّ بها فعلاً هي استدعاؤها -- استخدام إحداها كمُعامل أيسر في تعبير استدعاء.

وفي callValue() نضيف حالة نوع أخرى.

^code call-native (2 before, 1 after)

إن كان الكائن المُستدعى دالة أصلية، فإننا نستدعي دالة C في حينه. ولا حاجة إلى العبث بـ CallFrames أو أيّ شيء آخر. فنسلّم الأمر إلى C، ونحصل على النتيجة، ونعيدها إلى المكدّس. وهذا يجعل الدوال الأصلية أسرع ما يمكن أن نجعله.

ومع ذلك، ينبغي أن يستطيع المستخدمون استدعاء الدوال الأصلية، لكن لا توجد أيّ دوال لاستدعائها. وبدون شيء مثل واجهة دوال أجنبية، لا يستطيع المستخدمون تعريف دوالهم الأصلية الخاصة. وهذا مهمّتنا نحن منفّذي الآلة الافتراضية. سنبدأ بمساعدة لتعريف دالة أصلية جديدة معرَّضة لبرامج Lox.

^code define-native

تأخذ مؤشراً إلى دالة C والاسم الذي ستُعرف به في Lox. نلفّ الدالة في ObjNative ثم نخزّن ذلك في متغيّر عام بالاسم المعطى.

وأنت على الأرجح تتساءل لماذا ندفع الاسم والدالة على المكدّس ونسحبهما. يبدو غريباً، أليس كذلك؟ فهذا نوع من الأمور التي عليك الاهتمام بها حين يدخل جامع القمامة حيز التنفيذ. فكل من copyString() وnewNative() يخصّص ذاكرة ديناميكياً. وهذا يعني أنه بمجرد أن يتوفّر لدينا GC، يمكن لهما أن يُطلقا مجموعة عشوائية. وإن حدث ذلك، نحتاج إلى التأكّد من أن الجامع يعرف أننا لم ننتهِ من الاسم و ObjFunction حتى لا يحرّرهما من تحت أيدينا. وتخزينهما على مكدّس القيم يحقّق ذلك.

يبدو ساذجاً، لكن بعد كل ذلك العمل، سنضيف دالة أصلية صغيرة واحدة فقط.

^code clock-native

هذه تُعيد الوقت المنقضي منذ بدء تشغيل البرنامج بالثواني. وهي مفيدة لمقاييس أداء برامج Lox. وفي Lox، سنسمّيها clock().

^code define-native-clock (1 before, 1 after)

وللوصول إلى دالة clock() في مكتبة C القياسية، تحتاج وحدة "vm" إلى تضمين.

^code vm-include-time (1 before, 2 after)

كان ذلك قدراً كبيراً من المادة لنعمل عليها، لكننا فعلناها! اكتب هذا وجرّبه:

fun fib(n) {
  if (n < 2) return n;
  return fib(n - 2) + fib(n - 1);
}

var start = clock();
print fib(35);
print clock() - start;

نستطيع كتابة دالة فيبوناتشي متعاودة شديدة ذات كفاءة منخفضة. والأفضل، يمكننا قياس مدى ولاستطيعين كفاءتها بالتحديد. فليس هذا بالضبط أذكى طريقة لحساب عدد فيبوناششي. لكنه طريقة جيّدة لاختبار إجهاد دعم اختبار الأخطاء التطبيق الحاسب. اطلاحاط إنا ذات مرتبط بالاستخداد لحساب. طريقة جيّدة لاختبار إجهاد دعم تطبيق اللغة لاستدعاءات الدوال. وعلى جهازي، فإن تشغيل هذا في clox أسرع بحوالي خمس مرات منه في jlox. وهذا تحسّن لا بأس به.

التحديات

  1. قراءة حقل ip وكتابته من أكثر العمليات تكراراً داخل حلقة شيفرة البايت. نحن نصل إليه حالياً عبر مؤشر إلى CallFrame الحالي. وهذا يتطلّب توجيهاً إضافياً عبر مؤشر قد يجبر المعالج على تجاوز الذاكرة المخبّأة والوصول إلى الذاكرة الرئيسية. ويمكن أن يكون ذلك حفرة أداء حقيقية.

    على الأُثلى، ip في مسجِّل معالج أصلي. لا تسمح C لنا بأن نشترط ذلك دون النزول إلى تجميع مُضمَّن، لكن يمكننا تنظيم الشيفرة لتشجيع المصرّف على إجراء ذلك التحسين. إن خزّنّا ip مباشرةً في متغيّر محلّي C وسميناه register، فهناك احتمال جيّد أن يستجيب مصرّف C لطلبتنا المهذّبة.

    وهذا يعني أننا بحاجة إلى توخّي الحذر عند تحميل ip المحلّي وتخزينه مرةً أخرى في CallFrame الصحيح عند بداية استدعاءات الدوال ونهايتها. نفّذ هذا التحسين. اكتب بضع مقاييس أداء وانظر كيف يؤثر في الأداء. هل تعتقد أن تعقيد الشيفرة الإضافي يستحق ذلك؟

  2. استدعاءات الدوال الأصلية سريعة جزئياً لأننا لا نتحقّق من أن الاستدعاء يمرّر العدد نفسه من الوسائط الذي تتوقّعه الدالة. ولكن يجب أن نفحقق: أستدعاء غير صحيح لملائم بالوسائط قد يُسببب أثر معامل الاحتياج خاطئ بحد الوسائط قد يُسبب قراءة ذاكرة غير مهيأة. أضف فحص عدد الوسائط.

  3. لا يوجد حالياً أيّ وسيلة للدالة الأصلية للإشارة إلى خطأ زمني. وفي تنفيذ حقيقي، سنحتاج إلى دعم ذلك لأن الدوال الأصلية تعيش في عالم C المكتوب النوع ساكناً لكنها تُستدعى من عالم Lox المكتوب النوع ديناميكياً. فإذا حاول المستخدم مثلاً تمرير نصّ إلى sqrt()، فيحتاج تلك الدالة الأصلية إلى الإبلاغ عن خطأ زمني.

    وسّع نظام الدوال الأصلية لدعم ذلك. وكيف تؤثر هذه القدرة في أداء الاستدعاءات الأصلية؟

  4. أضف المزيد من الدوال الأصلية لتفعل أشياء تجدها مفيدة. اكتب بعض البرامج التي تستخدمها. ماذا أضفت؟ وكيف تؤثر في إحساس اللغة وعمليتها؟