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

تقييم التعبيرات

أنت خالقي، لكنني سيّدك؛ أطع!

ماري شيللي، فرانكشتاين

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

ومضة برق تضرب قصراً فيكتورياً. مخيف!

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

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

  1. ما أنواع القيم التي ننتجها؟

  2. كيف ننظّم تلك قطع الشيفرة؟

لنأخذهما واحداً تلو الآخر...

تمثيل القيم

في Lox، تُنشأ القيم (values) بواسطة الحرفية، ويحسبها التعبيرات، وتُخزَّن في المتغيّرات. يرى المستخدم هذه كأنها كائنات Lox، لكنها منفَّذة في اللغة التي كُتب مفسّرنا بها. يعني ذلك الجسر بين الكتابة الديناميكية لـ Lox والأنواع الساكنة في Java. فيمكن لمتغيّر في Lox أن يخزّن قيمة من أيّ نوع (Lox)، بل وحتى أن يخزّن قيماً من أنواع مختلفة في نقاط مختلفة من الزمن. فما نوع Java الذي يمكن أن نستخدمه لتمثيل ذلك؟

معطى متغيّر Java بذلك النوع الساكن، علينا أيضاً أن نحدّد أي نوع القيم يحمله في الزمن التشغيل. فحين ينفّذ مشغّل `+`، يحتاج إلى معرفة ما إذا يجمع رقمين أم يدمج نصّين. فهل هناك نوع في Java يمكنه أن يحمل الأرقام والنصوص والقيم المنطقية وما عداها؟ فهل هناك نوع يخبرنا بنوعه في الزمن التشغيل؟ يوجد! إنه java.lang.Object القديم.

في مواضع المفسّر التي نحتاج فيها إلى تخزين قيمة Lox، يمكننا استخدام Object كنوع. لدى Java نسخ مغلّفة (boxed) لأنواعها الأولية، جميعها ترث من Object، لذا يمكننا استخدام تلك للأنواع المدمجة في Lox:

نوع Lox التمثيل في Java
أيّ قيمة في Lox Object
nil null
Boolean Boolean
number Double
string String

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

تقييم التعبيرات

بعد ذلك، نحتاج إلى قطع شيفرة تنفّذ منطق التقييم لكل نوع من التعبيرات التي يمكننا تحليلها. يمكننا حشر تلك الشيفرة في أصناف شجرة التركيب في شيء يشبه دالة interpret().;- وبذلك يمكننا أن نقول لكل عقدة شجرة تركيب: «فسّر نفسك». هذا هو [نمط التصميم المُسمّى المُفسّر][] من «الرباط الأربعة». إنه نمط أنيق، لكن كما ذكرتُ سابقاً، يصبح فوضوياً إذا دفعنا كل أنواع المنطق إلى أصناف الشجرة.

بدلاً من ذلك، سنعيد استخدام [نمط الزائر][] (Visitor pattern) الأنيق لدينا. في الفصل السابق، أنشأنا صنف AstPrinter. كان يأخذ شجرة تركيب ويمرّ عليها تعاودياً، بيبني نصّاً يُعيده في النهاية. وهذا بالضبط ما يفعله مفسّر حقيقي تقريباً، باستثناء أنه بدلاً من دمج النصوص، يحسب القيم.

نبدأ بصنف جديد.

^code interpreter-class

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

تقييم الحرفية

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

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

^code visit-literal

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

تقييم الأقواس

العقدة التالية الأبسط للتقييم هي التجميع (grouping) -- العقدة التي تحصل عليها نتيجة استخدام أقواس صريحة في تعبير.

^code visit-grouping

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

ونعتمد هنا على دالة مساعدة ترسل التعبير ببساطة إلى تنفيذ الزائر في المفسّر:

^code evaluate

تقييم التعبيرات الأحادية

مثل التجميع، للتعبيرات الأحادية تعبير فرعي واحد يجب أن نقيّمه أولاً. والفرق هو أن التعبير الأحادي نفسه يقوم ببعض العمل بعدها.

^code visit-unary

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

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

يمكنك أن تبدأ في رؤية كيف يمرّ التقييم تعاودياً في الشجرة. فنحن لا نستطيع تقييم المشغّل الأحادي نفسه إلا بعد تقييم تعبير مُعامله الفرعي. وهذا يعني أن مفسّرنا يقوم بـمرور ما بعد الترتيب (post-order traversal) -- كل عقدة تقيّم أبناءها قبل أن تقوم بعملها الخاص.

المشغّل الأحادي الآخر هو النفي المنطقي.

^code unary-bang (1 before, 1 after)

التنفيذ بسيط، لكن ما هذا الأمر «الصحيح»؟ فنحن بحاجة إلى رحلة جانبية صغيرة إلى أحد أسئلة الفلسفة الغربية العظيمة: ما هي الحقيقة؟

الصحة والكاذبية

حسناً، ربما لن نتعمّق حقاً في السؤال الكوني، لكننا على الأقل داخل عالم Lox نحتاج إلى تحديد ما يحدث حين تستخدم شيئاً غير true أو false في عملية منطقية مثل ! أو في أيّ موضع آخر يُتوقَّع فيه قيمة منطقية.

يمكننا أن نقول ببساطة إن ذلك خطأ لأننا لا نقبل التحويلات الضمنية، لكن معظم اللغات المكتوبة النوع ديناميكياً ليست نزعة إلى هذا الحدّ. بدلاً من ذلك، تأخذ الكونَ من قيم جميع الأنواع وتقسمه إلى مجموعتين، إحداهما تعرّفها بأنها «صحيحة» أو «صادقة» أو (المفضّل لديّ) «truthy»، وباقيها «خاطئة» أو «falsey». وهذا التقسيم إلى حدٍّ ما اعتباطي ويصبح غريباً في بضع لغات.

تتبع Lox قاعدة Ruby البسيطة: false وnil هما falsey، وكل ما عداهما truthy. ونفّذ ذلك هكذا:

^code is-truthy

تقييم المشغّلات الثنائية

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

^code visit-binary

أظنّ أنك تستطيع استنتاج ما يجري هنا. والفرق الرئيسي عن مشغّل النفي الأحادي هو أن لدينا مُعاملين نقيّمهما.

تركتُ مشغّلاً حسابياً واحداً لأنه قليلاً ما يكون خاصّاً.

^code binary-plus (3 before, 1 after)

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

التالي هي مشغّلات المقارنة.

^code binary-comparison (1 before, 1 after)

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

آخر زوج من المشغّلات هو المساواة.

^code binary-equality

بخلاف مشغّلات المقارنة التي تتطلّب أرقاماً، تدعم مشغّلات المساواة مُعاملات من أيّ نوع، حتى المختلطة. لا يمكنك أن تسأل Lox هل 3 أصغر من "three"، لكن يمكنك أن تسأل هل هي مساوية لها.

وكما في truthiness، فإن منطق المساواة مُستخرَج إلى دالة منفصلة.

^code is-equal

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

ولحسن الحظ، كلاهما متشابه إلى حدٍّ كبير. فـ Lox لا تُجري تحويلات ضمنية في المساواة، وJava كذلك. علينا مع ذلك معالجة nil/null على نحو خاص كي لا نُلقي NullPointerException إن حاولنا استدعاء equals() على null. عدنا ذلك، فنحن بخير. وطرق equals() في Java على Boolean وDouble وString لها السلوك الذي نريده لـ Lox.

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

أخطاء الزمن التشغيلي

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

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

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

سلوك Java له ميزة واحدة على الأقل. فهو يوقف تنفيذ أي شيفرة على نحو صحيح عند حدوث الخطأ. فلنفترض أن المستخدم أدخل تعبيراً مثل:

2 * (3 / -"muffin")

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

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

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

كشف أخطاء الزمن التشغيلي

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

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

^code check-unary-operand (1 before, 1 after)

والشيفرة التي تفحص المُعامل هي:

^code check-operand

وحين يفشل الفحص، فإنها تُلقي واحداً من هذه:

^code runtime-error-class

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

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

أكبر من:

^code check-greater-operand (1 before, 1 after)

أكبر من أو يساوي:

^code check-greater-equal-operand (1 before, 1 after)

أصغر من:

^code check-less-operand (1 before, 1 after)

أصغر من أو يساوي:

^code check-less-equal-operand (1 before, 1 after)

الطرح:

^code check-minus-operand (1 before, 1 after)

القسمة:

^code check-slash-operand (1 before, 1 after)

الضرب:

^code check-star-operand (1 before, 1 after)

كل ذلك يعتمد على هذا المُدقِّق الذي يشبه إلى حدٍّ كبير نظيره الأحادي:

^code check-operands

آخر مشغّل متبقٍّ، وهو أيضاً الغريب ، هو الجمع. وبما أن + مُحمَّل للأعداد والنصوص، فلديه أصلاً شيفرة تفحص الأنواع. وكل ما نحتاج إليه هو الفشل إن لم يطابق أيّ من حالتي النجاح.

^code string-wrong-type (3 before, 1 after)

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

توصيل المفسّر

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

^code interpret

هذه تأخذ شجرة تركيب لتعبير وتُقيّمها. وإن نجحت، تُعيد evaluate() كائناً لقيمة النتيجة. ويحوّل interpret() ذلك إلى نصّ ويعرضه على المستخدم. ولتحويل قيمة Lox إلى نصّ، نعتمد على:

^code stringify

هذه قطعة شيفرة أخرى مثل isTruthy() تعبر الغشاء بين رؤية المستخدم لكائنات Lox وتمثيلها الداخلي في Java.

إنها بسيطة إلى حدٍّ كبير. وبما أن Lox صُمّمت لتكون مألوفة لمن يأتي من Java، فإن أشياء مثل القيم المنطقية تبدو نفسها في اللغتين. وحالتا الحدّ هما nil التي نمثّلها بـ null في Java، والأعداد.

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

الإبلاغ عن أخطاء الزمن التشغيلي

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

^code runtime-error-method

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

بعد عرض الخطأ، تضبط runtimeError() هذا الحقل:

^code had-runtime-error-field (1 before, 1 after)

يلعب ذلك الحقل دوراً صغيراً لكنه مهمّاً.

^code check-runtime-error (4 before, 1 after)

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

تشغيل المفسّر

الآن بما لدينا مفسّر، يستطيع صنف Lox أن يبدأ في استخدامه.

^code interpreter-instance (1 before, 1 after)

نجعل الحقل static حتى تعيد الاستدعاءات المتتالية لـ run() داخل جلسة REPL استخدام المفسّر نفسه. لا يختلف ذلك الآن، لكنه سيصبح كذلك لاحقاً حين يخزّن المفسّر متغيّرات عامة. وينبغي أن تبقى تلك المتغيّرات باقية طوال جلسة REPL.

وأخيراً، نزيل سطر الشيفرة المؤقتة من [الفصل الأخير][] الذي يطبع شجرة التركيب ونستبدله بهذا:

^code interpreter-interpret (3 before, 1 after)

لدينا الآن خط أنابيب لغة كامل: المسح، والتحليل، والتنفيذ. تهانينا، صار لديك الآن حاسبتك الحسابية الخاصة.

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

هيكل عظمي يلوّح تحيّةً.

التحديات

  1. السماح بإجراء المقارنات على أنواع غير الأعداد قد يكون مفيداً. فلل مشغّلات تفسير معقول على النصوص. وحتى المقارنات بين أنواع مختلطة، مثل 3 < "pancake"، قد تكون مفيدة لإتاحة أشياء مثل المجموعات المرتّبة من أنواع متباينة. أو أنها قد تؤدي ببساطة إلى أخطاء وارتباك.

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

  2. تعرّف لغة كثيرة + بحيث إذا كان أيّ من المُعاملين نصّاً، يُحوَّل الآخر إلى نصّ ثم تُدمج النتيجتان. فمثلاً "scone" + 4 ينتج scone4. وسّع الشيفرة في visitBinaryExpr() لدعم ذلك.

  3. ماذا يحدث الآن إن قسمتَ عدداً على صفر؟ وما رأيك في ما ينبغي أن يحدث؟ برّر اختيارك. كيف تتعامل اللغات الأخرى التي تعرفها مع القسمة على صفر، ولماذا تتّخذ الخيارات التي تتّخذها؟

    غيّر التنفيذ في visitBinaryExpr() ليكتشف خطأً زمنياً ويبلّغ عنه في هذه الحالة.

ملاحظة تصميم: الكتابة الساكنة والديناميكية

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

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

مثلاً، في Java، يفترض نظام النوع الساكن أن تعبير التحويل (cast) سينجح دائماً بأمان. فبعد تحويل قيمة ما، يمكنك معاملتها ساكناً على أنها نوع الوجهة دون أن تحصل على أي أخطاء تصريف. لكن التحويلات الهابطة (downcasts) يمكن أن تفشل، ومن البديهي. والسبب الوحيد في تمكين الفاحص الساكن من افتراض أن التحويلات تنجح دائماً دون انتهاك ضمانات سلامة اللغة هو أن التحويل يُفحص في الزمن التشغيل ويُلقي استثناءً عند الفشل.

مثال أدقّ هو المصفوفات المتناظرة في Java وC#. فقواعد الأنواع الفرعية الساكنة للمصفوفات تسمح بعمليات غير سليمة. تأمّل:

Object[] stuff = new Integer[1];
stuff[0] = "not an int!";

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

كان بإمكان Java تجنّب الحاجة إلى فحص ذلك في الزمن التشغيل بمنع التحويل في السطر الأول.—was يمكنها جعل المصفوفات غير قابلة للتباين (invariant) بحيث لا تكون مصفوفة Integers مصفوفة Objects. وهذا سليم ساكناً، لكنه يمنع أنماط شيفرة شائعة وآمنة لا تقرأ من المصفوفات إلا للقراءة. فالتناظر آمن إن لم تكتب إلى المصفوفة أبداً. وكان لتلك الأنماط أهمية خاصة بالنسبة لسهولة الاستخدام في Java 1.0 قبل أن يدعم الأنواع العامة. وقد أبدى James Gosling ومصمّمو Java الآخرون مقايضة بسيطة من السلامة الساكنة والأداء -- ففحوص تخزين المصفوفات تستهلك وقتاً -- مقابل بعض المرونة.

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

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