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

الدوال

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

دوغلاس ر. هوفستادتر، أنا حلقة غريبة

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

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

أنت على الأرجح على معرفة بصياغة استدعاء الدوال بنمط C، لكن القواعد اللغوية أدقّ مما قد تدرك. فعادةً ما تكون الاستدعاءات إلى دوال مُسمّاة مثل:

average(1, 2);

لكن اسم الدالة المُستدعاة ليس في الواقع جزءاً من صياغة الاستدعاء. فالشيء الذي يُستدعى -- وهو المُستدعى (callee) -- يمكن أن يكون أيّ تعبير يُقيَّم إلى دالة. (حسناً، يجب أن يكون تعبيراً ذي أولوية عالية إلى حدٍّ ما، لكن الأقواس تتكفّل بذلك.) مثلاً:

getCallback()();

هناك تعبيرا استدعاء هنا. فزوج الأقواس الأول له getCallback كـمُستدعى. أما الاستدعاء الثاني فله تعبير getCallback() كاملاً كـمُستدعى. إن الأقواس التالية لتعبير ما هي ما يدلّ على استدعاء دالة. ويمكنك أن تفكّر في الاستدعاء على نحو يشبه مشغّلاً postfix يبدأ بـ(.

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

unary          → ( "!" | "-" ) unary | call ;
call           → primary ( "(" arguments? ")" )* ;

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

arguments      → expression ( "," expression )* ;

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

أعترف بأن هذا يبدو أكثر لخبطةً نحوياً مما تتوقّع في النمط شديد الانتشار «صفر أو أكثر من أشياء مفصولة بفواصل». فهناك بعض الـ metasyntaxes المتطوّرة التي تتعامل مع هذا على نحو أفضل، لكن في BNF لدينا وفي كثير من مواصفات اللغات التي رأيتها، يكون بهذا الشكل المُعقّد.

وفي مولّد شجرة التركيب لدينا، نضيف عقدة جديدة.

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

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

افتح المحلل النحوي. هناك حيث كانت unary() تقفز مباشرةً إلى primary()، غيّرها لتستدعي، حسناً، call().

^code unary-call (3 before, 1 after)

وتعريفها هو:

^code call

الشيفرة هنا لا تتطابق تماماً مع القواعد اللغوية. لقد نقلتُ بعض الأشياء أن تكون الشيفرة أنظف -- إحدى الرفاهية التي نملكها في محلّل مكتوب بخط اليد. لكنها تشبه تقريباً كيف نحلل المشغّلات الثنائية. أولاً، نحلّل تعبيراً أوّلياً، وهو «المُعامل الأيسر» للاستدعاء. ثم، كلما رأينا (، نستدعي finishCall() لتحليل تعبير الاستدعاء باستخدام التعبير الذي حُلّل سابقاً كـمُستدعى. ويصبح التعبير المُعاد هو expr الجديد ونكرّر الحلقة لنرى ما إذا كان هو نفسه مُستدعى.

والشيفرة التي تحلّل قائمة الوسائط موجودة في هذه الدالة المساعدة:

^code finish-call

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

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

الحدّ الأقصى لعدد الوسائط

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

تتّخذ اللغات الأخرى مقارب مختلفة. فمواصفة C تقول إن التنفيذ المطابق يجب أن يدعم على الأقل 127 وسيطاً لدالة، لكنها لا تقول إن هناك حداً أعلى. أما مواصفة Java فتقول إن الدالة تستطيع قبول لا يزيد عن 255 وسيطاً.

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

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

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

تفسير استدعاءات الدوال

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

^code import-array-list (1 after)

وكالمعتاد، يبدأ التفسير بدالة زيارة جديدة لعقدة تعبير الاستدعاء الجديدة.

^code visit-call

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

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

ليس في هذه الواجهة الجديدة much much.

^code callable

نمرّر المفسّر احتياطاً في حال احتاج الصنف الذي يطبّق call() إليه. كما نعطيه قائمة قيم الوسائط المُقيَّمة. ومهمّة المنفِّذ عندئذٍ هي إعادة القيمة التي ينتجها تعبير الاستدعاء.

أخطاء أنواع الاستدعاء

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

"totally not a function"();

النصوص غير قابلة للاستدعاء في Lox. والتمثيل الزمني لنصّ Lox هو نصّ Java، لذا حين نحوّله إلى LoxCallable، ستُلقي JVM استثناء ClassCastException. ولا نريد لمفسّرنا أن يتقيّأ تتبّع مكدّس Java قبيحاً ويموت. بدلاً من ذلك، علينا فحص النوع بأنفسنا أولاً.

^code check-is-callable (2 before, 1 after)

ما زلنا نُلقي استثناءً، لكننا الآن نُلقي نوع استثناء خاصاً بنا، نوعاً يعرف المفسّر أن يلتقطه ويبلّغ عنه بأسلوب حسن.

فحص عدد الوسائط

تتعلّق المشكلة الأخرى بـعدد وسائط الدالة (arity). «عدد الوسائط» هو المصطلح الفخم لعدد الوسائط التي تتوقّعها دالة أو عملية. فالمشغّلات الأحادية عدد وسائطها واحد، والمشغّلات الثنائية اثنان، إلخ. أما الدوال فيحدّد عدد وسائطها عدد المعاملات التي تعلنها.

fun add(a, b, c) {
  print a + b + c;
}

هذه الدالة تُعرّف ثلاثة معاملات، هي a وb وc، لذا عدد وسائطها ثلاثة وهي تتوقّع ثلاثة وسائط. فماذا لو حاولتَ استدعاءها هكذا:

add(1, 2, 3, 4); // Too many.
add(1, 2);       // Too few.

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

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

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

يتطلب ذلك دالة جديدة في واجهة LoxCallable ليسألها عن عدد وسائطها.

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

يمكننا أن ندفع فحص عدد الوسائط إلى التنفيذ الملموس لـ call(). لكن بما أننا سنمتلك أصنافاً متعددة تطبّق LoxCallable، فسينتهي ذلك إلى تحقّق مكرّر موزّع على عدة أصناف. أما رفعه إلى دالة الزيارة فيتيح لنا فعله في مكان واحد.

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

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

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

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

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

قياس الوقت

حين نصل إلى [الجزء الثالث][] ونبدأ العمل على تنفيذ أكفأ بكثير لـ Lox، سنهتمّ بشدة بالأداء. وعمل الأداء يتطلب القياس، وذلك يعني مقاييس الأداء (benchmarks). وهي برامج تقيس الزمن الذي يستغرقه استثارة زاوية معيّنة من المفسّر.

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

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

لذلك سنضيف clock()، وهي دالة أصلية تُعيد عدد الثواني التي انقضت منذ نقطة ثابتة معيّنة في الزمن. والفرق بين استدعاءين متتاليين يخبرك كم من الوقت انقضى بين النداءين. هذه الدالة معرَّفة في النطاق العام، لذا لنتأكّد من أن المفسّر لديه وصول إليه.

^code global-environment (2 before, 2 after)

يتغيّر حقل environment في المفسّر عندما ندخل ونخرج من النطاقات المحلّية. فهو يتتبّع البيئة الحالية. وحقل globals الجديد يحمل مرجعاً ثابتاً إلى البيئة العامة الأبعدية.

وحين ننشئ مُفسّراً، نحشر الدالة الأصلية في ذلك النطاق العام.

^code interpreter-constructor (2 before, 1 after)

يعرّف هذا متغيّراً باسم "clock". وقيمته صنف Java مجهول الهوية يطبّق LoxCallable. ودالة clock() لا تأخذ وسائط، لذا عدد وسائطها صفر. وتنفيذ call() يستدعي دالة Java المقابلة ويحوّل النتيجة إلى قيمة مزدوجة بالثواني.

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

لنُخرج أنفسنا من أعمال تعريف الدوال ونترك مستخدمينا يتولّون الأمر...

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

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

declaration    → funDecl
               | varDecl
               | statement ;

تشير قاعدة declaration المحدَّثة إلى هذه القاعدة الجديدة:

funDecl        → "fun" function ;
function       → IDENTIFIER "(" parameters? ")" block ;

تستخدم قاعدة funDecl الرئيسية قاعدة مساعدة منفصلة هي function. فجملة إعلان الدالة هي الكلمة المفتاحية fun يليها الأشياء الخاصة بالدوال. وحين نصل إلى الأصناف، سنعد إعادة استخدام قاعدة function تلك لإعلان الدوال. لكنها لا تسبقها fun.

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

parameters     → IDENTIFIER ( "," IDENTIFIER )* ;

إنها مثل قاعدة arguments السابقة، باستثناء أن كل معامل مُعرِّف لا تعبير. وهناك قدر كبير من الصياغة الجديدة على المحلل النحوي أن يمضغها، لكن عقدة AST الناتجة ليست سيّئة.

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

عقدة الدالة لها اسم، وقائمة معاملات (أسماؤها)، ثم الجسم. ونخزّن الجسم كقائمة الجمل المحتواة داخل الأقواس المعقوفة.

وفي المحلل النحوي، ننسج الإعلان الجديد.

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

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

^code parse-function

في الوقت الحالي، هي تستهلك فقط رمز المُعرِّف لاسم الدالة. وقد تتساءل عن ذلك المعامل الصغير kind. تماماً كما نعيد استخدام القاعدة اللغوية، سنعيد استخدام دالة function() لاحقاً لتحليل الدوال داخل الأصناف. وحين نفعل ذلك، سنمرّر "method" كقيمة لكي kind حتى تكون رسائل الخطأ خاصّة بنوع الإعلان الجاري تحليله.

بعد ذلك، نحلّل قائمة المعاملات وزوج الأقواس الذي يلفّها.

^code parse-parameters (1 before, 1 after)

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

وكما نفعل مع وسائط استدعاءات الدوال، فإننا نتحقّق وقت التحليل من أنك لا تتجاوز الحدّ الأقصى لعدد المعاملات المسموح به للدالة.

وأخيراً، نحلّل الجسم ونلفّ كل شيء في عقدة دالة.

^code parse-body (1 before, 1 after)

لاحظ أننا نستهلك { في بداية الجسم هنا قبل استدعاء block(). وذلك لأن block() تفترض أن رمز القوس قد طابق بالفعل. واستهلاكه هنا يتيح لنا الإبلاغ عن رسالة خطأ أدقّ إن لم يُعثر على { لأننا نعرف أننا في سياق إعلان دالة.

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

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

هذا في جوهره ما هو صنف Stmt.Function. فهل يمكننا استخدام ذلك ببساطة؟ تقريباً، لكن ليس تماماً. فنحن نحتاج أيضاً إلى صنف يطبّق LoxCallable كي نستطيع استدعائه. ولا نريد أن تتسرّب مرحلة الزمن التشغيل من المفسّر إلى أصناف صياغة المقدّمة، لذا لا نريد لـ Stmt.Function نفسه أن يطبّق ذلك. بدلاً من ذلك، نلفّه في صنف جديد.

^code lox-function

ونطبّق call() الخاصة بـ LoxCallable هكذا:

^code function-call

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

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

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

مثلاً، إليك طريقة معقّدة للعدّ حتى ثلاثة:

fun count(n) {
  if (n > 1) count(n - 1);
  print n;
}

count(3);

تخيّل أننا أوقفنا المفسّر تماماً عند النقطة التي هو على وشك طباعة 1 فيها في أعمق استدعاء متداخل. فاستدعاءات الطباعة 2 و3 الخارجية لم تطبع قيمها بعد، لذا لا بدّ من وجود بيئات في مكان ما من الذاكرة لا تزال تخزّن الحقيقة أن n مربوط بـ 3 في سياق، وبـ 2 في آخر، وبـ 1 في الأعمق، مثل:

بيئة منفصلة لكل استدعاء متكرر.

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

إذن، لبرنامج كهذا:

fun add(a, b, c) {
  print a + b + c;
}

add(1, 2, 3);

عند نقطة استدعاء add()، ينشئ المفسّر شيئاً كهذا:

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

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

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

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

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

انتهينا؟ حسناً. لاحظ حين نربط المعاملات أننا نفترض أن قائمتي المعاملات والوسائط لهما الطول نفسه. وهذا آمن لأن visitCallExpr() يفحص عدد الوسائط قبل استدعاء call(). وهو يعتمد على الدالة في الإبلاغ عن عدد وسائطها.

^code function-arity

هذا معظم تمثيلنا للكائنات. وبما أننا هنا، فلنُنفّذ toString() أيضاً.

^code function-to-string

وهذا يعطي ناتجاً أجمل إذا قرّر المستخدم طباعة قيمة دالة.

fun add(a, b) {
  print a + b;
}

print add; // "<fn add>".

تفسير إعلانات الدوال

سنعود ونُنقّح LoxFunction قريباً، لكن هذا يكفي للبدء. الآن يمكننا زيارة إعلان دالة.

^code visit-function

وهذا مشابه لكيفية تفسيرنا لتعبيرات الحرفية الأخرى. فنحن نأخذ عقدة الدالة النحوية -- تمثيل وقت التصريف للدالة -- ونحوّلها إلى تمثيلها الزمني. وهنا، تلك LoxFunction تلفّ العقدة النحوية.

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

بهذا، يمكننا تعريف دوالنا واستدعاؤها بالكامل داخل Lox. جرّبه:

fun sayHi(first, last) {
  print "Hi, " + first + " " + last + "!";
}

sayHi("Dear", "Reader");

لا أعرف رأيك، لكن هذا يبدو لي لغة برمجة جدّية بكل المقاييس.

جمل الإرجاع

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

statement      → exprStmt
               | forStmt
               | ifStmt
               | printStmt
               | returnStmt
               | whileStmt
               | block ;

returnStmt     → "return" expression? ";" ;

لدينا إنتاجٌ آخر -- بل الأخير في الأساس -- تحت قاعدة statement venerable. فجملة return هي الكلمة المفتاحية return يليها تعبير اختياري وينتهي بفاصلة منقوطة.

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

fun procedure() {
  print "don't return anything";
}

var result = procedure();
print result; // ?

وهذا يعني أن كل دالة في Lox يجب أن تُعيد شيئاً، حتى لو لم تحوِ أيّ جمل return على الإطلاق. ونستخدم nil لهذا، ولهذا يُعيد تنفيذ call() في LoxFunction القيمة null في النهاية. وعلى المنوال نفسه، إن حذفتَ القيمة في جملة return فإننا ببساطة نتعامل معها على أنها تكافئ:

return nil;

وفي مولّد AST لدينا، نضيف عقدة جديدة.

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

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

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

وهذا يتفرّع إلى:

^code parse-return-statement

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

الإرجاع من الاستدعاءات

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

مثلاً، لنفترض أننا نشغّل هذا البرنامج وقد أوشكت على تنفيذ جملة return:

fun count(n) {
  while (n < 100) {
    if (n == 3) return n; // <--
    print n;
    n = n + 1;
  }
}

count(1);

يبدو مكدّس استدعاءات Java حالياً على نحو تقريبي هكذا:

Interpreter.visitReturnStmt()
Interpreter.visitIfStmt()
Interpreter.executeBlock()
Interpreter.visitBlockStmt()
Interpreter.visitWhileStmt()
Interpreter.executeBlock()
LoxFunction.call()
Interpreter.visitCallExpr()

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

دالة الزيارة لعقدة AST الجديدة تبدو هكذا:

^code visit-return

إن كان لدينا قيمة إرجاع، فإننا نقيّمها، وإلا فإننا نستخدم nil. ثم نأخذ تلك القيمة ونلفّها في صنف استثناء مخصّص ونلقيه.

^code return-exception

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

ونريد لهذا أن يفكّ كل الطريق إلى حيث بدأ استدعاء الدالة، أي دالة call() في LoxFunction.

^code catch-return (3 before, 1 after)

نلفّ الاستدعاء إلى executeBlock() داخل كتلة try-catch. وحين يلتقط استثناء إرجاع، يسحب القيمة ويجعلها قيمة الإرجاع من call(). وإذا لم يلتقط أيّاً من هذه الاستثناءات أبداً، فهذا يعني أن الدالة بلغت نهاية جسمها دون مصادفة جملة return. وفي تلك الحالة، هي تُعيد nil ضمناً.

لنجرّبها. لدينا أخيراً القوّة الكافية لدعم هذا المثال الكلاسيكي -- دالة متعاودة لحساب أعداد فيبوناتشي:

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

for (var i = 0; i < 20; i = i + 1) {
  print fib(i);
}

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

الدوال المحلّية والإغلاق

دوالنا غنية بالميزات إلى حدٍّ كبير، لكن هناك ثقباً واحداً يجب سدّه. وفي الواقع، الفجوة كبيرة بما يكفي We'll spend لقضاء معظم [الفصل التالي][] في سدّها، لكن يمكننا أن نبدأ هنا.

وتنفيذ call() في LoxFunction ينشئ بيئة جديدة يربط فيها معاملات الدالة. وحين عرضتُ عليك تلك الشيفرة، أهملتُ نقطة مهمة واحدة: ما ال أب لتلك البيئة؟

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

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

تأمّل هذا المثال الكلاسيكي:

fun makeCounter() {
  var i = 0;
  fun count() {
    i = i + 1;
    print i;
  }

  return count;
}

var counter = makeCounter();
counter(); // "1".
counter(); // "2".

هنا، تستخدم count() المتغيّر i، المُعلن خارجها في الدالة الحاوية makeCounter(). وتُعيد makeCounter() مرجعاً إلى دالة count() ثم ينتهي جسمها بتنفيذه بالكامل.

وفي الوقت نفسه، يستدعي الشيفرة في المستوى الأعلى دالة count() المُعادة. وهذا ينفّذ جسم count()، الذي يُسند إلى i ويقرأه، رغم أن الدالة التي عُرِّف فيها i قد خرجت بالفعل.

إن لم تصادف من قبل لغة ذات دوال متداخلة، فقد يبدو هذا مجنوناً، لكن المستخدمين يتوقّعون أن يعمل. وللأسف، إن شغّلتَه الآن، تحصل على خطأ متغيّر غير معرَّف في الاستدعاء counter() عندما يحاول جسم count() البحث عن i. وذلك لأن سلسلة البيئات السارية تبدو هكذا:

سلسلة البيئات من جسم count() إلى النطاق العام.

حين نستدعي count() (عبر المرجع إليه المخزَّن في counter)، ننشئ بيئة فارغة جديدة لجسم الدالة. وأب تلك البيئة هو البيئة العامة. فقدنا بيئة makeCounter() حيث i مربوط.

لنعد بالزمن قليلاً. إليك كيف تبدو سلسلة البيئات في اللحظة التي أعلنّا فيها count() داخل جسم makeCounter():

سلسلة البيئات داخل جسم makeCounter().

إذن عند النقطة التي تُعلَن فيها الدالة، يمكننا رؤية i. لكن حين نُرجع من makeCounter() ونخرج من جسمها، يتخلّص المفسّر من تلك البيئة. وبما أن المفسّر لا يحتفظ بالبيئة المحيطة بـ count()، فالأمر متروك لكائن الدالة نفسه ليمسك بها.

وتُسمّى بنية البيانات هذه إغلاقاً (closure) لأنها «تغلق على» المتغيّرات المحيطة وتتمسك بها حيث عُلنت الدالة. والإغلاقات موجودة منذ أيام Lisp الأولى، وقد ابتكر مخترعو اللغات طرقاً شتّى لتنفيذها. أما بالنسبة إلى jlox، فسنفعل أبسط ما ينجح. في LoxFunction، نضيف حقلاً لتخزين بيئة.

^code closure-field (1 before, 1 after)

ونهيّئ ذلك في المُنشئ.

^code closure-constructor (1 after)

وحين ننشئ LoxFunction، فإننا نلتقط البيئة الحالية.

^code visit-closure (1 before, 1 after)

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

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

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

سلسلة البيئات مع الإغلاق.

الآن، وكما ترى، يستطيع المفسّر أن يجد i حين يحتاجه لأنه في منتصف سلسلة البيئات. جرّب تشغيل مثال makeCounter() ذاك الآن. إنه يعمل!

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

التحديات

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

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

    اللغات التي تشجّع أسلوباً وظيفياً تدعم عادةً الدوال المجهولة أو lambdas -- صيغة تعبير تنشئ دالة دون ربطها باسم. أضف صياغة الدوال المجهولة إلى Lox بحيث يعمل هذا:

    fun thrice(fn) {
      for (var i = 1; i <= 3; i = i + 1) {
        fn(i);
      }
    }
    
    thrice(fun (a) {
      print a;
    });
    // "1".
    // "2".
    // "3".
    

    كيف تتعامل مع الحالة الصعبة وهي تعبير دالة مجهولة occurring في جملة تعبير:

    fun () {};
    
  3. هل هذا البرنامج صالح؟

    fun scope(a) {
      var a = "local";
    }
    

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