المسح المعجمي
خذ لقماتٍ كبيرة. فكلّ ما يستحقّ فعله يستحقّ الإفراط فيه.
روبرت إيه. هاينلاين، وقتٌ كافٍ للحبّ
الخطوة الأولى في أيّ مصرّف أو مفسّر هي المسح المعجمي (scanning). يتلقّى المُحلِّل المعجمي الشيفرة المصدرية الخام في صورة سلسلة من المحارف، ويجمعها في سلسلة من القطع التي نسمّيها رموزاً (tokens). وهذه هي "الكلمات" و"علامات الترقيم" ذات المعنى التي تشكّل قواعد اللّغة.
المسح المعجمي نقطة بدايةٍ جيدة لنا أيضاً لأنّ الشيفرة ليست
صعبة إلى حدٍّ كبير -- إنّها تقريباً جملة switch غروراً بنفسها.
وسيساعدنا على الإحماء قبل أن نتناول المواد الأكثر إثارة
لاحقاً. ومع نهاية هذا الفصل، سيكون لدينا مُحلِّل معجمي كامل
المزايا وسريع، يقرأ أيّ سلسلة من شيفرة Lox المصدرية ويُنتج الرموز
التي سنُطعمها للمحلل النحوي في الفصل التالي.
إطار المفسّر
ولأنّ هذا هو فصلنا الحقيقيّ الأول، فقبل أن نبدأ فعلاً في مسح بعض الشيفرة، نحتاج إلى رسم الشكل الأساسي لمفسّرنا jlox. وكلّ شيء يبدأ بصنفٍ في Java.
^code lox-class
ضع ذلك في ملفّ نصّي، واذهب لتُهيّئ محرّك برمجتك أو ملفّ Makefile أو أيّ شيءٍ تحتاجه. سأكون هنا حين تكون مستعدّاً. حسناً؟ حسناً!
Lox لغة نصوص، وهذا يعني أنّها تنفّذ مباشرةً من المصدر. يدعم مفسّرنا طريقتين لتشغيل الشيفرة. فإذا شغّلتَ jlox من سطر الأوامر و أعطيته مسار ملفٍ، فإنه يقرأ الملفّ وينفّذه.
^code run-file
وإذا أردتَ محادثةً أكثر ألفةً مع مفسّرك، يمكنك أيضاً تشغيله بصورة تفاعلية. شغّل jlox بلا أيّ وسائط، فيسقطك إلى مطرِب يمكنك أن تُدخل فيه الشيفرة وتُنفّذها سطراً سطراً.
^code prompt
دالة readLine()، كما يوحي اسمها المفيد جداً، تقرأ سطر مُدخَلات
من المستخدم في سطر الأوامر وتُعيد النتيجة. ولإنهاء تطبيقٍ تفاعلي
في سطر الأوامر، تكتب عادةً Control-D. والضغط على ذلك يُشير إلى
حالة "نهاية الملف" (end-of-file) للبرنامج. وعندما يحدث ذلك تُعيد
readLine() القيمة null، لذا نفحص ذلك كي نخرج من الحلقة.
كلٌّ من المطرِب ومشغّل الملفّ غلافٌ رقيق حول دالةٍ واحدة:
^code run
وليس مفيداً إلى حدٍّ كبير الآن بما أنّنا لم نكتب المفسّر بعد، لكنّنا نمشي بخطواتٍ صغيرة، أليس كذلك؟ الآن، هي يطبع الرموز التي سيُصدرها مُحلِّلنا المعجمي القادم كي نرى إن كنّا نتقدّم.
معالجة الأخطاء
بينما نُهيّئ الأمور، هناك قطعة بنيةٍ تحتيةٍ رئيسيّة أخرى وهي معالجة الأخطاء (error handling). فتُمرّ عليها الكتب الدراسية أحياناً لأنّها مسألة عمليةٌ أكثر منها مسألة علوم حاسوب رسمية. لكن إن كنتَ تهتمّ بصناعة لغةٍ قابلة للاستخدام فعلاً، فإنّ معالجة الأخطاء بلباقة أمرٌ حيويّ.
الأدوات التي تقدّمها لغتنا للتعامل مع الأخطاء تشكّل جزءاً كبيراً ومن واجهتها. فحين تكون شيفرة المستخدم تعمل، لا يفكّرون في لغتنا البتّة -- فذهنهم مشغولٌ كلّه بـبرنامجهم. ولا يلاحظون تطبيقنا عادةً إلّا حين تسوء الأمور.
وعندئذٍ، حين يحدث ذلك، يكون علينا أن نمنح المستخدم كلّ المعلومات التي يحتاجها لفهم ما حدث، ونقوده بلطفٍ إلى حيث يحاول الذهاب. والقيام بذلك على الوجه الجيّد يعني التفكير في معالجة الأخطاء طوال تنفيذ مفسّرنا، بدءاً من الآن.
^code lox-error
تخبر دالة error() هذه ومساعدتها report() المستخدم بوقوع خطأ
صياغة في سطرٍ ما. وهذه في الحقيقة أصغر حدٍّ يمكن أن يُدّعى به
أنّ لديك إبلاغٌ عن الأخطاء أصلاً. تخيّل لو تركتَ فاصلةً معلّقة
بالخطأ في استدعاء دالةٍ ما وطبع المفسّر:
Error: Unexpected "," somewhere in your code. Good luck finding it!
فهذا غير مفيدٍ إلى حدٍّ كبير. نحن بحاجة على الأقلّ إلى أن نُشير إليهم إلى السطر الصحيح. وأفضل من ذلك عمودَي البداية والنهاية كي يعرفوا أين في السطر. وأفضل من ذلك أن تعرض له السطر المُذنِب، مثل:
Error: Unexpected "," in argument list.
15 | function(first, second,);
^-- Here.
أودّ جداً أن أنفّذ شيئاً كهذا في هذا الكتاب، لكنّ الحقيقة الصريحة هي أنّه شيفرةُ معالجة نصوصٍ متّسخة كثيرة. مفيدةٌ جداً للمستخدم، لكنّها ليست ممتعةً للقراءة في كتاب، وليست مثيرةً للاهتمام تقنياً. لذلك سنتشبّث برقم السطر وحده. وفي مفسّراتك الخاصّة، من فضلك افعل ما قلتُه لا ما فعلتُه.
السبب الرئيسي لوضع دالة الإبلاغ عن الأخطاء هذه في صنف Lox
الرئيسيّ هو حقل hadError ذاك. وهو مُعرَّف هنا:
^code had-error (1 before)
سنستعمل هذا لضمان أننا لا نحاول تنفيذ شيفرة فيها خطأ معروف. كما أنّه يتيح لنا أن نخرج برمز خروجٍ غير صفري كما ينبغي لمواطنٍ جيد في سطر الأوامر.
^code exit-code (1 before, 1 after)
نحتاج إلى إعادة تعيين هذا العَلَم في الحلقة التفاعلية. فإذا أخطأ المستخدم، فلا ينبغي أن يموت جلسته بالكامل.
^code reset-had-error (1 before, 1 after)
السبب الآخر الذي أخرجت به الإبلاغ عن الأخطاء إلى هنا بدلاً من دفنه في المُحلِّل المعجمي والمراحل الأخرى التي قد يقع فيها الخطأ هو أن أذكّرك بأنّ فصل الشيفرة التي تُنتج الأخطاء عن الشيفرة التي تُبلّغ عنها ممارسةُ هندسيةٌ جيدة.
ستكتشف مراحلٌ مختلفة من الواجهة الأماميّة الأخطاء، لكن ليس من
وظائفها حقّاً أن تعرف كيف تعرضها على المستخدم. وفي تطبيق لغةٍ
كامل المزايا، من المرجّح أن تتوفّر لديك طرقٌ متعدّدة لعرض
الأخطاء: على stderr، أو في نافذة أخطاء محرّك البرمجة، أو
مُسجَّلةً في ملفّ، إلخ. ولا تريد أن تكون تلك الشيفرة مدهونةً في
كلّ أنحاء مُحلّلك المعجمي ومحلّلك النحوي.
في المثالي، كان لدينا تجريدٌ فعلي، أي نوعٌ ما من واجهة "ErrorReporter" تُمرَّر إلى المُحلِّل المعجمي والمحلّل النحوي، فنستطيع تبديل استراتيجيات الإبلاغ المختلفة. أمّا في مفسّرنا البسيط هنا، فلم أفعل ذلك، لكنّني على الأقلّ نقلت شيفرة الإبلاغ عن الأخطاء إلى صنفٍ مختلف.
ومع وجود معالجة أخطاءٍ أوّلية في مكانها، صار قشر تطبيقنا جاهزاً.
بمجرّد أن تملك صنف Scanner فيه دالة scanTokens()، يمكننا
أن نبدأ في تشغيله. وقبل أن نصل إلى ذلك، لنكن أكثرّ تحديداً ممّا
يعنيه الرمز.
الرموز النصّيّة والرموز
إليك سطراً من شيفرة Lox:
var language = "lox";
هنا، var هي الكلمة المفتاحية للتصريح بمتغيّر. فتسلسل المحارف
الثلاثي "v-a-r" يعني شيئاً. لكن إن نزعتَ ثلاثة حروف من وسط
language، مثل "g-u-a"، فلا تعني شيئاً بذاتها.
هذا هو موضوع التحليل المعجمي. فمهمّتنا هي المسح على قائمة المحارف وتجميعها في أصغر تسلسلاتٍ لا تزال تمثّل شيئاً. وتُسمّى كلّ واحدة من هذه التجمّعات من المحارف رمزاً نصّياً (lexeme). وفي ذلك السطر من الشيفرة، الرموز النصّية هي:
الرموز النصّية هي مجرّد الأجزاء الفرعية الخام من الشيفرة المصدرية. غير أنّه في أثناء تجميع تسلسلات المحارف في رموز نصّية، نصطدم أيضاً ببعض المعلومات النافعة الأخرى. فحين نأخذ الرمز النصّي ونحزمه مع تلك البيانات الأخرى، تكون النتيجة رمزاً. وهو يشمل أشياءً نافعةً مثل:
نوع الرمز
الكلمات المفتاحية جزءٌ من شكل قواعد اللّغة، لذا فغالباً ما
يحتوي المحلل النحوي على شيفرةٍ مثل: "إذا كان الرمز التالي هو
while فافعل..." وهذا يعني أنّ المحلل النحوي يريد أن يعرف ليس
فقط أنّه يملك رمزاً نصّياً لمعرّفٍ ما، بل أنّه يملك كلمةً
محجوزة (reserved)، وأيّ كلمةٍ مفتاحية هي.
يستطيع المحلل النحوي تصنيف الرموز من الرمز النصّي الخام بمقارنة النصوص، لكنّ ذلك بطيء وقبيح نوعاً ما. بدلاً من ذلك، عند النقطة التي نتعرّف فيها على رمزٍ نصّي، نتذكّر أيضاً أيّ نوعٍ من الرموز النصّية يمثّله. ولدينا نوعٌ مختلف لكلّ كلمة مفتاحية، وكلّ معامل، وكلّ علامة ترقيم، وكلّ نوع قيمة حرفية.
^code token-type
القيمة الحرفية
توجد رموز نصّية للقيم الحرفية -- أعداد ونصوص وما شابه. وبما أنّ المُحلِّل المعجمي مضطرٌّ للمشي على كلّ محرفٍ في القيمة الحرفية كي يتعرّف عليها على نحو صحيح، فهو يستطيع أيضاً أن يحوّل ذلك التمثيل النصّي للقيمة إلى الكائن الحيّ وقت التشغيل الذي سيستعمله المفسّر لاحقاً.
معلومات الموضع
حين كنتُ أتبشير الوعظ حول معالجة الأخطاء، رأينا أنّنا بحاجة إلى أن نخبر المستخدمين أين وقعت الأخطاء. وتتبّع ذلك يبدأ من هنا. ففي مفسّرنا البسيط، نلاحظ أيّ سطرٍ يظهر فيه الرمز فحسب، لكنّ التطبيقات الأذكى تُضمّن العمود والطول أيضاً.
نأخذ كلّ هذه البيانات ونغلّفها في صنف.
^code token-class
أصبح لدينا الآن كائنٌ ذو بنيةٍ كافية ليكون نافعاً لكلّ مراحل المفسّر اللاحقة.
اللّغات المنظَّمة والتعبيرات المنظَّمة
الآن بعد أن عرفنا ما نحاول إنتاجه، فلنُنتجه إذاً. جوهر المُحلِّل المعجمي حلقة. فبدءً من أوّل محرفٍ في الشيفرة المصدرية، يحدّد المُحلِّل المعجمي أيّ رمزٍ نصّي ينتمي إليه ذلك المحرف، ثمّ يستهلكه وأيّ محارف تالية تكون جزءاً من ذلك الرمز النصّي. وحين يصل إلى نهايته يُصدر رمزاً.
ثمّ يعود في الحلقة ويفعل ذلك من جديد، بدءاً من المحرف التالي تماماً في الشيفرة المصدرية. ويستمرّ في ذلك، يأكل المحارف، ويطلِع من حينٍ لآخر رموزاً، حتى يبلغ نهاية المُدخَل.
قد يبدو الجزء من الحلقة الذي ننظر فيه إلى حفنة محارف لتحديد نوع الرمز النصّي الذي "يطابقه" مألوفاً. فإن كنتَ تعرف التعبيرات المنظَّمة (regular expressions)، فقد تفكّر في تعريف تعبيرٍ منظَّم (regex) لكلّ نوعٍ من الرموز النصّية واستعمالها لمطابقة المحارف. فمثلاً، تمتلك Lox القواعد نفسها التي تملكها C للمعرّفات (أسماء المتغيّرات وما شابه). وهذا التعبير المنظَّم يطابق معرّفاً:
[a-zA-Z_][a-zA-Z_0-9]*
ولو كنتَ فعلاً تفكّر في التعبيرات المنظَّمة، فحدسك هنا عميق. القواعد التي تحدّد كيف تُجمِّع لغةٌ معيّنة المحارف في رموزٍ نصّية تُسمّى قواعدها المعجمية. وفي Lox، كما في معظم لغات البرمجة، قواعد تلك القواعد المعجمية بسيطةٌ بما يكفي كي تُصنَّف اللّغة على أنّها [لغةٌ منظَّمة][]. وهو "المنظَّم" نفسه الوارد في التعبيرات المنظَّمة.
بإمكانك أن تتعرّف بدقّة على جميع الرموز النصّية المختلفة لـLox باستعمال التعبيرات المنظَّمة إن أردت، وهناك كومةٌ من النظرية المثيرة الكامنة وراء لماذا هكذا وما الذي يعنيه. وأدواتٌ مثل Lex أو Flex مصمَّمة تحديداً لتتيح لك ذلك -- ارمِ حفنةً من التعبيرات المنظَّمة عليها فتأتيك بمُحلِّل معجمي كامل.
وبما أنّ هدفنا هو أن نفهم كيف يفعل المُحلِّل المعجمي ما يفعله، فسنُسند تلك المهمّة. فنحن في وجه سلعٍ مصنوعةٍ يدوياً.
صنف المُحلِّل المعجمي
بلا مزيد من الكلام، فلصنع لأنفسنا مُحلِّلاً معجمياً.
^code scanner-class
نخزّن الشيفرة المصدرية الخام كنصٍّ بسيط، ولدينا قائمة جاهزة لتملأ بالرموز التي سنُنتجها. وتبدو الحلقة التي ذكرناها هكذا:
^code scan-tokens
يعمل المُحلِّل المعجمي في طريقه خلال الشيفرة المصدرية، مُضيفاً الرموز حتى تنفد المحارف. ثمّ يُلحق رمز "نهاية الملف" الأخير. وليس ذلك ضرورياً تماماً، لكنّه يجعل محلّلنا النحوي أنظف قليلاً.
تعتمد هذه الحلقة على حقلَين لمتابعة موضع المُحلِّل المعجمي في الشيفرة المصدرية.
^code scan-state (1 before, 2 after)
حقلّا start و current إزاحتان تفهرسان في النصّ. فيشير حقل
start إلى أوّل محرفٍ في الرمز النصّي الجاري مسحه، ويشير
current إلى المحرف الذي يجري التفكير فيه حالياً. أمّا حقل line
فيتتبّع سطر المصدر الذي يقع فيه current كي نُنتج رموزاً تعرف
مواقعها.
ثمّ لدينا دالة مساعدة صغيرة تخبرنا إذا ما استهلكنا كلّ المحارف.
^code is-at-end
التعرّف على الرموز النصّية
في كلّ دورة من الحلقة، نمسح رمزاً واحداً. وهذا هو قلب المُحلِّل المعجمي الحقيقيّ. سنبدأ ببساطة. تخيّل لو كان كلّ رمزٍ نصّي بطول محرفٍ واحد فقط. كلّ ما عليك فعله هو استهلاك المحرف التالي واختيار نوع رمزٍ له. وعدّة رموزٍ نصّية هي فعلاً محرفٌ واحد في Lox، لذا لنبدأ بتلك.
^code scan-token
ومرةً أخرى، نحتاج إلى بضع دوالّ مساعدة.
^code advance-and-add-token
تستهلك دالة advance() المحرف التالي في الملفّ المصدري وتُعيده.
أينما كانت advance() للمدخلات، تكون addToken() للمخرجات. فهي
تأخذ نصّ الرمز النصّي الحاليّ وتُنشئ رمزاً جديداً له. وسنستعمل
الحِمل الزائد (overload) الآخر قريباً للتعامل مع الرموز ذات
القيم الحرفية.
أخطاء معجمية
قبل أن نبتعد كثيراً، فلنتوقّف لحظة للتفكير في الأخطاء على
المستوى المعجمي. فماذا يحدث إذا ألقي المستخدم ملفاً مصدرياً
يحتوي بعض المحارف التي لا تستعملها Lox، مثل @#^، في مفسّرنا؟
حالياً، تُطرح تلك المحارف بصمت. فهي ليست مستعملة من اللّغة،
لكنّ ذلك لا يعني أنّ المفسّر يستطيع التظاهر بأنها غير موجودة.
بدلاً من ذلك، نُبلّغ عن خطأ.
^code char-error (1 before, 1 after)
لاحظ أنّ المحرف الخاطئ لا يزال مستهلكاً من الاستدعاء الأسبق
لـadvance(). وهذا مهمّ كي لا ندخل في حلقةٍ لا نهائية.
ولاحظ أيضاً أنّنا نواصل المسح. فقد تكون هناك أخطاءٌ أخرى لاحقاً في البرنامج. ونمنح مستخدمينا تجربةً أفضل إن اكتشفنا أكبر قدرٍ ممكن منها دفعةً واحدة. وإلّا فسي يرى خطأً صغيراً فيصلحه، لا ليظهر الخطأ التالي فحسب، وهكذا. لعب "ضرب فقاعات أخطاء الصياغة Whac-A-Mole" ليس ممتعاً.
(لا تقلق. بما أنّ hadError سيُضبط، فلن نحاول أبداً تنفيذ
أيّ من الشيفرة، حتى رغم أننا نُواصل ونسح بقيةَها.)
المعاملات
لدينا رموزٌ نصّية أحادية المحرف تعمل، لكنّ ذلك لا يغطّي كلّ
معاملات Lox. فماذا عن !؟ إنه محرفٌ واحد، صحيح؟ أحياناً نعم،
لكن إن كان المحرف التالي مباشرةً هو علامة يساوي، فيجب أن
ننشئ رمزاً نصّياً != بدلاً من ذلك. لاحظ أنّ ! و = ليستا
معاملَين مستقلَّين. لا يمكنك كتابة ! = في Lox وجعلها تتصرّف
كمعامل عدم مساواة. ولهذا نحتاج إلى أن نمسح != كرمزٍ نصّياً
واحد. وعلى نحوٍ مشابه، يمكن لكلّ من < و > و = أن يُتبع بـ=
لإنشاء معاملات المساواة والمقارنة الأخرى.
ولكلّ هذه، نحتاج إلى النظر إلى المحرف الثاني.
^code two-char-tokens (1 before, 2 after)
وتستخدم تلك الحالات هذه الدالة الجديدة:
^code match
هي أشبه بـadvance() شرطيّة. ولا نستهلك المحرف الحالي إلّا إذا
كان هو ما نبحث عنه.
وباستعمال match()، نتعرّف على هذه الرموز النصّية على مرحلَين.
فحين نصل مثلاً إلى !، نقفز إلى حالة switch الخاصة به. وهذا
يعني أنّنا نعرف أنّ الرمز النصّي يبدأ بـ!. ثمّ ننظر إلى
المحرف التالي لنحدّد ما إذا كنا أمام != أم مجرّد !.
رموزٍ نصّية أطول
ما زال ينقصنا معاملٌ واحد: / للقسمة. ذلك المحرف يحتاج إلى
معالجةٍ خاصةٍ بسيطة، لأنّ التعليقات تبدأ أيضاً بشرطةٍ مائلة.
^code slash (1 before, 2 after)
وهذا مشابه للمعاملَين الآخرين ذي المحرفَين، إلّا أنّه حين نجد
/ ثانية، لا ننهي الرمز بعد. بدلاً من ذلك، نواصل استهلاك
المحارف حتى نصل إلى نهاية السطر.
هذه استراتيجيتنا العامة في التعامل مع الرموز النصّية الأطول. فبعد أن نكتشف بداية أحدها، ننتقل إلى شيفرةٍ خاصّة بذلك الرمز النصّي تواصل أكل المحارف حتى ترى النهاية.
ولدينا مساعدٌ آخر:
^code peek
إنّه أشبه بـadvance() نوعاً ما، لكنّه لا يستهلك المحرف. وتُسمّى
هذه النظرة التقدّمية (lookahead).
ولأنّها تنظر فقط إلى المحرف الحالي غير المستهلك، فلديك محرف
واحدٌ من النظرة التقدّمية. وكلّما كان هذا الرقم أصغر، كان
المُحلِّل المعجمي أسرع عموماً. وقواعد القواعد المعجمية تحدّد
كم من النظرة التقدّمية نحتاج. ولحسن الحظّ، تستعمل معظم اللّغات
المتداولة peek محرفاً واحداً أو اثنين إلى الأمام فحسب.
التعليقات رموزٌ نصّية، لكنّها غير ذات معنى، والمحلل النحوي لا
يريد التعامل معها. لذا حين نصل إلى نهاية التعليق، لا نُنادي
addToken(). وحين نعود في الحلقة لنبدأ الرمز النصّي التالي،
يُعاد تعيين start ويختفي الرمز النصّوي للتعليق في نفخةٍ من
الدخان.
وبما أنّنا في الموضوع، فحان وقت تخطّي تلك المحارف الأخرى عديمة المعنى: أسطرَ جديدة ومسافاتٍ بيضاء.
^code whitespace (1 before, 3 after)
وحين نلتقي بمسافةٍ بيضاء، نعود ببساطة إلى بداية حلقة المسح.
وذلك يبدأ رمزاً نصّياً جديداً بعد محرف المسافة. أمّا الأسطر
الجديدة فنفعل الشيء ذاته، لكنّنا نزيد عدّاد السطر أيضاً. (ولهذا
استعملنا peek() للعثور على السطر الجديد الذي ينهي التعليق بدلاً
من match(). نريد ذلك السطر الجديد أن يوصلنا إلى هنا كي نُحدّث
line.)
أصبح مُحلِّلنا المعجمي أذكى. فهو يستطيع التعامل مع شيفرة حرةٍ الشكلٍ نوعاً ما مثل:
// this is a comment
(( )){} // grouping stuff
!*+-/=<> <= == // operators
القيم الحرفية للنصوص
الآن وقد اطمأننّا إلى الرموز النصّية الأطول، فنحن مستعدّون
لمواجهة القيم الحرفية. سنبدأ بالنصوص، لأنّها تبدأ دائماً
بمحرفٍ معيّن، وهو ".
^code string-start (1 before, 2 after)
وهذا ما يُنادي:
^code string
وكما مع التعليقات، نستهلك المحارف حتى نلتقي بـ" التي تُنهي
النصّ. كما نتعامل بلباقة مع نفاد المُدخَل قبل إغلاق النصّ
ونُبلّغ عن خطأ لذلك.
ولسببٍ غير معيّن بعينه، تدعم Lox نصوصاً متعدّدة الأسطر. فهناك
مزايا وعيوب، لكنّ منعها كان أعقد قليلاً من السماح بها، لذا
تركتُها. وهذا يعني أيضاً أنّنا نحتاج إلى تحديث line عند
الوصول إلى سطرٍ جديد داخل نصّ.
وأخيراً، الجزء الأخير المثير للاهتمام هو أنّنا حين نُنشئ الرمز،
ننتج أيضاً قيمةَ النصّ الفعلية التي سيستعملها المفسّر لاحقاً.
وهنا لا يتطلّب ذلك التحويل سوى substring() لخلع علامتَي التنصيص
المحيطتين. ولو دعمت Lox تسلسلات الهروب مثل \n لفكّنا ترميزها
هنا.
القيم الحرفية للأعداد
كلّ الأعداد في Lox أعدادٌ عائمة (floating point) وقت التشغيل،
لكنّ القيم الحرفية الصحيحة والعشرية كلاهما مدعوم. القيمة
الحرفية للعدد هي سلسلةٌ من الأرقام،
يليها اختيارياً . وواحدٌ أو أكثر من الأرقام اللاحقة.
1234
12.34
لا نسمح بنقطةٍ عشريةٍ سابقة أو لاحقة، لذا كلاهما غير صالح:
.1234
1234.
يمكننا بسهولة دعم الأول، لكنّني تركته خارجاً لإبقاء الأمور
بسيطة. أمّا الثاني فيصير غريباً إن أردنا يوماً السماح بدوالّ على
الأعداد مثل 123.sqrt().
وللتعرّف على بداية رمزٍ نصّي لعدد، نبحث عن أيّ رقم. من المُملّ قليلاً إضافة حالاتٍ لكلّ رقمٍ عشري، لذا سنحشره في الحالة الافتراضية بدلاً من ذلك.
^code digit-start (1 before, 1 after)
وهذا يعتمد على الأداة الصغيرة التالية:
^code is-digit
بعد أن نعرف أنّنا في عدد، نفُرع إلى دالةٍ منفصلة لاستهلاك بقيّة القيمة الحرفية، كما نفعل مع النصوص.
^code number
نستهلك أكبر عددٍ من الأرقام نجده في الجزء الصحيح من القيمة
الحرفية. ثمّ نبحث عن جزءٍ كسريّ، وهو نقطةٌ عشرية (.) يليها
رقَمٌ واحدٌ على الأقلّ. وإن كان لدينا جزءٌ كسريّ، نُعيد استهلاك
أكبر عددٍ من الأرقام نجده.
النظرُ ما وراء النقطة العشرية يتطلّب محرفاً ثانياً من النظرة
التقدّمية، لأنّنا لا نريد استهلاك . حتى نتأكّد أنّ هناك رقماً
بعده. لذا نضيف:
^code peek-next
وأخيراً، نحوّل الرمز النصّي إلى قيمته العددية. ويستخدم مفسّرنا
نوع Double في Java لتمثيل الأعداد، لذا نُنتج قيمةً من ذلك
النوع. ونحن نستعمل طريقة التحليل في Java نفسها لتحويل الرمز
النصّي إلى عددٍ مزدوجٍ حقيقيّ في Java. يمكننا تنفيذ ذلك بأنفسنا،
لكن بصراحة، ما لم تكن تحاول الحشّ لاختبار برمجةٍ قادمة، فلا
يستحقّ ذلك وقتك.
والقيم الحرفية الباقية هي القيم المنطقيّة و nil، لكنّنا
نتعامل معها ككلماتٍ مفتاحية، وهذا يوصلنا إلى...
الكلمات المحجوزة والمعرّفات
مُحلِّلنا المعجمي وشيكٌ على الانتهاء. والجزء الوحيد المتبقّي من
القواعد المعجمية الذي يلزم تنفيذه هو المعرّفات وأقاربها
القريبون، الكلمات المحجوزة. وقد تظنّ أنّنا نستطيع مطابقة
كلماتٍ مفتاحية مثل or بالطريقة نفسها التي نتعامل بها مع
معاملاتٍ متعدّدة المحارف مثل <=.
case 'o':
if (match('r')) {
addToken(OR);
}
break;
فكّر فيما سيحدث لو سمّى مستخدمٌ متغيّراً باسم orchid. سيرى
المُحلِّل المعجمي أوّل حرفَين، or، وسيُصدر فوراً رمز الكلمة
المفتاحية or. وهنا نصل إلى مبدأٍ مهمّ اسمه أقصى مضغ (maximal munch). فحين يمكن
لقاعدتَي قواعدٍ معجمية أن تطابقا معاً قطعةً من الشيفرة ينظر
إليها المُحلِّل المعجمي، فأيّهما يطابق أكبر عددٍ من المحارف
يفوز.
وتقول هذه القاعدة إنّه إن استطعنا مطابقة orchid كمعرّف
و or ككلمةٍ مفتاحية، فالأوّل هو الفائز. ولهذا أيضاً افترضنا
من قبل ضمناً أنّ <= ينبغي أن يُمسح كرمزٍ واحد <= لا كـ<
يليه =.
تعني أقصى مضغ أنّه يتعذّر علينا كشف كلمةٍ محجوزة بسهولة قبل أن نصل إلى نهاية ما قد يكون معرّفاً بدلاً من ذلك. فبعد كلّ شيء، الكلمة المحجوزة هي معرّف، لكنّها مجرّد معرّفٍ احتلّته اللّغة لنفسها. ومن هنا جاء مصطلح الكلمة المحجوزة.
إذن نبدأ بافتراض أنّ أيّ رمزٍ نصّي يبدأ بحرفٍ أو بشرطة سفلية هو معرّف.
^code identifier-start (3 before, 3 after)
وتعيش بقيّة الشيفرة هنا:
^code identifier
ونعرّف ذلك بدلالة المساعدَين التاليَين:
^code is-alpha
وهذا يجعل المعرّفات تعمل. أما الكلمات المفتاحية فنتعامل معها مما إذا كان الرمز النصّي للمعرّف إحدى الكلمات المحجوزة. فإن كان كذلك، نستعمل نوع رمزٍ مخصّصاً لتلك الكلمة المفتاحية. ونعرّف مجموعة الكلمات المحجوزة في خريطة (map).
^code keyword-map
ثمّ، بعد أن نمسح معرّفاً، نفحص ما إذا كان يطابق أيّ شيءٍ في الخريطة.
^code keyword-type (2 before, 1 after)
وإن كان كذلك، نستعمل نوع رمز تلك الكلمة المفتاحية. وإلّا فهو معرّفٌ عاديّ يعرّفه المستخدم.
وبهذا، أصبح لدينا الآن مُحلِّل معجمي كامل لكامل القواعد المعجمية لـLox. شغّل REPL واكتب بعض الشيفرة الصالحة وغير الصالحة. هل يُنتج الرموز التي تتوقّعها؟ حاول أن تتوصل إلى بعض الحالات الحدّية المثيرة وانظر كيف يتعامل معها كما ينبغي.
التحديات
-
القواعد المعجمية لـPython و Haskell ليست منظَّمة. ماذا يعني ذلك، ولماذا ليستا كذلك؟
-
إلى جانب فصل الرموز -- التمييز بين
print fooوprintfoo-- لا تُستعمل المسافات كثيراً في معظم اللّغات. لكنّ في بضعة زوايا مظلمة، تؤثّر المسافة فعلاً على كيفية تحليل الشيفرة في CoffeeScript و Ruby والمعالج المسبق لـC. أين وما هو أثرها في كلّ واحدةٍ من تلك اللّغات؟ -
يطرح مُحلِّلنا المعجمي هنا، مثل معظمه، التعليقات والمسافات البيضاء لأنّ المحلل النحوي لا يحتاج إليها. لماذا قد ترغب في
-
أضف إلى مُحلِّل Lox دعماً لتعليقات الكتلة بأسلوب C على الشكل
/* ... */. تأكّد من التعامل مع الأسطر الجديدة فيها. وفكّر في السماح لها بالتعشيش. هل إضافة دعم التعشيش أكثر عملاً ممّا توقّعت؟ لماذا؟
ملاحظة تصميم: الفواصل المنقوطة الضمنية
المبرمجون اليوم منفّرون في اختيارهم للغات وصاروا متذوّقين في
الصياغة. يريدون لغتهم أن تبدو نظيفةً وعصرية. وإنّ "; كفاصل
صريح بين الجمل" هي بقعة طفيليّةٍ صياغوية يكاد كلّ لغة جديدة أن
تقشّرها (وبعض اللّغات القديمة مثل BASIC لم تكن تملكها أصلاً).
بدلاً من ذلك، يتعاملون مع السطر الجديد كفاصلٍ بين الجمل حيث يجري ذلك. وأينما يكون ذلك منطقياً هو الجزء الصعب. فبينما تقع معظم الجمل في سطرها الخاص، تحتاج أحياناً إلى مدّ جملةٍ واحدة عبر سطرَين. ولا ينبغي أن تُعامَل تلك الأسطر الجديدة المتشابكة كفواصل.
معظم الحالات الواضحة التي يجب فيها تجاهل السطر الجديد يسهل كشفها، لكنّ هناك حفنةً من الحالات القبيحة:
-
قيمة إرجاع في السطر التالي:
if (condition) return "value"هل "value" هي القيمة التي يُرجعها، أم لدينا جملة
returnبلا قيمة تليها جملة تعبير تحتوي قيمةً نصّية حرفية؟ -
تعبيرٌ بين قوسين في السطر التالي:
func (parenthesized)هل هذا استدعاءٌ لـ
func(parenthesized)، أم جملتا تعبير، واحدةٌ لـfuncوأخرى لتعبيرٍ بين قوسين؟
*رمز - في السطر التالي:
```js
first
-second
```
هل هذا `first - second` -- طرحٌ ثنائيّ -- أم جملتا تعبير،
واحدةٌ لـ `first` وأخرى لنفي `second`؟
في كلّ هذه الحالات، فإنّ معاملة السطر الجديد كفاصلٍ أو لا، كلاهما يُنتج شيفرةً صالحة، لكن ربما ليست الشيفرة التي يريدها المستخدم. وفي مختلف اللّغات، هناك تشكيلةٌ من القواعد تقلق القلب تُستخدم لتحديد أيّ الأسطر الجديدة فواصل. إليك بعضها:
-
Lua تتجاهل الأسطر الجديدة تماماً، لكنّها تتحكّم بقواعدها النحوية بدقّة بحيث لا يلزم أيّ فاصل بين الجمل في معظم الحالات. وهذا جائز تماماً:
a = 1 b = 2تتخلّص Lua من مشكلة
returnبأنّها تشترط أن تكون جملةreturnهي الجملة الأخيرة تماماً في الكتلة. فإذا كانت هناك قيمة بعدreturnوقبل الكلمة المفتاحيةend، فيجب أن تكون لتلك. أمّا الحالتان الأخرتان فتسمحان بفاصلةٍ منقوطة صريحة وتتوقّعان منك استعمالها. وعملياً، لا يحدث ذلك أبداً تقريباً، لأنّه لا فائدة من جملة تعبيرٍ بين قوسين أو من نفيٍ أحادي. -
Go تتعامل مع الأسطر الجديدة في المُحلِّل المعجمي. فإذا ظهر سطرٌ جديد بعد أحد أنواع الرموز العديدة المعروفة بإمكانية أن تنهي جملة، فالسطر الجديد يُعامَل كأنّه فاصلة منقوطة. وإلّا فتجاهَل. ويوفّر فريق Go منسّق شيفرة معيارياً، وهو gofmt، والمنظومة تتبنّاه بحماس، ما يضمن أنّ الشيفرة المكتوبة بالأسلوب المتّبع تعمل جيداً مع هذه القاعدة البسيطة.
-
Python تعامل كلّ الأسطر الجديدة على أنّها ذات دلالة ما لم تُستعمل شرطةٌ مائلة صريحة في نهاية السطر لمتابعته إلى السطر التالي. غير أنّ الأسطر الجديدة في أيّ موضع داخل زوجٍ من الأقواس (
()أو[]أو{}) تُتجاهَل. ويميل الأسلوب المتّبع بشدّة إلى الخيار الأخير.تعمل هذه القاعدة بشكلٍ جيّد مع Python لأنّها لغةٌ شديدة التوجّه نحو الجمل. وعلى نحوٍ خاص، تضمن قواعد Python النحوية ألّا تظهر جملةٌ أبداً داخل تعبير. وC تفعل الشيء ذاته، لكنّ لغاتٍ كثيرة أخرى لها صيغة "lambda" أو صيغة دالةٍ حرفية لا تفعل.
مثالٌ في JavaScript:
console.log(function() { statement(); });هنا يحتوي تعبير
console.log()على دالةٍ حرفية تحتوي بدورها على الجملةstatement();.ستحتاج Python إلى مجموعةٍ مختلفة من القواعد لضمّ الأسطر ضمنياً، إن استطعت العودة إلى جملة حيث تصير الأسطر الجديدة ذات معنى بينما ما زالت داخل الأقواس.
-
قاعدة "الإدراج الفاصل المنقوطي التلقائي" (automatic semicolon insertion) في JavaScript هي القاعدة الغريبة حقّاً. فبينما تفترض اللّغات الأخرى أنّ معظم الأسطر الجديدة ذات معنى وأنّ مجرّد القليل منها يجب تجاهله في الجمل الممتدّة عبر أسطر، تفترض JS العكس تماماً. فهي تعامل كلّ أسطرك الجديدة كمسافاتٍ بيضاءٍ عديمة المعنى إلّا إن صادفت خطأ تحليل. فإذا حدث ذلك، ترجع وتحاول تحويل السطر الجديد السابق إلى فاصلة منقوطة كي تحصل على شيءٍ صالح نحوياً.
لو شرحتُ بالتفصيل كيف يعمل ذلك أصلاً، لحوّلت هذه الملاحظة إلى مطالعةٍ تصميميةٍ هtjائلة، فضلاً عن الطرق المختلفة التي يكون فيها "حل" JavaScript فكرةً سيّئة. فهي فوضى. وJavaScript هي اللّغة الوحيدة التي أعرفها التي تطلب فيها أدلةُ أسلوبٍ كثيرة فواصلَ منقوطةً صريحةً بعد كلّ جملة، رغم أنّ اللّغة من حيث النظر تتيح لك إسقاطها.
إذا كنتَ تصمّم لغةً جديدة، فأنت على الأرجح يجب أن تتجنّب فاصلاً صريحاً بين الجمل. فالمبرمجون كائناتٍ مغراةٌ بالأناقة كبشرٍ آخرين، والفواصل المنقوطة بالٍ عتيقةٌ مثل الكلمات المفتاحية الكاملة بالأحرف الكبيرة. فقط تأكّد من اختيارك مجموعة قواعدٍ منطقيةٍ لقواعد لغتك الخاصة وأعرافها. ولا تفعل ما فعلته JavaScript.