الدوال والمُهيّئات
عندما تكون على أرض الرقص، لا شيء أمامك سوى الرقص.
أومبرتو إيكو، اللهب الغامض لملكة لوآنا
حان وقت أن تُحيي آلتنا الافتراضية (virtual machine) كائناتها الناشئة بالسلوك. وهذا يعني الدوال (methods) واستدعاءات الدوال. وبما أنّ المُهيّئات (initializers) نوعٌ خاصّ من الدوال، فهي مشمولةٌ بذلك أيضاً.
كل هذا أرضٌ مألوفة عند جئنا من مفسّر jlox السابق. أمّا الجديد في هذه الرحلة الثانية فهو تحسينٌ مهمّ سننفّذه لتصبح استدعاءات الدوال أسرع بسبع مرّات من أداء خطّ الأساس. لكن قبل أن نصل إلى ذلك الممتع، علينا أوّلاً أن نجعل الأساسيات تعمل.
إعلانات الدوال
لا يمكننا تحسين استدعاءات الدوال قبل أن تتوفّر لنا استدعاءات دوال، ولا يمكننا استدعاء دوال من غير أن تكون لدينا دوال نستدعيها، لذا سنبدأ بالإعلانات.
تمثيل الدوال
عادةً ما نبدأ في المُصرِّف (compiler)، لكن لنُنجِز نموذج الكائنات هذه المرة. التمثيل الزمني للدوال في clox يشبه ما هو عليه في jlox. يخزّن كل صنف (class) جدول تجزئة (hash table) للدوال. مفاتيحه هي أسماء الدوال، وكل قيمة هي ObjClosure لجسم الدالة.
^code class-methods (3 before, 1 after)
يبدأ الصنف الجديد تماماً بجدول دوال فارغ.
^code init-methods (1 before, 1 after)
تملك بنية ObjClass ذاكرة هذا الجدول، لذا حين يحرّر مدير الذاكرة صنفاً يجب تحرير
الجدول أيضاً.
^code free-methods (1 before, 1 after)
وبمناسبة الحديث عن مسيّري الذاكرة، يحتاج جامع القمامة (GC) إلى أن يتتبّع عبر الأصناف وصولاً إلى جدول الدوال. فإذا كان الصنف ما زال في متناول اليد (على الأرجح عبر نسخة (instance) ما)، فإنّ كلّ دواله تحتاج بطبيعة الحال إلى أن تبقى حوله أيضاً.
^code mark-methods (1 before, 1 after)
نستخدم دالة markTable() القائمة، وهي تتتبّع عبر سلسلة المفتاح وقيمته في كلّ
إدخال من إدخالات الجدول.
تخزين دوال الصنف مألوف جداً بالنظر إلى jlox. أمّا الجزء المختلف فهو كيفية امتلاء ذلك الجدول. كان لدى مُفسّرنا السابق وصول إلى عقدة شجرة التجريد (AST) كاملة لإعلان الصنف وكلّ الدوال التي احتوى عليها. وفي الزمن التشغيل، كان المُفسّر ببساطة يجوب قائمة الإعلانات تلك.
الآن، كلّ معلومة يريد المُصرِّف تمريرها إلى الزمن التشغيل يجب أن تتزحلق عبر واجهة سلسلة مسطّحة من تعليمات البايتكود. كيف نأخذ إعلان صنف، الذي قد يحتوي على مجموعة دوال كبيرة على أيّ حدّ، ونمثّله بالبايتكود؟ لننتقل إلى المُصرِّف لنكتشف ذلك.
تصريف إعلانات الدوال
تركّنا الفصل السابق على مُصرِّفٍ يصرّف الأصناف لكنه يسمح بجسم فارغ فقط. الآن نُدرج قليلاً من الشيفرة لتصريف سلسلة من إعلانات الدوال بين القوسين المعقوفين.
^code class-body (1 before, 1 after)
لا تملك Lox إعلانات حقول (fields)، لذا فأيّ شيء يسبق القوس المعقوف الختامي في نهاية جسم الصنف يجب أن يكون دالة. نتوقّف عن تصريف الدوال عند بلوغ ذلك القوس الأخير أو عند بلوغ نهاية الملف. أمّا هذا الفحص الأخير فيضمن ألّا يعلق مُصرِّفنا في حلقة لا نهائية إذا نسي المستخدم عن طريق المصادفة القوس الختامي.
الجزء الصعب في تصريف إعلان صنف هو أنّ الصنف قد يعلن أيّ عدد من الدوال. وعلى أيّ
حال، يحتاج الزمن التشغيل إلى أن يبحث عن كلّها ويربطها (binding) في الصنف. وهذا
حجمٌ كبيرٌ جداً ليُحشر في تعليمة OP_CLASS واحدة. بدلاً من ذلك، سيقسّم البايتكود
الذي نولّده لإعلان صنف العملية إلى سلسلة من التعليمات.
فالمُصرِّف يُصدر بالفعل تعليمة OP_CLASS التي تنشئ كائن ObjClass فارغاً جديداً. ثم
يُصدر تعليمات لتخزين الصنف في متغيّر (variable) يحمل اسم ذلك الصنف.
الآن، لكلّ إعلان دالة، نُصدر تعليمة OP_METHOD جديدة تضيف دالةً واحدة إلى ذلك
الصنف. وبعد أن تكون كلّ تعليمات OP_METHOD قد نُفِّذت، يبقى لدينا صنفٌ مكتمل
التشكّل. فبينما يرى المستخدم إعلان الصنف عمليةً ذرّيةً واحدة، تنفّذها الآلة
الافتراضية كسلسلة من التغييرات.
لتعريف دالة جديدة، تحتاج الآلة الافتراضية إلى ثلاثة أشياء:
-
اسم الدالة.
-
الإغلاق الخاصّ بجسم الدالة.
-
الصنف الذي ستُربط الدالة إليه.
سنكتب شيفرة المُصرِّف تدريجياً لنرى كيف تصل هذه الأشياء الثلاثة كلها إلى الزمن التشغيل، بدءاً من هنا:
^code method
مثل OP_GET_PROPERTY وغيرها من التعليمات التي تحتاج أسماء في الزمن التشغيل، يضيف
المُصرِّف المُعجم (lexeme) الخاصّ برمز اسم الدالة إلى جدول الثوابت، فيحصل على فهرس
في الجدول. ثم نُصدر تعليمة OP_METHOD بهذا الفهرس كمُعامل. هذا هو الاسم. ثم يبقى
جسم الدالة:
^code method-body (1 before, 1 after)
نستخدم دالة function() المساعدة ذاتها التي كتبناها لتصريف إعلانات الدوال. فهذه
الدالة المساعدة تُصرّف قائمة المعاملات التالية وجسم الدالة. ثم تُصدر الشيفرة
اللازمة لإنشاء ObjClosure وتركه على قمة المكدّس (stack). وفي الزمن التشغيل، ستجد
الآلة الافتراضية الإغلاق هناك.
أمّا الأخير فهو الصنف الذي تُربط الدالة إليه. أين يمكن للآلة الافتراضية أن تجده؟
للأسف، بحلول الوقت الذي نصل فيه إلى تعليمة OP_METHOD، لا نعرف أين هو. قد يكون
موجوداً على المكدّس، إذا أعلن المستخدم الصنف في نطاق
(scope) محلي. لكن إعلان صنف في المستوى الأعلى ينتهي بالـ ObjClass في جدول المتغيّرات
العالمي.
لا تخف. فالمُصرِّف يعرف اسم الصنف. يمكننا التقاطه مباشرةً بعد أن نستهلك رمزه.
^code class-name (1 before, 1 after)
ونحن نعرف أنّ لا تصريح آخر بهذا الاسم يمكن أن يحجب الصنف. لذا نلجأ إلى الحلّ السهل. قبل أن نبدأ بربط الدوال، نُصدر الشيفرة اللازمة لإعادة تحميل الصنف على قمة المكدّس.
^code load-class (2 before, 1 after)
قبل تصريف جسم الصنف مباشرةً، نستدعي namedVariable().
فهذه الدالة المساعدة تولّد شيفرة تُحمّل متغيّراً بالاسم المعطى على المكدّس. ثم نُصرّف
الدوال.
وهذا يعني أنّه عندما ننفّذ كلّ تعليمة OP_METHOD، يكون على قمة المكدّس إغلاق الدالة
وفوقه مباشرةً الصنف. وبعد أن نبلغ نهاية الدوال، لا تعود نحتاج إلى الصنف، فنأمر
الآلة الافتراضية بأن تُفرغه من المكدّس.
^code pop-class (1 before, 1 after)
وإذا جمعنا كلّ ذلك معاً، فهاهو مثال على إعلان صنف نلقيه على المُصرِّف:
class Brunch {
bacon() {}
eggs() {}
}
بالإعطاء على ذلك، فهاهو ما يولّده المُصرِّف وكيف تؤثّر تلك التعليمات في المكدّس في الزمن التشغيل:
لم يبقَ أمامنا سوى تنفيذ الجزء الزمني لتلك التعليمة الجديدة OP_METHOD.
تنفيذ إعلانات الدوال
أولاً، نُعرّف رمز العملية (opcode).
^code method-op (1 before, 1 after)
ونفكّها (disassemble) كما نفكّ التعليمات الأخرى ذات مُعاملات الثوابت النصّية.
^code disassemble-method (2 before, 1 after)
وفي المفسّر أيضاً نضيف حالة جديدة.
^code interpret-method (1 before, 1 after)
هناك، نقرأ اسم الدالة من جدول الثوابت ونمرّره إلى هنا:
^code define-method
إغلاق الدالة موجود على قمة المكدّس، فوق الصنف الذي ستُربط إليه. نقرأ خانتَي المكدّس هاتين ونخزّن الإغلاق في جدول دوال الصنف. ثم نُفرّغ الإغلاق من المكدّس لأنّنا انتهينا من استهلاكه.
لاحظ أنّنا لا نجري أيّ فحص لأنواع في الزمن التشغيل على الإغلاق أو على كائن الصنف.
استدعاء AS_CLASS() آمن لأنّ المُصرِّف نفسه هو الذي ولّد الشيفرة التي تجعل الصنف
يقع في تلك الخانة من المكدّس. فالآلة الافتراضية تثق
بمُصرِّفها الخاص.
بعد أن تكتمل سلسلة تعليمات OP_METHOD وتُفرغ OP_POP الصنف، سيكون لدينا صنفٌ
ذو جدول دوال ممتلئ بشكل جيّد، جاهز للبدء في فعل الأشياء. والخطوة التالية هي
استخراج تلك الدوال من جديد واستخدامها.
مراجع الدوال
معظم الوقت، يُنفذ الوصول إلى الدوال ثم استدعاؤها فوراً، ما ينتج هذه الصيغة المألوفة:
instance.method(argument);
لكن تذكّر أنّه في Lox وفي بعض اللغات الأخرى، هاتان الخطوتان منفصلتان ويمكن فصلهما.
var closure = instance.method;
closure(argument);
ولأنّ المستخدمين يستطيعون فصل العمليات، علينا تنفيذها بشكل منفصل. فالخطوة الأولى هي استخدام صيغة الخصائص النقطية القائمة لدينا للوصول إلى دالة معرَّفة على صنف النسخة. وينبغي أن تُعيد تلك شيئاً يمكن للمستخدم استدعاؤه بعد ذلك كدالة.
المسار الواضح هو البحث عن الدالة في جدول دوال الصنف وإعادة الـ ObjClosure المقابلة
لذلك الاسم. لكن علينا أيضاً أن نتذكّر أنّه عند الوصول إلى دالة، فإنّ this يُربط
بالنسخة التي تمّ الوصول إلى الدالة منها. وهنا المثال من حين أضفنا الدوال إلى
jlox:
class Person {
sayName() {
print this.name;
}
}
var jane = Person();
jane.name = "Jane";
var method = jane.sayName;
method(); // ?
ينبغي لهذا أن يطبع "Jane"، لذا يحتاج الكائن الذي تُعيده .sayName بطريقة ما إلى أن
يتذكّر النسخة التي تمّ الوصول إليه منها، حين يُستدعى لاحقاً. وفي jlox، نفّذنا تلك
"الذاكرة" باستخدام صنف البيئة (Environment) الموجود أصلاً في المُفسّر والمُخصَّص في
الذاكرة (heap-allocated)، وكانت يتولّى كلّ تخزين المتغيّرات.
ولآلةنا الافتراضية التي تعمل بالبايتكود معمارية أعقد في تخزين الحالة. فـ المتغيّرات المحلية والمؤقّتة تكون على المكدّس، والمتغيّرات العالميّة في جدول تجزئة، والمتغيّرات في الإغلاقات تستخدم القيم الملتقَطة. ويفرض ذلك حلّاً أعقد قليلاً لتتبّع المستقبِل (receiver) الدالة في clox، ونوعاً زمنياً جديداً.
الدوال المربوطة
عندما ينفّذ المستخدم وصولاً إلى دالة، سنبحث عن إغلاق تلك الدالة ونغلّفه في كائن
جديد هو "دالة مربوطة" يتتبّع النسخة التي تمّ الوصول إلى
الدالة منها. ويمكن استدعاء هذا الكائن المربوط لاحقاً كدالة. وعند استدعائه، تقوم
الآلة الافتراضية ببعض الحيل لربط this ليشير إلى المستقبِل داخل جسم الدالة.
وها هو نوع الكائن الجديد:
^code obj-bound-method (2 before, 1 after)
إنّه يغلّف المستقبِل وإغلاق الدالة معاً. ونوع المستقبِل هو Value رغم أنّ الدوال
لا يمكن استدعاؤها إلا على نسخ ObjInstance. وبما أنّ الآلة الافتراضية لا تهتمّ
بأيّ نوع من المستقبِل تملكه على أيّ حال، فإنّ استخدام Value يعني أننا لا نضطرّ إلى
الاستمرار في تحويل المؤشر مجدداً إلى Value عند تمريره إلى دوال أكثر عمومية.
البنية الجديدة تستلزم شيفرة التهيئة الاعتيادية (boilerplate) التي اعتدتَها الآن: حالة جديدة في تعداد أنواع الكائنات:
^code obj-type-bound-method (1 before, 1 after)
وماكررو (macro) للتحقّق من نوع قيمة:
^code is-bound-method (2 before, 1 after)
وماكررو آخر لتحويل القيمة إلى مؤشر ObjBoundMethod:
^code as-bound-method (2 before, 1 after)
ودالة لإنشاء ObjBoundMethod جديد:
^code new-bound-method-h (2 before, 1 after)
وتنفيذ تلك الدالة هنا:
^code new-bound-method
دالةٌ تشبه المُنشئ (constructor) تكتفي بتخزين الإغلاق والمستقبِل المعطَيين. وعندما لا تعود الدالة المربوطة محلّ ضروري، نحرّرها.
^code free-bound-method (1 before, 1 after)
تحمل الدالة المربوطة بضع مراجع، لكنها لا تملكها، لذا لا تحرّر شيئاً سوى نفسها. لكنّ تلك المراجع يتتبّعها جامع القمامة مع ذلك.
^code blacken-bound-method (1 before, 1 after)
وهذا يضمن أن مؤشّراً إلى دالة يُبقي المستقبِل في الذاكرة
كي يتمكّن this من العثور على الكائن حين تستدعي ذلك المؤشّر لاحقاً. كما أنّنا
نتتبّع إغلاق الدالة أيضاً.
وآخر عملية تدعمها كلّ الكائنات هي الطباعة.
^code print-bound-method (1 before, 1 after)
تطبع الدالة المربوطة تماماً بالطريقة نفسها التي تُطبع بها الدالة. فمن منظور المستخدم، الدالة المربوطة هي دالة. إنها كائن يستطيع استدعاؤه. ولا نكشف أنّ الآلة الافتراضية تنفّذ الدوال المربوطة باستخدام نوع كائن مختلف.
ارتدِ قبّعة حفلتك، لأنّنا بلغنا للتو معالماً صغيراً. فـ
ObjBoundMethod هو آخر نوع زمني نُضيفه إلى clox. وقد كتبت آخر ماكرروَي IS_ وAS_.
لم يبقَ إلّا فصول قليلة حتى نهاية الكتاب، ونقترب من آلة افتراضية مكتملة.
الوصول إلى الدوال
لنجعل نوع كائننا الجديد يفعل شيئاً. يُنفَّذ الوصول إلى الدوال باستخدام صيغة الخصائص
"النقطية" نفسها التي نفّذناها في الفصل الأخير. فالمُصرِّف يصرّف بالفعل التعبيرات
الصحيحة ويُصدر تعليمات OP_GET_PROPERTY لها. والتغييرات الوحيدة التي نحتاج إليها
هي في الجزء الزمني.
عند تنفيذ تعليمة الوصول إلى خاصية، تكون النسخة على قمة المكدّس. ومهمّة التعليمة هي العثور على حقل أو دالة بالاسم المعطى واستبدال قمة المكدّس بالخاصية التي تمّ الوصول إليها.
يتعامل المفسّر مع الحقول بالفعل، لذا ببساطة نوسّع حالة OP_GET_PROPERTY بقسم آخر.
^code get-method (5 before, 1 after)
نُدرج هذا بعد شيفرة البحث عن حقل على نسخة المستقبِل. فالحقول لها الأولوية على الدوال وتحجبها، لذا نبحث عن حقل أولاً. فإذا لم تكن لدى النسخة حقلٌ بالاسم المُعطى، فقد يكون الاسم يشير إلى دالة.
نأخذ صنف النسخة ونمرّره إلى دالة bindMethod() مساعدة جديدة. فإذا وجدت تلك الدالة
دالةً، تضعها على المكدّس وتعيد true. وإلا فإنّها تعيد false للدلالة على عدم
إمكان العثور على دالة بهذا الاسم. وبما أنّ الاسم لم يكن حقلاً أيضاً، فهذا يعني
أنّ لدينا خطأ في الزمن التشغيل، وهو ما يُنهي عمل المفسّر.
وها هو الجزء المفيد:
^code bind-method
أولاً نبحث عن دالة بالاسم المعطى في جدول دوال الصنف. فإذا لم نجد واحدة، نُبلّغ عن
خطأ في الزمن التشغيل ونخرج. أمّا إذا وجدناها، فإنّنا نأخذ الدالة ونغلّفها في
ObjBoundMethod جديد. ونلتقط المستقبِل من موضعه على قمة المكدّس. وأخيراً، نُفرغ
النسخة ونستبدل قمة المكدّس بالدالة المربوطة.
مثلاً:
class Brunch {
eggs() {}
}
var brunch = Brunch();
var eggs = brunch.eggs;
وها ما يحدث عندما تنفّذ الآلة الافتراضية استدعاء bindMethod() مقابل تعبير
brunch.eggs:
هذه كميّة كبيرة من الآلات تحت الغطاء، لكن من منظور المستخدم، كلّ ما يحصل عليه هو دالة يستطيع استدعاؤها.
استدعاء الدوال
يستطيع المستخدمون تعريف الدوال على الأصناف، والوصول إليها على النسخ، والحصول على
دوال مربوطة على المكدّس. لكنّهم ببساطة لا يستطيعون فعل
شيء مفيد بتلك كائنات الدوال المربوطة. فالعملية الناقصة هي استدعاؤها. والاستدعاءات
مُنفَّذة في callValue()، لذا نضيف حالة هناك لنوع الكائن الجديد.
^code call-bound-method (1 before, 1 after)
نُخرج الإغلاق الخام من ObjBoundMethod من جديد، ونستخدم دالة call() القائمة
للبدء في استدعاء ذلك الإغلاق بدفع CallFrame له إلى مكدّس الاستدعاءات. وهذا كلّ ما
يلزم لتشغيل برنامج Lox هذا:
class Scone {
topping(first, second) {
print "scone with " + first + " and " + second;
}
}
var scone = Scone();
scone.topping("berries", "cream");
تلك ثلاث خطوات كبيرة. يمكننا تعريف الدوال والوصول إليها واستدعاؤها. لكنّ هناك شيئاً ناقصاً. لقد ذهبنا إلى كلّ ذلك العناء لنغلّف إغلاق الدالة في كائن يربط المستقبِل، لكن حين نستدعي الدالة لا نستخدم ذلك المستقبِل إطلاقاً.
this
السبب في احتفاظ الدوال المربوطة بالمستقبِل هو أن يتيسّر الوصول إليه داخل جسم الدالة.
وتكشف Lox عن مستقبِل الدالة عبر تعبيرات this. حان وقت لبعض الصياغة الجديدة. فالمُحلِّل
اللساني (lexer) يتعامل بالفعل مع this كنوع رمز خاصّ، لذا فالخطوة الأولى هي توصيل
ذلك الرمز في جدول التحليل.
^code table-this (1 before, 1 after)
عندما يصطدم المحلّل (parser) بـ this في الموضع السابقة (prefix position)، فإنّه
يفوّض إلى دالة تحليل جديدة.
^code this
سنطبّق في clox أسلوب التنفيذ ذاته الذي استخدمناه في jlox لتعبير this. نتعامل مع
this كمتغيّر محلي نطاقه نحويّ (lexically scoped) تُهيَّأ قيمته بطريقة سحرية.
وتصريفه كمتغيّر محلي يعني أننا نحصل على كثير من السلوك مجاناً. وفي المقام الأوّل،
الإغلاقات داخل دالة ما تشير إلى this ستفعل الشيء الصحيح وتلتقط المستقبِل في قيمة
ملتقطَطة.
عندما تُستدعى دالة التحليل، يكون رمز this قد استُهلك للتو ومخزَّنٌ كالرمز السابق.
نُنادي دالتنا variable() القائمة التي تُصرّف تعبيرات المعرّفات كنصوص من الوصول إلى
متغيّرات. وهي تأخذ معاملاً منطقياً واحداً يخبرها هل ينبغي للمُصرِّف أن يبحث عن معامل
= تالٍ ويصرّف دالة ضبط (setter). ولا يمكنك الإسناد (assignment) إلى this، لذا
نمرّر false لمنع ذلك.
لا تهتمّ دالة variable() بأنّ لـ this نوع رمز خاصّاً وليس معرّفاً. فهي راضية عن
معاملة المُعجم "this" كما لو كان اسم متغيّر، ثم تبحث عنه باستخدام آلية حَلّ النطاق
(scope resolution) القائمة. وفي الوقت الحالي، سيفشل ذلك البحث لأنّنا لم نُعلن أبداً
متغيّراً اسمه "this". حان وقت التفكير في المكان الذي ينبغي أن تعيش فيه قيمة
المستقبِل في الذاكرة.
على الأقلّ ما دامت لم تُلتقط من الإغلاقات، تخزّن clox كلّ متغيّر محلي على مكدّس الآلة الافتراضية. فالمُصرِّف يتتبّع أيّ الخانات في نافذة مكدّس الدالة تملكها أيّ متغيّرات محلية. فإذا كنت تتذكّر، فإنّ المُصرِّف يحجز خانة المكدّس صفر بإعلان متغيّر محلي اسمه سلسلة فارغة.
بالنسبة لاستدعاءات الدوال، تنتهي تلك الخانة بأن تحمل الدالة المستدعاة. وبما أنّ
للخانة اسماً فارغاً، لا يصل إليها جسم الدالة أبداً. ويمكنك تخمين إلى أين يتّجه
الحديث. أمّا بالنسبة لاستدعاءات الدوال، يمكننا إعادة توظيف تلك الخانة لتخزين
المستقبِل. ستخزّن الخانة صفر النسخة التي يكون this مُربَطاً إليها. ولتصريف تعبيرات
this، لا يحتاج المُصرِّف سوى أن يعطي الاسم الصحيح لذلك المتغيّر المحلي.
^code slot-zero (1 before, 1 after)
نريد فعل ذلك للدوال فقط. فإعلانات الدوال لا تملك this. بل وإنّها يجب ألا تعلن
متغيّراً باسم "this"، بحيث إن كتبت تعبير this داخل إعلان دالة يقع هو نفسه داخل
دالة، فإنّ this يُحَلّ بشكل صحيح إلى مستقبِل الدالة الخارجية.
class Nested {
method() {
fun function() {
print this;
}
function();
}
}
Nested().method();
ينبغي لهذا البرنامج أن يطبع "Nested instance". ولتحديد الاسم الذي نمنحه لخانة
المكدّس صفر، يحتاج المُصرِّف إلى أن يعرف أهو يصرّف إعلان دالة عادية أم إعلان دالة
هي دالة (method)، لذا نضيف حالة جديدة إلى تعداد FunctionType للتمييز بين
الاثنتين.
^code method-type-enum (1 before, 1 after)
وحين نُصرّف دالة، نستخدم ذلك النوع.
^code method-type (2 before, 1 after)
الآن يمكننا تصريف الإشارات إلى المتغيّر الخاصّ "this" بشكل صحيح، وسيُصدر المُصرِّف
تعليمات OP_GET_LOCAL المناسبة للوصول إليه. بل ويمكن للإغلاقات حتى أن تلتقط this
وتخزّن المستقبِل في قيم ملتقَطة. بدعة حقاً.
إلّا أنّ المستقبِل في الزمن التشغيل ليس فعلياً في خانة الصفر. فالمُفسّر لم يفي بعد بجزءه من الصفقة. وهنا هو الإصلاح:
^code store-receiver (2 before, 2 after)
عندما تُستدعى دالة، تحتوي قمة المكدّس على كلّ المُعاملات، وتحتها مباشرةً يقع إغلاق الدالة المستدعاة. وهناك تحديداً ستكون خانة الصفر في الـ CallFrame الجديد. وسطر الشيفرة هذا يُدخل المستقبِل في تلك الخانة. فمثلاً، إعطاءً على استدعاء دالة كهذا:
scone.topping("berries", "cream");
نحسب خانة تخزين المستقبِل على النحو التالي:
يتخطّى -argCount المُعاملات، ويصحّح - 1 الأمر بحيث إنّ stackTop يشير إلى ما
بعد خانة المكدّس المستخدمة الأخيرة مباشرةً.
إساءة استخدام this
تدعم آلتنا الافتراضية الآن استخدام المستخدمين لـ this بشكل صحيح، لكنّنا نحتاج
أيضاً إلى التأكّد من أنّها تتعامل على النحو الصحيح مع الاستخدام الخاطئ لـ this.
تقول Lox إنّ ظهور تعبير this خارج جسم الدالة يُعدّ خطأ تصريف (compile error).
وينبغي للمُصرِّف أن يلتقط هذين الاستخدامين الخاطئين:
print this; // At top level.
fun notMethod() {
print this; // In a function.
}
إذن كيف يعرف المُصرِّف إن كان داخل دالة؟ الجواب الواضح هو النظر في FunctionType
للـ Compiler الحالي. وقد أضفنا للتوّ حالة تعداد هناك للتعامل مع الدوال بشكل
خاصّ. لكنّ ذلك لن يتعامل بشكل صحيح مع شيفرة مثل المثال السابق حيث تكون داخل دالة
تقع هي نفسها داخل دالة أخرى.
يمكننا محاولة حَلّ "this" ثم الإبلاغ عن خطأ إن لم يُعثر عليه في أيّ من النطاقات النحوية المحيطة. وسيعمل ذلك، لكنه سيتطلّب منا تحريك قطعة كبيرة من الشيفرة، لأنّ الشيفرة المسؤولة عن حَلّ متغيّر تتعامل ضمنياً مع الوصول على المستوى العام إن لم يُعثر على أيّ تصريح.
في الفصل التالي، سنحتاج إلى معلومات عن أقرب صنف مُحيط (enclosing). ولو كان لدينا ذلك، لاستطعنا استخدامه هنا لتحديد ما إذا كنّا داخل دالة. لذا فالأمر أيسر لنا في المستقبل إذا وضعنا تلك الآلية في موضعها الآن.
^code current-class (1 before, 2 after)
يشير متغيّر الوحدة هذا إلى بنية تمثّل الصنف الحالي، وهو أعمق صنف تجري تصريفه. ويبدو النوع الجديد كالتالي:
^code class-compiler-struct (1 before, 2 after)
لا نخزّن حالياً سوى مؤشر إلى ClassCompiler الخاصّ بالصنف المُحيط، إن وُجد. فتعشيش
إعلان صنف داخل دالة في صنف آخر أمرٌ غير شائع، لكنّ Lox تدعمه. تماماً كما هو الحال
مع بنية Compiler، يعني هذا أنّ ClassCompiler يشكّل قائمة مرتبطة (linked list)
تبدأ من الصنف الحالي الأعمق تجري تصريفه وتمتدّ عبر كلّ الأصناف المُحيطة.
إن لم نكن داخل أيّ إعلان صنف إطلاقاً، فإنّ متغيّر الوحدة currentClass يساوي
NULL. وعندما يبدأ المُصرِّف بتصريف صنف، يدفع ClassCompiler جديداً إلى ذلك
المكدّس المرتبط الضمني.
^code create-class-compiler (2 before, 1 after)
تعيش ذاكرة بنية ClassCompiler على مكدّس C مباشرةً، وهي قدرةٌ مفيدة نحصل عليها
بكتابة مُصرِّفنا بأسلوب النزول التكراري (recursive descent). وفي نهاية جسم الصنف،
نُفرغ ذلك المُصرِّف من المكدّس ونعيد المُحيط.
^code pop-enclosing (1 before, 1 after)
عندما ينتهي جسم صنف خارجيّ، سيكون enclosing يساوي NULL، لذا فإنّ هذا يُعيد
currentClass إلى NULL. إذن، لمعرفة ما إذا كنّا داخل صنف -- وبالتالي داخل دالة
-- يكفي أن نفحص متغيّر الوحدة ذاك.
^code this-outside-class (1 before, 1 after)
ومع ذلك، أصبح this خارج الصنف ممنوعاً بشكل صحيح. الآن تصبح دوالنا تشبه فعلاً
الدوال بالمعنى الكائني. فالوصول إلى المستقبِل يتيح لها التأثير في النسخة التي
استدعيتَ الدالة عليها. إننا نقترب من الهدف!
مُهيّئات النسخ
السبب في ربط اللغات الكائنية بين الحالة والسلوك -- وهو أحد المبادئ الجوهرية لهذا النمط -- هو ضمان بقاء الكائنات دائماً في حالة صالحة وذات معنى. فحين يكون الطريق الوحيد إلى حالة الكائن هو العبور عبر دواله، يمكن للدوال أن تتأكّد من عدم حدوث أيّ خلل. لكنّ ذلك يفترض أنّ الكائن بالفعل في حالة سليمة. فماذا عن لحظة إنشائه أوّلاً؟
تضمن اللغات الكائنية تهيئة الكائنات الجديدة بشكل سليم عبر المُنشئات (constructors)، والتي تنتج نسخة جديدة وتُهيّئ حالتها في آن واحد. وفي Lox، يخصيص الجزء الزمني نسخاً خاماً جديدة، وقد يعلن الصنف مُهيّئاً (initializer) لتهيئة أيّ حقول. وتعمل المُهيّئات تماماً كالدوال العادية، مع بعض التعديلات:
-
يستدعي الجزء الزمني دالة المُهيّئ تلقائياً كلّما أُنشئت نسخة من صنف.
-
يُعيد المستدعي الذي أنشأ نسخة النسخةَ إلى الوراء دائماً بعد انتهاء المُهيّئ، بغضّ النظر عمّا تعيده دالة المُهيّئ نفسها. فدالة المُهيّئ لا تحتاج إلى أن تُعيد
thisصراحةً. -
في الواقع، يُحظَر على المُهيّئ من إعادة أيّ قيمة على الإطلاق، إذ إنّ القيمة لن تكون مرئية على أيّ حال.
الآن وبعد أن ندعم الدوال، لا نحتاج لإضافة المُهيّئات سوى تنفيذ تلك القواعد الثلاث الخاصة. وسنمرّ عليها بالترتيب.
استدعاء المُهيّئات
أولاً، استدعاء init() تلقائياً على النسخ الجديدة:
^code call-init (1 before, 1 after)
بعد أن يخصيص الجزء الزمني النسخة الجديدة، نبحث عن دالة init() على الصنف. فإذا
وجدناها، نبدأ استدعاءً لها. وهذا يدفع CallFrame جديداً لإغلاق المُهيّئ إلى مكدّس
الاستدعاءات. لنقل إنّنا نشغّل هذا البرنامج:
class Brunch {
init(food, drink) {}
}
Brunch("eggs", "coffee");
عندما تنفّذ الآلة الافتراضية الاستدعاء لـ Brunch()، يحدث ما يلي:
أيّ مُعاملات مُمرَّرة إلى الصنف حين استدعناه ما زالت على المكدّس فوق النسخة. والـ
CallFrame الجديد لدالة init() يتشارك تلك النافذة من المكدّس، لذا تُمرَّر تلك
المُعاملات ضمنياً إلى المُهيّئ.
لا تشترط Lox على الصنف أن يعرّف مُهيّئاً. فإن كان محذوفاً، يُعيد الجزء الزمني ببساطة
النسخة الجديدة غير المُهيّأة. لكن، إن لم تكن هناك دالة init()، فلا معنى أصلاً
لتمرير مُعاملات إلى الصنف عند إنشاء النسخة. ونجعل ذلك خطأً.
^code no-init-arity-error (1 before, 1 after)
وحين يقدّم الصنف مُهيّئاً فعلاً، نحتاج أيضاً إلى التأكّد من أنّ عدد المُعاملات
المُمرَّرة يطابق عدد مُعاملات (arity) المُهيّئ. ولحسن الحظ، تفعل الدالة call()
المساعدة ذلك بالنسبة لنا بالفعل.
لاستدعاء المُهيّئ، يبحث الجزء الزمني عن دالة init() بالاسم. ونريد ذلك سريعاً لأنّه
يحدث في كلّ مرّة تُنشأ فيها نسخة. وهذا يعني أنّه من المفيد أن نستفيد من تداخل
السسلاسل (string interning) الذي نفّذناه بالفعل. ولتحقيق ذلك، تُنشئ الآلة الافتراضية
كائن ObjString للسلسلة "init" وتعيد استخدامه. وتعيش السلسلة في بنية الآلة
الافتراضية نفسها.
^code vm-init-string (1 before, 1 after)
نُنشئ السلسلة ونُدخلها في جدول السلاسل عند إقلاع الآلة الافتراضية.
^code init-init-string (1 before, 2 after)
نريدها أن تبقى، لذا يعتبرها جامع القمامة جذراً (root).
^code mark-init-string (1 before, 1 after)
انظر بتمعّن. هل ترى أيّ خلل في الأفق؟ لا؟ إنه خللٌ خفيّ. فجامع القمامة صار يقرأ
vm.initString. تتم تهيئة ذلك الحقل من نتيجة استدعاء copyString(). لكن نسخ سلسلة
يُخصص ذاكرة، وهو ما قد يُطلق جامع القمامة. فإن عمل الجامع في أسوأ توقيت ممكن، لقرأ
vm.initString قبل أن تتم تهيئته. لذا، أوّلاً، نُصفّر الحقل.
^code null-init-string (2 before, 2 after)
ونُصفّر المؤشّر عند إيقاف الآلة الافتراضية، لأنّ السطر التالي سيحرّره.
^code clear-init-string (1 before, 1 after)
حسناً، هذا يتيح لنا استدعاء المُهيّئات.
قيم الإرجاع في المُهيّئات
الخطوة التالية هي ضمان أنّ إنشاء نسخة من صنف له مُهيّئ يُعيد دائماً النسخة الجديدة،
وليس nil أو أيّ ما يُعيده جسم المُهيّئ. فحتى الآن، إذا عرّف صنفٌ مُهيّئاً، فإنّه عند
إنشاء نسخة، يدفع الـ VM استدعاءً لذلك المُهيّئ إلى مكدّس CallFrame. ثم يمضي الأمر
كما هو.
سيُتمّ استدعاء المستخدم الصنف لإنشاء النسخة عند عودة دالة المُهيّئ تلك، وسيترك على
المكدّس أيّ قيمة يضعها المُهيّئ هناك. وهذا يعني، ما لم يحرص المستخدم على وضع
return this; في نهاية المُهيّئ، فلن تخرج أيّ نسخة. وليست بمساعدة كبيرة.
لإصلاح ذلك، كلّما صرّف الجزء الأمامي دالة مُهيّئ، فإنّه يُصدر بايتكوداً مختلفاً في
نهاية الجسم لإعادة this من الدالة بدلاً من nil الضمنية التي تُعيدها معظم الدوال.
وللانجام بذلك، يحتاج المُصرِّف إلى أن يعرف فعلاً متى يكون يصرّف مُهيّئاً. ونكتشف ذلك
بفحص ما إذا كان اسم الدالة التي نُصرّفها هو "init".
^code initializer-name (1 before, 1 after)
نُعرّف نوع دالة جديداً للتمييز بين المُهيّئات وبقية الدوال.
^code initializer-type-enum (1 before, 1 after)
وكلّما أصدر المُصرِّف الإرجاع الضمني في نهاية الجسم، نفحص النوع لنقرّر ما إذا كنا نُدرج السلوك الخاصّ بالمُهيّئات.
^code return-this (1 before, 1 after)
في المُهيّئ، بدلاً من دفع nil إلى المكدّس قبل الإرجاع، نُحمّل خانة الصفر التي
تحتوي النسخة. ودالة emitReturn() هذه تُستدعى أيضاً عند تصريف تعليمة return
بلا قيمة، لذا فإنّ هذا يتعامل أيضاً بشكل صحيح مع الحالات التي يُرجع فيها المستخدم
مبكراً من داخل المُهيّئ.
إرجاعات خاطئة في المُهيّئات
الخطوة الأخيرة، وهي آخر بند في قائمة مزايا المُهيّئات الخاصّة، هي جعل محاولة إعادة شيء آخر من المُهيّئ خطأً. والآن بعد أن يتتبّع المُصرِّف نوع الدالة، أصبح هذا بسيطاً ومباشراً.
^code return-from-init (3 before, 1 after)
نُبلّغ عن خطأ إن كانت تعليمة return في مُهيّئ تحمل قيمة. ومع ذلك، فإنّنا نُصرّف
القيمة بعدها على أيّ حال كي لا يتشتّت المُصرِّف بسبب التعبير اللاحق ولا يُبلّغ عن
كومة من الأخطاء المتسلسلة.
عدا الوراثة، التي سنصل إليها قريباً، أصبح لدينا الآن نظام أصناف ذو ميزات كاملة إلى حدٍّ معقول يعمل في clox.
class CoffeeMaker {
init(coffee) {
this.coffee = coffee;
}
brew() {
print "Enjoy your cup of " + this.coffee;
// No reusing the grounds!
this.coffee = nil;
}
}
var maker = CoffeeMaker("coffee and chicory");
maker.brew();
بدعة جداً لبرنامج C كان يمكن أن يسع على قرص مَرن قديم.
استدعاءات محسَّنة
تنفّذ آلتنا الافتراضية بشكل صحيح دلالات اللغة الخاصة باستدعاءات الدوال والمُهيّئات. كان يمكننا التوقّف هنا. لكنّ السبب الرئيسي في بنينا تنفيذاً ثانياً كاملاً للغة Lox من الصفر هو التنفيذ أسرع من مُفسّرنا القديم المكتوب بـ Java. وحتى الآن، استدعاءات الدوال بطيئة حتى في clox.
تُعرّف دلالات Lox استدعاء الدالة عمليتين: الوصول إلى الدالة ثم استدعاء الناتج. ويجب أن تدعم آلتنا الافتراضية هاتين العمليتين بشكل منفصل لأنّ المستخدم يستطيع فصلهما. يمكنك الوصول إلى دالة من دون استدعائها ثم استدعاء الدالة المربوطة لاحقاً. ولا شيء نفّذناه حتى الآن هو غير ضروري.
لكنّ تنفيذ هاتين العمليتين دائماً بشكل منفصل له كلفة كبيرة. ففي كلّ مرّة واحدة
وصول فيها برنامج Lox إلى دالة ويستدعيها، يُخصيص مُكدسُ الذاكرة كائن
ObjBoundMethod جديداً، ويُهيّئ حقوله، ثم يُخرجها لتو. وبعدها، على جامع القمامة أن
يُنفق وقتاً في تحرير كلّ تلك الدوال المربوطة العابرة.
معظم الوقت، يصل برنامج Lox إلى دالة ثم يستدعيها فوراً. فالدالة المربوطة ينشئها تعليمة بايتكود واحدة ثم تستهلكها التعليمة التالية تماماً. بل إنّ الأمر سريع لدرجة أنّ المُصرِّف يستطيع حتى أن يرى نصّياً أنّ ذلك يحدث -- فوصولٌ إلى خاصية نقطية يليه قوس مفتوح هو على الأرجح استدعاء دالة.
وبما أننا نستطيع التعرّف على هاتين العمليتين وقت التصريف، لدينا الفرصة لإصدار تعليمة جديدة خاصّة تؤدّي استدعاء دالة محسَّناً.
نبدأ في الدالة التي تُصرّف تعبيرات الخصائص النقطية.
^code parse-call (3 before, 1 after)
بعد أن يكون المُصرِّف قد صرّف اسم الخاصية، نبحث عن قوس أيسر. فإذا طابقناه، فإنّنا
ننتقل إلى مسار شيفرة جديد. هناك، نُصرّف قائمة المُعاملات تماماً كما نفعل عند تصريف
تعبير استدعاء. ثم نُصدر تعليمة OP_INVOKE جديدة واحدة. وهي تأخذ مُعاملَين:
-
فهرس اسم الخاصية في جدول الثوابت.
-
عدد المُعاملات المُمرَّرة إلى الدالة.
بعبارة أخرى، تُدمج هذه التعليمة الواحدة مُعاملي تعليمتَي OP_GET_PROPERTY وOP_CALL
التي تحلّ محلّهما، بذلك الترتيب. إنها بالفعل اندماج لهاتين التعليمتين. لنُعرّفها.
^code invoke-op (1 before, 1 after)
ونضيفها إلى المفكّك (disassembler):
^code disassemble-invoke (2 before, 1 after)
هذه صيغة تعليمات جديدة خاصّة، لذا تحتاج منطق تفكيك مخصّصاً قليلاً.
^code invoke-instruction
نقرأ المُعاملَين ثم نطبع اسم الدالة وعدد المُعاملات معاً. أمّا في حلقة توجيه البايتكود داخل المفسّر فهناك يبدأ العمل الحقيقي.
^code interpret-invoke (1 before, 1 after)
معظم العمل يجري في invoke()، وسنصل إليها. هنا، نبحث عن اسم الدالة من المُعامل
الأول ثم نقرأ مُعامل عدد المُعاملات. ثم نسلّم الأمر إلى invoke() للقيام بالعمل
الثقيل. وتُعيد تلك الدالة true إذا نجح الاستدعاء. وكالمعتاد، تعني القيمة false
أنّ خطأً وقع في الزمن التشغيل. نفحص ذلك هنا ونُنهي المفسّر إذا حلّت الكارثة.
أخيراً، وبافتراض نجاح الاستدعاء، فإنّ هناك CallFrame جديداً على المكدّس، لذا نُحدّث
نسختنا المخزّنة مؤقتاً من الإطار الحالي في frame.
وهنا يحدث العمل المثير للاهتمام:
^code invoke
أولاً نلتقط المستقبِل من المكدّس. والمُعاملات المُمرَّرة إلى الدالة فوقه على المكدّس، لذا نتفحّص عدداً من الخانات لتلك الخانات. ثم إنّ الأمر بسيط: نحوّل الكائن إلى نسخة ونستدعي الدالة عليه.
لكنّ هذا يفترض بطبيعة الحال أن الكائن نسخة. تماماً كما في تعليمات
OP_GET_PROPERTY، نحتاج أيضاً إلى معالجة الحالة التي يحاول فيها المستخدم خطأً
استدعاء دالة على قيمة من نوع خاطئ.
^code invoke-check-type (1 before, 1 after)
هذا خطأ في الزمن التشغيل، لذا نُبلّغ عنه ونخرج. وإلا فإنّنا نأخذ صنف النسخة وننتقل إلى دالة المحتوى المحلية الجديدة الأخرى:
^code invoke-from-class
تجمع هذه الدالة منطق تنفيذ الآلة الافتراضية لتعليمتَي OP_GET_PROPERTY وOP_CALL،
بهذا الترتيب. أولاً نبحث عن الدالة بالاسم في جدول دوال الصنف. فإذا لم نجد واحدة،
نُبلّغ عن ذلك الخطأ الزمني ونخرج.
وإلا، فإنّنا نأخذ إغلاق الدالة وندفع استدعاءً له إلى مكدّس CallFrame. ولا نحتاج إلى
تخصيص كائن ObjBoundMethod في الذاكرة وتهيئته. بل إنّنا لا نحتاج حتى إلى لعب أيّ شيء على المكدّس. فالمستقبِل ومُعاملات الدالة موجودان
بالفعل في موضعَيهما الصحيحَين.
إن شغّلتَ الآلة الافتراضية ونفّذتَ برنامجاً صغيراً يستدعي الدوال الآن، فينبغي أن ترى
السلوك نفسه تماماً كما كان. لكن، إن أحسنا عملنا، فينبغي أن يكون الأداء قد تحسّن
كثيراً. فقد كتبتُ معياراً تجريبياً صغيراً (microbenchmark) ينفّذ دفعةً من 10,000
استدعاء دالة. ثم يختبر كم دفعةً يستطيع تنفيذها في 10 ثوانٍ. على حاسوبي، وبدون تعليمة
OP_INVOKE الجديدة، أنجز 1,089 دفعة. ومع هذا التحسين الجديد، أنجز 8,324 دفعة في
الوقت نفسه. هذا أسرع 7.6 مرّات، وهو تحسّن هائل حين يأتي إلى تحسين لغات البرمجة.
استدعاء الحقول
العقيدة الأساسية للتحسين هي: "لا تخرق الصحة." يحبّ المستخدمون أن يقدّم لهم تنفيذ اللغة إجابةً أسرع، لكن فقط إذا كانت تلك الإجابة هي الإجابة الصحيحة. وللأسف، فإنّ تنفيذنا لاستدعاءات الدوال الأسرع لا يلتزم بهذا المبدأ:
class Oops {
init() {
fun f() {
print "not a method";
}
this.field = f;
}
}
var oops = Oops();
oops.field();
يبدو السطر الأخير كأنه استدعاء دالة. والمُصرِّف يظنّ أنّ كذلك، فيُصدر تعليمة
OP_INVOKE لها بامتنان. لكنّه ليس كذلك. فما يحدث فعلياً هو وصول إلى حقل يُعيد دالة،
ثم تُستدعى تلك الدالة. وفي الوقت الحالي، بدلاً من تنفيذ ذلك بشكل صحيح، تُبلّغ آلتنا
الافتراضية عن خطأ في الزمن التشغيل عندما لا تجد دالة اسمها "field".
في وقت سابق، حين نفّذنا OP_GET_PROPERTY، تعاملنا مع كلٍّ من وصول الحقول ووصول
الدوال. ولتسحق هذا الخلل الجديد، علينا فعل الشيء ذاته مع OP_INVOKE.
^code invoke-field (1 before, 1 after)
الإصلاح بسيط تماماً. فقبل البحث عن دالة على صنف النسخة، نبحث عن حقل بالاسم نفسه.
وإذا وجدنا حقلاً، فإنّنا نخزّنه على المكدّس مكان المستقبِل، تحت قائمة المُعاملات.
وهذا هو سلوك OP_GET_PROPERTY، لأنّ تلك التعليمة تُنفَّذ قبل أن تُقيَّم قائمة
المُعاملات بين قوسين التالية.
ثم نُحاول استدعاء قيمة ذلك الحقل كقيمة قابلة للاستدعاء، على أمل أنّها كذلك. فالدالة
المساعدة callValue() ستفحص نوع القيمة وتستدعيها على النحو المناسب، أو تُبلّغ عن خطأ
في الزمن التشغيل إن لم تكن قيمة الحقل من نوع قابل للاستدعاء كإغلاق مثلاً.
وهذا كلّ ما يلزم لجعل تحسيننا آمناً تماماً. لكننا نُضحّي بقليل من الأداء، للأسف. لكنّ هذا هو الثمن الذي عليك دفعه أحياناً. فأنت تصاب أحياناً بالإحباط من تحسينات كان بمقدور أن نفعلها لو سمحت اللغة بحالة زاوية مزعجة. لكنّنا، بصفنا منفّذي لغات، مضطرّون إلى لعب اللعبة التي أُعطيتَ إياها.
تتبع الشيفرة التي كتبناها هنا نمطاً نموذجياً في التحسين:
-
التعرّف على عملية شائعة، أو تسلسل عمليات، ذو أهمية حاسمة للأداء. وفي هذه الحالة، هي وصول إلى دالة يليه استدعاء.
-
إضافة تنفيذ محسَّن لذلك النمط. وهذه هي تعليمة
OP_INVOKEلدينا. -
تحمي الشيفرة المحسَّنة ببعض المنطق الشرطي الذي يتحقّق من انطباق النمط فعلاً. فإن انطبق، بقينا على المسار السريع. وإلا، فسقطنا إلى سلوك غير محسَّن أبطأ لكنه أكثر متانة. وهنا، يعني ذلك التحقّق من أنّنا نستدعي دالة فعلاً ولا نصل إلى حقل.
ومع انتقال عملك في اللغة من جعل التنفيذ يعمل على الإطلاق إلى جعله يعمل بسرعة، ستجد نفسك تقضي وقتاً متزايداً في البحث عن أنماط كهذه وإضافة تحسينات محميّة لها. يقضي مهندسو الآلات الافتراضية المحترفون قسماً كبيراً من حياتهم المهنية في هذه الحلقة.
لكنّنا يمكننا التوقّف هنا مؤقتاً. ومع ذلك، يدعم clox الآن معظم مزايا لغة برمجة موجّهة للكائنات، وبأداء لائق.
التحديات
-
البحث في جدول التجزئة للعثور على دالة
init()الخاصة بصنفٍ ما يستغرق وقتاً ثابتاً، لكنه لا يزال بطيئاً إلى حدٍّ معقول. نفّذ شيئاً أسرع. اكتب معياراً تجريبياً وقِس فرق الأداء. -
في لغة مُنمَّطة ديناميكياً مثل Lox، قد يستدعي موقع استدعاء واحد (callsite) تشكيلة متنوّعة من الدوال على عدد من الأصناف طوال تنفيذ البرنامج. ومع ذلك، في الممارسة العملية، يكون موقع الاستدعاء في معظم الأوقات يستدعي الدالة نفسها بالضبط على الصنف نفسه طوال مدة التشغيل. ومعظم الاستدعاءات ليست متعدّدة الأشكال فعلياً حتى لو قالت اللغة إنّها تستطيع.
كيف تُحسّن تطبيقات اللغات المتقدّمة بناءً على هذه الملاحظة؟
-
عند تفسير تعليمة
OP_INVOKE، على الآلة الافتراضية أن تجري بحثين في جدول التجزئة. أولاً، تبحث عن حقل قد يحجب دالة، ولا تبحث عن دالة إلا إذا أخفق ذلك. أمّا الفحص الأول نادراً ما يكون مفيداً -- فمعظم الحقول لا تحتوي دوالاً. لكنّه ضروري لأنّ اللغة تقول إنّ الحقول والدوال يُنفَّذ عليها الوصول بالصيغة نفسها، وأنّ الحقول تحجب الدوال.وهذا اختيارٌ خاصّ باللغة يؤثّر في أداء تنفيذنا. فهل كان هذا الاختيار صحيحاً؟ ولو كانت Lox لغتك، فماذا كنت ستفعل؟
ملاحظة تصميم: ميزانية الغرابة
لا أزالتُ إلى الحين ذاكرتي أول مرة كتبتُ فيها برنامج BASIC صغيراً على TRS-80 وجعلتُ حاسوباً يفعل شيئاً لم يكن يفعله من قبل. بدا ذلك كأنّه قوّة خارقة (superpower). في أول مرة جَمعتُ منها قدراً ضئيلاً من محلّل ومفسّر بما يكفي لأكتب برنامجاً صغيراً بلغتي الخاصة يجعل حاسوباً يفعل شيئاً ما، كان ذلك كأنّه نوعٌ ما من قوّة خارقة فوق‑الترتيب (meta-superpower). كان شعوراً رائعاً ولا يزال كذلك.
أدركتُ أنّ بوسعتي تصميم لغة تبدو وتصرّف كما أختار. بدا ذلك كأنّي ذهبت طوال حياتي إلى مدرسة خاصة تشترط زياً موحّداً، ثم انتقلتُ في يوم ما إلى مدرسة عامة أستطيع فيها أن ألبس ما أريد. هل لا أحتاج أقواساً معقوفة للكتل؟ هل يمكنني استعمال شيء غير علامة المساواة للإسناد؟ هل يمكنني إنشاء كائنات بلا أصناف؟ وراثة متعدّدة و توصيفات متعدّدة (multimethods)؟ لغة ديناميكية تحمّل التحميل الزائد (overload) بشكل ساكن بحسب العدّية (arity)؟
طبيعياً، أخذتُ تلك الحرية وانطلقتُ بها. فجمعتُ أغرب قرارات تصميم اللغة وأكثرها اعتباطية. فواصل مائلة (apostrophes) بدل الأقواس المعقوفة في الأنواع العامة (generics). لا فواصل بين المُعاملات. وحَلّ التحميل الزائد (overload resolution) الذي يمكن أن يفشل في الزمن التشغيل. وقد فعلتُ الأشياء بشكل مختلف لمجرّد أن تختلف.
هذه تجربة ممتعة جداً أنصح بها بشدّة. فنحن بحاجة إلى المزيد من لغات البرمجة الغريبة والمتقدّمة. وأريد أن أرى المزيد من لغات الفنّ. ولا أزال أحياناً أصنع لغات تجريبية غريبة للمتعة.
غير أنّه، إن كان هدفك النجاح حيث يُعرَّف "النجاح" بأنّه عدد كبير من المستخدمين، فإنّ أولوياتك يجب أن تكون مختلفة. في تلك الحالة، يكون هدفك الأوّلي أن تحمّل لغتك إلى أدمغة أكبر عدد ممكن من الناس. وهذا صعبٌ جداً. إذ إنّ نقل بنية اللغة ودلالاتها من حاسوب إلى تريليونات من الخلايا العصبية يتطلّب جهداً بشرياً كبيراً.
المبرمجون محافظون بطبعهم في تعاملهم مع وقتهم، وحذرون بشأن اللغات التي يستحقون رفعها إلى أدمغتهم. فهم لا يريدون أن يُضيعوا وقتهم على لغة تنتهي في النهاية إلى أن تكون عديمة الفائدة لهم. وبصفتك مُصمّم لغة، فإنّ هدفك هو إذن أن تمنحهم أكبر قدر ممكن من قوة اللغة بأقلّ قدر ممكن من التعلّم المطلوب.
أحد المناهج الطبيعية هو البساطة. فكلّما قلّت المفاهيم والميزات في لغتك، قلّ الحجم الإجمالي للأشياء التي يجب تعلّمها. وهذه إحدى الأسباب التي تجعل لغات البرمجة النصية المتحرّكة (scripting) الصغيرة تجد نجاحاً غالباً رغم أنّها ليست قوية كاللغات الصناعية الكبيرة -- فهي أسهل في البداية، وبعد أن تستقرّ في عقل أحدهم يريد المستخدم أن يواصل استخدامها.
المشكلة في البساطة أنّ مجرد إلغاء الميزات يُضحي غالباً بالقوة والمعبيرية. وهناك فنّ في إيجاد الميزات التي تفوق ثمنها، لكنّ اللغات الصغيرة غالباً ما تفعل أقلّ فحسب.
هناك مسار آخر يتجنّب معظم تلك المشكلة. والحيلة هي إدراك أنّ المستخدم لا مضطرّ إلى تحميل لغتك بالكامل في رأسه، بل الجزء الذي لا يمتلكه أصلاً. وكما ذكرت في ملاحظة تصميم سابقة، فإنّ التعلّم مسألة نقل الفارق بين ما يعرفه المستخدم أصلاً وما يحتاج إلى معرفته.
كثير من المستخدمين المحتملين للغة يعرفون لغة برمجة أخرى. وأيّ ميزات تتشاركها لغتك مع تلك اللغة تكون أساساً "مجاناً" من ناحية التعلّم. فهي موجودة أصلاً في عقلهم، ولا عليهم سوى أن يدركوا أنّ لغتك تفعل الشيء نفسه.
بعبارة أخرى، المألوفة أداة رئيسية أخرى لخفض تكلفة تبنّي لغتك. وبالطبع، إن رفعتَ تلك السمة إلى أقصى حدّ، فإنّ النتيجة النهائية ستكون لغة مطابقة تماماً للغة موجودة بالفعل. وهذه ليست وصفة للنجاح، لأنّه في تلك النقطة لا يوجد أيّ حافز للمستخدمين للتبديل إلى لغتك أصلاً.
إذن عليك فعلاً أن تقدّم بعض الاختلافات المقنعة. أشياء تستطيع لغتك فعلها ولا تستطيع اللغات الأخرى، أو على الأقلّ لا تستطيع فعلها بالجودة نفسها. وأعتقد أنّ هذا أحد أعمال الموازنة الجوهرية في تصميم اللغات: القرب من اللغات الأخرى يخفض تكلفة التعلّم، في حين يرفع المزايا المقنعة.
أفكّر في هذا العمل بالموازنة بوصفه ميزانية غرابة، أو كما يسميها ستيف كلابنيك، "ميزانية الغرابة". لدى المستخدمين حدّ أدنى منخفض لإجمالي حجم الأشياء الجديدة التي هم مستعدّون لتقبّلها من أجل تعلّم لغة جديدة. فإن تجاوزتَ ذلك الحدّ، فلن يحضروا.
وفي الممارسة العملية، ألاحظ أنّ هذا يعني أنك ستنتهي إلى أن تكون محافظاً إلى حدٍّ كبير في الصياغة، وأكثر جرأة في الدلالات. فممتعةً كان ارتداء ثوب جديد، فإنّ استبدال الأقواس المعقوفة بفاصل كتل آخر من المرجّح جداً ألّا يضيف قوة حقيقية تُذكر للغة، لكنه يستهلك بعضاً من الغرابة. ومن الصعب على اختلافات الصياغة أن تُبرّر تكلفتها.
ومن الجهة الأخرى، للدلالات الجديدة أن ترفع قوة اللغة إلى حدٍّ كبير. فالتوصيفات متعدّدة (multimethods)، والخلطات (mixins)، والسمات (traits)، والانعكاس (reflection)، والأنواع التبعية (dependent types)، والبرمجة التبليغية في الزمن التشغيل (runtime metaprogramming)، وغيرها، ترفع جميعاً إلى حدٍّ كبير ما يستطيع المستخدم فعله باللغة.
وللأسف، ليس كونك محافظاً على هذا النحو ممتعاً بقدر تغيير كلّ شيء. لكنّ الأمر متروك لك أن تقرّر ما إذا كنت تريد مطاردة النجاح السائد من الأساس. فليس علينا جميعاً أن نكون فرقاً بوب ملائمة للإذاعة. إن أردتَ أن تكون لغتك مثل الجاز الحرّ أو موسيقى الدرون المعدنية (drone metal)، وكانك راضٍ عن حجم جمهور أصغر بالنسبة (لكن على الأرجح أكثر ولاءً)، فامضِ فيها.