صناعة المفسّرات › الأصناف والنسخ 12 / 35 السابق التالي

الأصناف والنسخ

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

دونا تارت، السنونو الذهبي

آخر ما بقي لتنفّذه في clox هو البرمجة الموجّهة للكائنات. البرمجة الموجّهة للكائنات (OOP) حزمة من الميزات المتشابكة: الأصناف، والنسخ، والحقول، والدوال، والمهيّئات، والوراثة. وباستخدام Java العالية المستوى نسبياً، حشرنا كل ذلك في فصلين. والآن بما أننا نبرمج بلغة C، التي تشبه بناء نموذج لبرج إيفل من أعواد الخشب، سنُفرد ثلاثة فصول لتغطية الإقليم نفسه. وهذا يجعل المرور في التنفيذ نزهةً مريحة. بعد فصول مرهقة مثل [الإغلاق][] و [جامع القمامة][]، فقد استحقحت راحة. وفي الواقع، ينبغي أن يكون الكتاب سهلاً من هنا فصاعداً.

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

كائنات الأصناف

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

بحلول هذه النقطة، صِرنا مُلمّاً تماماً بإضافة نوع كائن جديد إلى الآلة الافتراضية (virtual machine). نبدأ ببنية (struct).

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

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

يحتاج النوع الجديد إلى حالة مقابلة في تعداد ObjType.

^code obj-type-class (1 before, 1 after)

ويحصل ذلك النوع على زوج مقابل من الماكروهات (macros). أولاً، لاختبار نوع كائن:

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

ثم للتحويل من Value إلى مؤشر ObjClass:

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

تنشئ الآلة الافتراضية كائنات الأصناف الجديدة باستخدام هذه الدالة:

^code new-class-h (2 before, 1 after)

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

^code new-class

كلّه تقريباً شيفرة متكرّرة (boilerplate). تأخذ اسم الصنف كسلسلة وتخزّنه. وكل مرة يعلن فيها المستخدم صنفاً جديداً، تنشئ الآلة الافتراضية بنية ObjClass جديدة تمثّله.

عندما لا تعود الآلة الافتراضية إلى حاجة الصنف، فإنها تحرّره هكذا:

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

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

^code blacken-class (1 before, 1 after)

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

آخر عملية يمكن للآلة الافتراضية تنفيذها على صنف هي طباعته.

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

الصنف ببساطة يقول اسمه.

إعلانات الأصناف

بينما نملك التمثيل الزمني، نحن مستعدّون لإضافة دعم الأصناف إلى اللغة.بعد ذلك، ننتقل إلى المحلل النحوي (parser).

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

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

^code class-declaration

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

يُستخدم اسم الصنف أيضاً لربط كائن الصنف بمتغيّر يحمل الاسم نفسه. لذلك نعلن متغيّراً بذلك المُعرِّف مباشرةً بعد استهلاك رمزه.

بعد ذلك، نصدر تعليمة جديدة لإنشاء كائن الصنف فعلياً في الزمن التشغيل. takes تلك التعليمة فهرس جدول الثوابت لاسم الصنف كمعامل (operand).

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

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

المصرّف يُصدر تعليمة جديدة، فلنُعرّفها.

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

ونضيفها إلى المُفكّك (disassembler):

^code disassemble-class (2 before, 1 after)

لميزة تبدو كبيرة الحجم، فإن دعم المفسّر لها ضئيل.

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

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

ها هي ذي، صارت آلتنا الافتراضية تدعم الأصناف الآن. يمكنك تشغيل هذا:

class Brioche {}
print Brioche;

للأسف، الطباعة هي كل ما يمكنك فعله بالأصناف، لذا فالخطوة التالية هي جعلها أكثر نفعاً.

نسخ الأصناف

للأصناف غرضان رئيسيّان في اللغة:

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

  • إنها تحوي الدوال. وهذه الدوال تعرّف كيف تتصرّف كل نسخ الصنف.

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

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

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

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

لا نحتاج إلا إلى إضافة include، فيصبح الأمر منتهياً.

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

تحصل هذه البنية الجديدة على نوع كائن جديد.

^code obj-type-instance (1 before, 1 after)

أريد أن أُبطئ قليلاً هنا لأن مفهوم «النوع» في لغة Lox ومفهوم «النوع» في تنفيذ الآلة الافتراضية يتلامسان بطريقة قد تكون مُربكة. داخل شيفرة C التي تُكوّن clox، هناك عدد من الأنواع المختلفة لـ Obj — ObjString وObjClosure وغيرها. لكل منها تمثيله الداخلي ودلالاته الخاصة.

في لغة Lox، يستطيع المستخدمون تعريف أصنافهم الخاصة -- لنقل Cake وPie -- ثم إنشاء نسخ من تلك الأصناف. من منظور المستخدم، نسخة من Cake هي نوع كائن مختلف عن نسخة من Pie. لكن من منظور الآلة الافتراضية، كل صنف يعرّفه المستخدم ليس سوى قيمة أخرى من نوع ObjClass. وبالمثل، كل نسخة في برنامج المستخدم، مهما كان الصنف الذي هي نسخة منه، هي ObjInstance. نوع كائن الآلة الافتراضية الواحد هذا يغطي نسخ كل الأصناف. والعالمان يقابلان بعضهما بشكل يشبه ما يلي:

مجموعة من إعلانات الأصناف والنسخ، والتمثيلات الزمنية التي يقابل كل منها.

فهمتَ؟ حسناً، عد إلى التنفيذ. ونحصل أيضاً على ماكروهاتنا المعتادة.

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

و:

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

وبما أن الحقول تُضاف بعد إنشاء النسخة، فإن دالة «المُنشئ» تحتاج فقط إلى معرفة الصنف.

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

وننفّذ تلك الدالة هنا:

^code new-instance

نخزّن مرجعاً إلى صنف النسخة. ثم نهيّئ جدول الحقول ليكون جدول تجزئة فارغاً. وُلِد كائن رضيع جديد!

وفي الطرف الأكثر حُزناً من عمر النسخة، يتم تحريرها.

^code free-instance (3 before, 1 after)

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

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

^code blacken-instance (3 before, 1 after)

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

أقلّ أهمية لكنه لا يزال مهمّاً هو الطباعة.

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

النسخة تطبع اسمها متبوعاً بكلمة "instance". (الجزء الخاص بـ"instance" موجود أساساً كي لا تطبع الأصناف والنسخ الشيء نفسه.)

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

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

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

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

تقدّمنا خطوةً. يمكننا الآن تعريف الأصناف وإنشاء نسخ منها.

class Brioche {}
print Brioche();

لاحظ الأقواس بعد Brioche في السطر الثاني الآن. هذا يطبع "Brioche instance".

تعبيرات get وset

تمثيلنا للكائنات مخزّن للنسخ يستطيع أصلاً تخزين الحالة، لذا لم يبقَ سوى كشف تلك الوظيفة للمستخدم. يجري الوصول إلى الحقول وتعديلها باستخدام تعبيرات get وset. ولتسلك Lox مع التقاليد، تستخدم صيغة «النقطة» الكلاسيكية:

eclair.filling = "pastry creme";
print eclair.filling;

النقطة — أي full stop لأصدقائي الإنجليز — تعمل إلى حدٍّ ما كعامل ثنائي. فهناك تعبير على اليسار يُقيَّم أولاً وينتج نسخة. وبعد ذلك تأتي . متبوعةً باسم حقل. وبما"There وجود مُعامل سابق، فإننا نربط هذا في جدول التحليل كتعبير ثنائي (infix).

^code table-dot (1 before, 1 after)

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

^code compile-dot

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

لدينا شكلان جديدان من التعبيرات — الـ getters والـ setters — تتولاها هذه الدالة الواحدة. إن رأينا علامة مساواة بعد اسم الحقل، فلا بدّ أنها تعبير set يُسند إلى حقل. لكننا لا نسمح دائماً بوجود علامة مساواة بعد الحقل ليتم تصريفها. تأمّل:

a + b.c = 3

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

a + (b.c = 3)

المشكلة أن جانب = في تعبير set له أولوية أدنى بكثير من جزء .. قد يستدعي المحلل النحوي dot() في سياق أولويته أعلى مما يسمح بظهور setter. ولتجنّب السماح بذلك بشكل خاطئ، فإننا نحلل جزء المساواة ونصرّفها فقط عندما يكون canAssign true. وإذا ظهر رمز مساواة عندما يكون canAssign false، فإن dot() يتركه ويخرج. في تلك الحالة، سينتهي المصرّف في النهاية إلى صعود إلى parsePrecedence()، الذي يتوقّف عند = غير المتوقّع الذي ما زال رمزاً تالياً ويبلّغ عن خطأ.

إن وجدنا = في سياق مسموح به فعلاً، فإننا نصرّف التعبير الذي يليه. وبعد ذلك، نُصدر تعليمة OP_SET_PROPERTY جديدة. تأخذ هذه التعليمة معامل واحداً represent فهرس اسم الخاصية في جدول الثوابت. وإن لم نصرّف تعبير set، فنفترض أنه getter ونُصدر تعليمة OP_GET_PROPERTY، وهي أيضاً تأخذ معاملاً لاسم الخاصية.

الآن هو الوقت المناسب لتعريف هاتين التعليمة الجديدتين.

^code property-ops (1 before, 1 after)

وإضافة دعم لتفكيكهما:

^code disassemble-property-ops (1 before, 1 after)

تفسير تعبيرات getter وsetter

بالانتقال إلى الزمن التشغيلي، سنبدأ بتعبيرات get لأنها أبسط قليلاً.

^code interpret-get-property (1 before, 1 after)

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

بالطبع، قد لا يوجد الحقل. في Lox، فقد جعلنا ذلك خطأً زمنياً. لذا نضيف تحقّقاً لهذا ونُجهض التنفيذ إن حدث.

^code get-undefined (3 before, 2 after)

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

var obj = "not an instance";
print obj.field;

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

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

^code get-not-instance (1 before, 1 after)

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

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

^code interpret-set-property (2 before, 1 after)

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

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

class Toast {}
var toast = Toast();
print toast.jam = "grape"; // Prints "grape".

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

^code set-not-instance (1 before, 1 after)

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

class Pair {}

var pair = Pair();
pair.first = 1;
pair.second = 2;
print pair.first + pair.second; // 3.

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

التحديات

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

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

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

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

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

    كيف تتعامل هذا وتُحسّنه الإصدارات المتقوّنة من اللغات المكتوبة النوع ديناميكياً؟