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

المتغيّرات المحلّية

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

ويليام شكسبير، حلم ليلة صيف

قدّم [الفصل الأخير][] المتغيّرات إلى clox، لكن من نوع العالمي فقط. وفي هذا الفصل، سنوسّع ذلك ليشمل الكتل، والنطاق الكتلي، والمتغيّرات المحلّية (local variables). وفي jlox، تمكّنّا من حشر كل ذلك مع المتغيّرات العامة في فصل واحد. أمّا في clox، فقد استغرق ذلك فصليين من العمل، بالدرجة الأولى لأنّ كل شيء في C يتطلّب جهدًا أكبر بكل بساطة.

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

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

تمثيل المتغيّرات المحلّية

إنّ الشيء الجميل في التعديل على لغة برمجة في الأزمنة الحديثة هو وجود سلالة طويلة من اللغات الأخرى لتتعلّم منها. إذن كيف تدير لغة C ولغة Java متغيّرانيهما المحلّيين؟ على المكدّس (stack) بالطبع! فهما يستخدمان عادةً آليات المكدّس الأصلية التي تدعمها الشريحة ونظام التشغيل. ذلك أدنى قليلًا من المستوى بالنسبة لنا، لكن داخل العالم الافتراضي لـ clox، لدينا مكدّس خاصّ يمكننا استخدامه.

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

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

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

تنقّل عبر برنامج المثال هذا وراقب كيف تدخل المتغيّرات المحلّية نطاقها وتخرجه:

سلسلة من المتغيّرات المحلية تدخل النطاق وتخرج منه بأسلوب يشبه المكدّس.

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

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

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

^code compiler-struct (1 before, 2 after)

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

^code uint8-count (1 before, 2 after)

في بنية Compiler، يتتبّع الحقل localCount عدد المتغيّرات المحلّية الموجودة في النطاق — أي عدد خانات المصفوفة المستخدَمة. كما نتتبّع "عمق النطاق" (scope depth). وهو عدد الكتل المحيطة بجزء الشيفرة الحالي الذي نترجمه.

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

كل متغيّر محلّي في المصفوفة هو واحد من هذه:

^code local-struct (1 before, 2 after)

نخزّن اسم المتغيّر. وحين نحلّ مُعرِّفًا، نقارن اللمحة (lexeme) الخاصة به بكل اسم من أسماء المتغيّرات المحلّية للعثور على تطابق. من الصعب حلّ متغيّر إن كنت لا تعرف اسمه. يسجّل الحقل depth عمق النطاق للكتلة التي أُعلن فيها المتغيّر المحلّي. وهذه هي كل الحالة التي نحتاج إليها الآن.

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

^code current-compiler (1 before, 1 after)

وإليك دالة صغيرة لتهيئة المصرّف:

^code init-compiler

حين نُشغّل الآلة الافتراضية أول مرة، نستدعيها كي تجعل كل شيء في حالة نظيفة.

^code compiler (1 before, 1 after)

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

عبارات الكتل

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

statement      → exprStmt
               | printStmt
               | block ;

block          → "{" declaration* "}" ;

الكتل نوعٌ من التصريحات، لذا تذهب قاعدةها إلى إنتاج statement. والشيفرة المقابلة لتجميع واحدة تبدو هكذا:

^code parse-block (2 before, 1 after)

بعد تحليل القوس المعقوف الافتتاحي، نستخدم دالة المساعِدة هذه لتجميع بقية الكتلة:

^code block

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

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

^code begin-scope

كي "نُنشئ" نطاقًا، كل ما نفعله هو زيادة العمق الحالي. وهذا أسرع بكثير من jlox الذي كان يخصّص خريطة HashMap جديدة كاملة لكل نطاق. وبمعلومية beginScope()، تستطيع على الأرجح تخمين ما يفعله endScope().

^code end-scope

هذا كل ما في الأمر بالنسبة للكتل والنطاقات — تقريبًا — لذا نحن مستعدون لِدَسّ بعض المتغيّرات فيها.

تصريح المتغيّرات المحلّية

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

تدفّق الشيفرة داخل varDeclaration().

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

كلتا دالتَي المساعِدة تحتاجان إلى بعض التغييرات لدعم المتغيّرات المحلّية. في parseVariable()، نضيف:

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

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

أمّا في defineVariable()، فنحتاج إلى إصدار شيفرة تخزين متغيّر محلّي إن كنا في نطاق محلّي. وهي هكذا:

^code define-variable (1 before, 1 after)

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

خطوة بخطوة في تنفيذ شيفرة البايت يُظهر أنّ نتيجة كلّ مُهيِّئ تنتهي في خانة المتغيّر المحلي.

حسنًا، إذن ما الذي يعنيه "التصريح"؟ إليك ما تفعله:

^code declare-variable

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

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

^code add-local

هذه تهيّئ Local التالي المتاح في مصفوفة متغيّرات المصرّف. وهي تخزّن اسم المتغيّر وعمق النطاق الذي يملك المتغيّر.

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

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

^code too-many-locals (1 before, 1 after)

الحالة التالية أعقد. تأمّل:

{
  var a = "first";
  var a = "second";
}

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

لاحظ أنّ البرنامج أعلاه يختلف عن هذا:

{
  var a = "outer";
  {
    var a = "inner";
  }
}

من المقبول أن يكون لديك متغيّران بنفس الاسم في نطاقين مختلفين، حتى حين تتداخل النطاقان بحيث يكون كلاهما مرئيًّا في الوقت نفسه. هذا يُسمّى حجبًا (shadowing)، وLox يسمح بذلك. والخطأ الوحيد هو وجود متغيّرين بنفس الاسم في نطاق محلّي واحد.

نكتشف ذلك الخطأ هكذا:

^code existing-in-scope (1 before, 2 after)

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

لمعرفة ما إذا كان مُعرِّفان متطابقين، نستخدم هذا:

^code identifiers-equal

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

^code compiler-include-string (1 before, 2 after)

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

^code pop-locals (1 before, 1 after)

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

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

استخدام المتغيّرات المحلّية

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

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

^code named-local (1 before, 2 after)

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

بعد قليلٍ من الأسفل، نستخدم هذين المتغيّرين لإصدار التعليمات الصحيحة. للإسناد:

^code emit-set (2 before, 1 after)

وللوصول:

^code emit-get (2 before, 1 after)

القلب الحقيقي لهذا الفصل، الجزء الذي نحلّ فيه متغيّرًا محلّيًا، هو هنا:

^code resolve-local

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

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

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

تفسير المتغيّرات المحلّية

مصرّفنا يُصدر تعليمتين جديدتين، فلنجعلهما يعملان. الأولى هي تحميل متغيّر محلّي:

^code get-local-op (1 before, 1 after)

وتنفيذهما:

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

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

التالية هي الإسناد:

^code set-local-op (1 before, 1 after)

يمكنك على الأرجح أن تتوقّع التنفيذ.

^code interpret-set-local (1 before, 1 after)

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

مفكّكنا غير مكتمل دون دعم لهاتين التعليمتين الجديدتين.

^code disassemble-local (1 before, 1 after)

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

^code byte-instruction

حالة حدّية أخرى للنطاق

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

لدينا حالة حدّية أخرى نتعامل معها قبل أن ننهي هذا الفصل. تذكّر هذا الوحش الغريب الذي التقيناه أول مرة في تنفيذ jlox لحلّ المتغيّرات:

{
  var a = "outer";
  {
    var a = a;
  }
}

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

مثال على إعلان متغيّر مُعلَّم فيه «declared uninitialized» قبل اسم المتغيّر و«ready for use» بعد المُهيِّئ.

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

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

^code declare-undefined (1 before, 1 after)

لاحقًا، بمجرّد أن يُترجم مُهيّئ المتغيّر، نعلّمه على أنّه مُهيّأ.

^code define-local (1 before, 2 after)

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

^code mark-initialized

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

حين نحلّ مرجعًا إلى متغيّر محلّي، نتحقّق من عمق النطاق لنرى إن كان معرَّفًا تمامًا.

^code own-initializer-error (1 before, 1 after)

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

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

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

التحدّيات

  1. مصفوفة المتغيّرات المحلّية البسيطة لدينا تجعل حساب خانة المكدّس لكل متغيّر محلّي أمرًا سهلًا. لكنها تعني أنّه حين يحلّ المصرّف مرجعًا إلى متغيّر، علينا أن نُجري مسحًا خطّيًا (linear scan) عبر المصفوفة.

    ابتكر شيئًا أكثر كفاءة. هل ترى أنّ التعقيد الإضافي يستحقّ ذلك؟

  2. كيف تتعامل اللغات الأخرى مع شيفرة كهذه:

    var a = a;
    

    ماذا كنت ستفعل لو كانت لغتك؟ ولماذا؟

  3. تفرّق كثير من اللغات بين المتغيّرات التي يمكن إعادة إسنادها وتلك التي لا يمكن. في Java، يمنعك المعدِّل final من الإسناد إلى متغيّر. في JavaScript، يمكن إسناد متغيّر معلَن بـ let، لكن متغيّرًا معلَنًا بـ const لا يمكن. وتتعامل Swift مع let على أنه إسناد مفرد وتستخدم var للمتغيّرات القابلة للإسناد. وتستخدم Scala وKotlin val وvar.

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

  4. وسّع clox لتسمح لأكثر من 256 متغيّرًا محلّيًا بأن تكون في النطاق في الوقت نفسه.