الإغلاقات
كما قال ذلك الرجل، لكل مسألة معقّدةٍ حلٌّ بسيط، وهو حلٌّ خاطئ.
أومبرتو إيكو، بندول فوكو
بفضل اجتهادنا الدؤوب في الفصل الأخير، لدينا آلة افتراضية (virtual machine) تعمل فيها الدوال. وما ينقصها هو الإغلاقات (closures). باستثناء المتغيّرات العامة (global variables) — وهي نوعٌ خاصٌّ بذاته من الحيوانات — لا يملك أيّ دالة وسيلةً للإشارة إلى متغيّرٍ أُعلن خارج جسمها.
var x = "global";
fun outer() {
var x = "outer";
fun inner() {
print x;
}
inner();
}
outer();
شغّل هذا المثال الآن فيطبع "global". ومن المفترض أن يطبع "outer". ولإصلاح ذلك، نحتاج إلى تضمين النطاق المعجميّ (lexical scope) كاملًا لكل الدوال المحيطة عند حلّ (resolving) المتغيّر.
هذه المسألة أصعب في clox ممّا كانت عليه في jlox لأن آلتنا الافتراضية لشيفرة البايت (bytecode virtual machine) تخزّن المتغيّرات المحلّية (local variables) على مكدّس (stack). استخدمنا المكدّس لأنني ادّعيتُ أنّ للمتغيّرات المحلّية دلالة المكدّس — إذ تُبطَل بترتيب معكوس لترتيب إنشائها. لكن مع الإغلاقات، هذا صحيحٌ في الغالب فقط.
fun makeClosure() {
var local = "local";
fun closure() {
print local;
}
return closure;
}
var closure = makeClosure();
closure();
تعلن الدالة الخارجية makeClosure() متغيّرًا اسمه local. كما تنشئ دالةً
داخليةً اسمها closure() تلتقط ذلك المتغيّر. ثم تُرجِع makeClosure() إشارةً
إلى تلك الدالة. وبما أنّ الإغلاق يهرب وهو يحمل
معه المتغيّر المحلّي، فإنّ local يجب أن يعمّر أطول من الاستدعاء الذي أُنشئ
فيه.
يمكنك حلّ هذه المشكلة بتخصيص ذاكرة ديناميكيًا لكل المتغيّرات المحلّية. هذا ما يفعله jlox بوضع كل شيء في كائنات Environment التي تطفو هنا وهناك في كومة Java. لكننا لا نريد ذلك. فاستخدام مكدّس سريعٌ للغاية. معظم المتغيّرات المحلّية لا تلتقطها الإغلاقات، ولها فعلًا دلالة المكدّس. سيكون سيئًا أن نُبطئ كل تلك المتغيّرات لصالح المتغيّر المحلّي النادر الملتقَط.
يعني هذا نهجًا أعقد ممّا استخدمناه في مفسّرنا المكتوب بلغة Java. ولأنّ بعض المتغيّرات المحلّية لها أعمارٌ مختلفة كثيرًا، سوف تكون لدينا استراتيجيتان للتنفيذ. أمّا المتغيّرات المحلّية التي لا تُستخدم في الإغلاقات، فسنُبقيها على المكدّس كما هي. وحينما تلتقط إغلاقٌ متغيّرًا محلّيًا، سنعتمد حلًّا آخر يرفعه إلى الكومة حيث يستطيع أن يعيش طالما احتاج.
الإغلاقات موجودة منذ أيام Lisp الأولى حين كانت بايتات الذاكرة ودورات المعالج أغلى من الياقوت. وعلى مدى العقود التالية، ابتكر المخترقون جميعَ أنواع الطرق لتجميع الإغلاقات في تمثيلات وقت تشغيل محسَّنة. بعضها أكفأ لكنه يتطلّب عملية تجميع أعقد مما يسهل إدماجه بأثر رجعي في clox.
الأسلوب الذي أشرحه هنا مستمدّ من تصميم الآلة الافتراضية لـ Lua. إنه سريع، ومُوفِّر للذاكرة، ومُنفَّذ بشيفرة قليلة نسبيًا. والأعجب من ذلك أنه ينسجم طبيعيًا مع المصرّفات أحادية التمريرة (single-pass) التي يستخدمها كلٌّ من clox وLua. لكنه معقّد بعض الشيء. قد يستغرق الأمر وقتًا قبل أن تتّضح جميع القطع في ذهنك. سنبنيها خطوةً خطوة، وسأحاول أن أقدّم المفاهيم على مراحل.
كائنات الإغلاق
تمثّل آلتنا الافتراضية الدوال وقت التشغيل باستخدام ObjFunction. تُنشَأ هذه الكائنات من واجهة المستخدم الأمامية (front end) أثناء التجميع. أمّا وقت التشغيل، فكل ما تفعله الآلة هو تحميل كائن الدالة من جدول الثوابت (constant table) وربطه باسم. لا توجد عملية "تنشئ" دالةً وقت التشغيل. تماماً كـ القيم الحرفية (literals) للنصوص والأعداد، فإنّها ثوابت (constants) تُنشَأ حصريًا وقت التجميع.
أصبح ذلك منطقيًّا لأنّ كل البيانات التي تُكوّن دالةً معروفة وقت التجميع: قطعة الشيفرة بايت (chunk of bytecode) المجمَّعة من جسم الدالة، والثوابت المستخدَمة في الجسم. لكن ما إن ندخل الإغلاقات، لم تعد تلك التمثيلة كافية. تأمّل:
fun makeClosure(value) {
fun closure() {
print value;
}
return closure;
}
var doughnut = makeClosure("doughnut");
var bagel = makeClosure("bagel");
doughnut();
bagel();
تُعرّف الدالة makeClosure() دالةً وتُرجِعها. نستدعيها مرّتين فنحصل على
إغلاقين. أُنشئا من التصريح نفسه بالدالة المتداخلة، closure، لكنهما يغلقان
على قيمتين مختلفتين. وحين نستدعي الإغلاقين، يطبع كلٌّ منهما سلسلةً مختلفة.
يعني ذلك أننا نحتاج إلى بعض تمثيل وقت التشغيل لإغلاق يلتقط المتغيّرات المحلّية
المحيطة بالدالة كما هي حين يُنفَّذ تصريح الدالة، لا وقت تجميعه فحسب.
سنعمل تدريجيًا نحو التقاط المتغيّرات، لكن الخطوة الأولى الجيدة هي تعريف تمثيل ذلك الكائن. فنوع ObjFunction القائم يمثّل حالة "الخام" (raw) وقت التجميع لإعلان دالة، إذ تشترك كل الإغلاقات المُنشأة من تصريح واحد في الشيفرة والثوابت نفسها. أمّا وقت التشغيل، فعند تنفيذه تصريح دالة، نغلّف ObjFunction داخل بنية ObjClosure جديدة. ولبنية هذه الأخيرة إشارةٌ إلى الدالة المجرّدة الكامنة، إلى جانب حالة وقت تشغيل للمتغيّرات التي يغلق عليها الدالة.
سنغلّف كل دالة داخل ObjClosure، حتى لو كانت الدالة لا تغلق فعلًا على أيّ متغيّرات محلّية محيطة وتلتقطها. هذا هدرٌ قليل، لكنه يُبسّط الآلة الافتراضية لأننا نستطيع أن نفترض دائمًا أنّ الدالة التي نستدعيها هي ObjClosure. وتبدأ بنية هذه الجديدة هكذا:
^code obj-closure
في الوقت الحالي، هي ببساطة تشير إلى ObjFunction وتضيف محتويات ترويسة الكائن اللازمة. ومن خلال اجتياز المراسم المعتادة لإضافة نوع كائن جديد إلى clox، نُعلن دالة C لإنشاء إغلاق جديد.
^code new-closure-h (2 before, 1 after)
ثم نُنفّذها هنا:
^code new-closure
إنها تأخذ مؤشرًا إلى ObjFunction الذي تغلّفه. كما تهيّئ حقل النوع إلى نوع جديد.
^code obj-type-closure (1 before, 1 after)
وحين ننتهي من إغلاق، نحرّر ذاكرته.
^code free-closure (1 before, 1 after)
نحرّر ObjClosure نفسه فقط، لا ObjFunction. ذلك لأنّ الإغلاق لا يملك الدالة. قد يوجد عدّة إغلاقات تشير جميعها إلى الدالة نفسها، ولا منها يدّعي أيّ امتياز خاصّ عليها. لا يمكننا تحرير ObjFunction حتى تختفي كل الكائنات التي تشير إليه — بما فيها حتى الدالة المحيطة التي يحوي جدول ثوابتها. يبدو تتبّع ذلك صعبًا، وهو كذلك فعلًا! ولهذا سنكتب جامع نفايات (garbage collector) قريبًا ليتولّى إدارته نيابةً عنا.
ولنا أيضًا الماكروهات المعتادة للتحقّق من نوع قيمة.
^code is-closure (2 before, 1 after)
وللتحويل إلى نوع معيّن:
^code as-closure (2 before, 1 after)
الإغلاقات كائنات من الدرجة الأولى، لذا يمكنك طباعتها.
^code print-closure (1 before, 1 after)
تعرض تمامًا كما يفعل ObjFunction. من منظور المستخدم، الفرق بين ObjFunction وObjClosure تفصيلٌ تنفيذيٌّ مخفيٌّ بالكامل. وبانقضاء هذا الأمر، لدينا تمثيل عامل لكن فارغ للإغلاقات.
التجميع إلى كائنات الإغلاق
لدينا كائنات إغلاق، لكن آلتنا الافتراضية لا تنشئها أبدًا. والخطوة التالية هي أن نجعل المصرّف (compiler) يُصدر تعليمات تخبر وقت التشغيل متى يُنشئ ObjClosure جديدًا يغلّف ObjFunction معيّنًا. ويحدث هذا في نهاية تصريح الدالة بالضبط.
^code emit-closure (1 before, 1 after)
قبل ذلك، كانت الشيفرة بايت النهائية لإعلان دالة عبارة عن تعليمة واحدة
OP_CONSTANT لتحميل الدالة المجمَّعة من جدول ثوابت الدالة المحيطة ودفعها على
المكدّس. الآن لدينا تعليمة جديدة.
^code closure-op (1 before, 1 after)
مثل OP_CONSTANT تمامًا، فهي تأخذ مُعاملًا واحدًا يمثّل فهرسًا في جدول الثوابت
الخاصة بالدالة. لكن حين ننتقل إلى تنفيذ وقت التشغيل، نفعل شيئًا أكثر إثارة
للاهتمام.
أولًا، لنكن مخترقي الآلة مجتهدين ونُضيف دعمًا في المفكّك (disassembler) لهذه التعليمة.
^code disassemble-closure (2 before, 1 after)
هناك المزيد ممّا اعتدنا عليه في المفكّك هنا. بنهاية الفصل، ستكتشف أنّ
OP_CLOSURE تعليمة غير اعتيادية إلى حدٍّ كبير. فهي مباشرة الآن — مُعامل من
بايت واحد فقط — لكننا سنضيف إليها. وهذه الشيفرة تستشرف ذلك المستقبل.
تفسير إعلانات الدوال
معظم العمل الذي نحتاج إلى إنجازه يقع في وقت التشغيل. علينا بطبيعة الحال معالجة التعليمة الجديدة. لكننا نحتاج أيضًا إلى لمس كل جزء من شيفرة الآلة الافتراضية الذي يعمل مع ObjFunction وتغييره ليستخدم ObjClosure بدلًا منه — استدعاءات الدوال، وإطارات الاستدعاء (call frames)، وغيرها. سنبدأ بالتعلمية على أي حال.
^code interpret-closure (1 before, 1 after)
مثل تعليمة OP_CONSTANT التي استخدمناها سابقًا، أولًا نحمّل الدالة المجمَّعة من
جدول الثوابت. الفرق الآن أنّنا نغلّف تلك الدالة داخل ObjClosure جديد وندفع
النتيجة على المكدّس.
وحين تملك إغلاقًا، ستودّ في النهاية استدعائه.
^code call-value-closure (1 before, 1 after)
نُزيل الشيفرة الخاصة باستدعاء الكائنات التي نوعها OBJ_FUNCTION. وبما أننا
نغلّف كل الدوال داخل ObjClosures، فلن يحاول وقت التشغيل استدعاء ObjFunction
مجرّدًا أبدًا. تعيش تلك الكائنات في جداول الثوابت فقط وتُغلَّف فورًا في الإغلاقات قبل أن يراها أيّ شيء آخر.
نستبدل الشيفرة القديمة بشيفرة شديدة الشبه للاستدعاء إغلاق بدلًا منها. الفرق
الوحيد هو نوع الكائن الذي نمرّره إلى call(). أمّا التغييرات الحقيقية فهي في
تلك الدالة. أولًا، نحدّث توقيعها.
^code call-signature (1 after)
ثم، في جسم الدالة، نحتاج إلى إصلاح كل ما يشير إلى الدالة للتعامل مع أنّنا أدخلنا طبقة استدلال (indirection). نبدأ بالتحقّق من عدد الوسائط:
^code check-arity (1 before, 1 after)
التغيير الوحيد هو أنّنا نفكّ تغليف الإغلاق للوصول إلى الدالة الكامنة. الشيء
التالي الذي تفعله call() هو إنشاء CallFrame جديد. نغيّر تلك الشيفرة لتخزّن
الإغلاق في CallFrame وتحصل على مؤشّر الشيفرة بايت من دالة الإغلاق.
^code call-init-closure (1 before, 1 after)
وهذا يستلزم تغيير تصريح CallFrame أيضًا.
^code call-frame-closure (1 before, 1 after)
ذلك التغيير يُطلق بعض التغييرات المتسلسلة الأخرى. كل موضع في الآلة الافتراضية كان يصل إلى دالة CallFrame يحتاج إلى استخدام إغلاق بدلًا منها. أولًا، الماكرو لقراءة ثابت من جدول ثوابت الدالة الحالية:
^code read-constant (2 before, 2 after)
عند تمكين DEBUG_TRACE_EXECUTION، يحتاج إلى الوصول إلى القطعة (chunk) عبر
الإغلاق.
^code disassemble-instruction (1 before, 1 after)
وكذلك عند الإبلاغ عن خطأ وقت التشغيل:
^code runtime-error-function (1 before, 1 after)
اقتربنا. القطعة الأخيرة هي كتلة الشيفرة التي تهيّئ أوّل CallFrame لبدء تنفيذ الشيفرة ذات المستوى الأعلى لملف Lox.
^code interpret (1 before, 2 after)
لا يزال المصرّف يُرجِع ObjFunction خامًا عند تجميع ملف. هذا مقبول، لكنه يعني أنّنا نحتاج هنا إلى تغليفه داخل ObjClosure قبل أن تستطيع الآلة الافتراضية تنفيذه.
عدنا إلى مفسّر عامل. المستخدم لا يستطيع أن يلاحظ أي فرق، لكن المصرّف صار الآن يولّد شيفرة تخبر الآلة الافتراضية بأن تنشئ إغلاقًا لكل تصريح دالة. وكلما نفّذت الآلة الافتراضية تصريح دالة، فإنها تغلّف ObjFunction داخل ObjClosure جديد. وأمّا بقية الآلة الافتراضية فتعالج الآن تلك ObjClosures الطافية هنا وهناك. هذا هو الجزء الممل، وقد تخلّصنا منه. والآن نحن مستعدون لجعل هذه الإغلاقات تفعل شيئًا فعلًا.
القيم العليا
تعليماتنا القائمة لقراءة المتغيّرات المحلّية وكتابتها محدودة بنافذة مكدّس دالة واحدة. أمّا متغيّرات الدالة المحيطة فهي خارج نافذة الدالة الداخلية. سنحتاج إلى بعض التعليمات الجديدة.
قد يكون أبسط نهج هو تعليمة تأخذ إزاحةً نسبية لخانة مكدّس يمكنها الوصول إلى ما قبل نافذة الدالة الحالية. وسيعمل ذلك لو كانت المتغيّرات المُغلَق عليها دائمًا على المكدّس. لكن كما رأينا سابقًا، تفوق هذه المتغيّرات أحيانًا عمر الدالة التي أُعلنت فيها. وهذا يعني أنها لن تكون دائمًا على المكدّس.
إذن أبسط نهج تالٍ هو أن نأخذ أي متغيّر محلّي يُغلَق عليه ونتركه يعيش دائمًا في الكومة. وحين يُنفَّذ تصريح المتغيّر المحلّي في الدالة المحيطة، تخصّص الآلة الافتراضية ذاكرةً له ديناميكيًا. هكذا يستطيع أن يعيش طالما احتاج.
كان هذا نهجًا حسنًا لو لم يكن لدى clox مصرّف أحادي التمريرة. لكن ذلك القيد الذي اخترناه في تنفيذنا يجعل الأمور أصعب. تأمّل هذا المثال:
fun outer() {
var x = 1; // (1)
x = 2; // (2)
fun inner() { // (3)
print x;
}
inner();
}
هنا، يترجم المصرّف تصريح x عند (1) ويُصدر شيفرةً للإسناد عند (2). وهو
يفعل ذلك قبل أن يصل إلى تصريح inner() عند (3) ويكتشف أنّ x مُغلَق عليه
فعلًا. ليس لدينا وسيلة سهلة للرجوع إلى الخلف وإصلاح تلك الشيفرة المُصدَرة
أصلًا لمعاملة x بطريقة خاصة. وبدلًا من ذلك، نريد حلًّا يسمح للمتغيّر المُغلَق
عليه أن يعيش على المكدّس تمامًا كمتغيّر محلّي عادي حتى اللحظة التي يُغلَق
عليه فيها.
ولحسن الحظ، وبفضل فريق تطوير Lua، لدينا حل. نستخدم طبقة استدلال يسمونها upvalue. تشير القيمة العليا (upvalue) إلى متغيّر محلّي في دالة محيطة. ويحتفظ كل إغلاق بمصفوفة من القيم العليا، واحدة لكل متغيّر محلي محيط يستخدمه الإغلاق.
تشير القيمة العليا إلى داخل المكدّس إلى الموضع الذي يعيش فيه المتغيّر الذي التقطته. وحين يحتاج الإغلاق إلى الوصول إلى متغيّر مُغلَق عليه، فإنّه يمرّ عبر القيمة العليا المقابلة للوصول إليه. وحين يُنفَّذ تصريح دالة لأول مرة وننشئ إغلاقًا لها، تُنشئ الآلة الافتراضية مصفوفة القيم العليا وتوصّلها بـ"التقاط" المتغيّرات المحلّية المحيطة التي يحتاجها الإغلاق.
مثلًا، إذا ألقينا هذا البرنامج على clox،
{
var a = 3;
fun f() {
print a;
}
}
سيتضافر المصرّف ووقت التشغيل معًا لبناء مجموعة من الكائنات في الذاكرة على هذا النحو:
قد يبدو هذا مُرهقًا، لكن لا تخف. سنعمل على اختراجه. الجزء المهم هو أنّ القيم العليا تؤدّي دور طبقة الاستدلال اللازمة لمواصلة العثور على متغيّر محلّي ملتقَط حتى بعد أن ينتقل بعيدًا عن المكدّس. لكن قبل أن نصل إلى كل ذلك، لنركّز على تجميع المتغيّرات الملتقَطة.
تجميع القيم العليا
كما جرت العادة، نريد أن ننفّذ أكبر قدر ممكن من العمل وقت التجميع لإبقاء التنفيذ بسيطًا وسريعًا. ولأنّ المتغيّرات المحلّية نطاقها معجميّ في Lox، لدينا من المعرفة وقت التجميع ما يكفي لحلّ أي المتغيّرات المحلّية المحيطة التي تصل إليها دالة، وأين أعلن تلك المتغيّرات المحلّية. وهذا بدوره يعني أنّنا نعرف كم قيمة عليا يحتاجها الإغلاق، وأيّ متغيّرات يلتقطها، وأيّ خانات مكدّس تحوي تلك المتغيّرات في نافذة مكدّس الدالة المعلِنة.
حاليًا، حين يحلّ المصرّف مُعرِّفًا (identifier)، فهو يمشي في نطاقات الكتل الخاصة بالدالة الحالية من الأدنى إلى الأبعد. فإن لم نجد المتغيّر في تلك الدالة، نفترض أنّ المتغيّر يجب أن يكون عامًّا. نحن لا نأخذ النطاقات المحلّية للدوال المحيطة بعين الاعتبار — يتمّ تخطّيها تمامًا. إذن التغيير الأول هو إدراج خطوة حلّ لتلك النطاقات المحلّية الخارجية.
^code named-variable-upvalue (3 before, 1 after)
تبدو دالة resolveUpvalue() الجديدة كأنّها تبحث عن متغيّر محلّي معلَن في أيّ
من الدوال المحيطة. فإن وجدت واحدًا، تُرجِع "فهرس قيمة عليا" لذلك المتغيّر.
(سنتناول ما يعنيه ذلك لاحقًا.) وإلا، تُرجِع -1 للدلالة على أنّ المتغيّر لم
يُوجد. وإن وُجد، نستخدم هاتين التعليبتين الجديدتين لقراءة المتغيّر أو كتابته عبر
قيمته العليا:
^code upvalue-ops (1 before, 1 after)
نحن ننفّذ هذا النوع من العمل من الأعلى إلى الأسفل، لذا سأعرض لك كيف تعمل هذه وقت التشغيل قريبًا. الجزء الذي ينبغي التركيز عليه الآن هو كيف يحلّ المصرّف المُعرِّف فعلًا.
^code resolve-upvalue
نستدعي هذا بعد فشلنا في حلّ متغيّر محلّي في نطاق الدالة الحالية، لذا نعرف أنّ
المتغيّر ليس في المصرّف الحالي. تذكّر أنّ المصرّف (Compiler) يخزّن مؤشّرًا إلى
المصرّف الخاص بالدالة المحيطة، وهذه المؤشرات تشكّل سلسلةً مرتبطةً تمتدّ كلّ
الطريق إلى المصرّف الجذري (root Compiler) الخاص بالشيفرة ذات المستوى الأعلى.
لذا، إن كان المصرّف المحيط هو NULL، نعرف أنّنا وصلنا إلى الدالة الأكثر
خارجية من دون أن نجد متغيّرًا محلّيًا. ولا بدّ أن يكون المتغيّر عامًّا، لذا نُرجِع -1.
وإلا، نحاول حلّ المُعرِّف بوصفه متغيّرًا محلّيًا في المصرّف المحيط. بمعنى آخر، نبحث عنه خارج الدالة الحالية مباشرة. مثلًا:
fun outer() {
var x = 1;
fun inner() {
print x; // (1)
}
inner();
}
عند ترجمة تعبير المُعرِّف عند (1)، تبحث resolveUpvalue() عن متغيّر محلّي
x معلَن في outer(). فإن وُجد — كما هو في هذا المثال — فقد حللنا المتغيّر
بنجاح. ننشئ قيمة عليا كي تستطيع الدالة الداخلية الوصول إلى المتغيّر عبرها.
تُنشَأ القيمة العليا هنا:
^code add-upvalue
يحتفظ المصرّف بمصفوفة من بنى القيم العليا لتتبّع المُعرِّفات المُغلَق عليها التي حلّها في جسم كل دالة. تذكّر كيف كانت مصفوفة Local في المصرّف تطابق فهارس خانات المكدّس التي تعيش فيها المتغيّرات المحلّية وقت التشغيل؟ مصفوفة القيم العليا الجديدة تعمل بالطريقة نفسها. الفهارس في مصفوفة المصرّف تطابق الفهارس التي تعيش فيها القيم العليا في ObjClosure وقت التشغيل.
تضيف هذه الدالة قيمة عليا جديدة إلى تلك المصفوفة. كما تتتبّع عدد القيم العليا التي تستخدمها الدالة. وهي تخزّن ذلك العدد مباشرةً في ObjFunction نفسه لأنّنا سنحتاج أيضًا إلى هذا الرقم وقت التشغيل.
يتتبّع الحقل index فهرس خانة المتغيّر المحلّي المُغلَق عليه. هكذا يعرف
المصرّف أيّ متغيّر في الدالة المحيطة يحتاج إلى الالتقاط. سنعود إلى ما أُريد
بحقل isLocal بعد قليل. وأخيرًا، تُرجِع addUpvalue() فهرس القيمة العليا
المُنشأة في قائمة القيم العليا الخاصة بالدالة. يصبح ذلك الفهرس مُعاملًا
لتعليمتَي OP_GET_UPVALUE وOP_SET_UPVALUE.
هذه هي الفكرة الأساسية لحلّ القيم العليا، لكن الدالة ليست مكتملة بعد. قد يشير الإغلاق إلى المتغيّر نفسه في دالة محيطة عدّة مرّات. في تلك الحالة، لا نريد إهدار الوقت والذاكرة بإنشاء قيمة عليا منفصلة لكل تعبير مُعرِّف. ولإصلاح ذلك، قبل أن نضيف قيمة عليا جديدة، نتحقّق أولًا ممّا إذا كانت الدالة تملك بالفعل قيمة عليا تغلق على ذلك المتغيّر.
^code existing-upvalue (1 before, 1 after)
إن وجدنا قيمة عليا في المصفوفة يفهرس خانتها مطابقًا لفهرس الخانة التي نضيفها، نُرجِع ذلك فهرس القيمة العليا ونعيد استخدامه. وإلا، نكمل ونضيف القيمة العليا الجديدة.
تصل هاتان الدالتان إلى حالة جديدة كثيرة وتعدّلها، لذا لنعرّف تلك الحالة. أولًا، نضيف عدد القيم العليا إلى ObjFunction.
^code upvalue-count (1 before, 1 after)
نحن مبرمجو C واعون، لذا نُصفّر تلك القيمة عند تخصيص ObjFunction أول مرة.
^code init-upvalue-count (1 before, 1 after)
وفي المصرّف، نضيف حقلًا لمصفوفة القيم العليا.
^code upvalues-array (1 before, 1 after)
للبساطة، أعطيته حجمًا ثابتًا. تُرمِّز تعليمتَي OP_GET_UPVALUE وOP_SET_UPVALUE
فهرس القيمة العليا باستخدام مُعامل من بايت واحد، لذا هناك قيدٌ على عدد القيم
العليا التي يمكن أن تملكها دالة — أي عدد المتغيّرات الفريدة التي يمكن أن تغلق
عليها. وبناءً على ذلك، يمكننا تحمل مصفوفة ساكنة (static array) بهذا الحجم. ونحتاج
أيضًا إلى التأكّد من أنّ المصرّف لا يتجاوز ذلك الحد.
^code too-many-upvalues (5 before, 1 after)
وأخيرًا، بنية Upvalue نفسها.
^code upvalue-struct
يخزّن الحقل index خانة المتغيّر المحلّي التي يلتقطها القيمة العليا. أمّا
الحقل isLocal فيستحق قسمًا خاصًّا به، وسنصل إليه تاليًا.
تسطيح القيم العليا
في المثال الذي عرضته سابقًا، كان الإغلاق يصل إلى متغيّر معلَن في الدالة المحيطة مباشرة. ويدعم Lox أيضًا الوصول إلى متغيّرات محلّية معلَنة في أيّ نطاق محيط، كما في:
fun outer() {
var x = 1;
fun middle() {
fun inner() {
print x;
}
}
}
هنا، نحن نصل إلى x في inner(). ذلك المتغيّر ليس معرَّفًا في middle()، بل
في outer() على بُعد. ونحتاج إلى معالجة حالات مثل هذه أيضًا. قد تظنّ أنّ
الأمر ليس أصعب بكثير لأنّ المتغيّر سيكون ببساطة في موضع أبعد إلى الأسفل على
المكدّس. لكن فكّر في هذا المثال المتأمر:
fun outer() {
var x = "value";
fun middle() {
fun inner() {
print x;
}
print "create inner closure";
return inner;
}
print "return from outer";
return middle;
}
var mid = outer();
var in = mid();
in();
حين تشغّل هذا، من المفترض أن يطبع:
return from outer
create inner closure
value
أعرف، فهو ملتبس. الجزء المهم أنّ outer() — حيث يُعلن x — يستدير ويُسقط كل
متغيّراته من المكدّس قبل أن يُنفَّذ تصريح inner(). لذا، في اللحظة التي
ننشئ فيها الإغلاق لـ inner()، يكون x قد غادر المكدّس بالفعل.
وهنا، رسمتُ لك تدفّق التنفيذ:
لاحظ كيف يُسقط x ① قبل أن يُلتقط ② ثم يُستعمل لاحقًا ③؟ لدينا
في الواقع مشكلتان:
-
نحتاج إلى حلّ متغيّرات محلّية معلَنة في دوال محيطة أبعد من الدالة المحيطة مباشرة.
-
نحتاج إلى القدرة على التقاط متغيّرات غادرت المكدّس بالفعل.
ولحسن الحظ، نحن في منتصف إضافة القيم العليا إلى الآلة الافتراضية، والقيم العليا مصمَّمة صراحةً لتتبّع المتغيّرات التي هربت من المكدّس. لذا، في حيلة ذكية من المرجعية الذاتية، يمكننا استخدام القيم العليا للسماح للقيم العليا بالتقاط متغيّرات معلَنة خارج الدالة المحيطة مباشرة.
الحل هو السماح للإغلاق بالتقاط إمّا متغيّرًا محلّيًا وإمّا قيمة عليا قائمة في الدالة المحيطة مباشرة. إذا أشارت دالة متداخلة بعمق إلى متغيّر محلّي معلَن بعد عدة قفزات، فسنمرّره عبر كل الدوال الوسيطة بأن تُلتقط كل دالة قيمةً عليا تمسك بها الدالة التالية.
في المثال أعلاه، تلتقط middle() المتغيّر المحلّي x في الدالة المحيطة
مباشرة outer() وتخزّنه في قيمتها العليا الخاصة. وهي تفعل ذلك حتى مع أنّ
middle() نفسها لا تشير إلى x. ثم، حين يُنفَّذ تصريح inner()، يلتقط إغلاقه
القيمة العليا من ObjClosure الخاص بـ middle() التي التقطت x. الدالة تلتقط
— إمّا متغيّرًا محلّيًا وإمّا قيمة عليا — فقط من الدالة المحيطة مباشرة، وهي
مضمونة لأن تكون ما تزال موجودة في اللحظة التي يُنفَّذ فيها تصريح الدالة
الداخلية.
ولتنفيذ ذلك، تصبح resolveUpvalue() عودية (recursive).
^code resolve-upvalue-recurse (4 before, 1 after)
إنها فقط ثلاثة أسطر أخرى من الشيفرة، لكنني وجدت هذه الدالة بالغة التحدّي في ضبطها من أول محاولة. وذلك رغم أنّني لم أخترع أيّ شيء جديد، بل نقلتُ المفهوم من Lua فحسب. معظم الدوال العودية إما أن تنجز كل عملها قبل الاستدعاء العودي (اجتياز مُسبق pre-order traversal، أو "في طريق الهبوط")، وإما أن تنجز كل عملها بعد الاستدعاء العودي (اجتياز لاحق post-order traversal، أو "في طريق الصعود"). وهذه الدالة تفعل الأمرين. الاستدعاء العودي يقع في المنتصف تمامًا.
سنتدرّج فيها على مهل. أولًا، نبحث عن متغيّر محلّي مطابق في الدالة المحيطة. إن وجدناه، نلتقط ذلك المتغيّر المحلّي ونسترجع. هذه هي حالة الأساس.
وإلا، نبحث عن متغيّر محلّي أبعد من الدالة المحيطة مباشرة. نفعل ذلك باستدعاء
resolveUpvalue() عوديًا على المصرّف المحيط، لا المصرّف الحالي. تعمل سلسلة
استدعاءات resolveUpvalue() هذه على التقدّم بمحاذاة سلسلة المصرّفات المتداخلة
حتى تصل إلى إحدى حالتَي الأساس — إمّا تجد متغيّرًا محلّيًا حقيقيًا تلتقطه، وإمّا
تنفد المصرّفات.
حين يُوجد متغيّر محلّي، تلتقطه أعمق استدعاءات resolveUpvalue() المتداخلة وتُرجِع فهرس القيمة العليا. تعود تلك النتيجة إلى
الاستدعاء التالي الخاص بتصريح الدالة الداخلية. يلتقط ذلك الاستدعاء القيمة
العليا من الدالة المحيطة، وهكذا دواليك. ومع عودة كل استدعاء عودي من
resolveUpvalue()، ننفذ عودًا إلى تصريح الدالة الداخلية الأعمق حيث يظهر
المُعرِّف الذي نحلّه. وفي كل خطوة على طول الطريق، نضيف قيمة عليا إلى الدالة
الوسيطة ونمرّر فهرس القيمة العليا الناتج إلى الاستدعاء التالي.
قد يفيدك المرور على المثال الأصلي أثناء حلّ x:
لاحظ أنّ الاستدعاء الجديد لـ addUpvalue() يمرّر false للمُعامل isLocal.
والآن ترى أنّ ذلك العَلَم يتحكّم فيما إذا كان الإغلاق يلتقط متغيّرًا محلّيًا أم
قيمة عليا من الدالة المحيطة.
بحين يصل المصرّف إلى نهاية تصريح دالة، يكون كل مرجع متغيّر قد حُلّ إمّا كمتغيّر محلّي، وإمّا كقيمة عليا، وإمّا كمتغيّر عام. وقد تلتقط كل قيمة عليا بدورها متغيّرًا محلّيًا من الدالة المحيطة، أو قيمة عليا أخرى في حالة الإغلاقات المتعدّية (transitive closures). وأخيرًا، لدينا بيانات كافية لنُصدر شيفرة بايت تنشئ إغلاقًا وقت التشغيل يلتقط كل المتغيّرات الصحيحة.
^code capture-upvalues (1 before, 1 after)
تُعدّ تعليمة OP_CLOSURE فريدة من نوعها في أنّ ترميزها متغيّر الحجم. لكل قيمة
عليا يلتقطها الإغلاق، هناك مُعاملان من بايت واحد. يحدّد كل زوج من المُعاملات ما
الذي تلتقطه تلك القيمة العليا. إذا كان البايت الأول واحدًا، فإنّها تلتقط متغيّرًا
محلّيًا في الدالة المحيطة. وإذا كان صفرًا، فإنّها تلتقط إحدى قيم الدالة العليا.
والبايت التالي هو فهرس خانة المتغيّر المحلّي أو القيمة العليا المراد التقاطها.
هذا الترميز الغريب يعني أنّنا نحتاج إلى دعمٍ مخصّص في شيفرة التفكيك لـ
OP_CLOSURE.
^code disassemble-upvalues (1 before, 1 after)
مثلًا، خذ هذا الملف:
fun outer() {
var a = 1;
var b = 2;
fun middle() {
var c = 3;
var d = 4;
fun inner() {
print a + c + b + d;
}
}
}
إذا فكّكنا التعليمة التي تُنشئ الإغلاق الخاص بـ inner()، فستطبع:
0004 9 OP_CLOSURE 2 <fn inner>
0006 | upvalue 0
0008 | local 1
0010 | upvalue 1
0012 | local 2
لدينا تعليمتان أخريان أبسط نضيف لهما دعمًا في المفكّك.
^code disassemble-upvalue-ops (2 before, 1 after)
كلتاهما لهما مُعامل من بايت واحد، فلا شيء مثير هنا. لكننا نحتاج إلى إضافة include
حتى تصل وحدة التصحيح إلى AS_FUNCTION().
^code debug-include-object (1 before, 1 after)
بهذا يكون مصرّفنا حيث نريده. لكل تصريح دالة، يُخرج تعليمة OP_CLOSURE يليها
سلسلة من أزواج بايتات المُعاملات لكل قيمة عليا يحتاج إلى التقاطها وقت التشغيل.
حان الوقت القفز إلى ذلك الجانب من الآلة الافتراضية وتشغيل الأمور.
كائنات القيم العليا
أصبح كل تعليمة OP_CLOSURE متبوعةً بسلسلة البايتات التي تحدّد القيم العليا التي
يجب أن يملكها ObjClosure. وقبل أن نعالج تلك المُعاملات، نحتاج إلى تمثيل وقت
التشغيل للقيم العليا.
^code obj-upvalue
نعرف أنّ القيم العليا يجب أن تدير متغيّرات مُغلَق عليها لم تعد تعيش على المكدّس، ما يعني قدرًا ما من التخصيص الديناميكي. وأبسط طريقة لفعل ذلك في آلتنا الافتراضية هي البناء على نظام الكائنات الذي لدينا بالفعل. هكذا، حين ننفّذ جامع نفايات في الفصل التالي، يستطيع جامع النفايات إدارة ذاكرة القيم العليا أيضًا.
لذلك، تكون بنية القيمة العليا في وقت التشغيل هي ObjUpvalue مع حقل ترويسة Obj
المعتاد. يتبع ذلك حقل location يشير إلى المتغيّر المُغلَق عليه. لاحظ أنّ هذا
مؤشّر إلى Value، لا Value نفسها. إنها إشارة إلى متغيّر، لا إلى قيمة. وهذا
مهم لأنّه يعني أنّه حين نُسند إلى المتغيّر الذي تلتقطه القيمة العليا، فإنّنا
نُسند إلى المتغيّر الحقيقي لا إلى نسخة. مثلًا:
fun outer() {
var x = "before";
fun inner() {
x = "assigned";
}
inner();
print x;
}
outer();
من المفترض أن يطبع هذا البرنامج "assigned" رغم أنّ الإغلاق يُسند إلى x وأنّ
الدالة المحيطة تصل إليه.
لأنّ القيم العليا كائنات، لدينا كل آليات الكائنات المعتادة، بدءًا بدالة تشبه المُنشئ:
^code new-upvalue-h (1 before, 1 after)
إنها تأخذ عنوان الخانة التي يعيش فيها المتغيّر المُغلَق عليه. وهذه هي التنفيذ:
^code new-upvalue
نكتفي بتهيئة الكائن وتخزين المؤشّر. وهذا يتطلّب نوع كائن جديدًا.
^code obj-type-upvalue (1 before, 1 after)
وفي الجهة المقابلة، دالة تشبه المُدمِّر:
^code free-upvalue (3 before, 1 after)
يمكن لعدّة إغلاقات أن تغلق على المتغيّر نفسه، لذا لا يملك ObjUpvalue المتغيّر الذي يشير إليه. وعليه، الشيء الوحيد الذي يُحرَّر هو ObjUpvalue نفسه.
وأخيرًا، للطباعة:
^code print-upvalue (3 before, 1 after)
الطباعة غير مفيدة للمستخدم النهائي. القيم العليا كائنات فقط كي نستفيد من إدارة ذاكرة الآلة الافتراضية. وهي ليست قيمًا من الدرجة الأولى يمكن لمستخدم Lox الوصول إليها مباشرةً في برنامج. لذا لن تُنفَّذ هذه الشيفرة أبدًا... لكنها تمنع المصرّف من الصراخ في وجوهنا بسبب حالة switch غير معالجة، فها هي.
القيم العليا في الإغلاقات
حين قدّمت القيم العليا أول مرة، قلت إن لكل إغلاق مصفوفة منها. وقد وصلنا أخيرًا إلى تنفيذ ذلك.
^code upvalue-fields (1 before, 1 after)
قد يكون لدى الإغلاقات المختلفة أعدادٌ مختلفة من القيم العليا، لذا نحتاج إلى مصفوفة ديناميكية. أمّا القيم العليا نفسها فتُخصَّص ديناميكيًا أيضًا، لذا ينتهي بنا المطاف إلى مؤشّر مزدوج — مؤشّر إلى مصفوفة مخصَّصة ديناميكيًا من المؤشّرات إلى القيم العليا. كما نخزّن عدد العناصر في المصفوفة.
حين ننشئ ObjClosure، نخصّص مصفوفة قيم عليا بالحجم المناسب، وقد حدّدناه وقت التجميع وخزّنّاه في ObjFunction.
^code allocate-upvalue-array (1 before, 1 after)
قبل إنشاء كائن الإغلاق نفسه، نخصّص مصفوفة القيم العليا ونهيّئها جميعًا إلى
NULL. هذا الطقس الغريب حول الذاكرة هو رقصة متأنّية لإرضاء آلهة جمع النفايات
(القادمة). وهو يضمن ألّا يطّل مدير الذاكرة أبدًا على ذاكرة غير مهيّأة.
ثم نخزّن المصفوفة في الإغلاق الجديد، كما ننسخ العدد من ObjFunction.
^code init-upvalue-fields (1 before, 1 after)
وحين نحرّر ObjClosure، نحرّر مصفوفة القيم العليا أيضًا.
^code free-upvalues (1 before, 1 after)
لا يملك ObjClosure كائنات ObjUpvalue نفسها، لكنه يملك المصفوفة التي تحوي مؤشّرات إلى تلك القيم العليا.
نملأ مصفوفة القيم العليا في المفسّر حين ينشئ إغلاقًا. وهنا نمشي على كل
المُعاملات بعد OP_CLOSURE لنرى نوع القيمة العليا التي تلتقطها كل خانة.
^code interpret-capture-upvalues (1 before, 1 after)
هذه الشيفرة هي اللحظة السحرية حين يأتي الإغلاق إلى الحياة. نمرّ على كل قيمة
عليا يتوقّعها الإغلاق. ولكل واحدة منها، نقرأ زوجًا من بايتات المُعاملات. إذا
كانت القيمة العليا تغلق على متغيّر محلّي في الدالة المحيطة، فنترك
captureUpvalue() يتولّى العمل.
وإلا، نلتقط قيمة عليا من الدالة المحيطة. تُصدر تعليمة OP_CLOSURE في نهاية
تصريح دالة. وفي اللحظة التي ننفّذ فيها ذلك التصريح، تكون الدالة الحالية هي
الدالة المحيطة. هذا يعني أنّ إغلاق الدالة الحالية مخزّن في CallFrame أعلى مكدّس
الاستدعاءات. لذا، للالتقاط قيمة عليا من الدالة المحيطة، يمكننا قراءتها مباشرةً من
المتغيّر المحلّي frame الذي يخزّن مؤشّرًا إلى ذلك CallFrame.
أمّا الإغلاق على متغيّر محلّي فأكثر إثارة للاهتمام. معظم العمل يقع في دالة
منفصلة، لكننا أولًا نحسب المُعامل الذي نمرّره إليها. نحتاج إلى انتزاع مؤشّر إلى
خانة المتغيّر المحلّي الملتقَط في نافذة مكدّس الدالة المحيطة. تبدأ تلك النافذة
عند frame->slots الذي يشير إلى الخانة صفر. وإضافة index تُزيح ذلك إلى خانة
المتغيّر المحلّي الذي نريد التقاطه. ونمرّر ذلك المؤشّر هنا:
^code capture-upvalue
يبدو هذا سخيفًا قليلًا. كل ما يفعله هو إنشاء ObjUpvalue جديد يلتقط خانة المكدّس المعطاة ويعيدها. هل احتجنا إلى دالة منفصلة لهذا؟ حسنًا، لا، ليس بعد. لكنك تعرف أننا سننتهي إلى وضع المزيد من الشيفرة هنا.
أولًا، لنُنهي ما نعمل عليه. في شيفرة المفسّر التي تعالج OP_CLOSURE، ننتقل في
النهاية إلى اجتياز مصفوفة القيم العليا وتهيئة كل واحدة منها. وحين يكتمل ذلك،
لدينا إغلاق جديد بمصفوفة مليئة بقيم عليا تشير إلى متغيّرات.
وفي ضوء ذلك، يمكننا تنفيذ التعليمات التي تعمل مع تلك القيم العليا.
^code interpret-get-upvalue (1 before, 1 after)
المُعامل هو فهرس في مصفوفة القيم العليا للدالة الحالية. لذا نبحث ببساطة عن
القيمة العليا المقابلة ونفكّ مؤشّر location الخاص بها لقراءة القيمة في تلك
الخانة. وتعيين متغيّر مشابه.
^code interpret-set-upvalue (1 before, 1 after)
نأخذ القيمة الموجودة أعلى المكدّس ونخزّنها في الخانة التي يشير إليها
مؤشّر القيمة العليا المختارة. تماماً كما مع تعليمات المتغيّرات المحلّية، من
المهمّ أن تكون هذه التعليمات سريعة. فبرامج المستخدم تقرأ وتكتب المتغيّرات
باستمرار، فإن كانت بطيئة، صار كل شيء بطيئًا. وكما جرت العادة، فطريقة جعلها
سريعة هي إبقاؤها بسيطة. التعليمتان الجديدتان جيّدتان إلى حدٍّ ما: لا تدفّق
تحكّم، ولا حساب معقّد، مجرد استدلال مؤشّر أو اثنين واستدعاء push().
هذه نقطة تحوّل. ما دامت كل المتغيّرات تبقى على المكدّس، لدينا إغلاقات تعمل. جرّب هذا:
fun outer() {
var x = "outside";
fun inner() {
print x;
}
inner();
}
outer();
شغّل هذا، فيطبع "outside" بشكل صحيح.
القيم العليا المُغلَقة
بالطبع، إحدى سمات الإغلاقات الأساسية هي أنّها تمسك بالمتغيّر طالما احتاجت إليه، حتى بعد أن تعود الدالة التي أعلنته. وإليك مثال آخر ينبغي أن يعمل:
fun outer() {
var x = "outside";
fun inner() {
print x;
}
return inner;
}
var closure = outer();
closure();
لكن إن شغّلته الآن... فمن يعرف ماذا سيفعل؟ وقت التشغيل، سينتهي به المطاف إلى القراءة من خانة مكدّس لم تعد تحوي المتغيّر المُغلَق عليه. كما ذكرتُ مرّات قليلة، جوهر المسألة هو أنّ متغيّرات الإغلاقات ليس لها دلالة المكدّس. هذا يعني علينا رفعها من المكدّس حين تعود الدالة التي أُعلنت فيها. وهذا القسم الأخير من الفصل يفعل ذلك.
القيم والمتغيّرات
قبل أن نصل إلى كتابة الشيفرة، أودّ التعمّق في نقطة دلالية مهمّة. هل يغلق الإغلاق على قيمة أم على متغيّر؟ وهذا ليس سؤالًا أكاديميًّا فحسب. ولستُ أعمل على تمييز التفرّقات الدقيقة بين المصطلحين. تأمّل:
var globalSet;
var globalGet;
fun main() {
var a = "initial";
fun set() { a = "updated"; }
fun get() { print a; }
globalSet = set;
globalGet = get;
}
main();
globalSet();
globalGet();
تنشئ الدالة الخارجية main() إغلاقين وتخزّنهما في متغيّرات عامة كي يعيشا أطول من تنفيذ main() نفسه. كلا الإغلاقين
يلتقط المتغيّر نفسه. الإغلاق الأول يُسند قيمةً جديدة إليه، والإغلاق الثاني يقرأ
المتغيّر.
ماذا يطبع الاستدعاء لـ globalGet()؟ إذا كانت الإغلاقات تلتقط قيمًا، فإنّ كل
إغلاق يحصل على نسخته الخاصة من a بالقيمة التي كان لها a في اللحظة التي نُفّذ
فيها تصريح دالة الإغلاق. وسيعدّل الاستدعاء لـ globalSet() نسخة a في set()،
لكن نسخة get() لن تتأثّر. لذا، سيطبع الاستدعاء لـ globalGet() القيمة
"initial".
وإذا كانت الإغلاقات تغلق على متغيّرات، فإنّ get() وset() سيلتقط كلاهما —
سيُشير — المتغيّر القابل للتغيير نفسه. حين تغيّر set() قيمة a، فإنّها تغيّر
a نفسها التي يقرأ منها get(). فهناك a واحدة فقط. وبدوره، يعني ذلك أنّ
الاستدعاء لـ globalGet() سيطبع "updated".
أيّهما هو؟ الجواب في Lox ومعظم اللغات الأخرى التي أعرفها ذات الإغلاقات هو الثاني. الإغلاقات تلتقط متغيّرات. يمكنك التفكير فيها وكأنّها تلتقط المكان الذي تعيش فيه القيمة. وهذا مهمّ ينبغي أن ننبّه إليه أثناء تعاملنا مع متغيّرات مُغلَق عليها لم تعد على المكدّس. وحين ينتقل متغيّر إلى الكومة، نحرص على أن تحتفظ كل الإغلاقات التي تلتقط ذلك المتغيّر بإشارة إلى موقعه الجديد الوحيد. هكذا، حين يتغيّر المتغيّر، ترى كل الإغلاقات التغيير.
إغلاق القيم العليا
نعرف أنّ المتغيّرات المحلّية تبدأ دائمًا على المكدّس. هذا أسرع، ويتيح لمصرّفنا أحادي التمريرة أن يُصدر شيفرة قبل أن يكتشف أنّ المتغيّر التُقط. كما نعرف أنّ المتغيّرات المُغلَق عليها تحتاج إلى الانتقال إلى الكومة إذا كان الإغلاق يعيش أطول من الدالة التي يُعلن فيها المتغيّر المُلتقط.
متبعين Lua، سنستخدم القيمة عليا المفتوحة (open upvalue) للإشارة إلى قيمة عليا تشير إلى متغيّر محلّي ما يزال على المكدّس. وحين ينتقل متغيّر إلى الكومة، فإنّنا نُغلق القيمة العليا، والنتيجة بطبيعة الحال هي قيمة عليا مُغلَقة (closed upvalue). والسؤالان اللذان نحتاج إلى الإجابة عنهما هما:
-
إلى أين في الكومة يذهب المتغيّر المُغلَق عليه؟
-
متى نُغلق القيمة العليا؟
الجواب عن السؤال الأول سهل. لدينا بالفعل كائن مريح في الكومة يمثّل إشارة إلى متغيّر — ObjUpvalue نفسه. سينتقل المتغيّر المُغلَق عليه إلى حقل جديد في داخل بنية ObjUpvalue مباشرةً. وبهذه الطريقة لا نحتاج إلى أيّ تخصيص إضافي في الكومة لإغلاق قيمة عليا.
السؤال الثاني مباشر أيضًا. ما دام المتغيّر على المكدّس، قد تكون هناك شيفرة تشير إليه هناك، ويجب أن تعمل تلك الشيفرة بشكل صحيح. لذا فالوقت المنطقي لرفع المتغيّر إلى الكومة هو ما أمكن من التأخير. إذا نقلنا المتغيّر المحلّي بمجرّد أن يخرج عن نطاقه، نتيقّن تمامًا من ألّا تحاول أيّ شيفرة بعد تلك اللحظة الوصول إليه من المكدّس. وبعد أن يخرج المتغيّر عن نطاقه، يكون المصرّف قد أبلغ عن خطأ إن حاولت أيّ شيفرة استخدامه.
يصرّ المصرّف أصلًا بتعليمات OP_POP عند خروج متغيّر محلّي عن نطاقه. وإذا التقط
إغلاقٌ متغيّرًا، فسنُصدر بدلًا منها تعليمة أخرى ترفع ذلك المتغيّر من المكدّس إلى
قيمته العليا المقابلة. ولكي نفعل ذلك، يحتاج المصرّف إلى معرفة المتغيّرات المحلّية
المُغلَق عليها.
يحتفظ المصرّف أصلًا بمصفوفة من بنى Upvalue لكل متغيّر محلّي في الدالة لتتبّع تلك الحالة بالضبط. تلك المصفوفة جيّدة للإجابة عن سؤال "أيّ المتغيّرات يستخدمه هذا الإغلاق؟" لكنها غير مناسبة للإجابة عن سؤال "هل يلتقط أيّ دالة هذا المتغيّر المحلّي؟" وعلى وجه الخصوص، بمجرّد أن ينتهي المصرّف الخاص بإغلاق ما، لا يعود المصرّف الخاص بالدالة المحيطة التي التُقط متغيّراتها قادرًا على الوصول إلى أيّ من حالة القيم العليا.
بعبارة أخرى، يحتفظ المصرّف بمؤشّرات من القيم العليا إلى المتغيّرات المحلّية التي تلتقطها، لكن ليس في الاتجاه المعاكس. لذا نحتاج أولًا إلى إضافة تتبّع إضافي داخل بنية Local القائمة كي نستطيع أن نعرف إن كان متغيّر محلّي ما ملتقَطًا بإغلاق.
^code is-captured-field (1 before, 1 after)
هذا الحقل يساوي true إذا كان المتغيّر المحلّي ملتقَطًا بأيّ تصريح دالة متداخلة
لاحِق. في البداية، كل المتغيّرات المحلّية غير ملتقَطة.
^code init-is-captured (1 before, 1 after)
وكذلك، فإنّ "متغيّر الخانة صفر" الخاص الذي يُعلنه المصرّف ضمنًا ليس ملتقَطًا.
^code init-zero-local-is-captured (1 before, 1 after)
حين يحلّ المصرّف مُعرِّفًا، إن انتهينا إلى إنشاء قيمة عليا لمتغيّر محلّي، فإنّنا نعلّمه على أنه ملتقَط.
^code mark-local-captured (1 before, 1 after)
الآن، عند نهاية نطاق كتلة، حين يُصدر المصرّف شيفرة لتحرير خانات المكدّس الخاصة بالمتغيّرات المحلّية، يمكننا أن نعرف أيّها يحتاج إلى الرفع إلى الكومة. سنستخدم تعليمة جديدة لذلك.
^code end-scope (3 before, 2 after)
التعليمة لا تحتاج إلى مُعامل. نعرف أنّ المتغيّر سيكون دائمًا في أعلى المكدّس تمامًا في اللحظة التي تُنفَّذ فيها هذه التعليمة. نُعلن التعليمة.
^code close-upvalue-op (1 before, 1 after)
ونضيف دعمًا تافهًا في المفكّك لها:
^code disassemble-close-upvalue (1 before, 1 after)
ممتاز. الآن تخبر الشيفرة بايت المولَّدة وقت التشغيل تحديدًا متى يجب أن ينتقل كل متغيّر محلّي ملتقَط إلى الكومة. والأفضل، أنّها تفعل ذلك فقط للمتغيّرات المحلّية التي يستخدمها الإغلاق وتحتاج إلى هذه المعاملة الخاصة. وهذا يتّسق مع هدف الأداء العام لدينا وهو أن يدفع المستخدم ثمن الوظائف التي يستخدمها فقط. أمّا المتغيّرات التي لا تستخدمها الإغلاقات فتُولد وتموت على المكدّس بالكامل تمامًا كما كانت من قبل.
تتبّع القيم العليا المفتوحة
لننتقل إلى جانب وقت التشغيل. قبل أن نتمكّن من تفسير تعليمات
OP_CLOSE_UPVALUE، لدينا مسألة نحتاج إلى حلّها. في وقت سابق، حين تحدّثت عمّا إذا
كانت الإغلاقات تلتقط متغيّرات أم قيمًا، قلت إنّه من المهمّ أن تنتهي الإغلاقات
المتعدّدة التي تصل إلى المتغيّر نفسه إلى إشارة إلى موضع التخزين نفسه بالضبط في
الذاكرة. هكذا، إن كتب أحد الإغلاقات إلى المتغيّر، يرى الإغلاق الآخر التغيير.
الآن، إن التقط إغلاقان المتغيّر المحلّي نفسه، تُنشئ الآلة الافتراضية قيمة عليا منفصلة لكلٍّ منهما. والمشاركة الضرورية غائبة. حين ننقل المتغيّر بعيدًا عن المكدّس، فإن نقلناه إلى إحدى القيم العليا فقط، ستملك القيمة العليا الأخرى قيمةً يتيمة.
ولإصلاح ذلك، كلما احتاجت الآلة الافتراضية إلى قيمة عليا تلتقط خانة متغيّر محلّي معيّنة، سنبحث أولًا عن قيمة عليا قائمة تشير إلى تلك الخانة. فإن وُجدت، نعيد استخدامها. التحدّي هو أنّ كل القيم العليا المُنشأة سابقًا مدفونة في مصفوفات القيم العليا للإغلاقات المختلفة. وقد تكون تلك الإغلاقات في أيّ موضع في ذاكرة الآلة الافتراضية.
الخطوة الأولى هي أن تمنح الآلة الافتراضية قائمتها الخاصة بكل القيم العليا المفتوحة التي تشير إلى متغيّرات ما تزال على المكدّس. البحث في قائمة كلما احتاجت الآلة الافتراضية إلى قيمة عليا قد يبدو بطيئًا، لكن في الممارسة ليس سيئًا. يميل عدد المتغيّرات على المكدّس التي يُغلَق عليها فعلًا إلى أن يكون صغيرًا. وتصريحات الدوال التي تنشئ إغلاقات نادرًا ما تكون على مسارات تنفيذ حرجة للأداء في برنامج المستخدم.
والأفضل، يمكننا ترتيب قائمة القيم العليا المفتوحة حسب فهرس خانة المكدّس التي تشير إليها. الحالة المعتادة هي أنّ الخانة لم تُلتقط سابقًا — فمشاركة المتغيّرات بين الإغلاقات غير معتادة — والإغلاقات تميل إلى التقاط متغيّرات محلّية قريبة من أعلى المكدّس. إذا خزّنّا مصفوفة القيم العليا المفتوحة بترتيب خانات المكدّس، فبمجرد أن نتجاوز الخانة التي يعيش فيها المتغيّر المحلّي الذي نلتقطه، نعرف أنّه لن يُوجد. وحين يكون ذلك المتغيّر المحلّي قريبًا من أعلى المكدّس، يمكننا الخروج من الحلقة مبكرًا إلى حدٍّ كبير.
الحفاظ على قائمة مرتّبة يتطلّب إدراج العناصر في المنتصف بكفاءة. وهذا يقترح استخدام قائمة مرتبطة بدلًا من مصفوفة ديناميكية. ولأننا عرّفنا بنية ObjUpvalue بأنفسنا، فإنّ أسهل تنفيذ هو قائمة متغليسة (intrusive list) تضع مؤشّر التالي في داخل بنية ObjUpvalue نفسها.
^code next-field (1 before, 1 after)
حين نخصّص قيمة عليا، فإنّها لا تكون متّصلة بأيّ قائمة بعد، لذا يكون الرابط NULL.
^code init-next (1 before, 1 after)
تملك الآلة الافتراضية القائمة، لذا يذهب مؤشّر الرأس إلى داخل بنية الآلة الافتراضية الرئيسية مباشرةً.
^code open-upvalues-field (1 before, 1 after)
تبدأ القائمة فارغة.
^code init-open-upvalues (1 before, 1 after)
بدءًا من أول قيمة عليا تشير إليها الآلة الافتراضية، تشير كل قيمة عليا مفتوحة إلى القيمة العليا المفتوحة التالية التي تشير إلى متغيّر محلّي أبعد إلى الأسفل على المكدّس. هذا الملف، مثلًا،
{
var a = 1;
fun f() {
print a;
}
var b = 2;
fun g() {
print b;
}
var c = 3;
fun h() {
print c;
}
}
ينتج سلسلة من القيم العليا المترابطة على النحو التالي:
كلّما أغلقنا على متغيّر محلّي، وقبل إنشاء قيمة عليا جديدة، نبحث عن قيمة عليا قائمة في القائمة.
^code look-for-existing-upvalue (1 before, 1 after)
نبدأ من رأس القائمة، وهو القيمة العليا الأقرب إلى أعلى
المكدّس. نمشي في القائمة، مستخدمين مقارنةً صغيرة بين المؤشّرات لتجاوز كل قيمة عليا
تشير إلى خانات أعلى من الخانة التي نبحث عنها. وفعلنا ذلك، نتتبّع القيمة العليا
السابقة في القائمة. سنحتاج إلى تحديث مؤشّر next لتلك العقدة إذا انتهينا إلى إدراج
عقدة بعدها.
هناك ثلاثة أسباب يمكننا عندها الخروج من الحلقة:
-
خانة المتغيّر المحلّي التي توقّفنا عندها هي الخانة التي نبحث عنها. وجدنا قيمة عليا قائمة تلتقط المتغيّر، لذا نعيد استخدام تلك القيمة العليا.
-
نفدت القيم العليا التي نبحث فيها. حين يكون
upvalueهوNULL، فهذا يعني أنّ كل قيمة عليا مفتوحة في القائمة تشير إلى متغيّرات محلّية أعلى من الخانة التي نبحث عنها، أو (والأرجح) قائمة القيم العليا فارغة. وفي الحالتين، لم نجد قيمة عليا لخانتنا. -
وجدنا قيمة عليا خانتها المحلّية أسفل الخانة التي نبحث عنها. وبما أنّ القائمة مرتّبة، فهذا يعني أنّنا تجاوزنا الخانة التي نُغلق عليها، وبالتالي لا بدّ ألّا تكون هناك قيمة عليا قائمة لها.
في الحالة الأولى، انتهينا وقد استرجعنا. وإلا، فنحن ننشئ قيمة عليا جديدة لخانتنا المحلّية ونُدرجها في القائمة في الموضع الصحيح.
^code insert-upvalue-in-list (1 before, 1 after)
النسخة الحالية من هذه الدالة تنشئ القيمة العليا بالفعل، لذا لا نحتاج سوى إلى إضافة
شيفرة تُدرج القيمة العليا في القائمة. لقد خرجنا من اجتياز القائمة إمّا بتجاوز نهاية
القائمة، وإمّا بالتوقّف على أول قيمة عليا خانتها المكدّس أسفل الخانة التي نبحث
عنها. وفي الحالتين، يعني ذلك أنّنا نحتاج إلى إدراج القيمة العليا الجديدة قبل
الكائن الذي يشير إليه upvalue (والذي قد يكون NULL إن بلغنا نهاية القائمة).
ربما تعلّمته في "هياكل البيانات 101": لإدراج عقدة في قائمة مرتبطة، تجعل مؤشّر
next للعقدة السابقة يشير إلى عقدتك الجديدة. وقد كنا نتتبّع تلك العقدة السابقة
بسهولة أثناء مشينا في القائمة. ونحتاج أيضًا إلى معالجة حالة خاصة حين نُدرج قيمة عليا جديدة عند رأس القائمة، وفي تلك الحالة
يكون مؤشّر "next" هو مؤشّر رأس الآلة الافتراضية.
مع هذه الدالة المحدَّثة، تضمن الآلة الافتراضية الآن وجود ObjUpvalue واحد فقط لأي خانة محلّية معطاة. إذا التقط إغلاقان المتغيّر نفسه، فإنّهما سيحصلان على القيمة العليا نفسها. والآن نحن مستعدون لنقل تلك القيم العليا بعيدًا عن المكدّس.
إغلاق القيم العليا وقت التشغيل
يُصدر المصرّف بشكل مفيد تعليمة OP_CLOSE_UPVALUE لتخبر الآلة الافتراضية تحديدًا
متى يجب رفع متغيّر محلّي إلى الكومة. وتنفيذ تلك التعليمة هي مسؤولية المفسّر.
^code interpret-close-upvalue (1 before, 1 after)
حين نصل إلى التعليمة، يكون المتغيّر الذي نرفعه في أعلى المكدّس تمامًا. نستدعي دالة
مساعِدة، مُمرِّرًا عنوان خانة المكدّس تلك. تلك الدالة مسؤولة عن إغلاق القيمة العليا
ونقل المتغيّر المحلّي من المكدّس إلى الكومة. وبعد ذلك، تُصبح الآلة الافتراضية
حرّةً في التخلّص من خانة المكدّس، وهو ما تفعله باستدعاء pop().
الأمور الممتعة تحدث هنا:
^code close-upvalues
تأخذ هذه الدالة مؤشّرًا إلى خانة مكدّس. وهي تُغلق كل قيمة عليا مفتوحة تجدها تشير إلى تلك الخانة أو أيّ خانة أعلى منها على المكدّس. الآن، نمرّر مؤشّرًا إلى الخانة العليا على المكدّس فقط، لذا فإنّ جزء "أو ما فوقها" لا يدخل في الحساب، لكنه سيصبح كذلك قريبًا.
لنفعل ذلك، نمشي في قائمة القيم العليا المفتوحة للآلة الافتراضية، مرّة أخرى من الأعلى إلى الأسفل. إذا كان موقع قيمة عليا يشير إلى نطاق الخانات الذي نُغلقه، فإنّنا نُغلق القيمة العليا. وإلا، بمجرّد أن نصل إلى قيمة عليا خارج النطاق، نعرف أنّ الباقي كذلك، لذا نتوقّف عن التكرار.
طريقة إغلاق القيمة العليا باردة نوعًا ما. أولًا، ننسخ قيمة
المتغيّر إلى الحقل closed في ObjUpvalue. هناك تعيش المتغيّرات المُغلَق عليها في
الكومة. تحتاج تعليمتَي OP_GET_UPVALUE وOP_SET_UPVALUE إلى البحث عن المتغيّر هناك
بعد نقله. يمكننا إضافة بعض المنطق الشرطي في شيفرة المفسّر لهاتين التعليمتين
للتحقّق من عَلَم ما إذا كانت القيمة العليا مفتوحة أم مُغلَقة.
لكن هناك مستوى من الاستدلال قائم بالفعل — تلك التعليمات تفكّ مؤشّر location
للوصول إلى قيمة المتغيّر. وحين ينتقل المتغيّر من المكدّس إلى الحقل closed، فإنّنا
نكتفي بتحديث ذلك location ليشير إلى عنوان الحقل closed الخاص بـ ObjUpvalue.
لا نحتاج إلى تغيير طريقة تفسير OP_GET_UPVALUE وOP_SET_UPVALUE إطلاقًا. هذا يبقيهما
بسيطَين، وهو بدوره يبقيهما سريعَين. لكننا نحتاج فعلًا إلى إضافة الحقل الجديد إلى
ObjUpvalue.
^code closed-field (1 before, 1 after)
وينبغي أن نُصفّره حين ننشئ ObjUpvalue حتى لا تكون هناك ذاكرة غير مهيّأة طافية في الجوّ.
^code init-closed (1 before, 1 after)
كلما وصل المصرّف إلى نهاية كتلة، فإنه يتخلّص من كل المتغيّرات المحلّية في تلك الكتلة
ويُصدر OP_CLOSE_UPVALUE لكل متغيّر محلّي كان مُغلَقًا عليه. المصرّف لا يُصدر أيّ تعليمات في نهاية نطاق الكتلة الأوسع الذي يعرّف جسم
دالة. يحتوي ذلك النطاق على وسائط الدالة وأي متغيّرات محلّية مُعلَنة فورًا داخل
الدالة. وتلك تحتاج إلى الإغلاق أيضًا.
هذا هو السبب في قبول closeUpvalues() لمؤشّر إلى خانة مكدّس. حين تعود دالة، نستدعي
تلك الدالة المساعِدة نفسها ونمرّر أول خانة مكدّس تملكها الدالة.
^code return-close-upvalues (1 before, 1 after)
بمرّر الخانة الأولى في نافذة مكدّس الدالة، نُغلق كل قيمة عليا مفتوحة متبقية تملكها الدالة العائدة. وبهذا، لدينا الآن تنفيذ إغلاقات عامل بالكامل. تعيش المتغيّرات المُغلَق عليها طالما تحتاج إليها الدوال التي تلتقطها.
كان هذا عملًا كثيرًا! في jlox، خرجت الإغلاقات طبيعيًا من تمثيل البيئة لدينا. أمّا في clox، فقد اضطررنا إلى إضافة شيفرة كثيرة — تعليمات شيفرة بايت جديدة، وبنى بيانات أكثر في المصرّف، وكائنات وقت تشغيل جديدة. تتعامل الآلة الافتراضية مع متغيّرات الإغلاقات على نحو مختلف كثيرًا عن المتغيّرات الأخرى.
وهناك منطق في ذلك. من حيث تعقيد التنفيذ، أعطتنا jlox الإغلاقات "مجانًا". لكن من حيث الأداء، فإنّ إغلاقات jlox هي عكس ذلك تمامًا. بتخصيص كل البيئات في الكومة، تدفع jlox ثمن أداء كبيرًا مقابل كل المتغيّرات المحلّية، حتى الغالبيّة منها التي لا تلتقطها الإغلاقات أبدًا.
مع clox، لدينا نظام أعقد، لكن ذلك يتيح لنا تخصيص التنفيذ ليناسب نمطَي الاستخدام اللذين نلاحظهما للمتغيّرات المحلّية. بالنسبة لمعظم المتغيّرات التي لها فعلًا دلالة المكدّس، نخصّصها بالكامل على المكدّس وهو أمر بسيط وسريع. ثمّ، بالنسبة لبقية المتغيّرات المحلّية القليلة التي لا يعمل فيها ذلك، لدينا مسار ثانٍ أبطأ يمكننا الاشتراك فيه عند الحاجة.
ولحسن الحظ، لا يلاحظ المستخدمون هذا التعقيد. من منظورهم، المتغيّرات المحلّية في Lox بسيطة وموحّدة. اللغة نفسها بسيطة بقدر بساطة تنفيذ jlox. لكن تحت الغطاء، يراقب clox ما يفعله المستخدم ويحسّن لأجل استخداماته تحديدًا. ومع ازدياد نضج تطبيقات لغاتك، ستجد نفسك تفعل هذا أكثر. نسبةٌ كبيرة من "التحسين" تتعلّق بإضافة شيفرة للحالات الخاصة تكتشف استخدامات معيّنة وتوفّر مسارًا أسرع مبنيًا خصيصًا للشيفرة التي تتّفق مع ذلك النمط.
لدينا الآن النطاق المعجميّ يعمل بالكامل في clox، وهذه نقطة تحوّل كبيرة. والآن، بما لدينا دوال ومتغيّرات ذات أعمار معقّدة، لدينا أيضًا كمٌ كبير من الكائنات الطافية في كومة clox، مع شبكة من المؤشّرات تربطها ببعضها. و[الخطوة التالية][] هي اكتشاف كيفية إدارة تلك الذاكرة حتى نتمكّن من تحرير بعض تلك الكائنات حين لم تعد هناك حاجة إليها.
التحدّيات
-
تغليف كل ObjFunction داخل ObjClosure يُدخل مستوى من الاستدلال له تكلفة أداء. تلك التكلفة ليست ضرورية للدوال التي لا تغلق على أيّ متغيّرات، لكنها تتيح لوقت التشغيل معاملة كل الاستدعاءات بالتساوي.
غيّر clox ليغلّف فقط الدوال التي تحتاج إلى قيم عليا داخل ObjClosures. كيف تقارن بين تعقيد الشيفرة والأداء مع التغليف الدائم للدوال؟ احرص على قياس الأداء لبرامج تستخدم الإغلاقات وبرامج لا تستخدمها. كيف ينبغي أن تُرجّح أهمية كل قياس؟ إذا أصبح أحدهما أبطأ والآخر أسرع، فكيف تقرّر أيّ مقايضة تجري لتختار استراتيجية تنفيذ؟
-
اقرأ ملاحظة التصميم أدناه. سأنتظرك. والآن، كيف تظنّ أنّ Lox ينبغي أن يتصرّف؟ غيّر التنفيذ ليُنشئ متغيّرًا جديدًا لكل دورة من دورات الحلقة.
-
تعلّمنا koan مشهورة أنّ "الكائنات هي إغلاق رجل فقير" (والعكس). لا تدعم آلتنا الافتراضية الكائنات بعد، لكن بما لدينا من إغلاقات يمكننا تقريها. باستخدام الإغلاقات، اكتب برنامج Lox يُنمذج "كائنات" متجهات ثنائية الأبعاد. ينبغي له:
-
تعريف دالة "مُنشئ" تنشئ متجهًا جديدًا بالإحداثيَّي x وy المُعطَيين.
-
توفير "طرائق" للوصول إلى الإحداثيَّين x وy للقيم المُعادة من ذلك المُنشئ.
-
تعريف "طريقة" جمع تجمع متجهين وتُنتج ثالثًا.
-
ملاحظة تصميم: الإغلاق على متغيّر الحلقة
الإغلاقات تلتقط متغيّرات. حين يلتقط إغلاقان المتغيّر نفسه، يتشاركان إشارةً إلى موضع التخزين الأساسي نفسه. تصبح هذه الحقيقة ظاهرة حين تُسنَد قيم جديدة إلى المتغيّر. ومن البديهي طبعًا، إذا التقط إغلاقان متغيّرين مختلفين، فلا مشاركة بينهما.
var globalOne;
var globalTwo;
fun main() {
{
var a = "one";
fun one() {
print a;
}
globalOne = one;
}
{
var a = "two";
fun two() {
print a;
}
globalTwo = two;
}
}
main();
globalOne();
globalTwo();
يطبع هذا "one" ثم "two". في هذا المثال، من الواضح تمامًا أنّ متغيّري a مختلفان.
لكن ليس الأمر واضحًا بهذه الدرجة دائمًا. تأمّل:
var globalOne;
var globalTwo;
fun main() {
for (var a = 1; a <= 2; a = a + 1) {
fun closure() {
print a;
}
if (globalOne == nil) {
globalOne = closure;
} else {
globalTwo = closure;
}
}
}
main();
globalOne();
globalTwo();
الشيفرة ملتبسة لأنّ Lox لا يملك أنواع تجميع. الجزء المهم هو أنّ الدالة main()
تنفّذ دورتين من حلقة for. في كل مرة خلال الحلقة، هي تنشئ إغلاقًا يلتقط متغيّر
الحلقة. وهي تخزّن الإغلاق الأول في globalOne والثاني في globalTwo.
هناك بالتأكيد إغلاقان مختلفان. هل يغلقان على متغيّرين مختلفين؟ هل هناك a واحدة
فقط طوال مدّة الحلقة، أم أنّ كل دورة تحصل على متغيّر a خاص بها ومميّز؟
الملف هنا غريب ومصطنَع، لكن هذا يظهر فعلًا في شيفرة حقيقية في لغات ليست بحدّة clox. وإليك مثالًا بلغة JavaScript:
var closures = [];
for (var i = 1; i <= 2; i++) {
closures.push(function () { console.log(i); });
}
closures[0]();
closures[1]();
هل يطبع هذا "1" ثم "2"، أم أنّه يطبع "3" مرّتين؟ قد
تُفاجأ حين تسمع أنّه يطبع "3" مرّتين. في برنامج JavaScript هذا، يوجد متغيّر i
واحد فقط مدى حياتِه يشمل كل دورات الحلقة، بما فيها الخروج النهائي.
إن كنت على دراية بـ JavaScript، فمن المحتمل أنّك تعرف أنّ المتغيّرات المُعلَنة
باستخدام var تُرفع ضمنيًا (hoisted) إلى نطاق الدالة المحيطة أو النطاق ذي
المستوى الأعلى. وكأنّك حرفيًا كتبت هذا:
var closures = [];
var i;
for (i = 1; i <= 2; i++) {
closures.push(function () { console.log(i); });
}
closures[0]();
closures[1]();
عند تلك النقطة، يصبح أوضح أنّ هناك i واحدًا فقط. والآن، فكّر إن غيّرت البرنامج
ليستخدم الكلمة المفتاحية الأحدث let:
var closures = [];
for (let i = 1; i <= 2; i++) {
closures.push(function () { console.log(i); });
}
closures[0]();
closures[1]();
هل يتصرّف هذا البرنامج الجديد بالطريقة نفسها؟ لا. في هذه الحالة، هو يطبع "1" ثم
"2". كل إغلاق يحصل على i الخاص به. هذا غريب نوعًا ما حين تفكّر فيه. بند الزيادة
هو i++. وهذا يبدو ًا جدًا على أنّه يُسند إلى متغيّر قائم ويغيّره، لا أنّه ينشئ
متغيّرًا جديدًا.
لنجرّب بعض اللغات الأخرى. وإليك Python:
closures = []
for i in range(1, 3):
closures.append(lambda: print(i))
closures[0]()
closures[1]()
لا تملك Python نطاق كتلة حقًّا. المتغيّرات مُعلَنة ضمنيًا ويُحكم نطاقها تلقائيًا إلى
الدالة المحيطة. أشبه برفع المتغيّرات في JS، الآن بعد أن فكّرت في الأمر. لذا يلتقط
الإغلاقان المتغيّر نفسه. لكن بخلاف C، نحن لا نخرج من الحلقة بزيادة i بتجاوز
القيمة الأخيرة، لذا يطبع هذا "2" مرّتين.
وماذا عن Ruby؟ تملك Ruby طريقتين نموذجيتين للتكرار عدديًا. وإليك الأسلوب الأمبري البسيط:
closures = []
for i in 1..2 do
closures << lambda { puts i }
end
closures[0].call
closures[1].call
هذا، مثل Python، يطبع "2" مرّتين. لكن أسلوب Ruby الأكثر أصالة هو استخدام طريقة
each() الراقية الطرافة على كائنات المدى:
closures = []
(1..2).each do |i|
closures << lambda { puts i }
end
closures[0].call
closures[1].call
إن لم تكن على دراية بـ Ruby، فالجزء do |i| ... end هو في الأساس إغلاق يُنشأ
ويُمرَّر إلى الطريقة each(). و|i| هي توقيع المُعاملات للإغلاق. والطريقة each()
تستدعي ذلك الإغلاق مرّتين، مُمرِّرة 1 في i أول مرّة و2 في الثانية.
في هذه الحالة، "متغيّر الحلقة" هو في الواقع مُعامل دالة. وبما أنّ كل دورة من دورات الحلقة هي استدعاء منفصل للدالة، فإنّ تلك بالتأكيد متغيّرات منفصلة لكل استدعاء. لذا يطبع هذا "1" ثم "2".
إذا كانت اللغة تملك بنية تكرار قائمة على مُكرِّرات بمستوى أعلى مثل foreach في C#،
و"for" المُحسَّنة في Java، وfor-of في JavaScript، وfor-in في Dart، وغيرها،
فأعتقد أنّ من الطبيعي للقارئ أن تنشئ كل دورة متغيّرًا جديدًا. الشيفرة تبدو متغيّرًا
جديدًا لأنّ ترويسة الحلقة تبدو تصريح متغيّر. ولا يوجد تعبير زيادة يبدو كأنّه يغيّر ذلك
المتغيّر ليتقدّم إلى الخطوة التالية.
إن بحثت في StackOverflow وفي أماكن أخرى، تجد أدلّة على أنّ هذا هو ما يتوقّعه
المستخدمون، لأنّهم فوجئوا كثيرًا حين لا يحصلون عليه. وعلى وجه الخصوص، لم يكن C#
يَنشئ متغيّر حلقة جديدًا لكل دورة من دورات حلقة foreach أصلًا. كانت هذه مصدر
ارتباك شديد للمستخدمين حتى اتّخذوا الخطوة النادرة جدًّا من إطلاق تغيير مُخِلِق للغة. في
C# 5، تنشئ كل دورة متغيّرًا جديدًا تمامًا.
حلقات for القديمة بأسلوب C أصعب. بند الزيادة يبدو حقًّا كأنّه تغيير. وهذا يعني وجود
متغيّر واحد يُحدَّث في كل خطوة. لكن نادرًا ما يكون مفيدًا أن تتشارك كل دورة متغيّر
الحلقة. الوقت الوحيد الذي يمكنك حتى كشف فيه هذا هو حين تلتقطه الإغلاقات. ونادرًا ما
يكون من المفيد أن يكون لديك إغلاق يشير إلى متغيّر قيمته هي القيمة التي جعلتك تخرج من
الحلقة.
الجواب المفيد عمليًّا على الأرجح هو فعل ما تفعله JavaScript مع let في حلقات for.
اجعل يبدو كأنّه تغيير لكن أنشئ في الواقع متغيّرًا جديدًا في كل مرّة، لأنّ هذا هو ما
يريده المستخدمون. إنّه غريب نوعًا ما حين تفكّر فيه.