بنية وتفسير برامج الحاسوب › الحوسبة بآلات المسجّلات 6 / 7 السابق التالي

الاحتساب بآلات المسجّلات

هدفي أن أبيّن أنّ الآلة السماويّة ليست نوعًا من الكائن الإلهيّ الحيّ، بل نوعٌ من عمل الساعة (ومن يظنّ أنّ للساعة روحًا ينسب مجدَ صانعها إلى العمل)، إذ إن جميع الحركات المتعدّدة الأوجه - أو جميعها تقريبًا - ناجمةٌ عن قوّةٍ مادّيّةٍ في غاية البساطة، تمامًا كما أنّ جميع حركات الساعة ناجمةٌ عن ثقلٍ واحد. —يوهانس كيبلر (رسالة إلى هيروارت فون هوهنبورغ، 1605)

بدأنا هذا الكتاب بدراسة العمليّات وبوصفها من حيث إجراءاتٍ مكتوبةٍ بلغة Lisp. ولشرح معاني هذه الإجراءات، استخدمنا سلسلةً من نماذج التقييم: نموذج الاستبدال الوارد في الفصل 1، ونموذج البيئة الوارد في الفصل 3، والمُقيّم التعاكسيّ الوارد في الفصل 4. لقد أزاح فحصنا للمُقيّم التعاكسيّ - على وجه الخصوص - كثيرًا من غموض كيفيّة تفسير اللغات الشبيهة بـLisp. لكن حتّى المُقيّم التعاكسيّ يترك أسئلةً مهمّةً بلا جواب، إذ إنه لا يزيح الستر عن آليّات التحكّم في نظام Lisp. فإنّ المُقيّم لا يشرح - مثلًا - كيف ينجح تقييم تعبيرٍ جزئيّ في إعادة قيمةٍ إلى التعبير الذي يستخدم هذه القيمة، كما لا يشرح كيف تُنتج بعض الإجراءات التعاوديّة عمليّاتٍ تكراريّة (أي إنها مُقيَّمةٌ باستخدام فضاءٍ ثابت) بينما تُنتج إجراءاتٌ تعاوديّةٌ أخرى عمليّاتٍ تعاوديّة. وهذه الأسئلة تبقى بلا جواب لأنّ المُقيّم التعاكسيّ هو في ذاته برنامج Lisp، ومن ثمّ فهو يرث بنية التحكّم في نظام Lisp الكامن. ولكي نوفّر وصفًا أكثر اكتمالًا لبنية التحكّم في مُقيّم Lisp، يتحتّم علينا العمل على مستوى أكثر أوّليّةً من Lisp نفسها.

سنصف في هذا الفصل العمليّات من حيث تشغيل حاسوبٍ تقليديّ خطوةً بخطوة. وحاسوبٌ كهذا، أو آلة مسجّلات (register machine)، ينفّذ تتابعيًّا تعليمات (instructions) تتلاعب بمحتويات مجموعةٍ ثابتةٍ من عناصر التخزين تُسمّى مسجّلات (registers). فإنّ تعليمةً نموذجيّةً لآلة المسجّلات لتطبّق عمليّةً أوّليّةً على محتويات بعض المسجّلات وتُحيل النتيجة إلى مسجّلٍ آخر. وأوصافنا للعمليّات التي تنفّذها آلات المسجّلات ستبدو شديدة الشبه ببرامج «اللغة الآليّة» للحواسيب التقليديّة. غير أنّنا، بدلًا من التركيز على اللغة الآليّة لأيّ حاسوبٍ معيّن، سنفحص عدّة إجراءات Lisp ونُصمّم آلة مسجّلاتٍ خاصّةً لتنفيذ كلّ إجراءٍ منها. وبذلك، سنقترب من مهمّتنا من منظور مهندس عتادٍ لا من منظور مبرمجٍ بلغة آليّةٍ لحاسوب. وبتصميمنا آلات المسجّلات، سنطوّر آليّاتٍ لتنفيذ بنيات برمجةٍ مهمّةٍ كالتعاوب. وسنُقدّم أيضًا لغةً لوصف تصاميم آلات المسجّلات. وفي 5.2 سنُنفّذ برنامج Lisp يستخدم هذه الأوصاف لمحاكاة الآلات التي نصمّمها.

معظم العمليّات الأوّليّة لآلات المسجّلات لدينا بسيطةٌ للغاية. فإنّ عمليّةً ما قد تجمع الأعداد المقروءة من مسجّلين، وتُنتج نتيجةً تُخزَّن في مسجّلٍ ثالث. ومثل هذه العمليّة يمكن أداؤها بعتادٍ سهل الوصف. غير أنّنا، لكي نتعامل مع بنية القوائم، سنستخدم أيضًا عمليّات الذاكرة car وcdr وcons، التي تتطلّب آليّةً بالغة التعقيد لتخصيص التخزين. ونحن ندرس في 5.3 تنفيذها من حيث عمليّاتٍ أكثر أوّليّةً.

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

5.1 تصميم آلات المسجّلات

لتصميم آلة مسجّلات، يتحتّم أن نُصمّم مسارات بياناتها (data paths) (أي مسجّلاتها وعمليّاتها) ومتحكّمها (controller) الذي يرتّب هذه العمليّات. ولتوضيح تصميم آلة مسجّلاتٍ بسيطة، دعنا نفحص خوارزميّة إقليد، التي تُستخدم لاحتساب القاسم المشترك الأكبر لعددين صحيحين. فكما رأينا في 1.2.5، فإنّ خوارزميّة إقليد يمكن أداؤها بعمليّةٍ تكراريّةٍ، كما يحدّده الإجراء التالي:

(define (gcd a b)
  (if (= b 0)
      a
      (gcd b (remainder a b))))

فالآلة التي تُنجز هذه الخوارزميّة يتحتّم عليها تتبّع عددين، aa وbb، فلْنفترض أنّ هذين العددين مخزَّنان في مسجّلين يحملان هذين الاسمين. والعمليّات الأساس المطلوبة هي اختبار ما إذا كانت محتويات المسجّل b صفرًا، واحتساب باقي قسمة محتويات المسجّل a على محتويات المسجّل b. وعمليّة الباقي عمليّةٌ معقّدة، لكن لنفترض للحظةٍ أنّ لدينا جهازًا أوّليًّا يحتسب البواقي. وفي كلّ دورةٍ من خوارزميّة القاسم المشترك الأكبر، يتحتّم استبدال محتويات المسجّل a بمحتويات المسجّل b، كما يتحتّم استبدال محتويات b بباقي قسمة محتويات a القديمة على محتويات b القديمة. ولَكان الأمر مريحًا لو أُمكن أداء هذين الاستبدالين معًا، لكنّنا في نموذجنا لآلات المسجّلات سنفترض أنّ مسجّلًا واحدًا فقط يمكن أن تُحال إليه قيمةٌ جديدة في كلّ خطوة. ولأداء الاستبدالين، ستستخدم آلتنا مسجّلًا ثالثًا «مؤقّتًا»، نُسمّيه t. (فأوّلًا يُوضَع الباقي في t، ثم تُوضَع محتويات b في a، وأخيرًا يُوضَع الباقي المخزَّن في t في b.)

يمكننا توضيح المسجّلات والعمليّات المطلوبة لهذه الآلة باستخدام مخطّط مسار البيانات الموضّح في الشكل 5.1. وفي هذا المخطّط، تُمثَّل المسجّلات (a وb وt) بمستطيلات. وكلّ طريقٍ لإحلال قيمةٍ في مسجّلٍ يُشار إليه بسهمٍ ذي X خلف رأس السهم، مشيرًا من مصدر البيانات إلى المسجّل. وبوسعنا أن نفكّر في X بوصفه زرًّا، إذ إنّ ضغطه يسمح للقيمة عند المصدر بأن «تتدفّق» إلى المسجّل المعيَّن. والملصق المجاور لكلّ زرّ هو الاسم الذي سنستخدمه للإشارة إلى الزرّ. والأسماء اعتراضيّة، ويمكن اختيارها لتكون ذات قيمة تذكيريّة (فمثلًا، يُشار بـa<-b إلى ضغط الزرّ الذي يُحيل محتويات المسجّل b إلى المسجّل a). ومصدر البيانات لمسجّلٍ ما قد يكون مسجّلًا آخر (كما في الإحلال a<-b)، أو نتيجة عمليّة (كما في الإحلال t<-r)، أو ثابتًا (أي قيمةٌ مدمجةٌ لا يمكن تغييرها، وتُمثَّل في مخطّط مسار البيانات بمثلّثٍ يحتوي الثابت).

الشكل 5.1: مسارات البيانات لآلة القاسم المشترك الأكبر.

العمليّة التي تحتسب قيمةً من الثوابت ومحتويات المسجّلات تُمثَّل في مخطّط مسار البيانات بشبه منحرفٍ يحتوي اسم العمليّة. فمثلًا، يُمثّل الصندوق الموسوم بـrem في الشكل 5.1 عمليّةً تحتسب باقي قسمة محتويات المسجّلين a وb المرفَقين به. والأسهم (بلا أزرار) تتّجه من مسجّلات المداخل والثوابت إلى الصندوق، والأسهم تصل قيمة مخرج العمليّة بالمسجّلات. والاختبار يُمثَّل بدائرةٍ تحتوي اسم الاختبار. فمثلًا، تحتوي آلة القاسم المشترك الأكبر لدينا على عمليّةٍ تختبر ما إذا كانت محتويات المسجّل b صفرًا. وللاختبار أسهمٌ أيضًا من مسجّلات مداخله وثوابته، لكنّه لا يملك أسهم مخرجات؛ فقيمته يستخدمها المتحكّم لا مسارات البيانات. وبصورةٍ عامّة، يُظهر مخطّط مسار البيانات المسجّلات والعمليّات المطلوبة للآلة وكيف يتحتّم وصلها. فإذا نظرنا إلى الأسهم بوصفها أسلاكًا وإلى أزرار X بوصفها مفاتيح، فإنّ مخطّط مسار البيانات يشبه إلى حدٍّ بعيدٍ مخطّط الأسلاك لآلةٍ يمكن بناؤها من مكوّنات كهربائيّة.

ولكي تحتسب مسارات البيانات القواسم المشتركة الكبرى فعلًا، يتحتّم ضغط الأزرار في التتابع الصحيح. وسنصف هذا التتابع من حيث مخطّط متحكّم، كما هو موضّح في الشكل 5.2. وتُبيّن عناصر مخطّط المتحكّم كيف ينبغي تشغيل مكوّنات مسار البيانات. فالصناديق المستطيلة في مخطّط المتحكّم تُحدّد أزرار مسار البيانات التي يتحتّم ضغطها، والأسهم تصف التتابع من خطوةٍ إلى التي تليها. والمعيّن في المخطّط يُمثّل قرارًا. وسيتبع أحد سهمي التتابع، بحسب قيمة اختبار مسار البيانات المحدَّد في المعيّن. وبوسعنا تفسير المتحكّم بالاستعانة بمجادلةٍ فيزيائيّة: فتخيّل المخطّط متاهةً تتدحرج فيها كرة رخاميّة. فحين تتدحرج الكرة إلى صندوقٍ ما، تضغط زرّ مسار البيانات الذي يسمّيه الصندوق. وحين تتدحرج الكرة إلى عقدة قرار (كالاختبار الخاصّ بـb = 0)، فإنّها تترك العقدة على المسار الذي تحدّده نتيجة الاختبار المُشار إليه. ومجتمعةً، تصف مسارات البيانات والمتحكّم وصفًا كاملًا آلةً لاحتساب القواسم المشتركة الكبرى. ونبدأ المتحكّم (كرة الرخام المتدحرجة) من الموضع الموسوم بـstart، بعد وضع الأعداد في المسجّلين a وb. وحين يصل المتحكّم إلى done، سنجد قيمة القاسم المشترك الأكبر في المسجّل a.

الشكل 5.2: متحكّم آلة القاسم المشترك الأكبر.

التمرين 5.1: صمّم آلة مسجّلاتٍ لاحتساب العامليّات باستخدام الخوارزميّة التكراريّة التي يحدّدها الإجراء التالي. وارسم مخطّطات مسار البيانات والمتحكّم لهذه الآلة.

(define (factorial n)
  (define (iter product counter)
    (if (> counter n)
        product
        (iter (* counter product)
              (+ counter 1))))
  (iter 1 1))

5.1.1 لغةٌ لوصف آلات المسجّلات

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

نُعرّف مسارات بيانات آلةٍ بوصف مسجّلاتها وعمليّاتها. ولوصف مسجّلٍ، نُعطيه اسمًا ونُحدّد الأزرار التي تتحكّم في الإحلال إليه. ونُعطي كلًّا من هذه الأزرار اسمًا ونُحدّد مصدر البيانات التي تدخل المسجّل تحت تحكّم الزرّ. (والمصدر هو مسجّلٌ أو ثابتٌ أو عمليّة.) ولوصف عمليّةٍ، نُعطيها اسمًا ونُحدّد مداخلها (مسجّلات أو ثوابت).

نُعرّف متحكّم آلةٍ بوصفه تتابعًا من التعليمات (instructions) مع ملصقات (labels) تُحدّد نقاط الدخول (entry points) في التتابع. والتعليمة هي إحدى ما يلي:

  • اسم زرّ مسار بياناتٍ يتحتّم ضغطه لإحلال قيمةٍ إلى مسجّل. (وهذا يقابل صندوقًا في مخطّط المتحكّم.)
  • تعليمة test، تُؤدّي اختبارًا محدَّدًا.
  • تفرّعٌ شرطيّ (تعليمة branch) إلى موضعٍ يُشار إليه بملصق متحكّم، بناءً على نتيجة الاختبار السابق. (والاختبار والتفرّع معًا يقابلان معيّنًا في مخطّط المتحكّم.) فإن كان الاختبار خاطئًا، فعلى المتحكّم أن يواصل التعليمة التالية في التتابع. وإلّا، فعلى المتحكّم أن يواصل التعليمة التي تلي الملصق.
  • تفرّعٌ غير شرطيّ (تعليمة goto) يُسمّي ملصق متحكّمٍ يتحتّم مواصلة التنفيذ عنده.

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

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

الشكل 5.3: ↓↓ توصيف آلة القاسم المشترك الأكبر.

(data-paths
 (registers
  ((name a)
   (buttons ((name a<-b) 
             (source (register b)))))
  ((name b)
   (buttons ((name b<-t)
             (source (register t)))))
  ((name t)
   (buttons ((name t<-r)
             (source (operation rem))))))
 (operations
  ((name rem)
   (inputs (register a) (register b)))
  ((name =)
   (inputs (register b) (constant 0)))))

(controller
 test-b                ; label
   (test =)            ; test
   (branch 
    (label gcd-done))  ; conditional branch
   (t<-r)              ; button push
   (a<-b)              ; button push
   (b<-t)              ; button push
   (goto 
    (label test-b))    ; unconditional branch
 gcd-done)             ; label

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

وللحصول على هذه الصيغة من الوصف، سنستبدل أسماء الأزرار والعمليّات الاعتراضيّة بتعريفات سلوكها. أي إنّه، بدلًا من القول (في المتحكّم) «اضغط الزرّ tGCD machine is described as follows:

(controller
 test-b
   (test (op =) (reg b) (const 0))
   (branch (label gcd-done))
   (assign t (op rem) (reg a) (reg b))
   (assign a (reg b))
   (assign b (reg t))
   (goto (label test-b))
 gcd-done)

هذه الصيغة من الوصف أيسرُ قراءةً من الصيغة الموضّحة في الشكل 5.3، لكنّها تحمل أيضًا عيوبًا:

  • إنّها أكثر إطنابًا في الآلات الكبيرة، لأنّ الأوصاف الكاملة لعناصر مسار البيانات تُكرَّر كلّما ذُكرت العناصر في تتابع تعليمات المتحكّم. (وهذه ليست مشكلةً في مثال القاسم المشترك الأكبر، لأنّ كلّ عمليّةٍ وزرٍّ تُستخدم مرّةً واحدةً فقط.) فضلًا عن ذلك، فإنّ تكرار أوصاف مسار البيانات يحجب بنية مسار البيانات الفعليّة للآلة؛ فليس من الواضح في آلةٍ كبيرةٍ كم عدد المسجّلات والعمليّات والأزرار وكيف تترابط.
  • ولأنّ تعليمات المتحكّم في تعريف آلةٍ تبدو كتعبيرات Lisp، فمن السهل نسيان أنّها ليست تعابير Lisp اعتراضيّة. فإنّها لا تستطيع أن ترمّز إلّا عمليّات آلةٍ مشروعة. فإنّ العمليّات - مثلًا - لا تستطيع أن تعمل مباشرةً إلّا على الثوابت ومحتويات المسجّلات، لا على نتائج عمليّاتٍ أخرى.

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

التمرين 5.2: استخدم لغة آلات المسجّلات لوصف آلة العامليّات التكراريّة الواردة في التمرين 5.1.

الأفعال

لِنُعدّل آلة القاسم المشترك الأكبر بحيث نستطيع إدخال الأعداد التي نريد قاسمها المشترك الأكبر والحصول على الجواب مطبوعًا على طرفيّتنا. ولن نناقش كيف نصنع آلةً تستطيع القراءة والطباعة، بل سنفترض (كما نفعل حين نستخدم read وdisplay في Scheme) أنّ هاتين العمليّتين متاحتان كعمليّتين أوّليّتين.[1]

فـRead تشبه العمليّات التي كنا نستخدمها في أنّها تُنتج قيمةً يمكن تخزينها في مسجّل. لكنّ read لا تأخذ مداخلها من أيّ مسجّل؛ فقيمتها تعتمد على شيءٍ يحدث خارج الأجزاء من الآلة التي نصمّمها. وسنسمح لعمليّات آلتنا بأن يكون لها سلوكٌ كهذا، وبذلك سنرسم ونُرمّز استخدام read كما نفعل مع أيّ عمليّةٍ أخرى تحتسب قيمةً.

أمّا Print، من جهةٍ أخرى، فتختلف عن العمليّات التي كنا نستخدمها اختلافًا أساسيًّا: فإنّها لا تُنتج قيمة مخرجةً تُخزَّن في مسجّل. وإن كان لها أثر، فهذا الأثر ليس على جزءٍ من الآلة التي نصمّمها. وسنُشير إلى هذا النوع من العمليّات بوصفه فعلًا (action). وسنمثّل الفعلَ في مخطّط مسار البيانات تمامًا كما نمثّل عمليّةً تحتسب قيمةً، أي بشكل منحرفٍ يحتوي اسم الفعل. والأسهم تتّجه إلى صندوق الفعل من أيّ مداخلات (مسجّلات أو ثوابت). ونربط أيضًا زرًّا بالفعل. وضغط الزرّ يجعل الفعل يقع. ولكي نجعل متحكّمًا يضغط زرّ فعل، نستخدم نوعًا جديدًا من التعليمات يُسمّى perform. وبذلك، فإنّ فعل طباعة محتويات المسجّل a يُمثَّل في تتابع متحكّمٍ بالتعليمة

(perform (op print) (reg a))

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

الشكل 5.4: آلة قاسمٍ مشترك أكبر تقرأ المداخل وتطبع النتائج.

5.1.2 التجريد في تصميم الآلات

سنُعرّف غالبًا آلةً تتضمّن عمليّاتٍ «أوّليّة» هي في الواقع بالغة التعقيد. فإنّا - مثلًا - سنعامل في 5.4 و5.5 عمليّات التلاعب ببيئة Scheme بوصفها أوّليّة. ومثل هذا التجريد قيّمٌ لأنّه يسمح لنا بتجاهل تفاصيل أجزاءٍ من آلةٍ حتّى نتمكّن من التركيز على جوانب أخرى من التصميم. غير أنّ كوننا قد كنسنا قدرًا كثيرًا من التعقيد تحت البساط لا يعني أنّ تصميم آلةٍ غير واقعيّ. فنستطيع دائمًا أن نستبدل «الأوّليّات» المعقّدة بعمليّاتٍ أوّليّةٍ أبسط منها.

تأمّل آلة القاسم المشترك الأكبر. ففي الآلة تعليمةٌ تحتسب باقي قسمة محتويات المسجّلين a وb وتحيل النتيجة إلى المسجّل t. فإذا أردنا بناء آلة القاسم المشترك الأكبر دون استخدام عمليّة باقٍ أوّليّة، يتحتّم علينا أن نُحدّد كيف تُحتسب البواقي من حيث عمليّاتٍ أبسط، كالطرح. وفي الواقع، نستطيع كتابة إجراء Scheme يجد البواقي بهذه الطريقة:

(define (remainder n d)
  (if (< n d) n (remainder (- n d) d)))

وبوسعنا بذلك أن نستبدل عمليّة الباقي في مسارات بيانات آلة القاسم المشترك الأكبر بعمليّة طرحٍ واختبار مقارنة. يُظهر الشكل 5.5 مسارات البيانات والمتحكّم للآلة المطوّرة. والتعليمة

(assign t (op rem) (reg a) (reg b))

الواردة في تعريف متحكّم القاسم المشترك الأكبر تُستبدل بتتابعٍ من التعليمات يحتوي حلقة، كما هو موضّح في الشكل 5.6.

الشكل 5.5: مسارات البيانات والمتحكّم لآلة القاسم المشترك الأكبر المطوّرة.

الشكل 5.6: ↓↓ تتابع تعليمات المتحكّم الخاصّ بآلة القاسم المشترك الأكبر الواردة في الشكل 5.5.

(controller
 test-b
   (test (op =) (reg b) (const 0))
   (branch (label gcd-done))
   (assign t (reg a))
 rem-loop
   (test (op <) (reg t) (reg b))
   (branch (label rem-done))
   (assign t (op -) (reg t) (reg b))
   (goto (label rem-loop))
 rem-done
   (assign a (reg b))
   (assign b (reg t))
   (goto (label test-b))
 gcd-done)

التمرين 5.3: صمّم آلةً لاحتساب الجذور التربيعيّة باستخدام طريقة نيوتن، كما هي موصوفةٌ في 1.1.7:

(define (sqrt x)
  (define (good-enough? guess)
    (< (abs (- (square guess) x)) 0.001))
  (define (improve guess)
    (average guess (/ x guess)))
  (define (sqrt-iter guess)
    (if (good-enough? guess)
        guess
        (sqrt-iter (improve guess))))
  (sqrt-iter 1.0))

ابدأ بافتراض أنّ عمليّتَي good-enough? وimprove متاحتان كأوّليّتين. ثم أظهر كيف يُمكن توسيع هاتين من حيث العمليّات الحسابيّة. واصف كلّ نسخةٍ من تصميم آلة sqrt برسم مخطّط مسار بياناتٍ وكتابة تعريف متحكّمٍ بلغة آلات المسجّلات.

5.1.3 الروتينات الفرعيّة

عند تصميم آلةٍ تؤدّي احتسابًا ما، كثيرًا ما نُفضّل أن نُرتّب أن تتقاسم أجزاءٌ مختلفةٌ من الاحتساب المكوّنات بدلًا من مضاعفتها. تأمّل آلةً تتضمّن احتسابين للقاسم المشترك الأكبر، أحدهما يجد القاسم المشترك الأكبر لمحتويات المسجّلين a وb، والآخر يجد القاسم المشترك الأكبر لمحتويات المسجّلين c وd. وقد نبدأ بافتراض أنّ لدينا عمليّة gcd أوّليّة، ثم نُوسّع نسختَي gcd من حيث عمليّاتٍ أكثر أوّليّةً. يُظهر الشكل 5.7 أجزاء القاسم المشترك الأكبر وحدها من مسارات بيانات الآلة الناتجة، دون أن يُظهر كيف تتّصل ببقيّة الآلة. ويُظهر الشكل أيضًا الأجزاء المقابلة من تتابع متحكّم الآلة.

الشكل 5.7: أجزاء من مسارات البيانات وتتابع المتحكّم لآلةٍ ذات احتسابين للقاسم المشترك الأكبر.

لهذه الآلة صندوقا عمليّة باقٍ وصندوقا اختبار تساو. فإن كانت المكوّنات المضاعفة معقّدة، كما هو حال صندوق الباقي، فلن تكون هذه طريقةً اقتصاديّةً لبناء الآلة. ونستطيع تجنّب مضاعفة مكوّنات مسار البيانات باستخدام المكوّنات ذاتها لاحتسابَي القاسم المشترك الأكبر، بشرط ألّا يؤثّر ذلك في بقيّة احتساب الآلة الكبرى. فإذا لم تكن القيم في المسجّلين a وb مطلوبةً بحلول الوقت الذي يصل فيه المتحكّم إلى gcd-2 (أو إذا أُمكن نقل هذه القيم إلى مسجّلاتٍ أخرى لحفظها)، فنستطيع تغيير الآلة بحيث تستخدم المسجّلين a وb، بدلًا من المسجّلين c وd، في احتساب القاسم المشترك الأكبر الثاني كما الأوّل. فإن فعلنا ذلك، حصلنا على تتابع المتحكّم الموضّح في الشكل 5.8.

الشكل 5.8: ↓↓ أجزاء من تتابع المتحكّم لآلةٍ تستخدم مكوّنات مسار البيانات ذاتها لاحتسابَي قاسمٍ مشترك أكبر مختلفين.

gcd-1
 (test (op =) (reg b) (const 0))
 (branch (label after-gcd-1))
 (assign t (op rem) (reg a) (reg b))
 (assign a (reg b))
 (assign b (reg t))
 (goto (label gcd-1))
after-gcd-1
  …
gcd-2
 (test (op =) (reg b) (const 0))
 (branch (label after-gcd-2))
 (assign t (op rem) (reg a) (reg b))
 (assign a (reg b))
 (assign b (reg t))
 (goto (label gcd-2))
after-gcd-2

لقد أزلنا مكوّنات مسار البيانات المضاعفة (بحيث صارت مسارات البيانات كما في الشكل 5.1 من جديد)، لكنّ المتحكّم يملك الآن تتابعَي قاسمٍ مشترك أكبر يختلفان في ملصقي نقطة دخولهما فقط. وكان الأفضل أن نستبدل هذين التتابعين بتفرّعاتٍ إلى تتابعٍ واحدٍ - وهو روتين فرعيّ (subroutine) باسم gcd - وفي نهايته نتفرّع عائدين إلى الموضع الصحيح في تتابع التعليمات الرئيس. ونستطيع إنجاز هذا على النحو الآتي: قبل التفرّع إلى gcd، نضع قيمةً مميّزة (مثل 0 أو 1) في مسجّلٍ خاصّ، هو continue. وعند نهاية الروتين الفرعيّ gcd نعود إمّا إلى after-gcd-1 وإمّا إلى after-gcd-2، بحسب قيمة المسجّل continue. ويُظهر الشكل 5.9 الجزء المعنيّ من تتابع المتحكّم الناتج، الذي يتضمّن نسخةً واحدةً فقط من تعليمات gcd.

الشكل 5.9: ↓↓ استخدام مسجّل continue لتجنّب تتابع المتحكّم المضاعف الوارد في الشكل 5.8.

gcd
 (test (op =) (reg b) (const 0))
 (branch (label gcd-done))
 (assign t (op rem) (reg a) (reg b))
 (assign a (reg b))
 (assign b (reg t))
 (goto (label gcd))
gcd-done
 (test (op =) (reg continue) (const 0))
 (branch (label after-gcd-1))
 (goto (label after-gcd-2))
  …
;; Before branching to gcd from
;; the first place where it is needed,
;; we place 0 in the continue register
 (assign continue (const 0))
 (goto (label gcd))
after-gcd-1
  …
;; Before the second use of gcd, 
;; we place 1 in the continue register
 (assign continue (const 1))
 (goto (label gcd))
after-gcd-2

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

ولتعكس هذه القدرة، سنُوسّع تعليمة assign في لغة آلات المسجّلات لتسمح بأن يُحال إلى مسجّلٍ قيمةُ ملصقٍ من تتابع المتحكّم (كنوعٍ خاصٍّ من الثوابت). وسنُوسّع أيضًا تعليمة goto لتسمح بأن يواصل التنفيذ عند نقطة الدخول التي تصفها محتويات مسجّلٍ، لا عند نقطة الدخول التي يصفها ملصقٌ ثابتٌ فقط. وباستخدام هذين البناءين الجديدين نستطيع إنهاء الروتين الفرعيّ gcd بتفرّعٍ إلى الموضع المخزَّن في المسجّل continue. وهذا يؤدّي إلى تتابع المتحكّم الموضّح في الشكل 5.10.

الشكل 5.10: ↓↓ إنّ إحلال الملصقات إلى المسجّل continue يُبسّط الاستراتيجيّة الموضّحة في الشكل 5.9 ويُعمّمها.

gcd
 (test (op =) (reg b) (const 0))
 (branch (label gcd-done))
 (assign t (op rem) (reg a) (reg b))
 (assign a (reg b))
 (assign b (reg t))
 (goto (label gcd))
gcd-done
 (goto (reg continue))
  …
;; Before calling gcd, 
;; we assign to continue the label
;; to which gcd should return.
 (assign continue (label after-gcd-1))
 (goto (label gcd))
after-gcd-1
  …
;; Here is the second call to gcd,
;; with a different continuation.
 (assign continue (label after-gcd-2))
 (goto (label gcd))
after-gcd-2

والآلة التي لها أكثر من روتينٍ فرعيٍّ قد تستخدم مسجّلات استمرارٍ متعدّدة (مثلًا gcd-continue وfactorial-continue)، أو يمكننا أن نجعل جميع الروتينات الفرعيّة تتقاسم مسجّل continue واحدًا. والتقاسم أكثر اقتصاديّة، لكن يتحتّم علينا أن نكون حذرين إذا كان لدينا روتين فرعيّ (sub1) ينادي روتينًا فرعيًّا آخر (sub2). فما لم يحفظ sub1 محتويات continue في مسجّلٍ آخر قبل تهيئة continue للنداء إلى sub2، فلن يعرف sub1 إلى أين يذهب حين ينتهي. والآليّة التي نُطوّرها في القسم التالي لمعالجة التعاوب تُوفّر أيضًا حلًّا أفضل لهذه مسألة نداءات الروتينات الفرعيّة المتداخلة.

5.1.4 استخدام مكدسٍ لتنفيذ التعاوب

بالأفكار التي شُرِّحت حتّى الآن، نستطيع تنفيذ أيّ عمليّةٍ تكراريّةٍ بتحديد آلة مسجّلاتٍ لها مسجّلٌ يقابل كلّ متغيّر حالةٍ في العمليّة. فإنّ الآلة تُنفّذ مرّةً بعد مرّةٍ حلقة متحكّم، غالبةً محتويات المسجّلات، حتّى يتحقّق شرطٌ ما للإنهاء. وفي كلّ نقطةٍ في تتابع المتحكّم، تكون حالة الآلة (الممثّلة لحالة العمليّة التكراريّة) محدّدةً تمامًا بمحتويات المسجّلات (أي قيم متغيّرات الحالة).

أمّا تنفيذ العمليّات التعاوديّة فيتطلّب آليّةً إضافيّة. تأمّل الطريقة التعاوديّة التالية لاحتساب العامليّات، التي فحصناها أوّل مرّةٍ في 1.2.1:

(define (factorial n)
  (if (= n 1) 
      1
      (* (factorial (- n 1)) n)))

فكما نرى من الإجراء، فإنّ احتساب n!n ! يتطلّب احتساب (n−1)!( n − 1 ) ! . وأمّا آلة القاسم المشترك الأكبر لدينا، المأخوذة نموذجها من الإجراء

(define (gcd a b)
  (if (= b 0) 
      a
      (gcd b (remainder a b))))

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

أمّا في حالة العامليّات (أو أيّ عمليّةٍ تعاوديّة) فجواب مسألة العامليّات الجزئيّة الجديدة ليس جواب المسألة الأصليّة. فإنّ القيمة المتحصّلة لـ(n−1)!( n − 1 ) ! يتحتّم ضربها في nn للحصول على الجواب النهائيّ. فإذا حاولنا محاكاة تصميم القاسم المشترك الأكبر، وحلّلنا مسألة العامليّات الجزئيّة بإنقاص المسجّل n وإعادة تشغيل آلة العامليّات، فلن تعود لدينا القيمة القديمة للمسجّل n التي تضرب النتيجة فيها. وبذلك، نحتاج إلى آلة عامليّاتٍ ثانيةٍ تعمل على المسألة الجزئيّة. وهذه الآلة الثانية للعامليّات يتحتّم لها هي ذاتها مسألة عامليّاتٍ جزئيّة، تتطلّب آلة عامليّاتٍ ثالثة، وهكذا. وبما أنّ كلّ آلة عامليّاتٍ تحتوي آلة عامليّاتٍ أخرى داخلها، فإنّ الآلة الكلّيّة تحتوي عشًّا لانهائيًّا من آلاتٍ ممائلة، ومن ثمّ فلا يمكن بناؤها من عددٍ ثابتٍ محدودٍ من الأجزاء.

ومع ذلك، نستطيع تنفيذ عمليّة العامليّات كآلة مسجّلاتٍ إذا أمكننا أن نُرتّب استخدام المكوّنات ذاتها لكلّ نسخةٍ متداخلةٍ من الآلة. وتحديدًا، فإنّ الآلة التي تحتسب n!n ! ينبغي أن تستخدم المكوّنات ذاتها للعمل على مسألة احتساب (n−1)!( n − 1 ) ! الجزئيّة، وعلى مسألة احتساب (n−2)!( n − 2 ) ! الجزئيّة، وهكذا. وهذا أمرٌ معقول، لأنّ عمليّة العامليّات وإن كانت تقتضي أنّ عددًا غير محدودٍ من نسخ الآلة ذاتها لازمٌ لأداء احتسابٍ ما، فإنّ واحدةً فقط من هذه النسخ يلزم أن تكون نشطةً في أيّ لحظةٍ معيّنة. فحين تواجه الآلة مسألةً تعاوديّةً جزئيّة، فيمكنها أن تُعلّق العمل على المسألة الرئيسة، وأن تعيد استخدام الأجزاء المادّيّة ذاتها للعمل على المسألة الجزئيّة، ثم تواصل الاحتساب المعلّق.

وفي المسألة الجزئيّة، ستكون محتويات المسجّلات مختلفةً عمّا كانت عليه في المسألة الرئيسة. (وفي هذه الحالة، يُنقَص المسجّل n.) ولكي يصبح ممكنًا مواصلة الاحتساب المعلّق، يتحتّم على الآلة أن تحفظ محتويات أيّ مسجّلاتٍ ستكون مطلوبةً بعد حلّ المسألة الجزئيّة، بحيث يمكن استعادتها لمواصلة الاحتساب المعلّق. وفي حالة العامليّات، سنحفظ القيمة القديمة للمسجّل n، لتُستعاد حين ننتهي من احتساب عامليّات المسجّل n المنقوص.[2]

وبما أنّ ليس هناك حدٌّ أوّليّ (a priori) لعمق نداءات التعاوب المتداخلة، فقد نحتاج إلى حفظ عددٍ اعتباطيٍّ من قيم المسجّلات. وهذه القيم يتحتّم استعادتها بالعكس من الترتيب الذي حُفظت به، إذ في عشّ التعاوبات تكون المسألة الجزئيّة الأخيرة دخولًا أوّلها انتهاءً. وهذا يمليّ استخدام مكدس (stack)، أو بنية بيانات «آخر داخل، أوّل خارج»، لحفظ قيم المسجّلات. ونستطيع توسيع لغة آلات المسجّلات لتتضمّن مكدسًا بإضافة نوعين من التعليمات: فالقيم تُوضَع على المكدس باستخدام تعليمة save وتُستعاد من المكدس باستخدام تعليمة restore. وبعد أن يكون تتابعٌ من القيم قد حُفظ بـsave على المكدس، فإنّ تتابعًا من تعليمات restore سيسترجع هذه القيم بالترتيب العكسيّ.[3]

وبمعونة المكدس، نستطيع إعادة استخدام نسخةٍ واحدةٍ من مسارات بيانات آلة العامليّات لكلّ مسألة عامليّاتٍ جزئيّة. وهناك مسألة تصميمٍ ممائلة في إعادة استخدام تتابع المتحكّم الذي يُشغّل مسارات البيانات. فلإعادة تنفيذ احتساب العامليّات، لا يستطيع المتحكّم أن يعود إلى البداية بمجرّد حلقةٍ، كما في العمليّة التكراريّة، لأنّه بعد حلّ مسألة (n−1)!( n − 1 ) ! الجزئيّة ينبغي أن تضرب الآلة النتيجة في nn بعد. ويتحتّم على المتحكّم أن يُعلّق احتسابه لـn!n ! ، وأن يحلّ مسألة (n−1)!( n − 1 ) ! الجزئيّة، ثم يواصل احتسابه لـn!n ! . وهذه النظرة إلى احتساب العامليّات تُشير إلى استخدام آليّة الروتينات الفرعيّة الموصوفة في 5.1.3، التي تجعل المتحكّم يستخدم مسجّل continue للانتقال إلى الجزء من التتابع الذي يحلّ مسألةً جزئيّة ثم يواصل من حيث توقّف عن المسألة الرئيسة. ونستطيع بذلك أن نصنع روتينًا فرعيًّا للعامليّات يعود إلى نقطة الدخول المخزَّنة في المسجّل continue. وفي حوالي كلّ نداءٍ للروتين الفرعيّ، نحفظ continue ونستعيده كما نفعل مع المسجّل n، إذ إنّ كلّ «مستوى» من احتساب العامليّات سيستخدم المسجّل continue ذاته. أي إنّ الروتين الفرعيّ للعامليّات يتحتّم أن يضع قيمةً جديدة في continue حين ينادي ذاته من أجل مسألةٍ جزئيّة، لكنّه سيحتاج إلى القيمة القديمة لكي يعود إلى الموضع الذي ناداه لحلّ مسألةٍ جزئيّة.

يُظهر الشكل 5.11 مسارات البيانات والمتحكّم لآلةٍ تُنفّذ إجراء factorial التعاوديّ. وللآلة مكدسٌ وثلاثة مسجّلات، تُسمّى n وval وcontinue. ولكي نبسّط مخطّط مسار البيانات، لم نُسمِّ أزرار إحلال المسجّلات، بل أزرار عمليّات المكدس وحدها (sc وsn لحفظ المسجّلات، rc وrn لاستعادتها). ولتشغيل الآلة، نضع في المسجّل n العدد الذي نريد احتساب عامليّاته ونُشغّل الآلة. وحين تصل الآلة إلى fact-done، يكون الاحتساب قد انتهى وسيوجد الجواب في المسجّل val. وفي تتابع المتحكّم، يُحفظ المسجّلان n وcontinue قبل كلّ نداءٍ تعاوديّ ويُستعادان عند العودة من النداء. وتتمّ العودة من نداءٍ بالتفرّع إلى الموضع المخزَّن في continue. ويُهيَّأ المسجّل Continue عند بدء تشغيل الآلة بحيث تكون العودة الأخيرة إلى fact-done. وأمّا المسجّل val، الذي يحمل نتيجة احتساب العامليّات، فلا يُحفظ قبل النداء التعاوديّ، لأنّ المحتويات القديمة للمسجّل val غير مفيدةٍ بعد عودة الروتين الفرعيّ. والقيمة الجديدة وحدها، وهي القيمة التي يُنتجها الاحتساب الجزئيّ، هي المطلوبة.

الشكل 5.11: آلة عامليّاتٍ تعاوديّة.

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

تعاوبٌ مزدوج

لِنفحص عمليّةً تعاوديّةً أكثر تعقيدًا، وهي الاحتساب التعاوديّ الشجريّ لأعداد فيبوناتشي، الذي عرّفناه في 1.2.2:

(define (fib n)
  (if (< n 2) 
      n 
      (+ (fib (- n 1)) (fib (- n 2)))))

تمامًا كما هو الحال مع العامليّات، نستطيع تنفيذ الاحتساب التعاوديّ لفيبوناتشي كآلة مسجّلاتٍ ذات مسجّلات n وval وcontinue. والآلة أكثر تعقيدًا من آلة العامليّات، لأنّه هناك موضعان في تتابع المتحكّم نحتاج فيهما إلى أداء نداءات تعاوديّة - مرّةً لاحتساب Fib(n−1)Fib ( n − 1 ) ومرّةً لاحتساب Fib(n−2)Fib ( n − 2 ) . ولكي نتهيّأ لكلّ نداءٍ من هذين النداءين، نحفظ المسجّلات التي ستكون قيمُها مطلوبةً لاحقًا، ونضع المسجّل n على العدد الذي نحتاج إلى احتساب Fib له تعاوديًّا ( n−1n − 1 أو n−2n − 2 )، ونُحيل إلى continue نقطة الدخول في التتابع الرئيس التي نعود إليها (afterfib-n-1 أو afterfib-n-2، على الترتيب). ثم نذهب إلى fib-loop. وحين نعود من النداء التعاوديّ، يكون الجواب في val. ويُظهر الشكل 5.12 تتابع المتحكّم لهذه الآلة.

الشكل 5.12: ↓↓ متحكّم آلةٍ لاحتساب أعداد فيبوناتشي.

(controller
   (assign continue (label fib-done))
 fib-loop
   (test (op <) (reg n) (const 2))
   (branch (label immediate-answer))
   ;; set up to compute Fib(n − 1)
   (save continue)
   (assign continue (label afterfib-n-1))
   (save n)           ; save old value of n
   (assign n 
           (op -)
           (reg n)
           (const 1)) ; clobber n to n-1
   (goto 
    (label fib-loop)) ; perform recursive call
 afterfib-n-1 ; upon return, val contains Fib(n − 1)
   (restore n)
   (restore continue)
   ;; set up to compute Fib(n − 2)
   (assign n (op -) (reg n) (const 2))
   (save continue)
   (assign continue (label afterfib-n-2))
   (save val)         ; save Fib(n − 1)
   (goto (label fib-loop))
 afterfib-n-2 ; upon return, val contains Fib(n − 2)
   (assign n 
           (reg val)) ; n now contains Fib(n − 2)
   (restore val)      ; val now contains Fib(n − 1)
   (restore continue)
   (assign val        ; Fib(n − 1) + Fib(n − 2)
           (op +) 
           (reg val)
           (reg n))
   (goto              ; return to caller,
    (reg continue))   ; answer is in val
 immediate-answer
   (assign val 
           (reg n))   ; base case: Fib(n) = n
   (goto (reg continue))
 fib-done)

التمرين 5.4: حدّد آلات مسجّلاتٍ تُنفّذ كلّ إجراءٍ من الإجراءات التالية. ولكلّ آلةٍ، اكتب تتابع تعليمات متحكّمٍ وارسم مخطّطًا يُظهر مسارات البيانات.

الرفع التعاوديّ إلى قوّة:

(define (expt b n)
  (if (= n 0)
      1
      (* b (expt b (- n 1)))))

الرفع التكراريّ إلى قوّة:

(define (expt b n)
  (define (expt-iter counter product)
    (if (= counter 0)
        product
        (expt-iter (- counter 1)
                   (* b product))))
  (expt-iter n 1))

التمرين 5.5: حاكِ آلة العامليّات وآلة فيبوناتشي يدويًّا، مستخدِمًا مدخلًا غير تافه (يتطلّب تنفيذ نداءٍ تعاوديّ واحدٍ على الأقلّ). وأظهر محتويات المكدس عند كلّ نقطةٍ هامّةٍ في التنفيذ.

التمرين 5.6: يلاحظ بن بِتدل أنّ تتابع متحكّم آلة فيبوناتشي يملك save زائدةً وrestore زائدة، يمكن إزالتهما لجعل الآلة أسرع. فأين هاتان التعليمتان؟

5.1.5 ملخّص التعليمات

التعليمة المتحكِّمة في لغة آلات المسجّلات لدينا تأخذ إحدى الصيغ التالية، حيث كلّ ⟨inputi⟩⟨ i n p u t_{i} ⟩ هو إمّا (reg ⟨register-name⟩) وإمّا (const ⟨constant-value⟩). وقد طُرحت هذه التعليمات في 5.1.1:

(assign ⟨register-name⟩ (reg ⟨register-name⟩))
(assign ⟨register-name⟩ 
        (const ⟨constant-value⟩))
(assign ⟨register-name⟩ 
        (op ⟨operation-name⟩) 
        ⟨input₁⟩ … ⟨inputₙ⟩)
(perform (op ⟨operation-name⟩) 
         ⟨input₁⟩ 
         … 
         ⟨inputₙ⟩)
(test (op ⟨operation-name⟩) 
      ⟨input₁⟩ 
      … 
      ⟨inputₙ⟩)
(branch (label ⟨label-name⟩))
(goto (label ⟨label-name⟩))

وقد طُرح استخدام المسجّلات لحفظ الملصقات في 5.1.3:

(assign ⟨register-name⟩ (label ⟨label-name⟩))
(goto (reg ⟨register-name⟩))

وقد طُرحت تعليمات استخدام المكدس في 5.1.4:

(save ⟨register-name⟩)
(restore ⟨register-name⟩)

أمّا النوع الوحيد من ⟨constant-value⟩ الذي رأيناه حتّى الآن فهو العدد، لكنّنا سنستخدم لاحقًا السلاسل والرموز والقوائم. فمثلًا، (const "abc") هو السلسلة "abc"، و(const abc) هو الرمز abc، و(const (a b c)) هو القائمة (a b c)، و(const ()) هو القائمة الخالية.

5.2 محاكي آلة مسجّلات

ولنكتسب فهمًا حسنًا لتصميم آلات المسجّلات، يتحتّم علينا أن نختبر الآلات التي نصمّمها لنرى ما إذا كانت تؤدّي كما هو متوقّع. وإحدى طرق اختبار تصميمٍ هي محاكاة تشغيل المتحكّم يدويًّا، كما في التمرين 5.5. لكنّ هذا أمرٌ بالغ المملّة في جميع الآلات ما عدا أبسطها. ونبني في هذا القسم محاكيًا للآلات الموصوفة بلغة آلات المسجّلات. والمحاكي برنامج Scheme فيه أربعة إجراءات واجهة. فالأوّل يستخدم وصف آلة مسجّلاتٍ لبناء نموذج للآلة (بنية بياناتٍ أجزاؤها تقابل أجزاء الآلة التي تُحاكى)، والثلاثة الأخرى تسمح لنا بمحاكاة الآلة بالتلاعب بالنموذج:

(make-machine ⟨register-names⟩
              ⟨operations⟩
              ⟨controller⟩)

يبني نموذجًا للآلة ذات المسجّلات والعمليّات والمتحكّم المعطاة، ويُعيده.

(set-register-contents! ⟨machine-model⟩ 
                        ⟨register-name⟩ 
                        ⟨value⟩)

يُخزّن قيمةً في مسجّلٍ محاكىً في الآلة المعطاة.

(get-register-contents ⟨machine-model⟩
                       ⟨register-name⟩)

يُعيد محتويات مسجّلٍ محاكىً في الآلة المعطاة.

(start ⟨machine-model⟩)

يحاكي تنفيذ الآلة المعطاة، بدءًا من بداية تسلسل المتحكّم وتوقّفًا حين يبلغ نهاية التسلسل.

ومثالٌ على كيفيّة استخدام هذه الإجراءات، يمكننا تعريف gcd-machine ليكون نموذجًا لآلة GCD الواردة في 5.1.1 كما يلي:

(define gcd-machine
  (make-machine
   '(a b t)
   (list (list 'rem remainder) (list '= =))
   '(test-b
       (test (op =) (reg b) (const 0))
       (branch (label gcd-done))
       (assign t (op rem) (reg a) (reg b))
       (assign a (reg b))
       (assign b (reg t))
       (goto (label test-b))
     gcd-done)))

المَعطى الأوّل إلى make-machine هو قائمةٌ بأسماء المسجّلات. والمَعطى التالي هو جدول (قائمةٌ من قوائمٍ ثنائيّة العناصر) يُقرن كلّ اسم عمليّةٍ فيه بإجراءٍ من Scheme يُنفّذ تلك العمليّة (أي يُنتج قيمة الخرج ذاتها عند إعطائه قيم الدخل ذاتها). وأمّا المَعطى الأخير فيحدّد المتحكّم على هيئة قائمةٍ من اللصائق وتعليمات الآلة، كما في 5.1.

ولحساب القواسم المشتركة الكبرى بهذه الآلة، نُعيّن محتويات مسجّلات الدخل، ونُشغّل الآلة، وندرس النتيجة حين تنتهي المحاكاة:

(set-register-contents! gcd-machine 'a 206)
done

(set-register-contents! gcd-machine 'b 40)
done

(start gcd-machine)
done

(get-register-contents gcd-machine 'a)
2

وسيجرى هذا الاحتساب أبطأَ بكثيرٍ من إجراء gcd مكتوبٍ بـScheme، لأنّنا سنحاكي تعليمات آلةٍ منخفضة المستوى، مثل assign، بعمليّاتٍ أعقدَ بكثيرٍ.

التمرين 5.7: استخدم المُحاكي لاختبار الآلات التي صمّمتها في التمرين 5.4.

5.2.1 نموذج الآلة

نموذج الآلة الذي يولّده make-machine مُمثَّلٌ بإجراءٍ ذي حالة محلّيّة، باستخدام تقنيّات تمرير الرسائل المطوّرة في الفصل 3. فلبناء هذا النموذج، يبدأ make-machine بنداء الإجراء make-new-machine لبناء أجزاء نموذج الآلة المشتركة بين جميع آلات المسجّلات. وهذا النموذج الأساسيّ للآلة الذي يبنيه make-new-machine هو في جوهره حاوٍ لبعض المسجّلات ومكدس، إلى جانب آليّة تنفيذٍ تعالج تعليمات المتحكّم واحدةً واحدة.

ثم يمدّ Make-machine هذا النموذج الأساسيّ (بإرسال رسائل إليه) ليشمل مسجّلات الآلة المعيّنة وعمليّاتها ومتحكّمها. فأوّلًا يُخصِّص مسجّلًا في الآلة الجديدة لكلّ اسمٍ من أسماء المسجّلات المعطاة ويثبّت العمليّات المعيّنة في الآلة. ثم يستخدم مُجمِّعًا (assembler) (موصوفًا أدناه في 5.2.2) لتحويل قائمة المتحكّم إلى تعليماتٍ للآلة الجديدة ويثبّتها بوصفها تسلسل تعليمات الآلة. ويُعيد Make-machine نموذج الآلة المعدَّل قيمةً له.

(define (make-machine register-names 
                      ops 
                      controller-text)
  (let ((machine (make-new-machine)))
    (for-each (lambda (register-name)
                ((machine 'allocate-register) 
                 register-name))
              register-names)
    ((machine 'install-operations) ops)
    ((machine 'install-instruction-sequence)
     (assemble controller-text machine))
    machine))

المسجّلات

وسنمثّل المسجّل بإجراءٍ ذي حالة محلّيّة، كما في الفصل 3. فالإجراء make-register يُنشئ مسجّلًا يحتفظ بقيمةٍ يمكن الاطلاع عليها أو تغييرها:

(define (make-register name)
  (let ((contents '*unassigned*))
    (define (dispatch message)
      (cond ((eq? message 'get) contents)
            ((eq? message 'set)
             (lambda (value) 
               (set! contents value)))
            (else
             (error "Unknown request: 
                     REGISTER"
                    message))))
    dispatch))

والإجراءات التالية تُستخدم للوصول إلى المسجّلات:

(define (get-contents register)
  (register 'get))

(define (set-contents! register value)
  ((register 'set) value))

المكدس

ويمكننا أيضًا تمثيل المكدس بإجراءٍ ذي حالة محلّيّة. فالإجراء make-stack يُنشئ مكدسًا تتألّف حالته المحليّة من قائمةٍ بالعناصر الموجودة في المكدس. ويقبل المكدس طلبات push عنصرًا إلى المكدس، وpop العنصر الأعلى من المكدس وإعادته، وinitialize المكدس ليصبح فارغًا.

(define (make-stack)
  (let ((s '()))
    (define (push x)
      (set! s (cons x s)))
    (define (pop)
      (if (null? s)
          (error "Empty stack: POP")
          (let ((top (car s)))
            (set! s (cdr s))
            top)))
    (define (initialize)
      (set! s '())
      'done)
    (define (dispatch message)
      (cond ((eq? message 'push) push)
            ((eq? message 'pop) (pop))
            ((eq? message 'initialize) 
             (initialize))
            (else 
             (error "Unknown request: STACK"
                    message))))
    dispatch))

والإجراءات التالية تُستخدم للوصول إلى الأكداس:

(define (pop stack) (stack 'pop))
(define (push stack value)
  ((stack 'push) value))

الآلة الأساسيّة

يبني الإجراء make-new-machine، المبيَّن في الشكل 5.13، كائنًا تتألّف حالته المحليّة من مكدس، وتسلسل تعليماتٍ فارغٍ في بادئ الأمر، وقائمةٍ من العمليّات تحتوي في بادئ الأمر على عمليّةٍ لتهيئة المكدس، وجدول مسجّلات يحتوي في بادئ الأمر على مسجّلين، يُسمّيان flag وpc (اختصارًا لـ«عدّاد البرنامج»). ويُضيف الإجراء الداخليّ allocate-register مُدخلاتٍ جديدة إلى جدول المسجّلات، ويَبحَث الإجراء الداخليّ lookup-register عن المسجّلات في الجدول.

الشكل 5.13: ↓↓ الإجراء make-new-machine، الذي يُنفّذ نموذج الآلة الأساسيّ.

(define (make-new-machine)
  (let ((pc (make-register 'pc))
        (flag (make-register 'flag))
        (stack (make-stack))
        (the-instruction-sequence '()))
    (let ((the-ops
           (list 
            (list 'initialize-stack
                  (lambda () 
                    (stack 'initialize)))))
          (register-table
           (list (list 'pc pc) 
                 (list 'flag flag))))
      (define (allocate-register name)
        (if (assoc name register-table)
            (error 
             "Multiply defined register: " 
             name)
            (set! register-table
                  (cons 
                   (list name 
                         (make-register name))
                   register-table)))
        'register-allocated)
      (define (lookup-register name)
        (let ((val 
               (assoc name register-table)))
          (if val
              (cadr val)
              (error "Unknown register:" 
                     name))))
      (define (execute)
        (let ((insts (get-contents pc)))
          (if (null? insts)
              'done
              (begin
                ((instruction-execution-proc 
                  (car insts)))
                (execute)))))
      (define (dispatch message)
        (cond ((eq? message 'start)
               (set-contents! 
                pc
                the-instruction-sequence)
               (execute))
              ((eq? 
                message 
                'install-instruction-sequence)
               (lambda (seq) 
                 (set! 
                  the-instruction-sequence 
                  seq)))
              ((eq? message 
                    'allocate-register) 
               allocate-register)
              ((eq? message 'get-register) 
               lookup-register)
              ((eq? message 
                    'install-operations)
               (lambda (ops) 
                 (set! the-ops 
                       (append the-ops ops))))
              ((eq? message 'stack) stack)
              ((eq? message 'operations) 
               the-ops)
              (else (error "Unknown request: 
                            MACHINE"
                           message))))
      dispatch)))

يُستخدم المسجّل flag للتحكّم في التفرّع في الآلة المحاكاة. فتعليمات Test تضبط محتويات flag بنتيجة الاختبار (صحيحة أو خاطئة). وتقرّر تعليمات Branch ما إذا كانت ستتفرّع أم لا بفحص محتويات flag.

يحدّد المسجّل pc تتابع التعليمات أثناء جريان الآلة. وهذا التتابع مُنفَّذٌ بالإجراء الداخليّ execute. ففي نموذج المحاكاة، كلّ تعليمة آلةٍ هي بنية بيانات تتضمّن إجراءً بلا معطيات، يُسمّى إجراء تنفيذ التعليمة (instruction execution procedure)، بحيث إنّ نداء هذا الإجراء يحاكي تنفيذ التعليمة. وأثناء جريان المحاكاة، يُشير pc إلى المكان في تسلسل التعليمات الذي تبدأ فيه التعليمة التالية المراد تنفيذها. وExecute يجلب تلك التعليمة، ويَنفّذها بنداء إجراء تنفيذ التعليمة، ويكرّر هذه الدورة حتّى لا تبقى تعليماتٌ لتنفيذها (أي حتّى يُشير pc إلى نهاية تسلسل التعليمات).

كجزءٍ من عمليّته، يُعدّل كلّ إجراءٍ لتنفيذ تعليمةٍ المسجّل pc للإشارة إلى التعليمة التالية المراد تنفيذها. فتعليمات Branch وgoto تُغيّران pc ليُشير إلى الوجهة الجديدة. وجميع التعليمات الأخرى تُقدّم pc تقدّمًا بسيطًا، فتجعلُه يُشير إلى التعليمة التالية في التسلسل. ولاحِظ أنّ كلّ نداءٍ لـexecute يَنداء execute مرّةً أخرى، لكنّ هذا لا يُنتج حلقةً لا نهائيّةً لأنّ تشغيل إجراء تنفيذ التعليمة يُغيّر محتويات pc.

يُعيد Make-new-machine إجراء dispatch يُنفّذ وصولاً بتمرير الرسائل إلى الحالة الداخليّة. ولاحِظ أنّ تشغيل الآلة يُنجَز بتعيين pc إلى بداية تسلسل التعليمات ونداء execute.

وللتيسير، نوفّر واجهةً إجراءيّةً بديلةً لعمليّة start في الآلة، وكذلك إجراءاتٍ لتعيين محتويات المسجّلات وفحصها، كما هو محدَّدٌ في بداية 5.2:

(define (start machine)
  (machine 'start))

(define (get-register-contents 
         machine register-name)
  (get-contents 
   (get-register machine register-name)))

(define (set-register-contents! 
         machine register-name value)
  (set-contents! 
   (get-register machine register-name) 
   value)
  'done)

وهذه الإجراءات (والعديد من الإجراءات في 5.2.2 و5.2.3) تستخدم ما يلي للبحث عن المسجّل ذي الاسم المعطى في آلةٍ معطاة:

(define (get-register machine reg-name)
  ((machine 'get-register) reg-name))

5.2.2 المُجمِّع

يحوّل المُجمِّع تسلسل تعابير المتحكّم لآلةٍ إلى قائمةٍ مقابلةٍ من تعليمات الآلة، كلٌّ منها بإجراء تنفيذه. وبصورةٍ عامّة، فإنّ المُجمِّع يشبه إلى حدٍّ كبيرٍ المقيّمات التي درسناها في الفصل 4 — فهناك لغة دخل (في هذه الحالة، لغة آلات المسجّلات) وينبغي أن نؤدّي فعلًا مناسبًا لكلّ نوعٍ من التعابير في اللغة.

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

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

الإجراء assemble هو المدخل الرئيسيّ إلى المُجمِّع. وهو يأخذ نصّ المتحكّم ونموذج الآلة مَعطيين ويُعيد تسلسل التعليمات الذي سيُخزَّن في النموذج. ويَنداء Assemble الإجراء extract-labels لبناء قائمة التعليمات الأوّلية وجدول اللصائق من نصّ المتحكّم المعطى. والمَعطى الثاني إلى extract-labels هو إجراءٌ سيُنداء لمعالجة هذه النتائج: وهذا الإجراء يستخدم update-insts! لتوليد إجراءات تنفيذ التعليمات وإدراجها في قائمة التعليمات، ويُعيد القائمة المعدَّلة.

(define (assemble controller-text machine)
  (extract-labels controller-text
    (lambda (insts labels)
      (update-insts! insts labels machine)
      insts)))

يأخذ Extract-labels مَعطيين: قائمةً text (تسلسل تعابير تعليمات المتحكّم) وإجراء receive. وسيُنداء Receive بقيمتين: (1) قائمةً insts من بنيات بيانات التعليمات، كلٌّ منها يحتوي تعليمةً من text؛ و(2) جدولًا يُسمّى labels، يُقرن كلّ لصيقةٍ من text بالموضع في القائمة insts الذي تُعيّّنه اللصيقة.

(define (extract-labels text receive)
  (if (null? text)
      (receive '() '())
      (extract-labels 
       (cdr text)
       (lambda (insts labels)
         (let ((next-inst (car text)))
           (if (symbol? next-inst)
               (receive 
                   insts
                   (cons 
                    (make-label-entry 
                     next-inst
                     insts)
                    labels))
               (receive 
                   (cons (make-instruction 
                          next-inst)
                         insts)
                   labels)))))))

يعمل Extract-labels بمسح عناصر text تتابعيّ ليُراكِم insts وlabels. فإن كان العنصر رمزًا (وهو إذن لصيقة) أُضيف مُدخلٌ مناسبٌ إلى جدول labels. وإلّا فالعنصر يُراكَم على قائمة insts.[4]

يُعدّل Update-insts! قائمة التعليمات، التي تحتوي في بادئ الأمر على نصّ التعليمات فقط، لتتضمّن إجراءات التنفيذ المقابلة:

(define (update-insts! insts labels machine)
  (let ((pc (get-register machine 'pc))
        (flag (get-register machine 'flag))
        (stack (machine 'stack))
        (ops (machine 'operations)))
    (for-each
     (lambda (inst)
       (set-instruction-execution-proc!
        inst
        (make-execution-procedure
         (instruction-text inst) 
         labels
         machine
         pc
         flag
         stack
         ops)))
     insts)))

وبنية بيانات تعليمة الآلة تُقرن ببساطةٍ نصّ التعليمة بإجراء التنفيذ المقابل. وإجراء التنفيذ غير متوفّرٍ بعد حين يبني extract-labels التعليمة، ويُدرَج لاحقًا بـupdate-insts!.

(define (make-instruction text)
  (cons text '()))
(define (instruction-text inst) (car inst))
(define (instruction-execution-proc inst)
  (cdr inst))
(define (set-instruction-execution-proc!
         inst
         proc)
  (set-cdr! inst proc))

نصّ التعليمة لا يستخدمه مُحاكينا، لكنّ من المفيد الاحتفاظ به للتّنقيح (انظر التمرين 5.16).

وعناصر جدول اللصائق هي أزواج:

(define (make-label-entry label-name insts)
  (cons label-name insts))

وسيُبحَث عن المُدخلات في الجدول بـ

(define (lookup-label labels label-name)
  (let ((val (assoc label-name labels)))
    (if val
        (cdr val)
        (error "Undefined label: ASSEMBLE" 
               label-name))))

التمرين 5.8: إنّ شيفرة آلة المسجّلات التالية غامضة، لأنّ اللصيقة here مُعرَّفةٌ أكثر من مرّة:

start
  (goto (label here))
here
  (assign a (const 3))
  (goto (label there))
here
  (assign a (const 4))
  (goto (label there))
there

بالمُحاكي كما هو مكتوب، فماذا ستكون محتويات المسجّل a حين يبلغ التحكّم there؟ عدّل الإجراء extract-labels بحيث يُشير المُجمِّع إلى خطأٍ إذا استُخدم اسم اللصيقة ذاته للإشارة إلى موضعين مختلفين.

5.2.3 توليد إجراءات تنفيذ التعليمات

يَنداء المُجمِّع make-execution-procedure لتوليد إجراء تنفيذ تعليمةٍ. فمثل الإجراء analyze في مقيّم 4.1.7، فإنّه يُوزّع على نوع التعليمة لتوليد إجراء التنفيذ المناسب.

(define (make-execution-procedure 
         inst labels machine pc flag stack ops)
  (cond ((eq? (car inst) 'assign)
         (make-assign 
          inst machine labels ops pc))
        ((eq? (car inst) 'test)
         (make-test 
          inst machine labels ops flag pc))
        ((eq? (car inst) 'branch)
         (make-branch 
          inst machine labels flag pc))
        ((eq? (car inst) 'goto)
         (make-goto inst machine labels pc))
        ((eq? (car inst) 'save)
         (make-save inst machine stack pc))
        ((eq? (car inst) 'restore)
         (make-restore inst machine stack pc))
        ((eq? (car inst) 'perform)
         (make-perform
          inst machine labels ops pc))
        (else (error "Unknown instruction 
                      type: ASSEMBLE"
                     inst))))

فلكلّ نوعٍ من التعليمات في لغة آلات المسجّلات، هناك مُولِّدٌ يبني إجراء تنفيذٍ مناسبًا. وتفاصيل هذه الإجراءات تحدّد صياغة تعليمات لغة آلات المسجّلات الفرديّة ومعناها جميعًا. ونحن نستخدم تجريد البيانات لعزل الصياغة المفصّلة لتعبيرات آلات المسجّلات عن آليّة التنفيذ العامّة، كما فعلنا مع المقيّمات في 4.1.2، وذلك باستخدام إجراءات الصياغة لاستخراج أجزاء التعليمة وتصنيفها.

تعليمات Assign

إنّ الإجراء make-assign يتعامل مع تعليمات assign:

(define (make-assign 
         inst machine labels operations pc)
  (let ((target 
         (get-register 
          machine 
          (assign-reg-name inst)))
        (value-exp (assign-value-exp inst)))
    (let ((value-proc
           (if (operation-exp? value-exp)
               (make-operation-exp
                value-exp 
                machine
                labels
                operations)
               (make-primitive-exp
                (car value-exp)
                machine
                labels))))
      (lambda ()   ; execution procedure
                   ; for assign
        (set-contents! target (value-proc))
        (advance-pc pc)))))

يستخرج Make-assign اسم المسجّل الهدف (العنصر الثاني من التعليمة) وتعبير القيمة (بقيّة القائمة التي تشكّل التعليمة) من تعليمة assign باستخدام المحدِّدات

(define (assign-reg-name assign-instruction)
  (cadr assign-instruction))
(define (assign-value-exp assign-instruction)
  (cddr assign-instruction))

يُبحَث عن اسم المسجّل بـget-register لإنتاج كائن المسجّل الهدف. وتعبير القيمة يُمرَّر إلى make-operation-exp إذا كانت القيمة نتيجة عمليّة، وإلى make-primitive-exp وإلّا. وهذه الإجراءات (المبيَّنة أدناه) تُحلّل تعبير القيمة وتُنتج إجراء تنفيذٍ للقيمة. وهذا إجراءٌ بلا معطيات، يُسمّى value-proc، سيُقيَّم أثناء المحاكاة لإنتاج القيمة الفعليّة التي ستُحلَّ في المسجّل. ولاحِظ أنّ عمل البحث عن اسم المسجّل وتحليل تعبير القيمة يُؤدّى مرّةً واحدةً فقط، في وقت التجميع، لا في كلّ مرّةٍ تُحاكى فيها التعليمة. وهذا الاقتصاد في العمل هو سبب استخدامنا إجراءات التنفيذ، ويقابل مباشرةً الاقتصاد في العمل الذي حصلنا عليه بفصل تحليل البرنامج عن تنفيذه في مقيّم 4.1.7.

والنتيجة التي يُعيدها make-assign هي إجراء تنفيذ تعليمة assign. وحين يُنداء هذا الإجراء (بإجراء execute الخاصّ بنموذج الآلة)، فإنّه يضبط محتويات المسجّل الهدف بالقيمة المتحصّلة من تنفيذ value-proc. ثم يُقدّم pc إلى التعليمة التالية بتشغيل الإجراء

(define (advance-pc pc)
  (set-contents! pc (cdr (get-contents pc))))

إنّ Advance-pc هي النهاية الطبيعيّة لجميع التعليمات ما عدا branch وgoto.

تعليمات Test وbranch وgoto

يتعامل Make-test مع تعليمات test على نحوٍ مماثل. فإنّه يستخرج التعبير الذي يحدّد الشرط المراد اختباره ويولّد إجراء تنفيذٍ له. في وقت المحاكاة، يُنداء إجراء الشرط، وتُحلّ النتيجة في المسجّل flag، ويُقدَّم pc:

(define 
  (make-test 
   inst machine labels operations flag pc)
  (let ((condition (test-condition inst)))
    (if (operation-exp? condition)
        (let ((condition-proc
               (make-operation-exp
                condition 
                machine
                labels
                operations)))
          (lambda () 
            (set-contents! 
             flag (condition-proc))
            (advance-pc pc)))
        (error "Bad TEST instruction: 
                ASSEMBLE" inst))))

(define (test-condition test-instruction)
  (cdr test-instruction))

يفحص إجراء التنفيذ الخاص بتعليمة branch محتويات مسجّل flag وإمّا أن يضبط محتويات pc على مقصد التفريع (إذا أُخذ التفريع) وإمّا أن يُقدّم pc فقط (إذا لم يُؤخذ التفريع). لاحِظ أنّ المقصد المُشار إليه في تعليمة branch يجب أن يكون لافتة، وإجراء make-branch يفرض ذلك. لاحِظ أيضًا أنّ اللافتة تُبحَث عند التجميع، لا في كلّ مرّةٍ تُحاكى فيها تعليمة branch.

(define 
  (make-branch 
   inst machine labels flag pc)
  (let ((dest (branch-dest inst)))
    (if (label-exp? dest)
        (let ((insts
               (lookup-label 
                labels 
                (label-exp-label dest))))
          (lambda ()
            (if (get-contents flag)
                (set-contents! pc insts)
                (advance-pc pc))))
        (error "Bad BRANCH instruction: 
                ASSEMBLE"
               inst))))

(define (branch-dest branch-instruction)
  (cadr branch-instruction))

تشبه تعليمة goto التفريع، إلّا أنّ المقصد يمكن أن يُحدَّد إمّا كلافتةٍ وإمّا كمسجّل، ولا يوجد شرطٌ يُفحص — فـpc يُضبَط دائمًا على المقصد الجديد.

(define (make-goto inst machine labels pc)
  (let ((dest (goto-dest inst)))
    (cond ((label-exp? dest)
           (let ((insts
                  (lookup-label 
                   labels
                   (label-exp-label dest))))
             (lambda () 
               (set-contents! pc insts))))
          ((register-exp? dest)
           (let ((reg
                  (get-register 
                   machine
                   (register-exp-reg dest))))
             (lambda ()
               (set-contents! 
                pc
                (get-contents reg)))))
          (else (error "Bad GOTO instruction: 
                        ASSEMBLE"
                       inst)))))

(define (goto-dest goto-instruction)
  (cadr goto-instruction))

تعليمات أخرى

تستخدم تعليمتا المكدس save وrestore المكدس مع المسجّل المُعيَّن ببساطةٍ وتُقدّمان pc:

(define (make-save inst machine stack pc)
  (let ((reg (get-register 
              machine
              (stack-inst-reg-name inst))))
    (lambda ()
      (push stack (get-contents reg))
      (advance-pc pc))))

(define (make-restore inst machine stack pc)
  (let ((reg (get-register
              machine
              (stack-inst-reg-name inst))))
    (lambda ()
      (set-contents! reg (pop stack))
      (advance-pc pc))))

(define (stack-inst-reg-name 
         stack-instruction)
  (cadr stack-instruction))

إنّ نوع التعليمة الأخير، الذي يتعامل معه make-perform، يُنشئ إجراء تنفيذٍ للفعل الذي ينبغي أداؤه. وفي وقت المحاكاة، يُنفَّذ إجراء الفعل ويُقدَّم pc.

(define (make-perform 
         inst machine labels operations pc)
  (let ((action (perform-action inst)))
    (if (operation-exp? action)
        (let ((action-proc
               (make-operation-exp
                action
                machine
                labels
                operations)))
          (lambda ()
            (action-proc)
            (advance-pc pc)))
        (error "Bad PERFORM instruction: 
                ASSEMBLE"
               inst))))

(define (perform-action inst) (cdr inst))

إجراءات تنفيذ التعابير الجزئيّة

قد تكون قيمة تعبير reg أو label أو const لازمةً للإحلال إلى مسجّل (make-assign) أو كمدخلٍ لعمليّة (make-operation-exp، أدناه). والإجراء الآتي يُنشئ إجراءات تنفيذٍ لإنتاج قيم هذه التعابير أثناء المحاكاة:

(define (make-primitive-exp exp machine labels)
  (cond ((constant-exp? exp)
         (let ((c (constant-exp-value exp)))
           (lambda () c)))
        ((label-exp? exp)
         (let ((insts
                (lookup-label 
                 labels
                 (label-exp-label exp))))
           (lambda () insts)))
        ((register-exp? exp)
         (let ((r (get-register
                   machine
                   (register-exp-reg exp))))
           (lambda () (get-contents r))))
        (else (error "Unknown expression type: 
                      ASSEMBLE"
                     exp))))

تُحدَّد صياغة تعابير reg وlabel وconst بـ

(define (register-exp? exp)
  (tagged-list? exp 'reg))
(define (register-exp-reg exp)
  (cadr exp))
(define (constant-exp? exp)
  (tagged-list? exp 'const))
(define (constant-exp-value exp)
  (cadr exp))
(define (label-exp? exp)
  (tagged-list? exp 'label))
(define (label-exp-label exp) 
  (cadr exp))

قد تتضمّن تعليمات assign وperform وtest تطبيق عمليّة آلةٍ (مُحدَّدةً بتعبير op) على بعض العوامل (مُحدَّدةً بتعبيرات reg وconst). والإجراء الآتي يُنتج إجراء تنفيذٍ «لتعبير عمليّة» — وهو قائمةٌ تحتوي تعبير العمليّة وتعبيرات العوامل الآتية من التعليمة:

(define (make-operation-exp
         exp machine labels operations)
  (let ((op (lookup-prim 
             (operation-exp-op exp)
             operations))
        (aprocs
         (map (lambda (e)
                (make-primitive-exp 
                 e machine labels))
              (operation-exp-operands exp))))
    (lambda () (apply op (map (lambda (p) (p))
                              aprocs)))))

تُحدَّد صياغة تعابير العمليّات بـ

(define (operation-exp? exp)
  (and (pair? exp)
       (tagged-list? (car exp) 'op)))
(define (operation-exp-op operation-exp)
  (cadr (car operation-exp)))
(define (operation-exp-operands operation-exp)
  (cdr operation-exp))

لاحِظ أنّ معالجة تعابير العمليّات تشبه إلى حدٍّ بعيدٍ معالجة تطبيقات الإجراءات التي يجريها الإجراء analyze-application في المقيّم الوارد في 4.1.7، من حيث أنّنا نُنشئ إجراء تنفيذٍ لكلّ عامل. وفي وقت المحاكاة، نستدعي إجراءات العوامل ونطبّق إجراء Scheme الذي يحاكي العمليّة على القيم الناتجة. ويُوجَد إجراء المحاكاة بالبحث عن اسم العمليّة في جدول عمليّات الآلة:

(define (lookup-prim symbol operations)
  (let ((val (assoc symbol operations)))
    (if val
        (cadr val)
        (error "Unknown operation: ASSEMBLE"
               symbol))))

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

التمرين 5.10: صمِّم صياغةً جديدةً لتعليمات آلة المسجّلات وعدّل المحاكي ليستخدم صياغتك الجديدة. فهل تستطيع تنفيذ صياغتك الجديدة دون تغيير أيّ جزءٍ من المحاكي ما عدا إجراءات الصياغة في هذا القسم؟

التمرين 5.11: حين استحدثنا save وrestore في 5.1.4، لم نُحدّد ما يحدث إذا حاولت استعادة مسجّلٍ ليس آخر ما حُفظ، كما في التسلسل

(save y)
(save x)
(restore y)

ثمّة عدّة احتمالاتٍ معقولةٍ لمعنى restore:

  1. (restore y) يضع في y آخر قيمةٍ حُفظت في المكدس، أياً يكن المسجّل الذي جاءت منه تلك القيمة. وهذا هو سلوك محاكينا. أظهر كيف يمكن استثمار هذا السلوك لإلغاء تعليمةٍ واحدةٍ من آلة فيبوناتشي الواردة في 5.1.4 (الشكل 5.12).
  2. (restore y) يضع في y آخر قيمةٍ حُفظت في المكدس، لكن فقط إذا كانت تلك القيمة قد حُفظت من y؛ وإلّا فإنّه يُشير إلى خطأٍ. عدّل المحاكي ليسلوك هذا السلوك. سيتعيّن عليك تغيير save ليضع اسم المسجّل في المكدس إلى جانب القيمة.
  3. (restore y) يضع في y آخر قيمةٍ حُفظت من y أياً تكن المسجّلات الأخرى التي حُفظت بعد y ولم تُستعَد. عدّل المحاكي ليسلوك هذا السلوك. سيتعيّن عليك ربط مكدسٍ منفصلٍ بكلّ مسجّلٍ. وينبغي أن تجعل عمليّة initialize-stack تُهيّئ جميع مكدسات المسجّلات.

التمرين 5.12: يمكن استخدام المحاكي للمساعدة في تحديد مسار البيانات المطلوب لتنفيذ آلةٍ بمتحكّمٍ معطى. وسّع المُجمِّع ليخزّن المعلومات الآتية في نموذج الآلة: قائمةٌ بجميع التعليمات، بعد إزالة المكرّر منها، مرتّبةً بحسب نوع التعليمة (assign وgoto وما شابه)؛

قائمةٌ (دون مكرّراتٍ) بالمسجّلات المستخدمة لحفظ نقاط الدخول (وهي المسجّلات التي تشير إليها تعليمات goto)؛ قائمةٌ (دون مكرّراتٍ) بالمسجّلات التي تُحفَظ بـsave أو تُستعَد بـrestore؛ وبالنسبة لكلّ مسجّلٍ، قائمةٌ (دون مكرّراتٍ) بالمصادر التي يُحلَّل منها (فمثلًا، مصادر المسجّل val في آلة العامليّة الواردة في الشكل 5.11 هي (const 1) و((op *) (reg n) (reg val))).

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

التمرين 5.13: عدّل المحاكي كي يستخدم تسلسل المتحكّم لتحديد المسجّلات التي تمتلكها الآلة بدلًا من اشتراط قائمةٍ بالمسجّلات كمعطىً لـmake-machine. فبدلًا من تخصيص المسجّلات مُسبقًا في make-machine، يمكنك تخصيصها واحدةً تلو الأخرى حين تُشاهَد أوّلَ مرّةٍ أثناء تجميع التعليمات.

5.2.4 مراقبة أداء الآلة

المحاكاة مفيدةٌ لا للتحقّق من صحّة تصميم آلةٍ مقترحةٍ فحسب، بل أيضًا لقياس أداء الآلة. فإنّا نستطيع، مثلًا، تثبيت «مقياس» في برنامج المحاكاة لدينا يقيس عدد عمليّات المكدس المستخدمة في احتسابٍ ما. ولفعل ذلك، نعدّل مكدسنا المحاكى كي يُتابع عدد المرّات التي تُحفَظ فيها المسجّلات في المكدس وأقصى عمقٍ يبلغه المكدس، ونُضيف رسالةً إلى واجهة المكدس تطبع الإحصائيّات، كما هو موضّح أدناه. ونُضيف أيضًا عمليّةً إلى نموذج الآلة الأساسيّ لطبع إحصائيّات المكدس، بتهيئة the-ops في make-new-machine إلى

(list (list 'initialize-stack
            (lambda () 
              (stack 'initialize)))
      (list 'print-stack-statistics
            (lambda () 
              (stack 'print-statistics))))

وها هو الإصدار الجديد من make-stack:

(define (make-stack)
  (let ((s '())
        (number-pushes 0)
        (max-depth 0)
        (current-depth 0))
    (define (push x)
      (set! s (cons x s))
      (set! number-pushes (+ 1 number-pushes))
      (set! current-depth (+ 1 current-depth))
      (set! max-depth 
            (max current-depth max-depth)))
    (define (pop)
      (if (null? s)
          (error "Empty stack: POP")
          (let ((top (car s)))
            (set! s (cdr s))
            (set! current-depth
                  (- current-depth 1))
            top)))
    (define (initialize)
      (set! s '())
      (set! number-pushes 0)
      (set! max-depth 0)
      (set! current-depth 0)
      'done)

    (define (print-statistics)
      (newline)
      (display (list 'total-pushes 
                     '= 
                     number-pushes
                     'maximum-depth
                     '=
                     max-depth)))
    (define (dispatch message)
      (cond ((eq? message 'push) push)
            ((eq? message 'pop) (pop))
            ((eq? message 'initialize)
             (initialize))
            ((eq? message 'print-statistics)
             (print-statistics))
            (else
             (error "Unknown request: STACK"
                    message))))
    dispatch))

يصف التمرين 5.15 إلى التمرين 5.19 ميزات مراقبةٍ وتنقيحٍ مفيدةً أخرى يمكن إضافتها إلى محاكي آلة المسجّلات.

التمرين 5.14: قِس عدد الدفعات وأقصى عمقٍ للمكدس اللازمين لاحتساب n!n ! لقيمٍ صغيرةٍ متنوّعةٍ لـnn باستخدام آلة العامليّة الموضّحة في الشكل 5.11. واستنبط من بياناتك صيغًا بدلالة nn للعدد الكلّي لعمليّات الدفع ولأقصى عمقٍ للمكدس يُستخدم في احتساب n!n ! لأيّ n>1n > 1 . ولاحِظ أنّ كُلًّا من هذين دالّةٌ خطّيّةٌ في nn وبالتالي تُحدَّد بثابتين. ولكي تُطبع الإحصائيّات، سيتعيّن عليك توسيع آلة العامليّة بتعليماتِ تهيئة المكدس وطبع الإحصائيّات. وقد ترغب أيضًا في تعديل الآلة كي تقرأ قيمة nn مرارًا، وتحسب العامليّة، وتطبع النتيجة (كما فعلنا لآلة القاسم المشترك الأكبر في الشكل 5.4)، بحيث لا يضطرّك الأمر إلى استدعاء get-register-contents وset-register-contents! وstart مرارًا.

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

التمرين 5.16: وسّع المحاكي ليتيح تتبّع التعليمات. أي قبل تنفيذ كلّ تعليمةٍ، ينبغي أن يطبع المحاكي نصّ التعليمة. واجعل نموذج الآلة يقبل رسالتي trace-on وtrace-off لتشغيل التتبّع وإيقافه.

التمرين 5.17: وسّع تتبّع التعليمات الوارد في التمرين 5.16 بحيث يطبع المحاكي، قبل طبع تعليمةٍ ما، أيّ لافتاتٍ تسبق تلك التعليمة مباشرةً في تسلسل المتحكّم. واحرص على فعل ذلك بطريقةٍ لا تتداخل مع عدّ التعليمات (التمرين 5.15). وسيتعيّن عليك أن تجعل المحاكي يحتفظ بمعلومات اللافتات اللازمة.

التمرين 5.18: عدّل إجراء make-register الوارد في 5.2.1 بحيث يمكن تتبّع المسجّلات. وينبغي أن تقبل المسجّلات رسائل تُشغّل التتبّع وتُوقفه. وحين يُتْبَع مسجّلٌ ما، ينبغي أن يطبع إحلال قيمةٍ إلى المسجّل اسم المسجّل والمحتويات القديمة للمسجّل والمحتويات الجديدة التي تُحلَّل إليها. وسّع الواجهة إلى نموذج الآلة لتسمح لك بتشغيل التتبّع وإيقافه لمسجّلات آلةٍ معيَّنةٍ منها.

التمرين 5.19: تُريد أليسا ب. هاكر ميزة نقطة توقّف في المحاكي تساعدها على تنقيح تصاميم آلاتها. وقد عُهد إليك بتثبيت هذه الميزة لها. وهي تُريد أن تكون قادرةً على تحديد موضعٍ في تسلسل المتحكّم يتوقّف عنده المحاكي ويتيح لها فحص حالة الآلة. ومهمّتك تنفيذ الإجراء

(set-breakpoint ⟨machine⟩ ⟨label⟩ ⟨n⟩)

الذي يضع نقطة توقّفٍ قبل التعليمة رقم nthn^{th} بعد اللافتة المعطاة. مثلًا،

(set-breakpoint gcd-machine 'test-b 4)

يثبّت نقطة توقّفٍ في gcd-machine قبل الإحلال إلى المسجّل a. وحين يبلغ المحاكي نقطة التوقّف، ينبغي أن يطبع اللافتة وإزاحة نقطة التوقّف وأن يتوقّف عن تنفيذ التعليمات. وبوسع أليسا حينها استخدام get-register-contents وset-register-contents! للتلاعب بحالة الآلة المحاكاة. وينبغي أن تقدر بعد ذلك على متابعة التنفيذ بقولها

(proceed-machine ⟨machine⟩)

وينبغي أن تقدر أيضًا على إزالة نقطة توقّفٍ معيّنةٍ بـ

(cancel-breakpoint ⟨machine⟩ ⟨label⟩ ⟨n⟩)

أو على إزالة جميع نقاط التوقّف بـ

(cancel-all-breakpoints ⟨machine⟩)

5.3 تخصيص التخزين وجمع القمامة

في القسم 5.4، سنُظهر كيف نُنفّذ مقيّم Scheme بوصفه آلة مسجّلات. ولتبسيط المناقشة، سنفترض أنّ آلات المسجّلات لدينا يمكن تجهيزها بـذاكرةٍ مبنيّةٍ على القوائم (list-structured memory)، تكون فيها العمليّات الأساسيّة للتلاعب ببياناتٍ مبنيّةٍ على القوائم أوّليّةً. وافتراض وجود مثل هذه الذاكرة تجريدٌ مفيدٌ حين يكون المرء مُركّزًا على آليّات التحكّم في مفسّر Scheme، لكنّه لا يعكس نظرةً واقعيّةً عن عمليّات البيانات الأوّليّة الفعليّة للحاسبات المعاصرة. ولكي نحصل على صورةٍ أكملَ عن كيف يعمل نظام Lisp، ينبغي أن نبحث كيف يمكن تمثيل بنية القائمة بطريقةٍ متوافقةٍ مع ذاكرات الحاسوب التقليديّة.

ثمّة اعتباران في تنفيذ بنية القائمة. أمّا الأوّل فمسألة تمثيلٍ بحتة: كيف نُمثّل بنية «الصندوق والمؤشّر» (box-and-pointer) لأزواج Lisp، باستخدام قدرات التخزين والمعننة وحدها في ذاكرات الحاسوب النموذجيّة. وأمّا الاعتبار الثاني فيتعلّق بإدارة الذاكرة بينما يمضي الاحتساب. ويعتمد عمل نظام Lisp بشكلٍ حرجيٍّ على القدرة على إنشاء كائنات بياناتٍ جديدةٍ باستمرار. وتشمل هذه كائناتٍ تُنشَأ صراحةً بواسطة إجراءات Lisp التي يجري تفسيرها، فضلًا عن بنى يُنشئها المفسّر نفسه، كالبيئات وقوائم المعطيات. وعلى الرغم من أنّ إنشاء كائنات بياناتٍ جديدةٍ باستمرارٍ لن يُشكّل مشكلةً على حاسوبٍ يمتلك مقدارًا غير محدودٍ من الذاكرة سريعة العنونة، فإنّ ذاكرات الحاسوب متوفّرةٌ بأحجامٍ محدودةٍ فقط (للأسف الشديد). ولهذا تُوفّر أنظمة Lisp تسهيلًا لـالتخصيص التلقائيّ للتخزين (automatic storage allocation) دعّمًا لوهم الذاكرة غير المحدودة. وحين لم يَعد كائن بياناتٍ لازماً، تُعاد الذاكرة المُخصَّصة له تلقائيًّا وتُستخدم لبناء كائنات بياناتٍ جديدة. ثمّة تقنيّاتٌ متنوّعةٌ لتوفير مثل هذا التخصيص التلقائيّ للتخزين. وأمّا الطريقة التي سنناقشها في هذا القسم فتُسمّى جمع القمامة (garbage collection).

5.3.1 الذاكرة كمتجهات

يمكن النظر إلى ذاكرة الحاسوب التقليديّة بوصفها مصفوفةً من الخزائن الصغيرة، كلٌّ منها يمكن أن يحتوي قطعةً من المعلومات. ولكلّ خزانةٍ صغيرةٍ اسمٌ فريد، يُسمّى عنوانها أو موقعها. وتوفّر نظم الذاكرة النموذجيّة عمليّتَين أوّليّتَين: إحداهما تجلب البيانات المخزّنة في موقعٍ محدَّدٍ، والأخرى تُحلّل بيانات جديدة إلى موقعٍ محدَّدٍ. ويمكن زيادة عناوين الذاكرة لدعم النفاذ المتسلسل إلى مجموعةٍ ما من الخزائن الصغيرة. وبصورةٍ أعمّ، فإنّ عمليّات بياناتٍ مهمّةً كثيرةً تتطلّب أن تُعامَل عناوين الذاكرة كبياناتٍ، يمكن تخزينها في مواقع الذاكرة والتلاعب بها في مسجّلات الآلة. وتمثيل بنية القائمة أحد تطبيقات ما يُعرف بـحساب العناوين (address arithmetic).

ولنمذجة ذاكرة الحاسوب، نستخدم نوعًا جديدًا من بنى البيانات يُسمّى متجهًا (vector). وتجرّديًّا، المتجه كائن بياناتٍ مركّبٌ يمكن النفاذ إلى عناصره الفرديّة بواسطة فهرس (index) صحيحٍ في مقدارٍ من الزمن مستقلٌّ عن الفهرس.[5] ولكي نوصف عمليّات الذاكرة، نستخدم إجراءَي Scheme أوّليّين للتلاعب بالمتجهات:

  • يُعيد (vector-ref ⟨vector⟩ ⟨n⟩) العنصر رقم nthn^{th} من المتجه.
  • يضبط (vector-set! ⟨vector⟩ ⟨n⟩ ⟨value⟩) العنصر رقم nthn^{th} من المتجه على القيمة المعيَّنة.

فمثلًا، إذا كان v متجهًا، فإنّ (vector-ref v 5) يُحضِر المدخل الخامس في المتجه v و(vector-set! v 5 7) يُغيّر قيمة المدخل الخامس في المتجه v إلى 7.[6] أمّا في ذاكرة الحاسوب، فيمكن تنفيذ هذا النفاذ باستخدام حساب العناوين لدمج عنوانٍ أساس (base address) يُحدّد موقع البداية لمتجهٍ في الذاكرة مع فهرس يُحدّد إزاحة عنصرٍ معيّنٍ من المتجه.

تمثيل بيانات Lisp

نستطيع استخدام المتجهات لتنفيذ بنى الأزواج الأساسيّة المطلوبة لذاكرةٍ مبنيّةٍ على القوائم (list-structured memory). فلنتخيّل أنّ ذاكرة الحاسوب مقسومةٌ إلى متجهين: the-cars وthe-cdrs. وسنمثّل بنية القائمة على النحو الآتي: المؤشّر إلى زوجٍ هو فهرسٌ في المتجهين. وcar الزوج هو المدخل في the-cars بالفهرس المعيَّن، وcdr الزوج هو المدخل في the-cdrs بالفهرس المعيَّن. ونحتاج أيضًا إلى تمثيلٍ لكائناتٍ غير الأزواج (كالأعداد والرموز) وإلى طريقةٍ لتمييز نوع بياناتٍ عن آخر. ثمّة طرائق كثيرةٌ لإنجاز ذلك، لكنّها جميعًا تُختزل إلى استخدام مؤشّراتٍ معمَّمة النوع (typed pointers)، أي توسيع مفهوم «المؤشّر» ليشمل معلومات عن نوع البيانات.[7] ويمكّن نوع البيانات النظام من تمييز مؤشّرٍ إلى زوجٍ (وهو يتألّف من نوع البيانات «زوج» وفهرسٍ في متجهي الذاكرة) عن المؤشّرات إلى أنواع البيانات الأخرى (والتي تتألّف من نوع بياناتٍ آخر وممّا يُستخدم لتمثيل بيانات ذلك النوع). ويُعتبر كائنا بياناتٍ متطابقين (eq?) إذا كانت مؤشّراتهما متطابقة.[8] ويُوضيّح الشكل 5.14 استخدام هذه الطريقة لتمثيل القائمة ((1 2) 3 4)، التي يُعرَض مخطّط الصندوق والمؤشّر لها أيضًا. ونستخدم بادئاتٍ حرفيّةً للدلالة على معلومات نوع البيانات. وهكذا، يُرمز إلى المؤشّر إلى الزوج ذي الفهرس 5 بـp5، وتُرمَّز القائمة الخالية بمؤشّر e0، ويُرمز إلى المؤشّر إلى العدد 4 بـn4. وفي مخطّط الصندوق والمؤشّر، أشرنا أسفل يسار كلّ زوجٍ إلى فهرس المتجه الذي يُحدّد أين تُخزَّن car الزوج وcdr الزوج. والمواقع الخالية في the-cars وthe-cdrs قد تحتوي أجزاءً من بنى قوائمَ أخرى (غير ذات أهمّيّةٍ هنا).

الشكل 5.14: تمثيل القائمة ((1 2) 3 4) بمخطّط الصندوق والمؤشّر وبمتجهي الذاكرة.

وقد يتألّف المؤشّر إلى عددٍ، مثل n4، من نوعٍ يدلّ على بيانات عدديّةٍ إلى جانب التمثيل الفعليّ للعدد 4.[9] ولكي نتعامل مع الأعداد التي هي كبيرةٌ بحيث لا يمكن تمثيلها في المقدار الثابت من المساحة المُخصَّصة لمؤشّرٍ واحدٍ، فبوسعنا استخدام نوع بياناتٍ مستقلٍّ هو الأعداد الضخمة (bignum)، يكون فيه المؤشّر مُعيِّنًا لقائمةٍ تُخزَّن فيها أجزاء العدد.[10]

وقد يُمثَّل الرمز بمؤشّرٍ معمَّم النوع يُعيِّن تسلسلًا من المحارف التي تشكّل التمثيل المطبوع للرمز. ويُنشَأ هذا التسلسل بواسطة قارئ Lisp حين تُواجَه سلسلة المحارف أوّلَ ما تُواجَه في المُدخَل. وحيث إنّا نُريد أن يُتعرَّف على نسختين من رمزٍ بوصفهما «الرمز ذاته» بواسطة eq?، ونُريد أن يكون eq? اختبارًا بسيطًا لتساوي المؤشّرات، ينبغي أن نضمن أنّ قارئ الرموز، إذا رأى سلسلة المحارف ذاتها مرّتين، سيستخدم المؤشّر ذاته (إلى تسلسل المحارف ذاته) لتمثيل الحدثين. ولكي يُنجز ذلك، يُمسك القارئ جدولًا، يُسمّى تقليديًّا مصفوفة الرموز (obarray)، بجميع الرموز التي واجهها قطّ. وحين يواجه القارئ سلسلة محارفٍ ويكاد يبني رمزًا، يفحص مصفوفة الرموز ليرى هل واجه سلسلة المحارف ذاتها من قبل. فإن لم يكن قد واجهها، يستخدم المحارف لبناء رمزٍ جديد (مؤشّرٌ معمَّم النوع إلى تسلسل محارفٍ جديد) ويُدخل هذا المؤشّر في مصفوفة الرموز. وإن يكون القارئ قد واجه السلسلة من قبل، يُعيد مؤشّر الرمز المخزّون في مصفوفة الرموز. وتُسمّى عمليّة استبدال سلاسل المحارف بمؤشّراتٍ فريدةٍ إدخال الرموز (interning).

تنفيذ عمليّات القائمة الأوّليّة

وبالنظر إلى مخطّط التمثيل أعلاه، نستطيع استبدال كلّ عمليّة قائمةٍ «أوّليّة» في آلة المسجّلات بعمليّة متجهٍ أوّليّةٍ أو أكثر. وسنستخدم مسجّلين، the-cars وthe-cdrs، لتعيين متجهي الذاكرة، وسنفترض أنّ vector-ref وvector-set! متوفّرتان بوصفهما عمليّتَين أوّليّتَين. ونفترض أيضًا أنّ العمليّات العدديّة على المؤشّرات (كتزييد مؤشّرٍ، أو استخدام مؤشّر زوجٍ لفهرسة متجهٍ، أو جمع عددين) لا تستخدم إلّا جزء الفهرس من المؤشّر المعمَّم النوع.

فمثلًا، نستطيع جعل آلة مسجّلاتٍ تدعم التعليمات

(assign ⟨reg₁⟩ (op car) (reg ⟨reg₂⟩))
(assign ⟨reg₁⟩ (op cdr) (reg ⟨reg₂⟩))

إذا نفّذناها، على الترتيب، كـ

(assign ⟨reg₁⟩ 
        (op vector-ref)
        (reg the-cars)
        (reg ⟨reg₂⟩))
(assign ⟨reg₁⟩
        (op vector-ref)
        (reg the-cdrs)
        (reg ⟨reg₂⟩))

أمّا التعليمات

(perform (op set-car!) (reg ⟨reg₁⟩) (reg ⟨reg₂⟩))
(perform (op set-cdr!) (reg ⟨reg₁⟩) (reg ⟨reg₂⟩))

فمنفَّذةٌ كـ

(perform (op vector-set!)
         (reg the-cars)
         (reg ⟨reg₁⟩)
         (reg ⟨reg₂⟩))
(perform (op vector-set!)
         (reg the-cdrs)
         (reg ⟨reg₁⟩)
         (reg ⟨reg₂⟩))

أمّا cons فيُؤدّى بتخصيص فهرسٍ غير مستخدمٍ وتخزين معطيّ cons في the-cars وthe-cdrs في موضع المتجه المُفهرَس بذلك الفهرس. ونفترض وجود مسجّلٍ خاصٍّ، free، يحمل دائمًا مؤشّر زوجٍ يحتوي الفهرس المتوفّر التالي، وأنّه يمكننا تزييد جزء الفهرس من ذلك المؤشّر لإيجاد الموقع الحرّ التالي.[11] فمثلًا، التعليمة

(assign ⟨reg₁⟩
        (op cons)
        (reg ⟨reg₂⟩)
        (reg ⟨reg₃⟩))

منفَّذةٌ كتسلسل عمليّات المتجه الآتي:[12]

(perform (op vector-set!)
         (reg the-cars)
         (reg free)
         (reg ⟨reg₂⟩))
(perform (op vector-set!)
         (reg the-cdrs)
         (reg free)
         (reg ⟨reg₃⟩))
(assign ⟨reg₁⟩ (reg free))
(assign free (op +) (reg free) (const 1))

أمّا عمليّة eq?

(op eq?) (reg ⟨reg₁⟩) (reg ⟨reg₂⟩)

فتختبر ببساطةٍ تساوي جميع الحقول في المسجّلين، والمسيّمات مثل pair? وnull? وsymbol? وnumber? لا تحتاج إلّا إلى فحص حقل النوع.

تنفيذ المكدسات

وعلى الرغم من أنّ آلات المسجّلات لدينا تستخدم المكدسات، فليس علينا أن نفعل شيئًا خاصًّا هنا، إذ يمكن نمذجة المكدسات من حيث القوائم. فيمكن أن يكون المكدس قائمةً بالقيم المحفوظة، يُشير إليها مسجّلٌ خاصٌّ هو the-stack. وهكذا، يمكن تنفيذ (save ⟨reg⟩) كـ

(assign the-stack 
        (op cons)
        (reg ⟨reg⟩)
        (reg the-stack))

وبالمثل، يمكن تنفيذ (restore ⟨reg⟩) كـ

(assign ⟨reg⟩ (op car) (reg the-stack))
(assign the-stack (op cdr) (reg the-stack))

و(perform (op initialize-stack)) يمكن تنفيذه كـ

(assign the-stack (const ()))

يمكن توسيع هذه العمليّات أكثر بواسطة عمليّات المتجه (vector) المعطاة أعلاه. أمّا في البُنى الحاسوبيّة التقليديّة، فمن المفيدّ عادةً تخصيص المكدّس (stack) متجهًا (vector) منفصلًا. وعندئذٍ يمكن أداء الدفع إلى المكدّس وإخراج القيم منه بزيادة فهرسٍ داخل ذلك المتجه أو إنقاصه.

التمرين 5.20: ارسم تمثيل الصناديق والمؤشّرات (box-and-pointer) وتمثيل متجه الذاكرة (memory-vector) (كما في الشكل 5.14) لبنية القائمة (list structure) الناتجة عن

(define x (cons 1 2))
(define y (list x x))

حيث يكون مؤشّر free في البدء عند p1. فما القيمة النهائيّة لـfree؟ وأيّ مؤشّرات تمثّل قيمتَي x وy؟

التمرين 5.21: نفّذ آلات مسجّلاتٍ للإجراءات الآتية. وافترض أنّ عمليّات ذاكرة بنية القوائم متاحة كأوّليّاتٍ آليّة.

نسخة تعاوديّة من count-leaves:

(define (count-leaves tree)
  (cond ((null? tree) 0)
        ((not (pair? tree)) 1)
        (else 
         (+ (count-leaves (car tree))
            (count-leaves (cdr tree))))))

نسخة تعاوديّة من count-leaves بعدّاد صريح:

(define (count-leaves tree)
  (define (count-iter tree n)
    (cond ((null? tree) n)
          ((not (pair? tree)) (+ n 1))
          (else 
           (count-iter 
            (cdr tree)
            (count-iter (car tree) 
                        n)))))
  (count-iter tree 0))

التمرين 5.22: عرض التمرين 3.12 الوارد في 3.3.1 إجراءَ append الذي يلحق قائمتين لتكوين قائمةٍ جديدة، وإجراءَ append! الذي يدمج قائمتين وصْلهما معًا. صمّم آلة مسجّلاتٍ لتنفيذ كلٍّ من هذين الإجراءين. وافترض أنّ عمليّات ذاكرة بنية القوائم متاحة كعمليّاتٍ أوّليّة.

5.3.2 الحفاظ على وهم الذاكرة غير المحدودة

طريقة التمثيل المُوجَزة في 5.3.1 تحلّ مسألة تنفيذ بنية القوائم، شريطةَ أن تكون لدينا ذاكرةٌ غير محدودة. أمّا مع حاسوبٍ حقيقيّ، فسينفدنا في النهاية المكان الحُرّ الذي نُبنى فيه أزواجٌ جديدة.[13] غير أنّ معظم الأزواج المُولَّدة في احتسابٍ نموذجيٍّ لا تُستخدم إلّا للاحتفاظ بنتائج وسيطة. وبعد الاستفادة من هذه النتائج، لم تعُد الأزواج لازمةً — إنّها قمامة (garbage). فإنّ الاحتساب

(accumulate 
 + 
 0
 (filter odd? (enumerate-interval 0 n)))

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

وحتى نُعيد تدوير الأزواج، ينبغي أن تكون لدينا طريقةٌ لتحديد الأزواج المخصَّصة غير اللازمة (بمعنى أنّ محتوياتها لم تعُد قادرةً على التأثير في مستقبل الاحتساب). والطريقة التي سنفحصها لإنجاز ذلك تُعرف بـجمع القمامة (garbage collection). ويقوم جمع القمامة على الملاحظة أنّ الكائنات الوحيدة التي يمكنها التأثير في مستقبل الاحتساب، في أيّ لحظةٍ من تأويل Lisp، هي تلك التي يمكن الوصول إليها بسلسلةٍ من عمليّات car وcdr تبدأ من المؤشّرات الموجودة حاليًّا في مسجّلات الآلة.[14] ويمكن إعادة تدوير أيّ خليّة ذاكرةٍ غير متاحةٍ على هذا النحو.

ثمّة طرائق كثيرةٌ لأداء جمع القمامة. والطريقة التي سنفحصها هنا تُسمّى التوقّف والنسخ (stop-and-copy). والفكرة الأساسيّة هي تقسيم الذاكرة إلى نصفين: «ذاكرة العمل» و«الذاكرة الحُرّة». وعندما يبني cons أزواجًا، فإنّه يخصّصها في ذاكرة العمل. وحين تمتلئ ذاكرة العمل، نؤدّي جمع القمامة بتحديد موقع جميع الأزواج المفيدة في ذاكرة العمل ونسخها في مواضعَ متتاليةٍ في الذاكرة الحُرّة. (وتُحدَّد مواقع الأزواج المفيدة بتتبّع جميع مؤشّري car وcdr، بدءًا من مسجّلات الآلة.) وبما أنّنا لا ننسخ القمامة، فسيكون هناك - على الأرجح - ذاكرةٌ حُرّةٌ إضافيّةٌ نستطيع استخدامها لتخصيص أزواجٍ جديدة. فضلًا عن ذلك، لا شيء في ذاكرة العمل لازَمٌ، إذ إنّ جميع الأزواج المفيدة فيها قد نُسخت. وبذلك، فإنّ تبادلنا أدوار ذاكرة العمل والذاكرة الحُرّة يمكنّنا من مواصلة المعالجة؛ وسيُخصَّص الأزواج الجديدة في ذاكرة العمل الجديدة (وهي الذاكرة الحُرّة القديمة). وحين تمتلئ تلك، نستطيع نسخ الأزواج المفيدة إلى الذاكرة الحُرّة الجديدة (وهي ذاكرة العمل القديمة).[15]

تنفيذ جامع قمامةٍ بطريقة التوقّف والنسخ

نستخدم الآن لغة آلات المسجّلات لوصف خوارزميّة التوقّف والنسخ بتفصيلٍ أكثر. وسنفترض وجود مسجّلٍ يُسمّى root يحتوي مؤشّرًا إلى بنيةٍ تُشير في النهاية إلى جميع البيانات المتاحة. ويمكن ترتيب ذلك بتخزين محتويات جميع مسجّلات الآلة في قائمةٍ مخصَّصةٍ مسبقًا يُشير إليها root قبيل البدء في جمع القمامة.[16] ونفترض أيضًا أنّه - بالإضافة إلى ذاكرة العمل الحاليّة - ثمّة ذاكرةٌ حُرّةٌ متاحةٌ نستطيع نسخ البيانات المفيدة إليها. وتتألّف ذاكرة العمل الحاليّة من متجهاتٍ عناوينها الأساسيّة في مسجّلين يُسمّيان the-cars وthe-cdrs، والذاكرة الحُرّة في مسجّلين يُسمّيان new-cars وnew-cdrs.

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

الشكل 5.15: إعادة تشكيل الذاكرة بواسطة عمليّة جمع القمامة.

تُضبَط حالة عمليّة جمع القمامة بالإبقاء على مؤشّرين: free وscan. ويُهيَّأ هذان المؤشّران ليُشيرا إلى بداية الذاكرة الجديدة. وتبدأ الخوارزميّة بنقل الزوج الذي يُشير إليه root إلى بداية الذاكرة الجديدة. فيُنسخ الزوج، ويُعدَّل المؤشّر root ليُشير إلى الموقع الجديد، وتُزاد قيمة المؤشّر free. وبالإضافة إلى ذلك، يُؤشَّر الموقع القديم للزوج لإظهار أنّ محتوياته قد نُقلت. ويُنجز هذا التأشير على النحو الآتي: في موضع car، نضع وسمًا خاصًّا يُشير إلى أنّ هذا كائنٌ قد نُقل بالفعل. (ويُسمّى مثل هذا الكائن تقليديًّا القلب المكسور (broken heart).)[17] وفي موضع cdr نضع عنوان إعادة التوجيه (forwarding address) الذي يُشير إلى الموقع الذي نُقل إليه الكائن.

وبعد نقل الجذر، يدخل جامع القمامة دورته الأساسيّة. وفي كلّ خطوةٍ من الخوارزميّة، يُشير المؤشّر scan (الذي يُشير في البدء إلى الجذر المنقول) إلى زوجٍ نُقل إلى الذاكرة الجديدة لكنّ مؤشّريه car وcdr ما زالا يُشيران إلى كائناتٍ في الذاكرة القديمة. وتُنقل هذه الكائنات كلٌّ منها، وتُزاد قيمة المؤشّر scan. ولنقل كائنٍ (مثلًا الكائن الذي يُحدّده مؤشّر car للزوج الذي نمسحه) نتحقّق مما إذا كان الكائن قد نُقل بالفعل (كما يُبيّنه وجود وسم القلب المكسور في موضع car للكائن). فإن لم يكن الكائن قد نُقل بالفعل، ننسخه إلى الموضع الذي يُحدّده free، ونُحدّث free، ونُنشئ قلبًا مكسورًا في الموقع القديم للكائن، ونُحدّث المؤشّر إلى الكائن (في هذا المثال، مؤشّر car للزوج الذي نمسحه) ليُشير إلى الموقع الجديد. وإن كان الكائن قد نُقل بالفعل، فتُستبدل قيمة عنوان إعادة التوجيه الخاص به (الموجود في موضع cdr من القلب المكسور) بالمؤشّر في الزوج الذي يُمسح. وفي النهاية، ستكون جميع الكائنات المتاحة قد نُقلت ومُسحت، وعندئذٍ يفوق المؤشّر scan المؤشّر free وتنتهي العمليّة.

يمكننا تحديد خوارزميّة التوقّف والنسخ كسلسلةٍ من التعليمات لآلة مسجّلات. أمّا الخطوة الأساسيّة في نقل كائنٍ فتتمّ بروتينٍ فرعيٍّ يُسمّى relocate-old-result-in-new. ويحصل هذا الروتين الفرعيّ على معطاه، وهو مؤشّرٌ إلى الكائن المطلوب نقله، من مسجّلٍ يُسمّى old. وهو ينقل الكائن المعيَّن (بزيادة free أثناء ذلك)، ويضع مؤشّرًا إلى الكائن المنقول في مسجّلٍ يُسمّى new، ويعود بالتفرّع إلى نقطة الدخول المخزّنة في المسجّل relocate-continue. وللبدء في جمع القمامة، نستدعي هذا الروتين الفرعيّ لنقل مؤشّر root، بعد تهيئة free وscan. وحين يُنجز نقل root، نُنصّب المؤشّر الجديد بوصفه root الجديد وندخل الحلقة الرئيسيّة لجامع القمامة.

begin-garbage-collection
  (assign free (const 0))
  (assign scan (const 0))
  (assign old (reg root))
  (assign relocate-continue 
          (label reassign-root))
  (goto (label relocate-old-result-in-new))
reassign-root
  (assign root (reg new))
  (goto (label gc-loop))

في الحلقة الرئيسيّة لجامع القمامة، ينبغي أن نُحدّد فيما إذا كانت ثمّة كائناتٌ أخرى تبقّى مسحها. ونفعل ذلك باختبار تطابق المؤشّر scan مع المؤشّر free. فإن كان المؤشّران متساويين، فقد نُقلت جميع الكائنات المتاحة، ونتفرّع إلى gc-flip الذي يُرتّب الأمور حتّى نستطيع مواصلة الاحتساب المُقاطَع. وإن كان ثمّة أزواجٌ تبقّى مسحها، نستدعي روتين النقل لنقل car الزوج التالي (بوضع مؤشّر car في old). ويُهيَّأ المسجّل relocate-continue بحيث يعود الروتين الفرعيّ لتحديث مؤشّر car.

gc-loop
  (test (op =) (reg scan) (reg free))
  (branch (label gc-flip))
  (assign old 
          (op vector-ref)
          (reg new-cars)
          (reg scan))
  (assign relocate-continue 
          (label update-car))
  (goto (label relocate-old-result-in-new))

عند update-car، نُعدّل مؤشّر car للزوج الذي يُمسح، ثم نمضي إلى نقل cdr الزوج. ونعود إلى update-cdr حين يُنجز ذلك النقل. وبعد نقل cdr وتحديثه، نكون قد أنينا من مسح ذلك الزوج، فنواصل في الحلقة الرئيسيّة.

update-car
  (perform (op vector-set!)
           (reg new-cars)
           (reg scan)
           (reg new))
  (assign  old 
           (op vector-ref)
           (reg new-cdrs)
           (reg scan))
  (assign  relocate-continue
           (label update-cdr))
  (goto (label relocate-old-result-in-new))
update-cdr
  (perform (op vector-set!)
           (reg new-cdrs)
           (reg scan)
           (reg new))
  (assign  scan (op +) (reg scan) (const 1))
  (goto (label gc-loop))

يُنقل الروتين الفرعيّ relocate-old-result-in-new الكائنات على النحو الآتي: فإن كان الكائن المطلوب نقله (الذي يُشير إليه old) ليس زوجًا، نُعيد المؤشّر ذاته إلى الكائن دون تغيير (في new). (فقد نكون مثلًا نمسح زوجًا car هو العدد 4. فإن مثّلنا car بـn4، كما هو موصوف في 5.3.1، فنريد أن يبقى مؤشّر car «المنقول» هو n4.) وإلّا، ينبغي أن نُجري النقل. فإن احتوى موضع car للزوج المطلوب نقله على وسم القلب المكسور، فالزوج قد نُقل في الحقيقة بالفعل، فنستعيد عنوان إعادة التوجيه (من موضع cdr في القلب المكسور) ونُعيده في new. وإن كان المؤشّر في old يُشير إلى زوجٍ لم يُنقل بعد، ننقل الزوج إلى أوّل خليّة حُرّةٍ في الذاكرة الجديدة (التي يُشير إليها free) ونُنشئ قلبًا مكسورًا بتخزين وسم القلب المكسور وعنوان إعادة التوجيه في الموقع القديم. ويستخدم relocate-old-result-in-new مسجّلًا يُسمّى oldcr للاحتفاظ بـcar أو cdr للكائن الذي يُشير إليه old.[18]

relocate-old-result-in-new
  (test (op pointer-to-pair?) (reg old))
  (branch (label pair))
  (assign new (reg old))
  (goto (reg relocate-continue))
pair
  (assign  oldcr 
           (op vector-ref)
           (reg the-cars)
           (reg old))
  (test (op broken-heart?) (reg oldcr))
  (branch  (label already-moved))
  (assign  new (reg free)) ; new location for pair
  ;; Update free pointer.
  (assign free (op +) (reg free) (const 1))
  ;; Copy the car and cdr to new memory.
  (perform (op vector-set!)
           (reg new-cars)
           (reg new)
           (reg oldcr))
  (assign  oldcr 
           (op vector-ref)
           (reg the-cdrs)
           (reg old))
  (perform (op vector-set!)
           (reg new-cdrs)
           (reg new)
           (reg oldcr))
  ;; Construct the broken heart.
  (perform (op vector-set!)
           (reg the-cars)
           (reg old)
           (const broken-heart))
  (perform (op vector-set!)
           (reg the-cdrs)
           (reg old)
           (reg new))
  (goto (reg relocate-continue))
already-moved
  (assign  new
           (op vector-ref)
           (reg the-cdrs)
           (reg old))
  (goto (reg relocate-continue))

وفي نهاية عمليّة جمع القمامة تمامًا، نبادل دور الذاكرتين القديمة والجديدة بتبادل المؤشّرات: تبادل the-cars مع new-cars، وthe-cdrs مع new-cdrs. وسنكون عندئذٍ على استعدادٍ لأداء جمع قمامةٍ آخر في المرّة التالية التي تنفد فيها الذاكرة.

gc-flip
  (assign temp (reg the-cdrs))
  (assign the-cdrs (reg new-cdrs))
  (assign new-cdrs (reg temp))
  (assign temp (reg the-cars))
  (assign the-cars (reg new-cars))
  (assign new-cars (reg temp))

5.4 المقيّم ذو التحكّم الصريح

رأينا في 5.1 كيف نحوّل برامج Scheme بسيطةً إلى أوصاف آلات مسجّلات. وسنُجري هذا التحويل الآن على برنامجٍ أكثر تعقيدًا، وهو المقيّم الاستعادي الوارد في 4.1.1–4.1.4، الذي يُظهر كيف يمكن وصف سلوك مفسّر Scheme من حيث الإجراءين eval وapply. أمّا المقيّم ذو التحكّم الصريح (explicit-control evaluator) الذي نُطوّره في هذا القسم فيُظهر كيف يمكن وصف آليّات نداء الإجراءات وتمرير المعطيات الأساسيّة المستخدمة في عمليّة التقييم من حيث عمليّاتٍ على المسجّلات والمكدّس. وبالإضافة إلى ذلك، يمكن للمقيّم ذي التحكّم الصريح أن يخدم بوصفه تنفيذًا لمفسّر Scheme، مكتوبًا بلغةٍ تشبه تشابهًا شديدًا لغة الآلة الأصليّة للحواسيب التقليديّة. ويمكن تنفيذ المقيّم بواسطة محاكي آلة المسجّلات الوارد في 5.2. وبديلًا عن ذلك، يمكن استخدامه نقطةَ بدءٍ لبناء تنفيذٍ بلغة الآلة لمقيّم Scheme، أو حتّى آلةٍ خاصّةٍ الغرض لتقييم تعابير Scheme. ويُظهر الشكل 5.16 تنفيذًا ماديًّا من هذا القبيل: رقاقة سيليكونيّة تعمل بوصفها مقيّمًا لـScheme. وقد بدأ مصمّمو الرقاقة بمواصفات مسار البيانات والمتحكّم لآلة مسجّلاتٍ تشبه المقيّم الموصوف في هذا القسم، واستخدموا برامج أتمتة التصميم لبناء تخطيط الدارة المتكاملة.[19]

الشكل 5.16: تنفيذ لمقيّم Scheme برقاقة سيليكونيّة.

المسجّلات والعمليّات

عند تصميم المقيّم ذي التحكّم الصريح، ينبغي أن نُحدّد العمليّات التي ستُستخدم في آلة المسجّلات خاصّتنا. وقد وصفنا المقيّم الاستعادي من حيث صياغة مجرّدة، باستخدام إجراءاتٍ مثل quoted? وmake-procedure. وعند تنفيذ آلة المسجّلات، كان بوسعنا توسيع هذه الإجراءات إلى سلاسلَ من عمليّات ذاكرة بنية القوائم الأوّليّة، وتنفيذ هذه العمليّات على آلة المسجّلات خاصّتنا. غير أنّ ذلك كان سيجعل مقيّمنا طويلًا جدًّا، بحيث تُحجب البنية الأساسيّة بالتفاصيل. ولإيضاح العرض، سنُدرج كعمليّاتٍ أوّليّةٍ لآلة المسجّلات إجراءات الصياغة المعطاة في 4.1.2 وإجراءات تمثيل البيئات وغيرها من بيانات وقت التشغيل المعطاة في القسمين 4.1.3 و4.1.4. وحتى نُحدّد تحديدًا تامًّا مقيّمًا يُمكن برمجته بلغة آليّةٍ منخفضة المستوى أو تنفيذه عتاديًّا، كنا سنستبدل هذه العمليّات بعمليّاتٍ أكثر أوّليّة، باستخدام تنفيذ بنية القوائم الذي وصفناه في 5.3.

تتألّف آلة مسجّلات مقيّم Scheme خاصّتنا من مكدّس وسبعة مسجّلات: exp وenv وval وcontinue وproc وargl وunev. ويُستخدم exp في الاحتفاظ بالتعبير المطلوب تقييمه، ويحتوي env على البيئة التي ستُجرى فيها عمليّة التقييم. وعند نهاية تقييمٍ، يحتوي val على القيمة المتحصّلة من تقييم التعبير في البيئة المعيَّنة. ويُستخدم المسجّل continue لاستيفاء التعاود، كما هو موضّح في 5.1.4. (يحتاج المقيّم إلى نداء نفسه تعاوديًّا، إذ إنّ تقييم تعبيرٍ يتطلّب تقييم تعابيره الجزئيّة.) وتُستخدم المسجّلات proc وargl وunev في تقييم التركيبات.

لن نُقدّم مخطّط مسار بياناتٍ يُظهر كيف تُوصَل مسجّلات المقيّم وعمليّاته، كما أنّنا لن نُعطي القائمة التامّة لعمليّات الآلة. فهذه مستبطَنةٌ في متحكّم المقيّم، الذي سيُعرَض بتفصيلٍ.

5.4.1 قلب المقيّم ذي التحكّم الصريح

العنصر المركزيّ في المقيّم هو سلسلة التعليمات التي تبدأ عند eval-dispatch. وهي تقابل الإجراء eval في المقيّم الاستعادي الموصوف في 4.1.1. وحين يبدأ المتحكّم عند eval-dispatch، فهو يقيّم التعبير الذي يُحدّده exp في البيئة التي يُحدّدها env. وحين يكتمل التقييم، يذهب المتحكّم إلى نقطة الدخول المخزّنة في continue، ويحتفظ المسجّل val بقيمة التعبير. ومثلما في eval الاستعادي، فإنّ بنية eval-dispatch هي تحليل حالاتٍ على النوع الصياغيّ للتعبير المطلوب تقييمه.[20]

eval-dispatch
  (test (op self-evaluating?) (reg exp))
  (branch (label ev-self-eval))
  (test (op variable?) (reg exp))
  (branch (label ev-variable))
  (test (op quoted?) (reg exp))
  (branch (label ev-quoted))
  (test (op assignment?) (reg exp))
  (branch (label ev-assignment))
  (test (op definition?) (reg exp))
  (branch (label ev-definition))
  (test (op if?) (reg exp))
  (branch (label ev-if))
  (test (op lambda?) (reg exp))
  (branch (label ev-lambda))
  (test (op begin?) (reg exp))
  (branch (label ev-begin))
  (test (op application?) (reg exp))
  (branch (label ev-application))
  (goto (label unknown-expression-type))

تقييم التعابير البسيطة

الأعداد والمقاطع النصّيّة (وهي ذاتيّة التقييم)، والمتغيّرات، والاقتباسات، وتعبيرات lambda ليس لها تعابير جزئيّةٌ يُراد تقييمها. وبالنسبة لها، يضع المقيّم القيمة الصحيحة في المسجّل val ببساطة ويواصل التنفيذ عند نقطة الدخول التي يُحدّدها continue. ويُؤدّى تقييم التعابير البسيطة بشيفرة المتحكّم الآتية:

ev-self-eval
  (assign val (reg exp))
  (goto (reg continue))
ev-variable
  (assign val
          (op lookup-variable-value)
          (reg exp)
          (reg env))
  (goto (reg continue))
ev-quoted
  (assign val
          (op text-of-quotation)
          (reg exp))
  (goto (reg continue))
ev-lambda
  (assign unev
          (op lambda-parameters)
          (reg exp))
  (assign exp 
          (op lambda-body)
          (reg exp))
  (assign val 
          (op make-procedure)
          (reg unev)
          (reg exp)
          (reg env))
  (goto (reg continue))

لاحِظ كيف يستخدم ev-lambda المسجّلين unev وexp للاحتفاظ بوسائط تعبير lambda وجسمه، حتّى يمكن تمريرهما إلى العمليّة make-procedure، إلى جانب البيئة الموجودة في env.

تقييم تطبيقات الإجراءات

يُحدَّد تطبيق الإجراء بتركيبةٍ تحتوي على مشغّلٍ وعوامل. والمشغّل تعبيرٌ جزئيّ قيمته إجراء، والعوامل تعابير جزئيّةٌ قيمُها هي المعطيات التي ينبغي تطبيق الإجراء عليها. ويعالج eval الاستعادي التطبيقات بنداء نفسه تعاوديًّا لتقييم كلّ عنصرٍ من التركيبة، ثم بتمرير النتائج إلى apply الذي يؤدّي تطبيق الإجراء الفعليّ. ويفعل المقيّم ذو التحكّم الصريح الأمر ذاته؛ وهذه النداءات التعاوديّة تُنفَّذ بتعليمات goto، إلى جانب استخدام المكدّس لحفظ المسجّلات التي ستُستعاد بعد عودة النداء التعاوديّ. وقبل كلّ نداءٍ، سنحرص على تحديد المسجّلات التي ينبغي حفظها (لأنّ قيمها ستكون لازمةً لاحقًا).[21]

نبدأ تقييم تطبيقٍ بتقييم المشغّل لإنتاج إجراءٍ سيُطبَّق لاحقًا على العوامل المقيَّمة. ولتقييم المشغّل، ننقله إلى المسجّل exp ونذهب إلى eval-dispatch. والبيئة الموجودة في المسجّل env هي البيئة الصحيحة لتقييم المشغّل بالفعل. غير أنّنا نحفظ env لأنّنا سنحتاجها لاحقًا لتقييم العوامل. ونستخرج العوامل أيضًا في unev ونحفظ ذلك على المكدّس. ونُعِدّ continue بحيث يستأنف eval-dispatch عند ev-appl-did-operator بعد تقييم المشغّل. ومع ذلك، نحفظ أوّلًا القيمة القديمة لـcontinue، التي تُبيّن للمتحكّم أين يواصل بعد التطبيق.

ev-application
  (save continue)
  (save env)
  (assign unev (op operands) (reg exp))
  (save unev)
  (assign exp (op operator) (reg exp))
  (assign
   continue (label ev-appl-did-operator))
  (goto (label eval-dispatch))

وعند العودة من تقييم التعبير الجزئيّ المشغّل، نمضي إلى تقييم عوامل التركيبة وإلى تجميع المعطيات الناتجة في قائمةٍ محفوظةٍ في argl. ونُعيد أوّلًا العوامل غير المقيَّمة والبيئة. ونهيّئ argl إلى قائمةٍ خالية. ثم نُسنِد إلى المسجّل proc الإجراء الذي أنتجه تقييم المشغّل. فإن لم تكن ثمّة عوامل، ننتقل مباشرةً إلى apply-dispatch. وإلّا نحفظ proc على المكدّس ونبدأ حلقة تقييم المعطيات:[22]

ev-appl-did-operator
  (restore unev)             ; the operands
  (restore env)
  (assign argl (op empty-arglist))
  (assign proc (reg val))    ; the operator
  (test (op no-operands?) (reg unev))
  (branch (label apply-dispatch))
  (save proc)

تقيّم كلّ دورةٍ من حلقة تقييم المعطيات عاملًا من القائمة الموجودة في unev وتُجمّع النتيجة في argl. ولتقييم عاملٍ، نضعه في المسجّل exp ونذهب إلى eval-dispatch، بعد إعداد continue حتّى يستأنف التنفيذ في مرحلة تجميع المعطيات. لكنّنا نحفظ أوّلًا المعطيات المُجمَّعة حتّى الآن (المحفوظة في argl)، والبيئة (المحفوظة في env)، والعوامل الباقية المطلوب تقييمها (المحفوظة في unev). وتُعالَج حالةٌ خاصّةٌ لتقييم العامل الأخير، وذلك عند ev-appl-last-arg.

ev-appl-operand-loop
  (save argl)
  (assign exp
          (op first-operand)
          (reg unev))
  (test (op last-operand?) (reg unev))
  (branch (label ev-appl-last-arg))
  (save env)
  (save unev)
  (assign continue 
          (label ev-appl-accumulate-arg))
  (goto (label eval-dispatch))

وحين يُقيَّم عاملٌ، تُجمّع القيمة في القائمة المحفوظة في argl. ثم يُحذَف العامل من قائمة العوامل غير المقيَّمة في unev، ويستمرّ تقييم المعطيات.

ev-appl-accumulate-arg
  (restore unev)
  (restore env)
  (restore argl)
  (assign argl 
          (op adjoin-arg)
          (reg val)
          (reg argl))
  (assign unev
          (op rest-operands)
          (reg unev))
  (goto (label ev-appl-operand-loop))

يُعالَج تقييم المعطى الأخير معالجةً مختلفة. فلا حاجةَ إلى حفظ البيئة أو قائمة العوامل غير المقيَّمة قبل الانتقال إلى eval-dispatch، إذ لن تكونا مطلوبتين بعد تقييم العامل الأخير. وبناءً عليه، نعود من التقييم إلى نقطة دخولٍ خاصّةٍ هي ev-appl-accum-last-arg، التي تستعيد قائمة المعطيات، وتُجمّع المعطى الجديد، وتستعيد الإجراء المحفوظ، وتنطلق لأداء التطبيق.[23]

ev-appl-last-arg
  (assign continue 
          (label ev-appl-accum-last-arg))
  (goto (label eval-dispatch))
ev-appl-accum-last-arg
  (restore argl)
  (assign argl 
          (op adjoin-arg)
          (reg val)
          (reg argl))
  (restore proc)
  (goto (label apply-dispatch))

تُحدّد تفاصيل حلقة تقييم المعطيات الترتيب الذي يقيّم به المفسّر عوامل التركيبة (مثلًا، من اليسار إلى اليمين أو من اليمين إلى اليسار — انظر التمرين 3.8). وهذا الترتيب غير محدَّدٍ في المقيّم الاستعادي، الذي يرث بنية تحكّمه من Scheme الأساس الذي نُفّذ فيه.[24] وبما أنّ المحدِّد first-operand (المستخدم في ev-appl-operand-loop لاستخراج العوامل المتعاقبة من unev) مُنفَّذ بـcar وأنّ المحدِّد rest-operands مُنفَّذ بـcdr، فإنّ المقيّم ذا التحكّم الصريح سيقيّم عوامل التركيبة بترتيبٍ من اليسار إلى اليمين.

تطبيق الإجراءات

تقابل نقطة الدخول apply-dispatch الإجراء apply في المقيّم الاستعادي. وحين نصل إلى apply-dispatch، يحتوي المسجّل proc على الإجراء المطلوب تطبيقه، ويحتوي argl على قائمة المعطيات المقيَّمة التي ينبغي تطبيقه عليها. وقيمة continue المحفوظة (المُمرَّرة أصلًا إلى eval-dispatch والمحفوظة عند ev-application)، وهي التي تُبيّن أين نعود بنتيجة تطبيق الإجراء، موجودةٌ على المكدّس. وحين يكتمل التطبيق، يُحوِّل المتحكّم إلى نقطة الدخول التي تُحدّدها continue المحفوظة، مع نتيجة التطبيق في val. ومثلما في apply الاستعادي، ثمّة حالتان ينبغي النظر فيهما. فالإجراء المطلوب تطبيقه إمّا أن يكون أوّليًّا وإمّا أن يكون إجراءً مركّبًا.

apply-dispatch
  (test (op primitive-procedure?) (reg proc))
  (branch (label primitive-apply))
  (test (op compound-procedure?) (reg proc))
  (branch (label compound-apply))
  (goto (label unknown-procedure-type))

نفترض أنّ كلّ أوّليّةٍ مُنفَّذةٌ بحيث تحصل على معطياتها من argl وتضع نتيجتها في val. وحتى نُحدّد كيف تتناول الآلة الأوّليّات، سيتعيّن علينا أن نُقدّم سلسلةً من تعليمات المتحكّم لتنفيذ كلّ أوّليّةٍ وأن نُرتّب لـprimitive-apply أن يُوزّع إلى تعليمات الأوّليّة التي يُحدّدها محتوى proc. وبما أنّنا مهتمّون ببنية عمليّة التقييم أكثر من اهتمامنا بتفاصيل الأوّليّات، فسنستخدم بدلًا من ذلك عمليّة apply-primitive-procedure التي تُطبّق الإجراء الموجود في proc على المعطيات في argl. وبغرض محاكاة المقيّم بواسطة المحاكي الوارد في 5.2، نستخدم الإجراء apply-primitive-procedure الذي يستدعي نظام Scheme الأساس لأداء التطبيق، تمامًا كما فعلنا مع المقيّم الاستعادي في 4.1.4. وبعد احتساب قيمة التطبيق الأوّليّ، نستعيد continue ونذهب إلى نقطة الدخول المعيَّنة.

primitive-apply
  (assign val (op apply-primitive-procedure)
              (reg proc)
              (reg argl))
  (restore continue)
  (goto (reg continue))

ولتطبيق إجراءٍ مركّب، نمضي تمامًا كما فعلنا مع المقيّم الاستعادي. فنبني إطارًا يربط وسائط الإجراء بالمعطيات، ونستخدم هذا الإطار لتوسيع البيئة التي يحملها الإجراء، ونُقيّم في هذه البيئة المُوسَّعة سلسلة التعابير التي تكوّن جسم الإجراء. ويتولّى ev-sequence، الموصوف أدناه في 5.4.2، تقييم السلسلة.

compound-apply
  (assign unev 
          (op procedure-parameters)
          (reg proc))
  (assign env
          (op procedure-environment)
          (reg proc))
  (assign env
          (op extend-environment)
          (reg unev)
          (reg argl)
          (reg env))
  (assign unev
          (op procedure-body)
          (reg proc))
  (goto (label ev-sequence))

إنّ compound-apply هو الموضع الوحيد في المفسّر الذي يُسنَد فيه إلى المسجّل env قيمةٌ جديدةٌ إطلاقًا. ومثلما في المقيّم الاستعادي، تُبنى البيئة الجديدة من البيئة التي يحملها الإجراء، إلى جانب قائمة المعطيات وقائمة المتغيّرات المقابلة لها التي ستُربط.

5.4.2 تقييم التسلسل والتعاود الذيلي

الجزء من المقيّم ذي التحكّم الصريح عند ev-sequence يقابل إجراء eval-sequence في المقيّم الاستعادي. وهو يعالج سلاسل التعابير في أجساد الإجراءات أو في تعابير begin الصريحة.

تُقيَّم تعابير begin الصريحة بوضع سلسلة التعابير المطلوب تقييمها في unev، وحفظ continue على المكدّس، والقفز إلى ev-sequence.

ev-begin
  (assign unev
          (op begin-actions)
          (reg exp))
  (save continue)
  (goto (label ev-sequence))

وتُعالَج السلاسل الضمنيّة في أجساد الإجراءات بالقفز إلى ev-sequence من compound-apply، وعندها يكون continue موجودًا بالفعل على المكدّس، بعد أن حُفظ عند ev-application.

تُشكّل المدخلان عند ev-sequence وev-sequence-continue حلقةً تُقيّم كلّ تعبيرٍ في السلسلة تباعًا. وتُحفظ قائمة التعابير غير المقيَّمة في unev. وقبل تقييم كلّ تعبيرٍ، نتحقّق مما إذا كانت ثمّة تعابيرُ إضافيّةٌ تبقّى تقييمها في السلسلة. فإن كان الأمر كذلك، نحفظ بقيّة التعابير غير المقيَّمة (المحفوظة في unev) والبيئة التي ينبغي تقييمها فيها (المحفوظة في env) ونستدعي eval-dispatch لتقييم التعبير. وتُستعاد المسجّلان المحفوظان عند العودة من هذا التقييم، عند ev-sequence-continue.

أمّا التعبير الأخير في السلسلة فيُعالَج معالجةً مختلفة، عند نقطة الدخول ev-sequence-last-exp. وبما أنّ لا تعابيرَ أخرى تبقّى تقييمها بعد هذا التعبير، فلا حاجةَ إلى حفظ unev أو env قبل الانتقال إلى eval-dispatch. وقيمة السلسلة كلّها هي قيمة التعبير الأخير، فإنّه بعد تقييم التعبير الأخير لا يبقى شيءٌ يُفعَل إلّا المواصلة عند نقطة الدخول المحفوظة حاليًّا على المكدّس (والتي حفظها ev-application أو ev-begin.) وبدلًا من إعداد continue بحيث يعود eval-dispatch إلى هنا ثمّ استعادة continue من المكدّس والمواصلة عند نقطة الدخول تلك، نستعيد continue من المكدّس قبل الانتقال إلى eval-dispatch، حتّى يواصل eval-dispatch عند نقطة الدخول تلك بعد تقييم التعبير.

ev-sequence
  (assign exp (op first-exp) (reg unev))
  (test (op last-exp?) (reg unev))
  (branch (label ev-sequence-last-exp))
  (save unev)
  (save env)
  (assign continue
          (label ev-sequence-continue))
  (goto (label eval-dispatch))
ev-sequence-continue
  (restore env)
  (restore unev)
  (assign unev
          (op rest-exps)
          (reg unev))
  (goto (label ev-sequence))
ev-sequence-last-exp
  (restore continue)
  (goto (label eval-dispatch))

التعاوب الذيلي

قلنا في الفصل 1 إنّ العمليّة التي يصفها إجراءٌ مثل

(define (sqrt-iter guess x)
  (if (good-enough? guess x)
      guess
      (sqrt-iter (improve guess x) x)))

عمليّةٌ تكراريّة. فرغم أنّ الإجراء تعاوديّ صياغيًّا (معرَّفٌ من حيث ذاته)، فإنّه ليس ضروريًّا منطقيًّا أن يحفظ المُقيّم معلوماتٍ عند الانتقال من نداءٍ إلى sqrt-iter إلى النداء التالي.[25] ويُسمّى المُقيّم القادر على تنفيذ إجراءٍ مثل sqrt-iter دون أن يتطلّب مساحةً متزايدةً مع استمرار الإجراء في نداء ذاته مُقيّمًا تعاوديًّا ذيليًّا (tail-recursive). أمّا تنفيذ المقيّم البعديّ الوارد في الفصل 4 فلا يحدّد ما إذا كان المقيّم تعاوديًّا ذيليًّا، لأنّ ذلك المقيّم يرث آليّة حفظ الحالة من Scheme الأساس. غير أنّنا مع مقيّم ذي المتحكّم الصريح نستطيع تتبّع عمليّة التقييم لنرى متى تُسبّب نداءات الإجراءات تراكمًا صافيًّا للمعلومات على المكدّس.

إنّ مقيّمنا تعاوديّ الذيلي، لأنّه لتقييم التعبير الأخير في تسلسلٍ ننتقل انتقالًا مباشرًا إلى eval-dispatch دون حفظ أيّ معلومات على المكدّس. وبذلك، فإنّ تقييم التعبير الأخير في تسلسلٍ - حتى لو كان نداء إجراءٍ (كما في sqrt-iter، حيث ينتهي تعبير if، وهو التعبير الأخير في جسم الإجراء، إلى نداءٍ لـsqrt-iter) - لن يُسبّب تراكم أيّ معلومات على المكدّس.[26]

لو أنّنا لم نفكّر في الاستفادة من كون حفظ المعلومات غير ضروريّ في هذه الحالة، لكان ممكناً أن ننفّذ eval-sequence بمعاملة جميع تعابير التسلسل معاملةً واحدة - حفظ المسجّلات، وتقييم التعبير، والعودة لاستعادة المسجّلات، وتكرار ذلك حتّى يُقيَّم جميع التعبيرات:[27]

ev-sequence
  (test (op no-more-exps?) (reg unev))
  (branch (label ev-sequence-end))
  (assign exp (op first-exp) (reg unev))
  (save unev)
  (save env)
  (assign continue
          (label ev-sequence-continue))
  (goto (label eval-dispatch))
ev-sequence-continue
  (restore env)
  (restore unev)
  (assign unev (op rest-exps) (reg unev))
  (goto (label ev-sequence))
ev-sequence-end
  (restore continue)
  (goto (reg continue))

قد يبدو هذا تغييرًا طفيفًا في شيفرتنا السابقة لتقييم التسلسل: الفرق الوحيد هو أنّنا نمرّ بدورة الحفظ والاستعادة للتعبير الأخير في تسلسلٍ كما نمرّ بها لسواه. وسيظلّ المفسّر يُعطي القيمة نفسها لأيّ تعبير. لكنّ هذا التغيير قاتل بالنسبة للتنفيذ التعاوديّ الذيلي، لأنّنا نضطرّ الآن إلى العودة بعد تقييم التعبير الأخير في تسلسلٍ لكي نتراجع عن عمليّات حفظ المسجّلات (العقيمة). وهذه الحفظات الإضافيّة ستتراكم في عُشّ نداءات الإجراءات. ونتيجةً لذلك، فإنّ عمليّاتٍ مثل sqrt-iter ستتطلّب مساحةً متناسبةً مع عدد التكرارات بدلًا من مساحةٍ ثابتة. وهذا فرقٌ قد تكون أهمّيته كبيرة. فإنّه، مع التعاوب الذيلي، يمكن التعبير عن حلقةٍ لا نهائيّة باستخدام آليّة نداء الإجراءات وحدها:

(define (count n)
  (newline)
  (display n)
  (count (+ n 1)))

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

5.4.3 التعابير الشرطيّة، والإحلالات، والتعريفات

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

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

ev-if
  (save exp)   ; save expression for later
  (save env)
  (save continue)
  (assign continue (label ev-if-decide))
  (assign exp (op if-predicate) (reg exp))
  ; evaluate the predicate:
  (goto (label eval-dispatch))

حين نعود من تقييم المسيّم، نختبر ما إذا كانت قيمته صحيحة أم خاطئة، ووِفقًا للنتيجة نضع التالي أو البديل في exp قبل الانتقال إلى eval-dispatch. ولاحِظ أنّ استعادة env وcontinue هنا تُهيّئ eval-dispatch ليكون البيئة الصحيحة وأن يواصل في الموضع الصائب لاستلام قيمة تعبير if.

ev-if-decide
  (restore continue)
  (restore env)
  (restore exp)
  (test (op true?) (reg val))
  (branch (label ev-if-consequent))
ev-if-alternative
  (assign exp (op if-alternative) (reg exp))
  (goto (label eval-dispatch))
ev-if-consequent
  (assign exp (op if-consequent) (reg exp))
  (goto (label eval-dispatch))

الإحلالات والتعريفات

تُعالَج الإحلالات بـev-assignment، الذي يُبلَغ من eval-dispatch وتعبير الإحلال موضوعٌ في exp. والشيفرة عند ev-assignment تُقيّم أوّلًا جزء القيمة من التعبير ثم تُثبّت القيمة الجديدة في البيئة. وتُفترض إتاحة set-variable-value! كعمليّة آلة.

ev-assignment
  (assign unev 
          (op assignment-variable)
          (reg exp))
  (save unev)   ; save variable for later
  (assign exp
          (op assignment-value)
          (reg exp))
  (save env)
  (save continue)
  (assign continue
          (label ev-assignment-1))
  ; evaluate the assignment value:
  (goto (label eval-dispatch))  
ev-assignment-1
  (restore continue)
  (restore env)
  (restore unev)
  (perform (op set-variable-value!)
           (reg unev)
           (reg val)
           (reg env))
  (assign val
          (const ok))
  (goto (reg continue))

أمّا التعريفات فتُعالَج بطريقةٍ مماثلة:

ev-definition
  (assign unev 
          (op definition-variable)
          (reg exp))
  (save unev)   ; save variable for later
  (assign exp 
          (op definition-value)
          (reg exp))
  (save env)
  (save continue)
  (assign continue (label ev-definition-1))
  ; evaluate the definition value:
  (goto (label eval-dispatch))  
ev-definition-1
  (restore continue)
  (restore env)
  (restore unev)
  (perform (op define-variable!)
           (reg unev)
           (reg val)
           (reg env))
  (assign val (const ok))
  (goto (reg continue))

التمرين 5.23: وسّع المقيّم ليعامل التعابير المشتقّة مثل cond وlet وما أشبه ذلك (4.1.2). ويجوز لك «الغشّ» والافتراض أنّ محوّلات الصياغة مثل cond->if متاحةٌ كعمليّات آلة.[28]

التمرين 5.24: نفّذ cond كصيغةٍ خاصّةٍ أوّليّةٍ جديدة دون اختزالها إلى if. وسيتعيّن عليك بناء حلقةٍ تختبر مسيّمات بنود cond المتتالية حتّى تجد صحيحًا منها، ثم استخدام ev-sequence لتقييم أفعال البند.

التمرين 5.25: عدّل المقيّم بحيث يستخدم التقييم بالترتيب الاعتيادي، استنادًا إلى المقيّم المتأخّر الوارد في 4.2.

5.4.4 تشغيل المقيّم

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

وسنُثبّت حلقة قيادةٍ في آلة المقيّم لدينا. وهذه تؤدّي دور الإجراء driver-loop الوارد في 4.1.4. وسيطبع المقيّم - تكراريًّا - حافزًا، ويقرأ تعبيرًا، ويُقيّمه بالانتقال إلى eval-dispatch، ويطبع النتيجة. والتعليمات الآتية تُشكّل بداية تسلسل متحكّم المقيّم ذي المتحكّم الصريح:[29]

read-eval-print-loop
  (perform (op initialize-stack))
  (perform (op prompt-for-input)
           (const ";;; EC-Eval input:"))
  (assign exp (op read))
  (assign env (op get-global-environment))
  (assign continue (label print-result))
  (goto (label eval-dispatch))
print-result
  (perform (op announce-output)
           (const ";;; EC-Eval value:"))
  (perform (op user-print) (reg val))
  (goto (label read-eval-print-loop))

حين نصادف خطأً في إجراءٍ (كـ«خطأ نوع إجراء غير معروف» المُشار إليه عند apply-dispatch)، نطبع رسالة خطأٍ ونعود إلى حلقة القيادة.[30]

unknown-expression-type
  (assign 
   val
   (const unknown-expression-type-error))
  (goto (label signal-error))
unknown-procedure-type
  ; clean up stack (from apply-dispatch):
  (restore continue)    
  (assign 
   val
   (const unknown-procedure-type-error))
  (goto (label signal-error))
signal-error
  (perform (op user-print) (reg val))
  (goto (label read-eval-print-loop))

ولأغراض المحاكاة، نُهيّئ المكدّس في كلّ مرورٍ بحلقة القيادة، إذ قد لا يكون فارغًا بعد أن يقاطع خطأٌ (كمتغيّرٍ غير معرَّف) تقييمًا.[31]

إذا جمعنا جميع مقاطع الشيفرة المقدَّمة في 5.4.1–5.4.4، نستطيع إنشاء نموذج آلة مقيّمٍ نستطيع تشغيله باستخدام محاكي آلات المسجّلات الوارد في 5.2.

(define eceval
  (make-machine
   '(exp env val proc argl continue unev)
   eceval-operations
   '(read-eval-print-loop
     ⟨entire machine controller 
      as given above⟩)))

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

(define eceval-operations
  (list (list 'self-evaluating? 
              self-evaluating)
        ⟨complete list of operations 
         for eceval machine⟩))

وأخيرًا، نستطيع تهيئة البيئة العامّة وتشغيل المقيّم:

(define the-global-environment
  (setup-environment))

(start eceval)

;;; EC-Eval input:
(define (append x y)
  (if (null? x)
      y
      (cons (car x) (append (cdr x) y))))

;;; EC-Eval value:
ok

;;; EC-Eval input:
(append '(a b c) '(d e f))

;;; EC-Eval value:
(a b c d e f)

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

مراقبة أداء المقيّم

قد تكون المحاكاة أداةً قويّةً لتوجيه تنفيذ المقيّمات. فالمحاكاة تُسهّل - لا استكشاف متغيّرات تصميم آلة المسجّلات فحسب، بل وأيضًا مراقبة أداء المقيّم المحاكى. فإنّ أحد العوامل المهمّة في الأداء هو مدى كفاءة المقيّم في استخدامه المكدّس. ونستطيع ملاحظة عدد عمليّات المكدّس اللازمة لتقييم تعابير مختلفة بتعريف آلة مسجّلات المقيّم باستخدام نسخة المحاكي التي تجمع إحصائيّات حول استخدام المكدّس (5.2.4)، وبإضافة تعليمةٍ عند نقطة دخول print-result في المقيّم لطباعة الإحصائيّات:

print-result
  ; added instruction:
  (perform (op print-stack-statistics))
  (perform (op announce-output)
           (const ";;; EC-Eval value:"))
  … ; same as before

أمّا تفاعلاتنا مع المقيّم الآن فتصير على النحو الآتي:

;;; EC-Eval input:
(define (factorial n)
  (if (= n 1) 1 (* (factorial (- n 1)) n)))
(total-pushes = 3, maximum-depth = 3)

;;; EC-Eval value:
ok

;;; EC-Eval input:
(factorial 5)
(total-pushes = 144, maximum-depth = 28)

;;; EC-Eval value:
120

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

التمرين 5.26: استخدم المكدّس المراقَب لاستكشاف خاصّيّة التعاوب الذيليّ في المقيّم (5.4.2). شغّل المقيّم وعرّف إجراء factorial التكراريّ الوارد في 1.2.1:

(define (factorial n)
  (define (iter product counter)
    (if (> counter n)
        product
        (iter (* counter product)
              (+ counter 1))))
  (iter 1 1))

شغّل الإجراء ببعض القيم الصغيرة لـnn . وسجّل أقصى عمقٍ للمكدّس وعدد الدفعات اللازمة لاحتساب n!n ! لكلّ واحدةٍ من هذه القيم.

  1. ستجد أنّ أقصى عمقٍ لازمٍ لتقييم n!n ! مستقلٌّ عن nn . فما هو ذلك العمق؟
  2. استخرج من بياناتك صيغةً بدلالة nn للعدد الإجماليّ لعمليّات الدفع المستخدمة في تقييم n!n ! لأيّ n≥1n ≥ 1 . ولاحِظ أنّ عدد العمليّات المستخدمة دالّةٌ خطّيّةٌ في nn وهو محدَّدٌ بالتالي بثابتين.

التمرين 5.27: للمقارنة مع التمرين 5.26، استكشف سلوك الإجراء الآتي في احتساب العوامل المضروب تعاوديًّا:

(define (factorial n)
  (if (= n 1)
      1
      (* (factorial (- n 1)) n)))

وبتشغيل هذا الإجراء بالمكدّس المراقَب، حدّد - كدالّةٍ في nn - أقصى عمقٍ للمكدّس والعدد الإجماليّ للدفعات المستخدمة في تقييم n!n ! لـn≥1n ≥ 1 . (وهاتان الدالّتان خطّيّتان أيضًا.) ولخّص تجاربك بملء الجدول الآتي بالتعابير الملائمة بدلالة nn : MaximumNumberofdepthpushesRecursivefactorialIterativefactorialMaximum Number of depth pushes Recursive factorial Iterative factorial أمّا أقصى عمقٍ فهو مقياسٌ لمقدار المساحة التي يستخدمها المقيّم في إجراء الاحتساب، أمّا عدد الدفعات فيقارن جيّدًا بالوقت اللازم.

التمرين 5.28: عدّل تعريف المقيّم بتغيير eval-sequence كما هو موصوفٌ في 5.4.2 بحيث لا يعود المقيّم تعاوديًّا ذيليًّا. وأعد تشغيل تجاربك من التمرين 5.26 والتمرين 5.27 لتُظهر أنّ كلا النسختين من إجراء factorial تتطلّبان الآن مساحةً تنمو خطّيًّا مع معطاهما.

التمرين 5.29: راقب عمليّات المكدّس في احتساب فيبوناتشي التعاوديّ الشجريّ:

(define (fib n)
  (if (< n 2)
      n
      (+ (fib (- n 1)) (fib (- n 2)))))
  1. أعطِ صيغةً بدلالة nn لأقصى عمقٍ للمكدّس لازمٍ لاحتساب Fib(n)Fib ( n ) لـn≥2n ≥ 2 . تلميح: لقد قلنا في 1.2.2 إنّ المساحة التي تستخدمها هذه العمليّة تنمو خطّيًّا مع nn .
  2. أعطِ صيغةً للعدد الإجماليّ للدفعات المستخدمة في احتساب Fib(n)Fib ( n ) لـn≥2n ≥ 2 . ومن المفترض أن تجد أنّ عدد الدفعات (الذي يقارن جيّدًا بالوقت المستخدم) ينمو أسّيًّا مع nn . تلميح: لِنفترض أنّ S(n)S ( n ) هو عدد الدفعات المستخدمة في احتساب Fib(n)Fib ( n ) . ومن المفترض أن تتمكّن من إثبات وجود صيغةٍ تُعبِّر عن S(n)S ( n ) بدلالة S(n−1)S ( n − 1 ) وS(n−2)S ( n − 2 ) وثابت «عبء» ثابتٍ kk مستقلٍّ عن nn . أعطِ تلك الصيغة، وقل ما هو kk . ثم أظهر أنّ S(n)S ( n ) يمكن التعبير عنه بـa⋅Fib(n+1)+ba ⋅ Fib ( n + 1 ) + b وأعطِ قيمتَي aa وbb .

التمرين 5.30: إنّ مقيّمنا يلتقط ويُشير حاليًّا إلى نوعين فقط من الأخطاء - أنواع التعابير غير المعروفة وأنواع الإجراءات غير المعروفة. أمّا الأخطاء الأخرى فتخرج بنا من حلقة القراءة والتقييم والطباعة للمقيّم. وحين نشغّل المقيّم باستخدام محاكي آلات المسجّلات، فإنّ هذه الأخطاء تُلتقط بنظام Scheme الأساس. وهذا يقابل انهيار الحاسوب حين يرتكب برنامج المستخدم خطأً.[32] وإنّ جعل نظام أخطاءٍ حقيقيّ يعمل مشروعٌ كبير، لكنّ الجهد المبذول لفهم ما يقتضيه الأمر هنا جديرٌ به حقًّا. فالأخطاء التي تقع في عمليّة التقييم، كمحاولة الوصول إلى متغيّرٍ غير مربوط، يمكن التقاطها بتغيير عمليّة البحث بحيث تُعيد شيفرة شرطٍ مميّزة، لا يمكن أن تكون قيمةً ممكنةً لأيّ متغيّر مستخدم. ويستطيع المقيّم اختبار شيفرة الشرط هذه ثم فعل ما يلزم للانتقال إلى signal-error. اعثر على جميع الأماكن في المقيّم حيث يكون مثل هذا التغيير ضروريًّا وأصلحها. وهذا عملٌ كثير.

والأسوأ من ذلك مشكلة معالجة الأخطاء التي تُشير إليها تطبيقات الإجراءات الأوّليّة، كمحاولة القسمة على صفر أو محاولة استخراج car لرمز. ففي نظامٍ مكتوبٍ باحترافٍ وذو جودةٍ عالية، يُفحص كلّ تطبيقٍ أوّليّ من حيث السلامة كجزء من الأوّليّة ذاتها. فإنّ كلّ نداءٍ لـcar مثلًا قد يفحص أوّلًا أنّ المعطى زوج. وإذا لم يكن المعطى زوجًا، فإنّ التطبيق يُعيد شيفرة شرطٍ مميّزةً إلى المقيّم، والذي يُبلّغ عن الفشل بدوره. ونستطيع ترتيب هذا في محاكي آلات المسجّلات بجعل كلّ إجراءٍ أوّليّ يفحص قابليّة التطبيق ويُعيد شيفرة شرطٍ مميّزةً مناسبةً عند الفشل. وحينئذٍ تستطيع الشيفرة primitive-apply في المقيّم فحص شيفرة الشرط والانتقال إلى signal-error إذا لزم الأمر. ابنِ هذه البنية واجعلها تعمل. وهذا مشروعٌ كبير.

5.5 التصريف

إنّ المقيّم ذا المتحكّم الصريح الوارد في 5.4 آلةُ مسجّلاتٍ متحكّمها يفسّر برامج Scheme. وفي هذا القسم سنرى كيف نشغّل برامج Scheme على آلة مسجّلاتٍ متحكّمها ليس مفسّر Scheme.

إنّ آلة المقيّم ذي المتحكّم الصريح عامّة - فهي قادرة على أداء أيّ عمليّةٍ حسابيّةٍ يمكن وصفها بـScheme. ومتحكّم المقيّم ينسّق استخدام مسارات بياناته لأداء الاحتساب المطلوب. وبذلك، فإنّ مسارات بيانات المقيّم عامّة: فهي كافيّة لأداء أيّ احتسابٍ نرغبه، بمتحكّمٍ مناسب.[33]

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

ثمّة استراتيجيّتان شائعتان لردم الفجوة بين اللغات العالية المستوى ولغات آلات المسجّلات. ويُمثّل المقيّم ذو المتحكّم الصريح استراتيجيّة التفسير. فالمفسّر المكتوب باللغة الأصليّة لآلةٍ يُهيّئ الآلة لتنفيذ برامجٍ مكتوبةٍ بلغةٍ (تُسمّى لغة المصدر (source language)) قد تختلف عن اللغة الأصليّة للآلة التي تُجري التقييم. وتُنفَّذ الإجراءات الأوّليّة للغة المصدر كمكتبةٍ من الإجراءات الفرعيّة المكتوبة باللغة الأصليّة للآلة المعطاة. أمّا البرنامج المراد تفسيره (ويُسمّى البرنامج المصدريّ (source program)) فيُمثَّل كبنية بيانات. والمفسّر يجتاز بنية البيانات هذه، محلّلًا البرنامج المصدريّ. وبينما يفعل ذلك، فإنّه يحاكي السلوك المقصود من البرنامج المصدريّ بنداء الإجراءات الفرعيّة الأوّليّة الملائمة من المكتبة.

وفي هذا القسم نستكشف الاستراتيجيّة البديلة، وهي التصريف (compilation). فإنّ المصرِّف الخاصّ بلغة مصدرٍ وآلةٍ معيّنتين يُترجم برنامجًا مصدريًّا إلى برنامجٍ مكافئٍ (يُسمّى البرنامج الهدف (object program)) مكتوبٍ بلغة الآلة الأصليّة. أمّا المصرِّف الذي ننفّذه في هذا القسم فيُترجم البرامج المكتوبة بـScheme إلى تسلسلاتٍ من التعليمات التي يُنفَّذ باستخدام مسارات بيانات آلة المقيّم ذي المتحكّم الصريح.[34]

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

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

استعراض المصرِّف

إنّ مصرّفنا يشبه مفسّرنا إلى حدٍّ كبير، في بنيته وفي الوظيفة التي يؤدّيها. ووفقًا لذلك، فإنّ الآليّات التي يستخدمها المصرِّف لتحليل التعابير ستشبه تلك التي يستخدمها المفسّر. وبالإضافة إلى ذلك، ولتسهيل التوفيق بين الشيفرة المصرَّفة والشيفرة المفسَّرة، سنُصمّم المصرِّف ليُنتج شيفرةً تلتزم بالأعراف ذاتها في استخدام المسجّلات التي يلتزم بها المفسّر: فتُحفظ البيئة في المسجّل env، وتُتراكم قوائم المعطيات في argl، ويكون الإجراء المطلوب تطبيقه في proc، وتُعيد الإجراءات أجوبتها في val، ويُحفظ الموضع الذي ينبغي أن يعود إليه الإجراء في continue. وبصورةٍ عامّة، يُترجم المصرِّف برنامجًا مصدريًّا إلى برنامجٍ هدفٍ يؤدّي - في الجوهري - عمليّات المسجّل ذاتها التي كان المفسّر سيؤدّيها في تقييم البرنامج المصدريّ ذاته.

وهذا الوصف يقترح استراتيجيّة لتنفيذ مصرِّفٍ أوّليّ: نجتاز التعبير بالطريقة ذاتها التي يجتازها المفسّر. وحين نصادف تعليمة مسجّلٍ كان المفسّر سيؤدّيها في تقييم التعبير، فإنّنا لا ننفّذ التعليمة بل نُراكمها في تسلسلٍ بدلًا من ذلك. وتسلسل التعليمات الناتج سيكون شيفرة الهدف. وتأمّل ميزة كفاءة التصريف على التفسير. ففي كلّ مرّةٍ يُقيّم فيها المفسّر تعبيرًا - (f 84 96) مثلًا - فإنّه يؤدّي عمل تصنيف التعبير (باكتشاف أنّ هذا تطبيق إجراء) واختبار نهاية قائمة العوامل (باكتشاف أنّ هناك عاملين). أمّا مع مصرِّف، فإنّ التعبير يُحلَّل مرّةً واحدةً فقط، حين يُنتَج تسلسل التعليمات في زمن التصريف. وشيفرة الهدف التي يُنتجها المصرِّف تحتوي فقط على التعليمات التي تُقيّم المشغّل والعاملين، وتبني قائمة المعطيات، وتُطبّق الإجراء (في proc) على المعطيات (في argl).

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

وبوصفه مثالًا على ذلك، تأمّل التركيب (f 84 96). فقبل أن يُقيّم المفسّر مشغّل التركيب، فإنّه يتهيّأ لهذا التقييم بحفظ المسجّلات التي تحتوي العوامل والبيئة، والتي ستكون قيمُها لازمةً لاحقًا. ثم يُقيّم المفسّر المشغّل ليحصل على النتيجة في val، ويستعيد المسجّلات المحفوظة، وأخيرًا ينقل النتيجة من val إلى proc. لكنّنا في التعبير الخاصّ الذي نتعامل معه، المشغّل هو الرمز f، الذي يُنجَز تقييمه بعمليّة الآلة lookup-variable-value، وهي لا تُغيّر أيّ مسجّل. والمصرِّف الذي ننفّذه في هذا القسم سيستفيد من هذه الحقيقة ويُنتج شيفرةً تُقيّم المشغّل باستخدام التعليمة

(assign proc 
        (op lookup-variable-value)
        (const f)
        (reg env))

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

ويستطيع المصرِّف أيضًا تحسين الوصول إلى البيئة. فإنّه بعد تحليل الشيفرة، يستطيع المصرِّف - في حالاتٍ كثيرةٍ - أن يعرف في أيّ إطارٍ سيقع متغيّرٌ خاصّ، وأن يصل إلى ذلك الإطار مباشرةً، بدلًا من إجراء بحث lookup-variable-value. وسنناقش كيف ننفّذ وصول المتغيّرات هذا في 5.5.6. لكنّنا، حتّى ذلك الحين، سنركّز على نوع تحسينات المسجّلات والمكدّس الموصوف أعلاه. ثمّة تحسيناتٌ أخرى كثيرةٌ يستطيع المصرِّف أداءها، كترميز العمليّات الأوّليّة «سطرًا» بدلًا من استخدام آليّة apply عامّة (انظر التمرين 5.38)؛ لكنّنا لن نُشدّد على هذه هنا. أمّا هدفنا الرئيسيّ في هذا القسم فهو توضيح عمليّة التصريف في سياقٍ مبسّطٍ (لكنه لا يزال مثيرًا للاهتمام).

5.5.1 بنية المصرِّف

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

والإجراء compile هو التوزيع الأعلى مستوىً في المصرِّف. وهو يقابل الإجراء eval الوارد في 4.1.1، والإجراء analyze الوارد في 4.1.7، ونقطة دخول eval-dispatch في المقيّم ذي المتحكّم الصريح الوارد في 5.4.1. وإنّ المصرِّف - كالمفسّرات - يستخدم إجراءات صياغة التعبير المعرَّفة في 4.1.2.[35] ويُجري compile تحليل حالاتٍ على النوع الصياغيّ للتعبير المطلوب تصريفه. فلكلّ نوعٍ من التعابير، يوزّع إلى مولّد شيفرةٍ (code generator) متخصّص:

(define (compile exp target linkage)
  (cond ((self-evaluating? exp)
         (compile-self-evaluating 
          exp target linkage))
        ((quoted? exp) 
         (compile-quoted exp target linkage))
        ((variable? exp)
         (compile-variable 
          exp target linkage))
        ((assignment? exp)
         (compile-assignment
          exp target linkage))
        ((definition? exp)
         (compile-definition
          exp target linkage))
        ((if? exp)
         (compile-if exp target linkage))
        ((lambda? exp)
         (compile-lambda exp target linkage))
        ((begin? exp)
         (compile-sequence 
          (begin-actions exp) target linkage))
        ((cond? exp) 
         (compile 
          (cond->if exp) target linkage))
        ((application? exp)
         (compile-application 
          exp target linkage))
        (else
         (error "Unknown expression type: 
                 COMPILE" 
                exp))))

الأهداف والروابط

إنّ compile ومولّدات الشيفرة التي ينديها يأخذان - بالإضافة إلى التعبير المطلوب تصريفه - معطيين. هناك هدف (target)، يُحدّد المسجّل الذي ينبغي أن تُعيد فيه الشيفرة المصرَّفة قيمة التعبير. وهناك أيضًا واصف ربط (linkage descriptor)، يصف كيف ينبغي أن تسير الشيفرة الناتجة عن تصريف التعبير بعد أن تُتمّ تنفيذها. ويستطيع واصف الربط أن يُلزم الشيفرة بأداء واحدةٍ من الأمور الثلاثة الآتية:

  • المواصلة عند التعليمة التالية في التسلسل (وهذا ما يُحدّده واصف الربط next)،
  • العودة من الإجراء قيد التصريف (وهذا ما يُحدّده واصف الربط return)، أو
  • القفز إلى نقطة دخولٍ مُسمّاة (وهذا ما يُحدّده استخدام التسمية المُعيّنة واصفًا للربط).

فمثلًا، ينبغي لتصريف التعبير 5 (الذي يُقيّم ذاتيًّا) بهدفٍ هو المسجّل val وبربطٍ هو next أن يُنتج التعليمة

(assign val (const 5))

وينبغي لتصريف التعبير ذاته بربطٍ هو return أن يُنتج التعليمات

(assign val (const 5))
(goto (reg continue))

في الحالة الأولى، سيستمرّ التنفيذ بالتعليمة التالية في التسلسل. وفي الحالة الثانية، سنعود من نداء إجراء. وفي كلتي الحالتين، ستُوضع قيمة التعبير في مسجّل الهدف val.

تسلسلات التعليمات واستخدام المكدّس

يُعيد كلّ مولّد شيفرةٍ تسلسل تعليمات (instruction sequence) يحتوي شيفرة الهدف التي ولّدها للتعبير. أمّا توليد الشيفرة لتعبيرٍ مركّبٍ فيُنجَز بدمج مُخرَج مولّدات شيفرةٍ أبسط للتعابير المكوّنة، تمامًا كما يُنجَز تقييم تعبيرٍ مركّبٍ بتقييم التعابير المكوّنة له.

إنّ أبسط طريقةٍ لدمج تسلسلات التعليمات هو إجراءٌ يُسمّى append-instruction-sequences. وهو يأخذ كمعطيات أيّ عددٍ من تسلسلات التعليمات التي يُنفَّذ بالتعاقب؛ فإنّه يُلحق بعضها ببعض ويُعيد التسلسل المدمَج. أي أنّّه، إذا كان ⟨seq1⟩⟨ s e q_{1} ⟩ و⟨seq2⟩⟨ s e q_{2} ⟩ تسلسلَي تعليمات، فإنّ تقييم

(append-instruction-sequences ⟨seq₁⟩ ⟨seq₂⟩)

يُنتج التسلسل

⟨seq₁⟩
⟨seq₂⟩

كلّما قد تحتاج المسجّلات إلى الحفظ، تستخدم مولّدات الشيفرة في المصرِّف preserving، وهي طريقة أكثر دقّةً لدمج تسلسلات التعليمات. وpreserving تأخذ ثلاثة معطيات: مجموعةً من المسجّلات وتسلسلي تعليمات يُنفَّذان بالتعاقب. وهي تُلحق التسلسلين بعضهما ببعض بحيث تُحفظ محتويات كلّ مسجّلٍ في المجموعة على امتداد تنفيذ التسلسل الأوّل، إذا كان ذلك لازمًا لتنفيذ التسلسل الثاني. أي أنّّه، إذا عدّل التسلسل الأوّل المسجّل وكان التسلسل الثاني يحتاج فعلًا إلى محتويات المسجّل الأصليّة، فإنّ preserving تلفّ save وrestore للمسجّل حول التسلسل الأوّل قبل إلحاق التسلسلين. وإلّا، فإنّ preserving تُعيد تسلسلات التعليمات الملحوقة فحسب. وبذلك، فإنّ (preserving (list ⟨reg₁⟩ ⟨reg₂⟩) ⟨seg₁⟩ ⟨seg₂⟩) مثلًا يُنتج واحدًا من تسلسلات التعليمات الأربعة الآتية، بحسب كيفيّة استخدام ⟨seq1⟩⟨ s e q_{1} ⟩ و⟨seq2⟩⟨ s e q_{2} ⟩ لـ⟨reg1⟩⟨ r e g_{1} ⟩ و⟨reg2⟩⟨ r e g_{2} ⟩ : ⟨seq1⟩(save(save(save⟨reg2⟩)⟨seq2⟩⟨reg1⟩)⟨reg2⟩)(save⟨reg1⟩)⟨seq1⟩⟨seq1⟩⟨seq1⟩(restore(restore(restore⟨reg1⟩)⟨reg1⟩)⟨reg2⟩)(restore⟨reg2⟩)⟨seq2⟩⟨seq2⟩⟨seq2⟩⟨ s e q_{1} ⟩ (save (save (save ⟨ r e g_{2} ⟩ ) ⟨ s e q_{2} ⟩ ⟨ r e g_{1} ⟩ ) ⟨ r e g_{2} ⟩ ) (save ⟨ r e g_{1} ⟩ ) ⟨ s e q_{1} ⟩ ⟨ s e q_{1} ⟩ ⟨ s e q_{1} ⟩ (restore (restore (restore ⟨ r e g_{1} ⟩ ) ⟨ r e g_{1} ⟩ ) ⟨ r e g_{2} ⟩ ) (restore ⟨ r e g_{2} ⟩ ) ⟨ s e q_{2} ⟩ ⟨ s e q_{2} ⟩ ⟨ s e q_{2} ⟩

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

من حيث المبدأ، كان بإمكاننا تمثيل تسلسل تعليماتٍ كقائمةٍ من التعليمات. وعندئذٍ يستطيع append-instruction-sequences دمج تسلسلات التعليمات بأداء append عاديّ على القوائم. لكنّ preserving سيصير عندئذٍ عمليّةً معقّدة، لأنّه سيتعيّن عليها تحليل كلّ تسلسل تعليماتٍ لتحديد كيفيّة استخدام التسلسل لمسجّلاته. وpreserving سيكون غير كفؤٍ فضلًا عن كونه معقّدًا، لأنّه سيتعيّن عليه تحليل كلّ واحدٍ من معطياته التي هي تسلسلات تعليمات، حتّى وإن كانت هذه التسلسلات قد بُنيت بدورها بنداءاتٍ إلى preserving، وفي هذه الحالة تكون أجزاؤها قد حُلِّلت سابقًا. ولكي نتجنّب مثل هذا التحليل المتكرّر، سنربط بكلّ تسلسل تعليماتٍ بعض المعلومات حول استخدامه للمسجّلات. وحين نبني تسلسل تعليماتٍ أساسيًّا، سنُوفّر هذه المعلومات صراحةً، والإجراءات التي تدمج تسلسلات التعليمات ستستخرج معلومات استخدام المسجّلات للتسلسل المدمَج من المعلومات المرتبطة بالتسلسلات المكوّنة.

سيحتوي تسلسل التعليمات على ثلاثة أجزاء من المعلومات:

  • مجموعة المسجّلات التي يجب تهيئتها قبل تنفيذ التعليمات في التسلسل (يُقال عن هذه المسجّلات أنّ التسلسل يحتاج إليها)،
  • مجموعة المسجّلات التي تُعدّل قيمها التعليمات في التسلسل، و
  • التعليمات الفعليّة (المسمّاة أيضًا العبارات) في التسلسل.

وسنمثّل تسلسل التعليمات كقائمةٍ من أجزائه الثلاثة. وباني تسلسلات التعليمات هو إذن

(define (make-instruction-sequence 
         needs modifies statements)
  (list needs modifies statements))

فمثلًا، تسلسل التعليمتين الذي يبحث عن قيمة المتغيّر x في البيئة الحاليّة، ويسند النتيجة إلى val، ثم يرجع، يتطلّب أن يكون المسجّلان env وcontinue قد هُيّئا، ويعدّل المسجّل val. وبذلك، سيُبنى هذا التسلسل على النحو الآتي

(make-instruction-sequence
 '(env continue)
 '(val)
 '((assign val
           (op lookup-variable-value)
           (const x)
           (reg env))
   (goto (reg continue))))

ونحتاج أحيانًا إلى بناء تسلسل تعليماتٍ بلا عبارات:

(define (empty-instruction-sequence)
  (make-instruction-sequence '() '() '()))

الإجراءات الخاصة بدمج تسلسلات التعليمات موضّحة في 5.5.4.

التمرين 5.31: في تقييم تطبيق إجراءٍ، يحفظ مُقيِّم التحكّم الصريح دائمًا المسجّل env ويعيده حول تقييم المشغّل، ويحفظ env ويعيده حول تقييم كلّ عاملٍ باستثناء الأخير، ويحفظ argl ويعيده حول تقييم كلّ عامل، ويحفظ proc ويعيده حول تقييم تسلسل العوامل. فبالنسبة لكلّ تركيبةٍ من التركيبات الآتية، قُل أيّ عمليّات save وrestore هذه زائدةٌ عن الحاجة وبإمكانها بالتالي أن تُحذف بواسطة آليّة preserving في المصرِّف:

(f 'x 'y)
((f) 'x 'y)
(f (g 'x) y)
(f (g 'x) 'y)

التمرين 5.32: باستخدام آليّة preserving، سيتجنّب المصرِّف حفظ env وإعادته حول تقييم مشغّل تركيبةٍ في الحالة التي يكون فيها المشغّل رمزًا. وبإمكاننا أيضًا أن نُدمج تحسيناتٍ كهذه في المُقيِّم. ففي الحقيقة، إنّ مُقيِّم التحكّم الصريح الوارد في 5.4 يؤدّي تحسينًا مشابهًا بالفعل، وذلك بمعاملة التركيبات التي لا عوامل لها كحالةٍ خاصّة. وسّع مُقيِّم التحكّم الصريح ليتعرّف على التركيبات التي مشغّلها رمز كصنفٍ منفصلٍ من التعابير، وليستثمر هذه الحقيقة في تقييم مثل هذه التعابير.

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

5.5.2 تصريف التعابير

في هذا القسم والقسم التالي نُنفّذ مولّدات الشيفرة التي يُوزّع إليها الإجراء compile.

تصريف شيفرة الوصل

بصورةٍ عامّة، ستنتهي مخرجات كلّ مولّد شيفرةٍ بتعليماتٍ - تُولَّدها الإجراء compile-linkage - تُنفّذ الوصل المطلوب. فإن كان الوصل هو return فيجب أن نُولّد التعليمة (goto (reg continue)). وهذا يحتاج المسجّل continue ولا يعدّل أيّ مسجّلات. فإن كان الوصل هو next، فنحن لا نحتاج إلى إضافة أيّ تعليمات. وإلّا، فالوصل عنوانٌ (label)، ونُولّد goto إلى ذلك العنوان، وهو تعليمةٌ لا تحتاج ولا تعدّل أيّ مسجّلات.[36]

(define (compile-linkage linkage)
  (cond ((eq? linkage 'return)
         (make-instruction-sequence 
          '(continue)
          '()
          '((goto (reg continue)))))
        ((eq? linkage 'next)
         (empty-instruction-sequence))
        (else
         (make-instruction-sequence '() '()
          `((goto (label ,linkage)))))))

تُلحَق شيفرة الوصل بتسلسل تعليماتٍ بواسطة preserving للمسجّل continue، إذ إنّ وصل return سيحتاج المسجّل continue: فإن كان تسلسل التعليمات المعطى يُعدّل continue وشيفرة الوصل تحتاجه، فسيُحفظ continue ويُستعاد.

(define (end-with-linkage 
         linkage instruction-sequence)
  (preserving '(continue)
   instruction-sequence
   (compile-linkage linkage)))

تصريف التعابير البسيطة

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

(define (compile-self-evaluating 
         exp target linkage)
  (end-with-linkage
   linkage (make-instruction-sequence 
            '()
            (list target)
            `((assign ,target (const ,exp))))))

(define (compile-quoted exp target linkage)
  (end-with-linkage
   linkage
   (make-instruction-sequence
    '()
    (list target)
    `((assign 
       ,target
       (const ,(text-of-quotation exp)))))))

(define (compile-variable
         exp target linkage)
  (end-with-linkage 
   linkage
   (make-instruction-sequence 
    '(env)
    (list target)
    `((assign ,target
              (op lookup-variable-value)
              (const ,exp)
              (reg env))))))

جميع تعليمات الإسناد هذه تُعدّل المسجّل الهدف، وتلك التي تبحث عن قيمة متغيّر تحتاج المسجّل env.

وتُعالَج الإحلالات والتعريفات إلى حدٍّ كبيرٍ كما تُعالَج في المفسّر. فنحن نُولّد تعاوديًّا شيفرةً تحتسب القيمة التي ستُسنَد إلى المتغيّر، ونُلحق بها تسلسلًا من تعليمتين يُعيّن المتغيّر فعلًا أو يُعرّفه ويُسنِد قيمة التعبير كلّه (الرمز ok) إلى المسجّل الهدف. والتصريف التعاوديّ له هدفٌ هو val ووصلٌ هو next حتّى تضع الشيفرة نتيجتها في val وتواصل بالشيفرة المُلحَقة بعدها. أمّا الإلحاق فيُجرى مع حفظ env، إذ إنّ البيئة لازمة لتعيين المتغيّر أو تعريفه، وقد تكون شيفرة قيمة المتغيّر تصريفَ تعبيرٍ مركّب قد يُعدّل المسجّلات بطرائق اعتباطيّة.

(define (compile-assignment 
         exp target linkage)
  (let ((var (assignment-variable exp))
        (get-value-code
         (compile (assignment-value exp) 
                  'val
                  'next)))
    (end-with-linkage 
     linkage
     (preserving 
      '(env)
      get-value-code
      (make-instruction-sequence
       '(env val)
       (list target)
       `((perform (op set-variable-value!)
                  (const ,var)
                  (reg val)
                  (reg env))
         (assign ,target (const ok))))))))

(define (compile-definition 
         exp target linkage)
  (let ((var (definition-variable exp))
        (get-value-code
         (compile (definition-value exp)
                  'val
                  'next)))
    (end-with-linkage
     linkage
     (preserving 
      '(env)
      get-value-code
      (make-instruction-sequence
       '(env val)
       (list target)
       `((perform (op define-variable!)
                  (const ,var)
                  (reg val)
                  (reg env))
         (assign ,target (const ok))))))))

التسلسل المُلحَق المكوّن من تعليمتين يحتاج env وval ويُعدّل الهدف. ولاحِظ أنّنا مع أنّنا نحفظ env من أجل هذا التسلسل، فإنّا لا نحفظ val، لأنّ get-value-code مُصمَّمٌ لوضع نتيجته في val صراحةً لاستخدام هذا التسلسل لها. (في الحقيقة، لو حفظنا val، لكان لدينا عِلّة، لأنّ هذا من شأنه أن يُسبّب استعادة المحتويات السابقة لـval مباشرةً بعد تشغيل get-value-code.)

تصريف التعابير الشرطيّة

شيفرة تعبير if المُصَرَّف بهدفٍ ووصلٍ معطيين لها الصيغة الآتية

⟨compilation of predicate, 
 target val, linkage next⟩
 (test (op false?) (reg val))
 (branch (label false-branch))
true-branch
 ⟨compilation of consequent with given 
  target and given linkage or after-if⟩
false-branch
 ⟨compilation of alternative 
  with given target and linkage⟩
after-if

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

(define (compile-if exp target linkage)
  (let ((t-branch (make-label 'true-branch))
        (f-branch (make-label 'false-branch))
        (after-if (make-label 'after-if)))
    (let ((consequent-linkage
           (if (eq? linkage 'next) 
               after-if
               linkage)))
      (let ((p-code 
             (compile (if-predicate exp)
                      'val
                      'next))
            (c-code
             (compile (if-consequent exp) 
                      target 
                      consequent-linkage))
            (a-code
             (compile (if-alternative exp)
                      target
                      linkage)))
        (preserving 
         '(env continue)
         p-code
         (append-instruction-sequences
          (make-instruction-sequence 
           '(val) 
           '()
           `((test (op false?) (reg val))
             (branch (label ,f-branch))))
          (parallel-instruction-sequences
           (append-instruction-sequences 
            t-branch c-code)
           (append-instruction-sequences
            f-branch a-code))
          after-if))))))

يُحفظ env حول شيفرة المسيّم لأنّ الفرعين الصحيح والخاطئ قد يحتاجانه، ويُحفظ continue لأنّ شيفرة الوصل في هذين الفرعين قد تحتاجه. وشيفرة الفرعين الصحيح والخاطئ (اللذين لا يُنفّذان تسلسليًّا) تُلحَق باستخدام مُدَمِّجٍ خاصّ هو parallel-instruction-sequences الموصوف في 5.5.4.

ولاحِظ أنّ cond تعبيرٌ مشتقّ، فكلّ ما يلزم المصرِّف ليتعامل معه هو أن يُطبّق المحوّل cond->if (من 4.1.2) ثم يُصَرِّف تعبير if الناتج.

تصريف التسلسلات

إنّ تصريف التسلسلات (الواردة في أجسام الإجراءات أو في تعابير begin الصريحة) يوازي تقييمها. فيُصَرَّف كلّ تعبيرٍ من التسلسل - التعبير الأخير بالوصل المحدَّد للتسلسل، والتعبيرات الأخرى بوصل next (لتنفيذ بقيّة التسلسل). وتُلحَق تسلسلات التعليمات الخاصّة بالتعابير المنفردة لتكوين تسلسل تعليماتٍ واحد، بحيث يُحفظ env (اللازم لبقيّة التسلسل) وcontinue (الذي قد يكون لازما للوصل عند نهاية التسلسل).

(define (compile-sequence seq target linkage)
  (if (last-exp? seq)
      (compile (first-exp seq) target linkage)
      (preserving '(env continue)
       (compile (first-exp seq) target 'next)
       (compile-sequence (rest-exps seq)
                         target
                         linkage))))

تصريف تعابير lambda

إنّ تعابير lambda تُنشئ الإجراءات. ويجب أن تكون الشيفرة الهدف لتعبير lambda على الصيغة الآتية

⟨construct procedure object 
 and assign it to target register⟩
⟨linkage⟩

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

⟨construct procedure object 
 and assign it to target register⟩
 ⟨code for given linkage⟩ or 
  (goto (label after-lambda))
 ⟨compilation of procedure body⟩
after-lambda

يُولّد compile-lambda شيفرة بناء كائن الإجراء متبوعةً بشيفرة جسم الإجراء. وسيُبنى كائن الإجراء في وقت التشغيل بدمج البيئة الحاليّة (البيئة عند نقطة التعريف) مع نقطة الدخول إلى جسم الإجراء المُصَرَّف (وهو عنوانٌ حديث التوليد).[38]

(define (compile-lambda exp target linkage)
  (let ((proc-entry 
         (make-label 'entry))
        (after-lambda 
         (make-label 'after-lambda)))
    (let ((lambda-linkage
           (if (eq? linkage 'next)
               after-lambda
               linkage)))
      (append-instruction-sequences
       (tack-on-instruction-sequence
        (end-with-linkage 
         lambda-linkage
         (make-instruction-sequence 
          '(env)
          (list target)
          `((assign 
             ,target
             (op make-compiled-procedure)
             (label ,proc-entry)
             (reg env)))))
        (compile-lambda-body exp proc-entry))
       after-lambda))))

يستخدم compile-lambda المُدَمِّج الخاصّ tack-on-instruction-sequence بدلًا من append-instruction-sequences (5.5.4) لإلحاق جسم الإجراء بشيفرة تعبير lambda، لأنّ الجسم ليس جزءًا من تسلسل التعليمات الذي سيُنفَّذ عند الدخول إلى التسلسل المدمج؛ بل هو في التسلسل فقط لأنّ ذلك كان موضعًا ملائمًا لوضع الجسم فيه.

يُعمّر compile-lambda-body شيفرة جسم الإجراء. فتبدأ هذه الشيفرة بعنوانٍ لنقطة الدخول. ثمّ تأتي تعليماتٌ ستُسبّب انتقال بيئة التقييم في وقت التشغيل إلى البيئة الصحيحة لتقييم جسم الإجراء - أي بيئةُ تعريف الإجراء، ممدودةً لتشمل ربط الوسائط الشكليّة بالمعطيات التي يُدعى بها الإجراء. وبعد هذا تأتي شيفرة تسلسل التعابير الذي يؤلّف جسم الإجراء. ويُصَرَّف التسلسل بوصل return وهدف val حتّى ينتهي بالرجوع من الإجراء ونتيجة الإجراء في val.

(define (compile-lambda-body exp proc-entry)
  (let ((formals (lambda-parameters exp)))
    (append-instruction-sequences
     (make-instruction-sequence 
      '(env proc argl)
      '(env)
      `(,proc-entry
        (assign env 
                (op compiled-procedure-env)
                (reg proc))
        (assign env
                (op extend-environment)
                (const ,formals)
                (reg argl)
                (reg env))))
     (compile-sequence (lambda-body exp)
                       'val
                       'return))))

5.5.3 تصريف التركيبات

إنّ جوهر عمليّة التصريف هو تصريف تطبيقات الإجراءات. وشيفرة تركيبةٍ مُصَرَّفة بهدفٍ ووصلٍ معطيين لها الصيغة الآتية

⟨compilation of operator, 
 target proc, linkage next⟩
⟨evaluate operands and construct 
 argument list in argl⟩
⟨compilation of procedure call 
 with given target and linkage⟩

قد يلزم حفظ المسجّلات env وproc وargl وإعادتها خلال تقييم المشغّل والعوامل. ولاحِظ أنّ هذا هو الموضع الوحيد في المصرِّف الذي يُحدَّد فيه هدفٌ غير val.

تُولَّد الشيفرة المطلوبة بواسطة compile-application. فهذا يُصَرِّف تعاوديًّا المشغّل، لإنتاج شيفرةٍ تضع الإجراء المطلوب تطبيقه في proc، ويُصَرِّف العوامل، لإنتاج شيفرةٍ تُقيّم عوامل التطبيق المنفردة. وتُدمَج تسلسلات التعليمات الخاصّة بالعوامل (بواسطة construct-arglist) مع شيفرةٍ تبني قائمة المعطيات في argl، وتُدمَج شيفرة قائمة المعطيات الناتجة مع شيفرة الإجراء ومع الشيفرة التي تؤدّي نداء الإجراء (والتي يُنتجها compile-procedure-call). وعند إلحاق تسلسلات الشيفرة، يجب حفظ المسجّل env حول تقييم المشغّل (إذ إنّ تقييم المشغّل قد يُعدّل env، الذي سيكون لازمًا لتقييم العوامل)، ويجب حفظ المسجّل proc حول بناء قائمة المعطيات (إذ إنّ تقييم العوامل قد يُعدّل proc، الذي سيكون لازمًا لتطبيق الإجراء الفعليّ). ويجب حفظ continue على مدى ذلك كلّه أيضًا، إذ إنّه لازم للوصل في نداء الإجراء.

(define (compile-application 
         exp target linkage)
  (let ((proc-code 
         (compile (operator exp) 'proc 'next))
        (operand-codes
         (map (lambda (operand)
                (compile operand 'val 'next))
              (operands exp))))
    (preserving 
     '(env continue)
     proc-code
     (preserving 
      '(proc continue)
      (construct-arglist operand-codes)
      (compile-procedure-call 
       target
       linkage)))))

ستُقيّم شيفرة بناء قائمة المعطيات كلّ عاملٍ إلى val ثم تعمل cons لتلك القيمة على قائمة المعطيات المتراكمة في argl. وبما أنّنا نعمل cons للمعطيات على argl تسلسليًّا، فيجب أن نبدأ بالمعطى الأخير وننتهي بالأوّل، حتّى تظهر المعطيات في القائمة الناتجة مرتّبةً من الأوّل إلى الأخير. وبدلًا من إهدار تعليمةٍ بتهيئة argl إلى القائمة الخالية للإعداد لهذا التسلسل من التقييمات، نجعل أوّل تسلسل شيفرةٍ يبني argl الأوّليّة. وبذلك، فإنّ الصيغة العامّة لبناء قائمة المعطيات هي كالآتي:

⟨compilation of last operand, targeted to val⟩
(assign argl (op list) (reg val))
⟨compilation of next operand, targeted to val⟩
(assign argl (op cons) (reg val) (reg argl))
…
⟨compilation of first operand, targeted to val⟩
(assign argl (op cons) (reg val) (reg argl))

يجب حفظ argl حول تقييم كلّ عاملٍ باستثناء الأوّل (حتّى لا تُفقد المعطيات المتراكمة حتّى الآن)، ويجب حفظ env حول تقييم كلّ عاملٍ باستثناء الأخير (لاستخدامه في تقييمات العوامل التالية).

إنّ تصريف شيفرة المعطيات هذه شاقٌّ بعض الشيء، بسبب المعاملة الخاصّة لأوّل عاملٍ يُقيَّم والحاجة إلى حفظ argl وenv في مواضع مختلفة. ويأخذ الإجراء construct-arglist كمعطياتٍ الشيفرة التي تُقيّم العوامل المنفردة. فإن لم تكن هناك عواملٌ إطلاقًا، فهو يُخرج التعليمة الآتية ببساطةٍ

(assign argl (const ()))

وإلّا، فإنّ construct-arglist يُنشئ شيفرةً تُهيّئ argl بالمعطى الأخير، ويلحق بها شيفرةً تُقيّم بقيّة المعطيات وتُضمّها إلى argl تتابعًا. ولكي نعالج المعطيات من الأخير إلى الأوّل، يجب أن نعكس قائمة تسلسلات شيفرة العوامل عن الترتيب الذي يُوفّره compile-application.

(define (construct-arglist operand-codes)
  (let ((operand-codes 
         (reverse operand-codes)))
    (if (null? operand-codes)
        (make-instruction-sequence 
         '() 
         '(argl)
         '((assign argl (const ()))))
        (let ((code-to-get-last-arg
               (append-instruction-sequences
                (car operand-codes)
                (make-instruction-sequence 
                 '(val)
                 '(argl)
                 '((assign argl
                           (op list)
                           (reg val)))))))
          (if (null? (cdr operand-codes))
              code-to-get-last-arg
              (preserving 
               '(env)
               code-to-get-last-arg
               (code-to-get-rest-args
                (cdr operand-codes))))))))

(define (code-to-get-rest-args operand-codes)
  (let ((code-for-next-arg
         (preserving 
          '(argl)
          (car operand-codes)
          (make-instruction-sequence 
           '(val argl)
           '(argl)
           '((assign argl
                     (op cons)
                     (reg val)
                     (reg argl)))))))
    (if (null? (cdr operand-codes))
        code-for-next-arg
        (preserving 
         '(env)
         code-for-next-arg
         (code-to-get-rest-args 
          (cdr operand-codes))))))

تطبيق الإجراءات

وبعد تقييم عناصر تركيبةٍ، يجب أن تُطبّق الشيفرة المُصَرَّفة الإجراءَ الموجود في proc على المعطيات الموجودة في argl. وإنّ الشيفرة تُجري التوزيع عينه أساسًا كما يُجريه الإجراء apply في المُقيِّم الحلقيّ الوسيط (metacircular evaluator) الوارد في 4.1.1 أو نقطة الدخول apply-dispatch في مُقيِّم التحكّم الصريح الوارد في 5.4.1. فهي تفحص ما إذا كان الإجراء المطلوب تطبيقه إجراءً أوّليًّا أم إجراءً مُصَرَّفًا. فبالنسبة للإجراء الأوّليّ، فهي تستخدم apply-primitive-procedure؛ وسنرى قريبًا كيف تتعامل مع الإجراءات المُصَرَّفة. وشيفرة تطبيق الإجراء لها الصيغة الآتية:

(test (op primitive-procedure?) (reg proc))
 (branch (label primitive-branch))
compiled-branch
 ⟨code to apply compiled procedure 
  with given target and appropriate linkage⟩
primitive-branch
 (assign ⟨target⟩
         (op apply-primitive-procedure)
         (reg proc)
         (reg argl))
 ⟨linkage⟩
after-call

لاحِظ أنّ الفرع المُصَرَّف يجب أن يتجاوز الفرع الأوّليّ. وبناءً على ذلك، فإن كان وصلة نداء الإجراء الأصليّ هو next، فيجب أن يستخدم الفرع المركّب وصلةً تقفز إلى عنوانٍ يُدرج بعد الفرع الأوّليّ. (وهذا شبيه بالوصل المستخدم للفرع الصحيح في compile-if.)

(define (compile-procedure-call
         target linkage)
  (let ((primitive-branch 
         (make-label 'primitive-branch))
        (compiled-branch 
         (make-label 'compiled-branch))
        (after-call
         (make-label 'after-call)))
    (let ((compiled-linkage
           (if (eq? linkage 'next)
               after-call
               linkage)))
      (append-instruction-sequences
       (make-instruction-sequence 
        '(proc)
        '()
        `((test 
           (op primitive-procedure?)
           (reg proc))
          (branch 
           (label ,primitive-branch))))
       (parallel-instruction-sequences
        (append-instruction-sequences
         compiled-branch
         (compile-proc-appl 
          target
          compiled-linkage))
        (append-instruction-sequences
         primitive-branch
         (end-with-linkage
          linkage
          (make-instruction-sequence
           '(proc argl)
           (list target)
           `((assign 
              ,target
              (op apply-primitive-procedure)
              (reg proc)
              (reg argl)))))))
       after-call))))

الفرعان الأوّليّ والمركّب، كالفرعين الصحيح والخاطئ في compile-if، يُلحَقان باستخدام parallel-instruction-sequences بدلًا من append-instruction-sequences الاعتياديّ، لأنّهما لن يُنفَّذا تسلسليًّا.

تطبيق الإجراءات المصرَّفة

الشيفرة التي تتولّى تطبيق الإجراءات هي أدقّ أجزاء المصرِّف، مع أنّ متسلسلات التعليمات التي تُنتجها قصيرةٌ جدًّا. فالإجراء المصرَّف (كما يبنيه compile-lambda) له نقطةُ دخولٍ، وهي عنوانٌ يُعرّف موضع بداية شيفرة الإجراء. والشيفرة عند نقطة الدخول هذه تحسب نتيجةً في val وتعود بتنفيذ التعليمة (goto (reg continue)). وهكذا، فقد نتوقّع أن تكون الشيفرة الخاصّة بتطبيق إجراءٍ مصرَّفٍ (التي سيُنتجها compile-proc-appl) بهدفٍ ووصلةٍ معطيّين على هذه الصورة إذا كانت الوصلة عنوانًا

(assign continue 
        (label proc-return))
 (assign val
         (op compiled-procedure-entry)
         (reg proc))
 (goto (reg val))
proc-return
 (assign ⟨target⟩ 
         (reg val))   ; included if target is not val
 (goto (label ⟨linkage⟩))   ; linkage code

أو على هذه الصورة إذا كانت الوصلة هي return.

(save continue)
 (assign continue 
         (label proc-return))
 (assign val 
         (op compiled-procedure-entry)
         (reg proc))
 (goto (reg val))
proc-return
 (assign ⟨target⟩
         (reg val))   ; included if target is not val
 (restore continue)
 (goto (reg continue))   ; linkage code

تُهيِّئ هذه الشيفرة continue بحيث يعود الإجراء إلى عنوانٍ اسمه proc-return وتقفز إلى نقطة دخول الإجراء. والشيفرة عند proc-return تنقل نتيجة الإجراء من val إلى المسجّل الهدف (إن لزم الأمر) ثم تقفز إلى الموضع الذي تُحدّده الوصلة. (فالوصلة تكون دائمًا return أو عنوانًا، لأنّ compile-procedure-call يستبدل وصلة next المخصّصة للفرع المركّب بعنوانٍ اسمه after-call.)

في الحقيقة، إذا لم يكن الهدف هو val، فهذه هي بالضبط الشيفرة التي سيُنتجها مصرّفنا.[39] غير أنّ الهدف عادةً هو val (والمرّة الوحيدة التي يُحدّد فيها المصرِّف مسجّلًا مختلفًا هي عندما يكون الهدف تقييمَ مشغّلٍ في proc)، ولذلك تُوضع نتيجة الإجراء مباشرةً في المسجّل الهدف ولا حاجة إلى العودة إلى موضعٍ خاصٍّ ينسخها. وبدلًا من ذلك، نبسّط الشيفرة بتهيئة continue بحيث «يعود» الإجراء مباشرةً إلى الموضع الذي تُحدّده وصلة المُنادِي:

⟨set up continue for linkage⟩
(assign val 
        (op compiled-procedure-entry)
        (reg proc))
(goto (reg val))

إذا كانت الوصلة عنوانًا، نُهيِّئ continue بحيث يعود الإجراء إلى ذلك العنوان. (أي أنّ (goto (reg continue)) الذي يُنهي به الإجراء عمله يصبح مكافئًا للـ(goto (label ⟨linkage⟩)) عند proc-return أعلاه.)

(assign continue 
        (label ⟨linkage⟩))
(assign val
        (op compiled-procedure-entry)
        (reg proc))
(goto (reg val))

إذا كانت الوصلة هي return، فلا نحتاج إلى تهيئة continue إطلاقًا: فهو يحمل الموضع المطلوب أصلًا. (أي أنّ (goto (reg continue)) الذي يُنهي به الإجراء عمله ينتقل مباشرةً إلى الموضع الذي كان (goto (reg continue)) عند proc-return سينتقل إليه.)

(assign val
        (op compiled-procedure-entry)
        (reg proc))
(goto (reg val))

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

أمّا لو كنّا قد تعاملنا مع حالة نداء إجراءٍ بوصلةٍ return وهدفٍ هو val كما عُرض أعلاه لهدفٍ ليس val، لدمّرنا التعاود الذيليّ. فسيظلّ نظامنا يُعطي القيمة ذاتها لأيّ تعبيرٍ. لكنّنا كنّا، في كلّ مرّةٍ ننادي فيها إجراءً، سنحفظ continue ونعود بعد النداء للتراجع عن الحفظ (عديم الفائدة). وهذه الحفظات الإضافيّة ستتراكم أثناء عشٍّ من نداءات الإجراءات.[40]

يُنتج compile-proc-appl شيفرة تطبيق الإجراءات أعلاه بدرس أربع حالات، تبعًا لما إذا كان الهدف للنداء هو val ولما إذا كانت الوصلة هي return. ولاحِظ أنّ متسلسلات التعليمات مُعلَنةٌ بأنّها تعدّل جميع المسجّلات، إذ إنّ تنفيذ جسم الإجراء قد يغيّر المسجّلات بطرائق اعتباطيّة.[41] ولاحِظ أيضًا أنّ متسلسلة الشيفرة للحالة ذات الهدف val والوصلة return مُعلَنةٌ باحتياجها إلى continue: فعلى الرغم من أنّ continue غير مستخدمٍ صراحةً في متسلسلة التعليمتين، ينبغي أن نتيقّن من أنّ continue سيحمل القيمة الصحيحة عندما ندخل الإجراء المصرَّف.

(define (compile-proc-appl target linkage)
  (cond ((and (eq? target 'val)
              (not (eq? linkage 'return)))
         (make-instruction-sequence 
          '(proc)
          all-regs
          `((assign continue (label ,linkage))
            (assign 
             val 
             (op compiled-procedure-entry)
             (reg proc))
            (goto (reg val)))))
        ((and (not (eq? target 'val))
              (not (eq? linkage 'return)))
         (let ((proc-return 
                (make-label 'proc-return)))
           (make-instruction-sequence 
            '(proc)
            all-regs
            `((assign continue 
                      (label ,proc-return))
              (assign 
               val 
               (op compiled-procedure-entry)
               (reg proc))
              (goto (reg val))
              ,proc-return
              (assign ,target (reg val))
              (goto (label ,linkage))))))
        ((and (eq? target 'val)
              (eq? linkage 'return))
         (make-instruction-sequence 
          '(proc continue) 
          all-regs
          '((assign 
             val 
             (op compiled-procedure-entry)
             (reg proc))
            (goto (reg val)))))
        ((and (not (eq? target 'val))
              (eq? linkage 'return))
         (error "return linkage, 
                 target not val: COMPILE"
                target))))

5.5.4 دمج متسلسلات التعليمات

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

(define (registers-needed s)
  (if (symbol? s) '() (car s)))
(define (registers-modified s)
  (if (symbol? s) '() (cadr s)))
(define (statements s)
  (if (symbol? s) (list s) (caddr s)))

ولتحديد ما إذا كانت متسلسلةٌ معطاةٌ تحتاج مسجّلًا معطىً أو تعدّله نستخدم المُسَيِّمات

(define (needs-register? seq reg)
  (memq reg (registers-needed seq)))
(define (modifies-register? seq reg)
  (memq reg (registers-modified seq)))

بدلالة هذه المُسَيِّمات والمحدِّدات، نستطيع تنفيذ مُجمِّعات متسلسلات التعليمات المتنوّعة المستخدمة في أرجاء المصرِّف.

المُجمِّع الأساسيّ هو append-instruction-sequences. فهو يأخذ كمعطياتٍ عددًا اعتباطيًّا من متسلسلات التعليمات التي ستُنفَّذ متسلسلةً ويُعيد متسلسلة تعليماتٍ بياناتُها هي بيانات جميع المتسلسلات ملحقةً بعضها ببعض. والنقطة الدقيقة هي تحديد المسجّلات التي تحتاجها المتسلسلة الناتجة وتعدّلها. فإنّها تعدّل تلك المسجّلات التي تعدّلها أيٌّ من المتسلسلات؛ وتحتاج تلك المسجّلات التي يجب تهيئتها قبل أن تُشغَّل المتسلسلة الأولى (المسجّلات التي تحتاجها المتسلسلة الأولى)، مع تلك المسجّلات التي تحتاجها أيٌّ من المتسلسلات الأخرى والتي لا تُهيَّأ (تُعدَّل) بواسطة المتسلسلات السابقة لها.

تُلحَق المتسلسلات اثنتين في كلّ مرّةٍ بواسطة append-2-sequences. فهذا يأخذ متسلسلتي تعليماتٍ seq1 وseq2 ويُعيد متسلسلة التعليمات التي بياناتُها هي بيانات seq1 متبوعةً ببيانات seq2، ومسجّلاتها المعدَّلة هي تلك المسجّلات التي تعدّلها إمّا seq1 وإمّا seq2، ومسجّلاتها المطلوبة هي المسجّلات التي تحتاجها seq1 مع تلك المسجّلات التي تحتاجها seq2 والتي لا تعدّلها seq1. (وبدلالة عمليّات المجموعات، مجموعة المسجّلات المطلوبة الجديدة هي اتحاد مجموعة المسجّلات التي تحتاجها seq1 مع الفرق بين مجموعة المسجّلات التي تحتاجها seq2 والمسجّلات التي تعدّلها seq1.) وهكذا، يُنفَّذ append-instruction-sequences كما يلي:

(define (append-instruction-sequences . seqs)
  (define (append-2-sequences seq1 seq2)
    (make-instruction-sequence
     (list-union 
      (registers-needed seq1)
      (list-difference 
       (registers-needed seq2)
       (registers-modified seq1)))
     (list-union
      (registers-modified seq1)
      (registers-modified seq2))
     (append (statements seq1)
             (statements seq2))))
  (define (append-seq-list seqs)
    (if (null? seqs)
        (empty-instruction-sequence)
        (append-2-sequences 
         (car seqs)
         (append-seq-list (cdr seqs)))))
  (append-seq-list seqs))

ويستخدم هذا الإجراء بعض العمليّات البسيطة للتلاعب بمجموعاتٍ ممثَّلةٍ كقوائم، شبيهةً بتمثيل المجموعات (غير المرتّب) الموصوف في 2.3.3:

(define (list-union s1 s2)
  (cond ((null? s1) s2)
        ((memq (car s1) s2)
         (list-union (cdr s1) s2))
        (else
         (cons (car s1)
               (list-union (cdr s1) s2)))))

(define (list-difference s1 s2)
  (cond ((null? s1) '())
        ((memq (car s1) s2)
         (list-difference (cdr s1) s2))
        (else 
         (cons (car s1)
               (list-difference (cdr s1)
                                s2)))))

يأخذ preserving، مُجمِّع متسلسلات التعليمات الرئيسيّ الثاني، قائمةً من المسجّلات regs ومتسلسلتي تعليماتٍ seq1 وseq2 ستُنفَّذان متسلسلةً. وهو يُعيد متسلسلة تعليماتٍ بياناتُها هي بيانات seq1 متبوعةً ببيانات seq2، مع تعليمات save وrestore مناسقتين حول seq1 لحماية المسجّلات في regs التي تعدّلها seq1 لكنّها يحتاجها seq2. ولتحقيق ذلك، ينشئ preserving أوّلًا متسلسلةً تحتوي تعليمات save المطلوبة متبوعةً ببيانات seq1 متبوعةً بتعليمات restore المطلوبة. وتحتاج هذه المتسلسلة المسجّلات التي تُحفظ وتُستعاد إضافةً إلى المسجّلات التي تحتاجها seq1، وتعدّل المسجّلات التي تعدّلها seq1 عدا تلك التي تُحفظ وتُستعاد. ثُمّ تُلحَق هذه المتسلسلة المُعزَّزة وseq2 بالطريقة المعتادة. وينفّذ الإجراء التالي هذه الاستراتيجيّة تعاوديًّا، نازلًا في قائمة المسجّلات الواجب حمايتها:[42]

(define (preserving regs seq1 seq2)
  (if (null? regs)
      (append-instruction-sequences seq1 seq2)
      (let ((first-reg (car regs)))
        (if (and 
             (needs-register? seq2 first-reg)
             (modifies-register? seq1 
                                 first-reg))
            (preserving 
             (cdr regs)
             (make-instruction-sequence
              (list-union 
               (list first-reg)
               (registers-needed seq1))
              (list-difference
               (registers-modified seq1)
               (list first-reg))
              (append `((save ,first-reg))
                      (statements seq1)
                      `((restore ,first-reg))))
             seq2)
            (preserving 
             (cdr regs)
             seq1
             seq2)))))

وهناك مُجمِّع متسلسلاتٍ آخر، هو tack-on-instruction-sequence، تستخدمه compile-lambda لإلحاق جسم إجراءٍ بمتسلسلةٍ أخرى. ولأنّ جسم الإجراء ليس «على الخطّ» ليُنفَّذ كجزءٍ من المتسلسلة المدمجة، فإنّ استخدامه للمسجّلات لا يؤثّر في استخدام المتسلسلة المضيفة له للمسجّلات. ولذلك نتجاهل مجموعتي المسجّلات المطلوبة والمعدَّلة الخاصّتين بجسم الإجراء حين نُلحقه بالمتسلسلة الأخرى.

(define (tack-on-instruction-sequence 
         seq body-seq)
  (make-instruction-sequence
   (registers-needed seq)
   (registers-modified seq)
   (append (statements seq)
           (statements body-seq))))

يستخدم compile-if وcompile-procedure-call مُجمِّعًا خاصًّا يُسمّى parallel-instruction-sequences لإلحاق الفرعين البديلين اللذين يليان اختبارًا. فلن يُنفَّذ الفرعان متسلسلين أبدًا؛ ففي أيّ تقييمٍ معيّنٍ للاختبار، يُدخل أحد الفرعين أو الآخر. ولهذا السبب، تظلّ المسجّلات التي يحتاجها الفرع الثاني مطلوبةً بواسطة المتسلسلة المدمجة، حتّى لو عدّلها الفرع الأوّل.

(define (parallel-instruction-sequences 
         seq1 seq2)
  (make-instruction-sequence
   (list-union (registers-needed seq1)
               (registers-needed seq2))
   (list-union (registers-modified seq1)
               (registers-modified seq2))
   (append (statements seq1)
           (statements seq2))))

5.5.5 مثال على شيفرة مصرَّفة

والآن بعد أن رأينا جميع عناصر المصرِّف، دعنا نفحص مثالًا على شيفرةٍ مصرَّفةٍ لنرى كيف تتناسب الأجزاء معًا. فسنُصرّف تعريف إجراء factorial تعاوديٍّ بنداء compile:

(compile
 '(define (factorial n)
    (if (= n 1)
        1
        (* (factorial (- n 1)) n)))
 'val
 'next)

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

يحدّد compile أنّ التعبير تعريفٌ، فينادي compile-definition ليُصرّف شيفرةً تحسب القيمة التي ستُسَنَّد (موجّهةً إلى val)، تليها شيفرةٌ لتنصيب التعريف، تليها شيفرةٌ لوضع قيمة define (وهي الرمز ok) في المسجّل الهدف، تليها أخيرًا شيفرة الوصلة. ويُحتفظ بـenv أثناء احتساب القيمة، لأنّها لازمة لتنصيب التعريف. ولأنّ الوصلة هي next، فلا وجود لشيفرة الوصلة في هذه الحالة. وهكذا يكون الهيكل العظميّ للشيفرة المصرَّفة

⟨save env if modified by code to compute value⟩
  ⟨compilation of definition value, 
   target val, linkage next⟩
  ⟨restore env if saved above⟩
  (perform (op define-variable!)
           (const factorial)
           (reg val)
           (reg env))
  (assign val (const ok))

التعبير الذي سيُصرَّف لإنتاج قيمة المتغيّر factorial هو تعبير lambda قيمتُه هي الإجراء الذي يحسب العامليّات. ويتعامل compile مع هذا بنداء compile-lambda، الذي يُصرّف جسم الإجراء، ووسمه كنقطة دخولٍ جديدة، ويُنتج التعليمة التي ستدمج جسم الإجراء عند نقطة الدخول الجديدة مع بيئة وقت التشغيل وتُسند النتيجة إلى val. ثم تقفز المتسلسلة فوق شيفرة الإجراء المصرَّفة، التي تُدرَج عند هذه النقطة. وتبدأ شيفرة الإجراء ذاتها بتمديد بيئة تعريف الإجراء بإطارٍ يربط الوسيط الشكليّ n بمعطى الإجراء. ثم يأتي جسم الإجراء الفعليّ. وبما أنّ هذه الشيفرة الخاصّة بقيمة المتغيّر لا تعدّل المسجّل env، فإنّ تعليمتَي save وrestore الاختياريّتين المعروضتين أعلاه لا تُنتَجان. (فشيفرة الإجراء عند entry2 لا تُنفَّذ في هذه المرحلة، فاستخدامها لـenv غير ذي صلة.) ولذلك، يصبح الهيكل العظميّ للشيفرة المصرَّفة

  (assign val (op make-compiled-procedure)
              (label entry2)
              (reg env))
  (goto (label after-lambda1))
entry2
  (assign env (op compiled-procedure-env)
              (reg proc))
  (assign env (op extend-environment)
              (const (n))
              (reg argl)
              (reg env))
  ⟨compilation of procedure body⟩
after-lambda1
  (perform (op define-variable!)
           (const factorial)
           (reg val) (reg env))
  (assign val (const ok))

يُصرَّف جسم الإجراء دائمًا (بواسطة compile-lambda-body) كمتسلسلةٍ هدفُها val ووصلتُها return. والمتسلسلة في هذه الحالة تتألّف من تعبير if واحد:

(if (= n 1)
    1
    (* (factorial (- n 1)) n))

يُنتج compile-if شيفرةً تحسب المُسَيِّم أوّلًا (موجّهةً إلى val)، ثم تفحص النتيجة وتتفرّع حول الفرع الصحيح إذا كان المُسَيِّم خاطئًا. ويُحتفظ بـenv وcontinue حول شيفرة المُسَيِّم، إذ قد تكونان لازمين لبقية تعبير if. وبما أنّ تعبير if هو التعبير الأخير (والوحيد) في المتسلسلة المكوّنة لجسم الإجراء، فهدفُه val ووصلتُه return، ولذلك يُصرَّف الفرعان الصحيح والخاطئ كلاهما بهدف val ووصلة return. (أي أنّ قيمة التعبير الشرطيّ، وهي القيمة التي يحسبها أيٌّ من فرعيه، هي قيمة الإجراء.)

⟨save continue, env if modified by 
 predicate and needed by branches⟩
  ⟨compilation of predicate, 
   target val, linkage next⟩
  ⟨restore continue, env if saved above⟩
  (test (op false?) (reg val))
  (branch (label false-branch4))
true-branch5
  ⟨compilation of true branch, 
   target val, linkage return⟩
false-branch4
  ⟨compilation of false branch, 
   target val, linkage return⟩
after-if3

المُسَيِّم (= n 1) هو نداءُ إجراءٍ. وهو يبحث عن المشغّل (الرمز =) ويضع قيمتَه في proc. ثم يجمّع المعطيات 1 وقيمة n في argl. ثم يختبر ما إذا كان proc يحتوي إجراءً أوّليًّا أو إجراءً مركّبًا، ويُوجّه إلى فرعٍ أوّليٍّ أو فرعٍ مركّبٍ تبعًا لذلك. ويستأنف الفرعان عند العنوان after-call. ومتطلّبات صون المسجّلات حول تقييم المشغّل والعوامل لا تؤدّي إلى حفظ أيّ مسجّلات، لأنّ تلك التقييمات لا تعدّل المسجّلات المعنيّة في هذه الحالة.

  (assign proc (op lookup-variable-value)
               (const =) 
               (reg env))
  (assign val (const 1))
  (assign argl (op list) (reg val))
  (assign val (op lookup-variable-value)
              (const n)
              (reg env))
  (assign argl (op cons) (reg val) (reg argl))
  (test (op primitive-procedure?) (reg proc))
  (branch (label primitive-branch17))
compiled-branch16
  (assign continue (label after-call15))
  (assign val (op compiled-procedure-entry)
              (reg proc))
  (goto (reg val))
primitive-branch17
  (assign val (op apply-primitive-procedure)
              (reg proc)
              (reg argl))
after-call15

يُصرَّف الفرع الصحيح، وهو الثابت 1، (بهدف val ووصلة return) إلى

(assign val (const 1))
(goto (reg continue))

شيفرة الفرع الخاطئ هي نداء إجراءٍ آخر، الإجراء فيه هو قيمة الرمز *، والمعطيات هي n ونتيجة نداء إجراءٍ آخر (نداء لـfactorial). وكلٌّ من هذه النداءات يُهيِّئ proc وargl وفرعيه الأوّليّ والمركّب الخاصّين به. ويُظهر الشكل 5.17 التصريف الكامل لتعريف إجراء factorial. ولاحِظ أنّ تعليمتَي save وrestore المحتملتين لـcontinue وenv حول المُسَيِّم، والمبيَّنتين أعلاه، تُنتَجان فعلًا، لأنّ هذين المسجّلين يعدّلهما نداء الإجراء في المُسَيِّم ويحتاجهما نداء الإجراء والوصلة return في الفرعين.

الشكل 5.17: ↓↓ تصريف تعريف إجراء factorial.

;; construct the procedure and skip over code
;; for the procedure body
  (assign val
          (op make-compiled-procedure) 
          (label entry2) 
          (reg env))
  (goto (label after-lambda1))
entry2     ; calls to factorial will enter here
  (assign env 
          (op compiled-procedure-env)
          (reg proc))
  (assign env
          (op extend-environment) 
          (const (n)) 
          (reg argl) 
          (reg env))
;; begin actual procedure body
  (save continue)
  (save env)
;; compute (= n 1)
  (assign proc 
          (op lookup-variable-value) 
          (const =) 
          (reg env))
  (assign val (const 1))
  (assign argl (op list) (reg val))
  (assign val 
          (op lookup-variable-value) 
          (const n) 
          (reg env))
  (assign argl (op cons) (reg val) (reg argl))
  (test (op primitive-procedure?) (reg proc))
  (branch (label primitive-branch17))
compiled-branch16
  (assign continue (label after-call15))
  (assign val
          (op compiled-procedure-entry)
          (reg proc))
  (goto (reg val))
primitive-branch17
  (assign val 
          (op apply-primitive-procedure) 
          (reg proc) 
          (reg argl))
after-call15   ; val now contains result of (= n 1)
  (restore env)
  (restore continue)
  (test (op false?) (reg val))
  (branch (label false-branch4))
true-branch5  ; return 1
  (assign val (const 1))
  (goto (reg continue))

false-branch4
;; compute and return (* (factorial (- n 1)) n)
  (assign proc 
          (op lookup-variable-value) 
          (const *) 
          (reg env))
  (save continue)
  (save proc)   ; save * procedure
  (assign val 
          (op lookup-variable-value) 
          (const n) 
          (reg env))
  (assign argl (op list) (reg val))
  (save argl)   ; save partial argument list for *
;; compute (factorial (- n 1)), 
;; which is the other argument for *
  (assign proc
          (op lookup-variable-value) 
          (const factorial) 
          (reg env))
  (save proc)  ; save factorial procedure
;; compute (- n 1), which is the argument for factorial
  (assign proc 
          (op lookup-variable-value)
          (const -) 
          (reg env))
  (assign val (const 1))
  (assign argl (op list) (reg val))
  (assign val 
          (op lookup-variable-value) 
          (const n) 
          (reg env))
  (assign argl (op cons) (reg val) (reg argl))
  (test (op primitive-procedure?) (reg proc))
  (branch (label primitive-branch8))
compiled-branch7
  (assign continue (label after-call6))
  (assign val
          (op compiled-procedure-entry)
          (reg proc))
  (goto (reg val))
primitive-branch8
  (assign val 
          (op apply-primitive-procedure) 
          (reg proc) 
          (reg argl))

after-call6   ; val now contains result of (- n 1)
  (assign argl (op list) (reg val))
  (restore proc) ; restore factorial
;; apply factorial
  (test (op primitive-procedure?) (reg proc))
  (branch (label primitive-branch11))
compiled-branch10
  (assign continue (label after-call9))
  (assign val
          (op compiled-procedure-entry)
          (reg proc))
  (goto (reg val))
primitive-branch11
  (assign val 
          (op apply-primitive-procedure) 
          (reg proc) 
          (reg argl))
after-call9      ; val now contains result 
                 ; of (factorial (- n 1))
  (restore argl) ; restore partial argument list for *
  (assign argl (op cons) (reg val) (reg argl))
  (restore proc) ; restore *
  (restore continue)
;; apply * and return its value
  (test (op primitive-procedure?) (reg proc))
  (branch (label primitive-branch14))
compiled-branch13
;; note that a compound procedure here
;; is called tail-recursively
  (assign val
          (op compiled-procedure-entry)
          (reg proc))
  (goto (reg val))
primitive-branch14
  (assign val 
          (op apply-primitive-procedure) 
          (reg proc) 
          (reg argl))
  (goto (reg continue))
after-call12
after-if3
after-lambda1
;; assign the procedure to the variable factorial
  (perform (op define-variable!) 
           (const factorial) 
           (reg val) 
           (reg env))
  (assign val (const ok))

التمرين 5.33: تأمّل التعريف الآتي لإجراءٍ لحساب العامليّ، وهو يختلف اختلافًا طفيفًا عن المعطى أعلاه:

(define (factorial-alt n)
  (if (= n 1)
      1
      (* n (factorial-alt (- n 1)))))

صرّف هذا الإجراء وقارِن الشيفرة الناتجة بتلك التي أُنتجت للإجراء factorial. اشرح أيّ فروقٍ تجدها. فهل ينفّذ أحد البرنامجين بكفاءةٍ أعلى من الآخر؟

التمرين 5.34: صرّف إجراء العامليّ التكراريّ

(define (factorial n)
  (define (iter product counter)
    (if (> counter n)
        product
        (iter (* counter product)
              (+ counter 1))))
  (iter 1 1))

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

التمرين 5.35: ما التعبير الذي صُرّف لإنتاج الشيفرة الموضّحة في الشكل 5.18؟

الشكل 5.18: ↓↓ مثالٌ على ناتج المصرِّف. انظر التمرين 5.35.

(assign val (op make-compiled-procedure) 
            (label entry16) 
            (reg env))
  (goto (label after-lambda15))
entry16
  (assign env (op compiled-procedure-env)
              (reg proc))
  (assign env (op extend-environment) 
              (const (x)) 
              (reg argl) 
              (reg env))
  (assign proc (op lookup-variable-value) 
               (const +) 
               (reg env))
  (save continue) (save proc) (save env)
  (assign proc (op lookup-variable-value) 
               (const g) 
               (reg env))
  (save proc)
  (assign proc (op lookup-variable-value) 
               (const +) 
               (reg env))
  (assign val (const 2))
  (assign argl (op list) (reg val))
  (assign val (op lookup-variable-value)
              (const x) 
              (reg env))
  (assign argl (op cons)
               (reg val)
               (reg argl))
  (test (op primitive-procedure?)
        (reg proc))
  (branch (label primitive-branch19))
compiled-branch18
  (assign continue (label after-call17))
  (assign val
          (op compiled-procedure-entry)
          (reg proc))
  (goto (reg val))
primitive-branch19
  (assign val
          (op apply-primitive-procedure)
          (reg proc) 
          (reg argl))
after-call17
  (assign argl (op list) (reg val))
  (restore proc)
  (test (op primitive-procedure?)
        (reg proc))
  (branch (label primitive-branch22))
compiled-branch21
  (assign continue (label after-call20))
  (assign val
          (op compiled-procedure-entry)
          (reg proc))
  (goto (reg val))
primitive-branch22
  (assign val 
          (op apply-primitive-procedure) 
          (reg proc) 
          (reg argl))
after-call20
  (assign argl (op list) (reg val))
  (restore env)
  (assign val
          (op lookup-variable-value) 
          (const x) 
          (reg env))
  (assign argl
          (op cons)
          (reg val)
          (reg argl))
  (restore proc)
  (restore continue)
  (test (op primitive-procedure?)
        (reg proc))
  (branch (label primitive-branch25))
compiled-branch24
  (assign val (op compiled-procedure-entry)
              (reg proc))
  (goto (reg val))
primitive-branch25
  (assign val 
          (op apply-primitive-procedure)
          (reg proc) 
          (reg argl))
  (goto (reg continue))
after-call23
after-lambda15
  (perform (op define-variable!) 
           (const f) 
           (reg val) 
           (reg env))
  (assign val (const ok))

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

التمرين 5.37: إحدى طرائق فهم آليّة preserving في المصرِّف - المستخدمة لتحسين استغلال المكدّس - هي رؤية العمليّات الإضافيّة التي كانت ستُولَّد لو لم نستخدم هذه الفكرة. عدّل preserving بحيث تُولَّد عمليّتا save وrestore دائمًا. صرّف بعض التعابير البسيطة وحدّد عمليّات المكدّس غير الضروريّة التي تُولَّد. قارِن الشيفرة بتلك التي تُولَّد مع بقاء آليّة preserving على حالها.

مصرِّفنا بارعٌ في تجنّب عمليّات المكدّس غير الضروريّة، لكنّه ليس بارعًا على الإطلاق حين يُصرّف نداءات الإجراءات الأوّليّة للغة من حيث العمليّات الأوّليّة التي تزوّدها الآلة. فتأمّل، مثلًا، كم الشيفرة التي تُصرَّف لاحتساب (+ a 1): فالشيفرة تُنشئ قائمةَ معطياتٍ في argl، وتضع إجراء الجمع الأوّليّ (الذي تجده بالبحث عن الرمز + في البيئة) في proc، وتختبر ما إذا كان الإجراء أوّليًّا أم مركّبًا. والمصرِّف يُولّد دائمًا شيفرةً لأداء الاختبار، وكذلك شيفرةً للفرعين الأوّليّ والمركّب (ولن يُنفَّذ إلّا أحدهما). ونحن لم نُظهر الجزء من المتحكّم الذي يُنفّذ الأوّليّات، لكنّنا نفترض أنّ هذه التعليمات تستخدم العمليّات الحسابيّة الأوّليّة في مسارات بيانات الآلة. وتأمّل كم الشيفرة الأقلّ التي كانت ستُولَّد لو كان المصرِّف قادرًا على الترميز المباشر (open-coding) للأوّليّات - أي لو كان قادرًا على توليد شيفرةٍ تستخدم عمليّات الآلة الأوّليّة هذه مباشرةً. فقد يُصرَّف التعبير (+ a 1) إلى شيءٍ بسيطٍ كالآتي:[43]

(assign val (op lookup-variable-value) 
            (const a) 
            (reg env))
(assign val (op +)
            (reg val)
            (const 1))

سنُوسّع في هذا التمرين مصرِّفنا ليدعم الترميز المباشر لأوّليّاتٍ منتقاة. وستُولَّد شيفرةٌ خاصّةٌ لنداءات هذه الإجراءات الأوّليّة بدلًا من شيفرة تطبيق الإجراءات العامّة. ولكي نُسند هذا، سنُعزّز آلتنا بمسجّلي معطياتٍ خاصّين، arg1 وarg2. وستأخذ عمليّات الآلة الحسابيّة الأوّليّة معطياتها من arg1 وarg2. وقد توضع النتائج في val أو arg1 أو arg2.

ينبغي أن يكون المصرِّف قادرًا على التعرّف على تطبيق أوّليٍّ مُرمَّزٍ مباشرةً في البرنامج المصدريّ. وسنُعزّز التوزيع في الإجراء compile ليُتعرّف على أسماء هذه الأوّليّات بالإضافة إلى الكلمات المحجوزة (الصيغ الخاصّة) التي يتعرّف عليها حاليًّا.[44] ولكلّ صيغةٍ خاصّةٍ في مصرِّفنا مولّدُ شيفرة. وسنبني في هذا التمرين عائلةً من مولّدات الشيفرة للأوّليّات المُرمَّزة مباشرةً.

  1. تحتاج الأوّليّات المُرمَّزة مباشرةً - بخلاف الصيغ الخاصّة - جميعًا إلى تقييم عواملها. اكتب مولّد شيفرةٍ spread-arguments لاستخدامه من جميع مولّدات الشيفرة التي تُرمّز مباشرةً. وينبغي لـspread-arguments أن تأخذ قائمة عواملٍ وتُصرّف العوامل المعطاة موجَّهةً إلى مسجّلات المعطيات المتتالية. ولاحِظ أنّ العامل قد يحتوي نداءً لأوّليٍّ مُرمَّزٍ مباشرةً، ومن ثمّ يتعيّن الحفاظ على مسجّلات المعطيات أثناء تقييم العامل.
  2. لكلّ من الإجراءات الأوّليّة = و* و- و+، اكتب مولّد شيفرةٍ يأخذ تركيبًا ذي المشغّل المذكور، مع هدفٍ وواصف ربط، ويُنتج شيفرةً تنشر المعطيات في المسجّلات ثم تُداء العمليّة موجَّهةً إلى الهدف المعطى بالربط المعطى. وليس عليك إلّا التعامل مع التعابير ذات العاملين. واجعل compile يُوزّع إلى مولّدات الشيفرة هذه.
  3. جرّب مصرِّفك الجديد على مثال factorial. قارِن الشيفرة الناتجة بالناتج المُنتَج دون الترميز المباشر.
  4. وسّع مولّدي الشيفرة الخاصّين بـ+ و* بحيث يتمكّنان من التعامل مع تعابيرَ ذات أعدادٍ اعتباطيّةٍ من العوامل. وسيتعيّن تصريف التعبير ذي أكثر من عاملين إلى تسلسلٍ من العمليّات، لكلٍّ منها مدخلان فقط.

5.5.6 العنونة المعجميّة

إحدى أكثر التحسينات شيوعًا التي تُجريها المصرّفات هي تحسين البحث عن المتغيّرات. فمصرِّفنا، كما نفّذناه حتّى الآن، يُولّد شيفرةً تستخدم عمليّة lookup-variable-value الخاصّة بآلة المُقيِّم. وتبحث هذه العمليّة عن متغيّرٍ بمقارنته بكلّ متغيّرٍ مربوطٍ حاليًّا، مُنتقلةً إطارًا بعد إطارٍ إلى الخارج عبر بيئة وقت التشغيل. وقد يكون هذا البحث مكلفًا إذا كانت الأطر متداخلةً بعمقٍ أو إذا كان المتغيّرات كثيرة. فتأمّل، مثلًا، مسألة البحث عن قيمة x أثناء تقييم التعبير (* x y z) في تطبيق الإجراء الذي يُعيده

(let ((x 3) (y 4))
  (lambda (a b c d e)
    (let ((y (* a b x))
          (z (+ c d x)))
      (* x y z))))

وحيث إنّ تعبير let ليس إلّا سُكّرًا نحويًّا لتركيب lambda، فإنّ هذا التعبير مكافئٌ لـ

((lambda (x y)
   (lambda (a b c d e)
     ((lambda (y z) (* x y z))
      (* a b x)
      (+ c d x))))
 3
 4)

في كلّ مرّةٍ تبحث فيها lookup-variable-value عن x، ينبغي أن تُحدّد أنّ الرمز x ليس eq? بالنسبة إلى y أو z (في الإطار الأوّل)، ولا بالنسبة إلى a أو b أو c أو d أو e (في الإطار الثاني). وسنفترض، في الوقت الحاضر، أنّ برامجنا لا تستخدم define - أي أنّ المتغيّرات لا تُربط إلّا بـlambda. ولأنّ لغتنا محدودة النطاق معجميًّا، فإنّ بيئة وقت التشغيل لأيّ تعبيرٍ ستكون ذات بنيةٍ تُوازي البنية المعجميّة للبرنامج الذي يظهر فيه التعبير.[45] وبالتالي، فيستطيع المصرِّف أن يعرف، حين يُحلّل التعبير أعلاه، أنّ المتغيّر x في (* x y z) سيوجد، في كلّ مرّةٍ يُطبَّق فيها الإجراء، على مسافة إطارين من الإطار الحاليّ وسيكون أوّل متغيّرٍ في ذلك الإطار.

ويمكننا استثمار هذه الحقيقة باختراع نوعٍ جديدٍ من عمليّات البحث عن المتغيّرات، هو lexical-address-lookup، الذي يأخذ بيئةً وعنوانًا معجميًّا يتألّف من عددين: رقم إطار، يُحدّد كم إطارًا ينبغي تجاوزه، ورقم إزاحة، يُحدّد كم متغيّرًا ينبغي تجاوزه في ذلك الإطار. وستُنتج Lexical-address-lookup قيمة المتغيّر المخزّن عند ذلك العنوان المعجميّ نسبةً إلى البيئة الحاليّة. فإذا أضفنا عمليّة lexical-address-lookup إلى آلتنا، أمكننا أن نجعل المصرِّف يُولّد شيفرةً تشير إلى المتغيّرات باستخدام هذه العمليّة بدلًا من lookup-variable-value. وبالمثل، فيمكن لشيفرتنا المصرّفة أن تستخدم عمليّةً جديدةً هي lexical-address-set! بدلًا من set-variable-value!.

ولتوليد شيفرةٍ كهذه، ينبغي أن يكون المصرِّف قادرًا على تحديد العنوان المعجميّ لمتغيّرٍ يُوشك أن يُصرّف إشارةً إليه. ويعتمد العنوان المعجميّ لمتغيّرٍ في برنامجٍ على موضع المرء في الشيفرة. فتأمّل، مثلًا، في البرنامج الآتي، عنوان x في التعبير ⟨e1⟩ هو (2, 0) - أي إطاران إلى الخلف وأوّل متغيّرٍ في الإطار. وعند تلك النقطة يكون y عند العنوان (0, 0) وc عند العنوان (1, 2). وفي التعبير ⟨e2⟩، يكون x عند (1, 0)، وy عند (1, 1)، وc عند (0, 2).

((lambda (x y)
   (lambda (a b c d e)
     ((lambda (y z) ⟨e1⟩)
      ⟨e2⟩
      (+ c d x))))
 3
 4)

إحدى طرائق جعل المصرِّف يُنتج شيفرةً تستخدم العنونة المعجميّة هي المحافظة على بنية بياناتٍ تُسمّى بيئة وقت التصريف. وهي تتابع أيّ المتغيّرات ستكون في أيّ المواضع وفي أيّ الأطر في بيئة وقت التشغيل حين تُنفَّذ عمليّة وصولٍ معيّنةٍ إلى متغيّر. وبيئة وقت التصريف قائمةٌ من الأطر، يحتوي كلٌّ منها على قائمةٍ من المتغيّرات. (ولن تكون هناك بالطبع أيّ قيمٍ مربوطةٍ بالمتغيّرات، إذ إنّ القيم لا تُحتسب في وقت التصريف.) وتصير بيئة وقت التصريف معطىً إضافيًّا لـcompile وتُمرَّر إلى كلّ مولّد شيفرة. ويستخدم نداء compile في المستوى الأعلى بيئةَ وقت تصريفٍ فارغة. وحين يُصرَّف جسم lambda، تُوسّع compile-lambda-body بيئةَ وقت التصريف بإطارٍ يحتوي وسائط الإجراء، بحيث يُصرَّف التسلسل المكوِّن للجسم بتلك البيئة المُوسَّعة. وعند كلّ نقطةٍ في التصريف، تستخدم compile-variable وcompile-assignment بيئةَ وقت التصريف لتوليد العناوين المعجميّة المناسبة.

يصف التمرين 5.39 إلى التمرين 5.43 كيف يُتْم هذه المخططات لاستراتيجيّة العنونة المعجميّة بغية إدماج البحث المعجميّ في المصرِّف. ويصف التمرين 5.44 استخدامًا آخر لبيئة وقت التصريف.

التمرين 5.39: اكتب إجراءً lexical-address-lookup يُنفّذ عمليّة البحث الجديدة. وينبغي أن يأخذ معطيين - عنوانًا معجميًّا وبيئةَ وقت تشغيل - ويُعيد قيمة المتغيّر المخزّن عند العنوان المعجميّ المحدَّد. وينبغي لـLexical-address-lookup أن يُشير إلى خطأٍ إذا كانت قيمة المتغيّر هي الرمز *unassigned*.[46] واكتب أيضًا إجراءً lexical-address-set! يُنفّذ العمليّة التي تُغيّر قيمة المتغيّر عند عنوانٍ معجميٍّ محدَّد.

التمرين 5.40: عدّل المصرِّف ليحافظ على بيئة وقت التصريف كما وُصِف أعلاه. أي أضِف معطى بيئةِ وقت تصريفٍ إلى compile وإلى مولّدات الشيفرة المختلفة، ووسّعه في compile-lambda-body.

التمرين 5.41: اكتب إجراءً find-variable يأخذ متغيّرًا وبيئةَ وقت تصريفٍ كمعطيين ويُعيد العنوان المعجميّ للمتغيّر نسبةً إلى تلك البيئة. فمثلًا، في المقطع البرمجيّ الموضّح أعلاه، تكون بيئة وقت التصريف أثناء تصريف التعبير ⟨e1⟩ هي ((y z) (a b c d e) (x y)). وينبغي لـFind-variable أن يُنتج

(find-variable 
 'c '((y z) (a b c d e) (x y)))
(1 2)

(find-variable 
 'x '((y z) (a b c d e) (x y)))
(2 0)

(find-variable 
 'w '((y z) (a b c d e) (x y)))
not-found

التمرين 5.42: باستخدام find-variable من التمرين 5.41، أعد كتابة compile-variable وcompile-assignment ليُخرِجا تعليمات العنوان المعجميّ. وفي الحالات التي تُعيد فيها find-variable القيمة not-found (أي حيث لا يكون المتغيّر في بيئة وقت التصريف)، ينبغي أن تجعل مولّدات الشيفرة تستخدم عمليّات المُقيِّم، كما سبق، للبحث عن الربط. (والمكان الوحيد الذي يمكن أن يكون فيه متغيّرٌ لا يُعثَر عليه في وقت التصريف هو البيئة العامّة، وهي جزءٌ من بيئة وقت التشغيل لكنّها ليست جزءًا من بيئة وقت التصريف.[47] وبالتالي، فإن شئتَ، أمكنك أن تجعل عمليّات المُقيِّم تنظر مباشرةً في البيئة العامّة، التي يمكن الحصول عليها بالعمليّة (op get-global-environment)، بدلًا من أن تجعلها تبحث في بيئة وقت التشغيل كلّها الموجودة في env.) واختبر المصرِّف المُعدَّل على بعض الحالات البسيطة، كتركيب lambda المتداخل في بداية هذا القسم.

التمرين 5.43: لقد حاججنا في 4.1.6 بأنّ التعريفات الداخليّة لبنية الكتل لا ينبغي اعتبارها defines «حقيقيّة». فالأولى أن يُفسَّر جسم الإجراء كما لو كانت المتغيّرات الداخليّة المُعرَّفة تُثبَّت متغيّرات lambda اعتياديّةً تُهيَّأ بقيمها الصحيحة باستخدام set!. وأظهر 4.1.6 والتمرين 4.16 كيف يُعدَّل المُقيِّم التحاكميّ (metacircular evaluator) لإنجاز هذا بمسح التعريفات الداخليّة وإخراجها. عدّل المصرِّف ليؤدّي التحويل ذاته قبل أن يُصرّف جسم إجراء.

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

(lambda (+ * a b x y)
  (+ (* a x) (* b y)))

الذي يحسب تركيبةً خطّيّةً من x وy. فقد ندعوه بمعطيات +matrix و*matrix وأربع مصفوفات، لكن المصرِّف المُرمِّز مباشرةً كان سيظلّ يُرمّز الـ+ والـ* في (+ (* a x) (* b y)) مباشرةً كأوّليَّي + و*. عدّل المصرِّف المُرمِّز مباشرةً ليستشير بيئة وقت التصريف بغية تصريف الشيفرة الصحيحة للتعابير التي تشمل أسماء الإجراءات الأوّليّة. (وستعمل الشيفرة صحيحةً ما دام البرنامج لا يُعرّف هذه الأسماء بـdefine أو set!.)

5.5.7 ربط الشيفرة المصرّفة بالمُقيِّم

لم نشرح بعد كيف تُحمَّل الشيفرة المصرّفة في آلة المُقيِّم ولا كيف تُشغَّل. وسنفترض أنّ آلة المُقيِّم صريحة التحكّم قد عُرِّفت كما في 5.4.4، مع العمليّات الإضافيّة المحدَّدة في الحاشية 323. وسنُنفّذ إجراءً compile-and-go يُصرّف تعبير Scheme، ويُحمّل الشيفرة الهدفيّة الناتجة في آلة المُقيِّم، ويجعل الآلة تُشغّل الشيفرة في البيئة العامّة للمُقيِّم، وتطبع النتيجة، وتدخل حلقة المقود (driver loop) الخاصّة بالمُقيِّم. وسنُعدّل المُقيِّم أيضًا حتّى تستطيع التعابير المُفسَّرة أن تنادي الإجراءات المصرّفة كما تنادي المُفسَّرة. وبوسعنا حينئذٍ أن نضع إجراءً مصرّفًا في الآلة وأن نستخدم المُقيِّم لندائه:

(compile-and-go
 '(define (factorial n)
    (if (= n 1)
        1
        (* (factorial (- n 1)) n))))

;;; EC-Eval value:
ok

;;; EC-Eval input:
(factorial 5)

;;; EC-Eval value:
120

وليتمكّن المُقيِّم من التعامل مع الإجراءات المصرّفة (لتقييم نداء factorial أعلاه، مثلًا)، يتعيّن علينا تغيير الشيفرة عند apply-dispatch (5.4.1) بحيث تتعرّف على الإجراءات المصرّفة (بوصفها متميّزةً عن الإجراءات المركّبة أو الأوّليّة) وتنقل التحكّم مباشرةً إلى نقطة دخول الشيفرة المصرّفة:[48]

apply-dispatch
  (test (op primitive-procedure?) (reg proc))
  (branch (label primitive-apply))
  (test (op compound-procedure?) (reg proc))
  (branch (label compound-apply))
  (test (op compiled-procedure?) (reg proc))
  (branch (label compiled-apply))
  (goto (label unknown-procedure-type))

compiled-apply
  (restore continue)
  (assign val
          (op compiled-procedure-entry)
          (reg proc))
  (goto (reg val))

لاحِظ استعادة continue عند compiled-apply. وتذكّر أنّ المُقيِّم رُتّب بحيث تكون الاستمراريّة في قمّة المكدّس عند apply-dispatch. أمّا نقطة دخول الشيفرة المصرّفة، من جهةٍ أخرى، فتتوقّع أن تكون الاستمراريّة في continue، ومن ثمّ يتعيّن استعادة continue قبل تنفيذ الشيفرة المصرّفة.

وليُتاح لنا تشغيل بعض الشيفرة المصرّفة حين نُشغّل آلة المُقيِّم، نُضيف تعليمة branch في بداية آلة المُقيِّم، تجعل الآلة تنتقل إلى نقطة دخولٍ جديدة إذا كان مسجّل flag مضبوطًا.[49]

;; branches if flag is set:
(branch (label external-entry)) 
read-eval-print-loop
  (perform (op initialize-stack))
  …

تفترض External-entry أنّ الآلة تُشغَّل وval يحتوي موقع تسلسل تعليماتٍ يضع نتيجةً في val وينتهي بـ(goto (reg continue)). فالانطلاق من نقطة الدخول هذه يقفز إلى الموقع الذي يُعيّنه val، لكنّه يُسند أوّلًا continue بحيث يعود التنفيذ إلى print-result، الذي يطبع القيمة في val ثم يذهب إلى بداية حلقة القراءة والتقييم والطباعة الخاصّة بالمُقيِّم.[50]

external-entry
  (perform (op initialize-stack))
  (assign env (op get-global-environment))
  (assign continue (label print-result))
  (goto (reg val))

والآن يمكننا استخدام الإجراء الآتي لتصريف تعريف إجراءٍ وتنفيذ الشيفرة المصرّفة وتشغيل حلقة القراءة والتقييم والطباعة حتّى نتمكّن من تجربة الإجراء. ولأنّنا نريد للشيفرة المصرّفة أن تعود إلى الموقع في continue بنتيجتها في val، فإنّنا نُصرّف التعبير بهدفٍ هو val وربطٍ هو return. وَلِكي نحوّل الشيفرة الهدفيّة التي يُنتجها المصرِّف إلى تعليماتٍ قابلةٍ للتنفيذ على آلة مسجّلات المُقيِّم، فإنّنا نستخدم الإجراء assemble من محاكي آلة المسجّلات (5.2.2). ثم نُهيّئ مسجّل val ليشير إلى قائمة التعليمات، ونضبط flag بحيث يذهب المُقيِّم إلى external-entry، ونُشغّل المُقيِّم.

(define (compile-and-go expression)
  (let ((instructions
         (assemble 
          (statements
           (compile 
            expression 'val 'return))
          eceval)))
    (set! the-global-environment
          (setup-environment))
    (set-register-contents! 
     eceval 'val instructions)
    (set-register-contents! 
     eceval 'flag true)
    (start eceval)))

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

(compile-and-go
 '(define (factorial n)
    (if (= n 1)
        1
        (* (factorial (- n 1)) n))))
(total-pushes = 0, maximum-depth = 0)

;;; EC-Eval value:
ok

;;; EC-Eval input:
(factorial 5)
(total-pushes = 31, maximum-depth = 14)

;;; EC-Eval value:
120

قارِن هذا المثال بتقييم (factorial 5) باستخدام النسخة المُفسَّرة من الإجراء ذاته، الموضّحة في نهاية 5.4.4. فقد احتاجت النسخة المُفسَّرة 144 مدفعةً وعمقَ مكدّسٍ أقصاه 28. وهذا يُبيّن التحسين الناتج عن استراتيجيّة التصريف لدينا.

التفسير والتصريف

بالبرامج الواردة في هذا القسم، يمكننا الآن أن نُجرّب استراتيجيّي التنفيذ البديلتين المتمثّلتين في التفسير والتصريف.[51] فالمفسّر يرفع الآلة إلى مستوى برنامج المستخدم؛ أمّا المصرِّف فيخفض برنامج المستخدم إلى مستوى لغة الآلة. ويمكننا اعتبار لغة Scheme (أو أيّ لغة برمجةٍ) عائلةً متماسكةً من التجريدات القائمة على لغة الآلة. والمفسّرات حسنةٌ للتطوير التفاعليّ للبرامج ولتنقيحها، لأنّ خطوات تنفيذ البرنامج مُنظَّمةٌ من حيث هذه التجريدات، ومن ثمّ فهي أكثر وضوحًا للمبرمج. والشيفرة المصرّفة قادرةٌ على التنفيذ أسرعَ، لأنّ خطوات تنفيذ البرنامج مُنظَّمةٌ من حيث لغة الآلة، ولأنّ المصرِّف حرٌّ في إجراء تحسيناتٍ تتجاوز التجريدات ذات المستوى الأعلى.[52]

وتؤدّي بديلتا التفسير والتصريف أيضًا إلى استراتيجيّاتٍ مختلفةٍ لنقل اللغات إلى حواسيب جديدة. فافترض أنّنا نرغب في تنفيذ Lisp على آلةٍ جديدة. فإحدى الاستراتيجيّات هي الانطلاق بالمُقيِّم صريح التحكّم الوارد في 5.4 وترجمة تعليماته إلى تعليماتٍ للآلة الجديدة. واستراتيجيّةٌ مختلفة هي الانطلاق بالمصرِّف وتغيير مولّدات الشيفرة بحيث تُولّد شيفرةً للآلة الجديدة. والاستراتيجيّة الثانية تسمح لنا بتشغيل أيّ برنامج Lisp على الآلة الجديدة بتصريفه أوّلًا بالمصرِّف العامل على نظام Lisp الأصليّ لدينا، وربطه بنسخةٍ مصرّفةٍ من مكتبة وقت التشغيل.[53] والأفضل من ذلك، أنّنا نستطيع تصريف المصرِّف نفسه، وتشغيل هذا الأخير على الآلة الجديدة لتصريف برامج Lisp أخرى.[54] أو نستطيع تصريف أحد مفسّري 4.1 لإنتاج مفسّرٍ يعمل على الآلة الجديدة.

التمرين 5.45: بمقارنة عمليّات المكدّس التي تستخدمها الشيفرة المصرّفة بتلك التي يستخدمها المُقيِّم للاحتساب ذاته، نستطيع تحديد المدى الذي يُحسّن به المصرِّف استغلال المكدّس، سواءً في السرعة (بتقليل العدد الكلّيّ لعمليّات المكدّس) أو في المكان (بتقليل عمق المكدّس الأقصى). ومقارنة هذا الاستغلال المُحسَّن بأداء آلةٍ خاصّةٍ للاحتساب ذاته تُعطي إشارةً ما عن جودة المصرِّف. فقد طُلب منك في التمرين 5.27 أن تُحدّد، كدالّةٍ في nn ، عدد المدفعات وعمق المكدّس الأقصى اللذين يحتاجهما المُقيِّم لاحتساب n!n ! باستخدام إجراء العامليّ التعاوديّ المعطى أعلاه. وطُلب منك في التمرين 5.14 أن تُجري القياسات ذاتها لآلة العامليّ الخاصّة الموضّحة في الشكل 5.11. وأدِر الآن التحليل ذاته باستخدام إجراء factorial المصرّف.

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

التمرين 5.46: أدِر تحليلًا مثل التحليل الوارد في التمرين 5.45 لتحديد فعّاليّة تصريف إجراء فيبوناتشي التعاوديّ الشجريّ

(define (fib n)
  (if (< n 2)
      n
      (+ (fib (- n 1)) (fib (- n 2)))))

مقابل فعّاليّة استخدام آلة فيبوناتشي الخاصّة الواردة في الشكل 5.12. (ولقياس الأداء المُفسَّر، انظر التمرين 5.29.) فبالنسبة إلى فيبوناتشي، فإنّ المورد الزمنيّ المستخدم ليس خطّيًّا في n;n ; ومن ثمّ فلن تقترب نسب عمليّات المكدّس من قيمةٍ حدّيّةٍ مستقلّةٍ عن nn .

التمرين 5.47: وصف هذا القسم كيف يُعدَّل المُقيِّم صريح التحكّم حتّى تستطيع الشيفرة المُفسَّرة أن تنادي إجراءاتٍ مصرّفة. أُظهر كيف يُعدَّل المصرِّف حتّى تستطيع الإجراءات المصرّفة أن تنادي، ليس الإجراءات الأوّليّة والمصرّفة فحسب، بل الإجراءات المُفسَّرة أيضًا. وهذا يتطلّب تعديل compile-procedure-call لمعالجة حالة الإجراءات المركّبة (المُفسَّرة). وتأكّد من معالجة جميع توليفات target وlinkage ذاتها كما في compile-proc-appl. ولأداء تطبيق الإجراء فعليًّا، تحتاج الشيفرة إلى القفز إلى نقطة دخول compound-apply في المُقيِّم. ولا يمكن الإشارة إلى هذا العنوان مباشرةً في الشيفرة الهدفيّة (إذ إنّ المُجمِّع يشترط أن تكون جميع العناوين التي تشير إليها الشيفرة التي يُجمّعها معرَّفةً هناك)، ومن ثمّ سنُضيف مسجّلًا يُسمّى compapp إلى آلة المُقيِّم لحفظ نقطة الدخول هذه، ونُضيف تعليمةً لتهيئته:

  (assign compapp (label compound-apply))
  ;; branches if flag is set:
  (branch (label external-entry))
read-eval-print-loop …

ولاختبار شيفرتك، ابدأ بتعريف إجراءٍ f ينادي إجراءً g. واستخدم compile-and-go لتصريف تعريف f وتشغيل المُقيِّم. ثم، بالكتابة على المُقيِّم، عرّف g وحاول نداء f.

التمرين 5.48: إنّ واجهة compile-and-go المُنفَّذة في هذا القسم حرجةُ التعامل، إذ لا يمكن نداء المصرِّف إلّا مرّةً واحدةً (عند تشغيل آلة المُقيِّم). عزّز واجهة المصرِّف-المفسّر بتوفير أوّليٍّ compile-and-run يمكن نداؤه من داخل المُقيِّم صريح التحكّم على النحو الآتي:

;;; EC-Eval input:
(compile-and-run
 '(define (factorial n)
    (if (= n 1)
        1
        (* (factorial (- n 1)) n))))

;;; EC-Eval value:
ok

;;; EC-Eval input:
(factorial 5)

;;; EC-Eval value:
120

التمرين 5.49: كبديلٍ عن استخدام حلقة القراءة والتقييم والطباعة الخاصّة بالمُقيِّم صريح التحكّم، صمّم آلة مسجّلاتٍ تُداء حلقة قراءة-تصريف-تنفيذ-طباعة. أي أنّ الآلة ينبغي أن تُشغّل حلقةً تقرأ تعبيرًا، وتُصرّفه، وتُجمّع الشيفرة الناتجة وتنفّذها، وتطبع النتيجة. وهذا سهلُ التشغيل في تهيئتنا المُحاكاة، إذ يمكننا ترتيب أمر نداء الإجراءين compile وassemble بوصفهما «عمليّتَي آلة مسجّلات».

التمرين 5.50: استخدم المصرِّف لتصريف المُقيِّم التحاكميّ (metacircular evaluator) الوارد في 4.1 وتشغيل هذا البرنامج باستخدام محاكي آلة المسجّلات. (ولتصريف أكثر من تعريفٍ في المرّة الواحدة، يمكنك حزم التعريفات في begin.) وسيعمل المفسّر الناتج ببطءٍ شديدٍ بسبب مستويات التفسير المتعدّدة، لكنّ جعل جميع التفاصيل تعمل تمرينٌ مفيدٌ للغاية.

التمرين 5.51: طوّر تنفيذًا بدائيًّا لـScheme في لغة C (أو لغة أخرى منخفضة المستوى من اختيارك) بترجمة المُقيِّم صريح التحكّم الوارد في 5.4 إلى C. ولكي تُشغّل هذه الشيفرة، ستحتاج أيضًا إلى تزويد روتينات تخصيص تخزينٍ مناسبةٍ ودعمٍ آخر لوقت التشغيل.

التمرين 5.52: كنقيضٍ لـالتمرين 5.51، عدّل المصرّف ليُصرِّف إجراءات Scheme إلى سلاسل من تعليمات C. صرِّف المُقيِّم الحلقيّ الوسيط (metacircular evaluator) الوارد في 4.1 لتُنتج مفسّرًا لـ Scheme مكتوبًا بلغة C.


  1. هذا الافتراض يُخفي جانبًا كبيرًا من التعقيد. فعادةً ما يُخصَّص جزءٌ كبيرٌ من تنفيذ نظام Lisp لجعل عمليّتي القراءة والطباعة تعملان. ↩︎

  2. قد يُقال إنّنا لسنا بحاجةٍ إلى حفظ n القديمة؛ إذ يمكننا، بعد إنقاصها وحلّ المسألة الفرعيّة، أن نزيدها ببساطةٍ فنستعيد القيمة القديمة. ومع أنّ هذه الاستراتيجيّة تنجح مع المضروب (factorial)، فإنّها لا تنجح عمومًا، لأنّ القيمة القديمة لمسجّلٍ لا يمكن دائمًا استخلاصها من القيمة الجديدة. ↩︎

  3. سنرى في 5.3 كيف نُنفّذ المكدس باستخدام عمليّاتٍ أوّليّةٍ أكثر. ↩︎

  4. استخدام إجراء receive هنا طريقةٌ لجعل extract-labels يُعيد فعليًّا قيمتين—labels وinsts—دون أن نُشكّل صراحةً بنية بيانات مركّبةٍ للاحتفاظ بهما. ثمّة تنفيذٌ بديل يُعيد زوجًا صريحًا من القيم، وهو scheme (define (extract-labels text) (if (null? text) (cons '() '()) (let ((result (extract-labels (cdr text)))) (let ((insts (car result)) (labels (cdr result))) (let ((next-inst (car text))) (if (symbol? next-inst) (cons insts (cons (make-label-entry next-inst insts) labels)) (cons (cons (make-instruction next-inst) insts) labels))))))) ↩︎

  5. كان بوسعنا تمثيل الذاكرة كقوائمَ من العناصر. لكن زمن الوصول لم يكن ليكون حينها مستقلًّا عن الفهرس، إذ إنّ الوصول إلى العنصر رقم undefinedundefined في قائمةٍ يتطلّب undefinedundefined عمليّة cdr. ↩︎

  6. تكاملًا، ينبغي أن نُحدّد عمليّة make-vector تبني المتجهات. لكن في التطبيق الحاضر سنستخدم المتجهات فقط لنمذجة تقسيماتٍ ثابتةٍ من ذاكرة الحاسوب. ↩︎

  7. هذه هي الفكرة عينها الخاصةّ بـ«البيانات الموسومة (tagged data)» التي أوردناها في الفصل 2 للتعامل مع العمليّات العامّة. لكن هنا تُدرَج أنواع البيانات في مستوى الآلة الأوّليّ بدلًا من بنائها باستخدام القوائم. ↩︎

  8. يمكن ترميز معلومات النوع بطرائق متنوّعة، حسب تفاصيل الآلة التي سيُنفّذ عليها نظام Lisp. وستعتمد كفاءة تنفيذ برامج Lisp بقوةٍ على مدى حكمة هذا الخيار، لكن من الصعب صياغة قواعد تصميمٍ عامّةٍ للخيارات الجيّدة. وأبسط طريقةٍ لتنفيذ المؤشّرات الموسومة بالنوع هي تخصيص مجموعةٍ ثابتةٍ من البتّات في كلّ مؤشّرٍ لتكون حقل النوع الذي يُرمز نوع البيانات. وتشمل الأسئلة المهمّة الواجب تناولها عند تصميم مثل هذا التمثيل ما يلي: كم بتّةٍ للنوع تلزم؟ وما الحجم الذي يجب أن تبلغه مؤشرات المتجهات؟ ومدى كفاءة استخدام تعليمات الآلة الأوّليّة في التلاعب بحقول نوع المؤشرات؟ وتُوصف الآلات التي تتضمّن عتادًا خاصًّا للتعامل الفعّال مع حقول النوع بأنّها ذات بنيةٍ موسومة (tagged architectures). ↩︎

  9. يحدّد هذا القصد في تمثيل الأعداد ما إذا كان يمكن استخدام eq?، الذي يختبر تساوي المؤشرات، لاختبار تساوي الأعداد. فإذا احتوى المؤشّر على العدد نفسه، كانت للأعداد المتساوية المؤشّر نفسه. أمّا إذا احتوى المؤشّر على فهرس موضعٍ يُخزَّن فيه العدد، فلن يكون مضمونًا أن تكون للمتساوي من الأعداد مؤشرات متساوية إلّا إذا حرصنا على ألّا نخزّن العدد نفسه في أكثر من موضعٍ واحد. ↩︎

  10. هذا أشبه بكتابة عددٍ كتسلسلٍ من الأرقام، إلّا أنّ كلّ «رقم» هنا عددٌ يتراوح بين الصفر وأكبر عددٍ يمكن تخزينه في مؤشّرٍ واحد. ↩︎

  11. ثمّة طرق أخرى لإيجاد مواضع تخزينٍ حرّة. فيمكننا مثلًا ربط كلّ الأزواج غير المستخدمة بعضها ببعض في قائمةٍ حرّة (free list). إنّ مواضعنا الحرّة متتالية (وبالتالي يمكن الوصول إليها بزيادة مؤشّرٍ) لأنّنا نستخدم جامع قمامةٍ ضاغطًا، كما سنرى في 5.3.2. ↩︎

  12. هذا هو في الجوهر تنفيذ cons بدلالة set-car! وset-cdr!، على النحو الموصوف في 3.3.1. والعمليّة get-new-pair المستخدمة في ذلك التنفيذ تُنجَز هنا بواسطة المؤشّر free. ↩︎

  13. قد لا يبقى هذا صحيحًا في المستقبل، لأنّ الذاكرات قد تكبر حدًّا يستحيل معه نفاد الذاكرة الحرّة في عمر الحاسوب. ففي السنة نحو undefinedundefined ميكروثانية، فلو أنّنا نُنشئ cons مرّةً كلّ ميكروثانية، لأضحى يلزمنا نحو undefinedundefined خليّةٍ من الذاكرة لبناء آلةٍ تعمل ثلاثين سنةً دون أن تنفد ذاكرتها. وتبدو هذه الكميّة من الذاكرة سخيفةً بمعايير اليوم، لكنّها ليست مستحيلةً فيزيائيًّا. ومن جهةٍ أخرى، تصبح المعالجات أسرع فأسرع، وقد يضمّ حاسوب المستقبل أعدادًا كبيرةً من المعالجات تعمل على التوازي على ذاكرةٍ واحدة، فقد يصبح ممكنًا استهلاك الذاكرة أسرع بكثيرٍ ممّا افترضناه. ↩︎

  14. نفترض هنا أنّ المكدس ممثَّلٌ كقائمةٍ على النحو الموضح في 5.3.1، بحيث تكون عناصر المكدس قابلةً للوصول عبر المؤشّر الكائن في مسجّل المكدس. ↩︎

  15. ابتكر هذه الفكرة مينسكي ونفّذها أوّل مرّة، كجزءٍ من تنفيذ Lisp على آلة PDP-1 في مختبر أبحاث الإلكترونيات في MIT. وطوّرها فينيتشيل ويوكيلسون (1969) لاستخدامها في تنفيذ Lisp الخاصّ بنظام المشاركة الزمنيّة Multics. ثمّ طور بايكر (1978) نسخةً «في الزمن الحقيقي» من الطريقة، لا تتطلّب وقوف الاحتساب أثناء جمع القمامة. وامتدّت فكرة بايكر على يد هيويت وليبرمان ومون (انظر ليبرمان وهيويت 1983) لتستفيد من أنّ بعض البنى أكثر تقلّبًا وبعضها الآخر أكثر ديمومة. وثمّة تقنيّة أخرى شائعة لجمع القمامة هي طريقة الكي والمسح (mark-sweep). وتتمثّل في تقفّي كلّ البنى القابلة للوصول من مسجّلات الآلة ووسم كلّ زوجٍ نصل إليه. ثمّ نمسح الذاكرة كلّها، وكلّ موضعٍ غير موسومٍ «يُكنس» كانقالًا للقمامة ويُتاح لإعادة الاستخدام. ويمكن العثور على مناقشةٍ كاملةٍ لطريقة الكي والمسح في ألين 1978. أمّا خوارزميّة مينسكي-فينيتشيل-يوكيلسون فهي الخوارزميّة السائدة في الأنظمة كبيرة الذاكرة، لأنّها لا تفحص إلّا الجزء المفيد من الذاكرة. وهذا بخلاف الكي والمسح، حيث ينبغي لمرحلة الكنس أن تفحص الذاكرة كلّها. وميزةٌ ثانية لطريقة التوقّف والنسخ أنّها جامع قمامةٍ ضاغط (compacting). أي أنّ البيانات المفيدة ستنُقل في نهاية مرحلة جمع القمامة إلى مواضع ذاكرةٍ متتالية، مع ضغط كلّ الأزواج النفاية خارجها. وقد يكون هذا اعتبارًا بالغ الأهميّة للأداء في الآلات ذات الذاكرة الافتراضيّة، حيث قد يتطلّب الوصول إلى عناوين ذاكرةٍ متباعدةٍ بعيدًا عمليّاتِ تبديل صفحاتٍ إضافيّة. ↩︎

  16. لا تتضمّن قائمة المسجّلات هذه المسجّلات التي يستخدمها نظام تخصيص التخزين—root وthe-cars وthe-cdrs وغيرها من المسجّلات التي ستُعرَّف في هذا القسم. ↩︎

  17. اصطلح على مصطلح القلب المكسور (broken heart) ديفيد كريسي، الذي كتب جامع قمامةٍ للغة MDL، وهي لهجةٌ من Lisp طُوّرت في MIT في أوائل سبعينيّات القرن العشرين. ↩︎

  18. يستخدم جامع القمامة المُسَيِّم منخفض المستوى pointer-to-pair? بدلًا من عمليّة بنية القوائم pair?، لأنّه قد توجد في نظامٍ حقيقيّ أمورٌ شتّى تُعامَل معاملة الأزواج لأغراض جمع القمامة. ففي نظامٍ من أنظمة Scheme مطابق لمعيار IEEE، مثلًا، قد يُنفّذ كائن الإجراء كنوعٍ خاصّ من «الزوج» لا يحقّقه المسيّم pair?. ولأغراض المحاكاة، يمكن تنفيذ pointer-to-pair? بدلالة pair?. ↩︎

  19. انظر باتالي وآخرون 1982 لمزيدٍ من المعلومات عن الرقاقة وعن الطريقة التي صُمّمت بها. ↩︎

  20. في متحكّمنا، يُكتب التوزيع كسلسلةٍ من تعليمتَي test وbranch. وبدلًا من ذلك، كان في الإمكان كتابته بأسلوبٍ موجَّهٍ بالبيانات (وكذلك سيكون الأمر في نظامٍ حقيقيّ على الأرجح) لتجنّب الحاجة إلى إجراء اختباراتٍ متسلسلةٍ وتسهيل تعريف أنواع تعبيراتٍ جديدة. ومن المرجّح أنّ آلةً مصمّمةً لتشغيل Lisp تتضمّن تعليمة dispatch-on-type تُنفّذ مثل هذه التوزيعات الموجَّهة بالبيانات بكفاءة. ↩︎

  21. هذه نقطة مهمّةٌ وإنّما خفيّةٌ في ترجمة الخوارزميّات من لغةٍ إجرائيّة، مثل Lisp، إلى لغة آلات المسجّلات. فبدلًا من حفظ ما يلزم فقط، كان بوسعنا حفظ كلّ المسجّلات (عدا val) قبل كلّ نداءٍ تعاوديّ. ويُسمّى هذا نظامَ المكدس المؤطَّر. وهذا ينجح، لكنّه قد يحفظ مسجّلاتٍ أكثر من اللازم؛ وقد يكون هذا اعتبارًا مهمًّا في نظامٍ تكون فيه عمليّات المكدس مكلفة. كما أنّ حفظ مسجّلاتٍ لن تُحتاج محتوياتها لاحقًا قد يُبقي بياناتٍ لا فائدة منها كان يمكن جمعها كانقالًا، فتتفرّغ مساحتها لإعادة الاستخدام. ↩︎

  22. نضيف إلى إجراءات بنية البيانات الخاصّة بالمُقيّم الواردة في 4.1.3 الإجراءَين التاليين للتلاعب بقوائم المعطيات: scheme (define (empty-arglist) '()) (define (adjoin-arg arg arglist) (append arglist (list arg))) ↩︎

  23. يُعرَف تحسين معاملة المُعامل الأخير معاملةً خاصّةً باسم التعاوب الذيلي في evlis (انظر واند 1980). وكان بوسعنا أن نكون أكفأ قليلًا في حلقة تقييم المعطيات لو جعلنا تقييم المُعامل الأوّل حالةً خاصّةً أيضًا. وهو ما كان سيسمح لنا بتأجيل تهيئة argl حتّى بعد تقييم المُعامل الأوّل، تفاديًا لحفظ argl في هذه الحالة. والمصرّف الوارد في 5.5 يُجري هذا التحسين. (قارن بإجراء construct-arglist الوارد في 5.5.3.) ↩︎

  24. يحدّد ترتيب تقييم المُعاملات في المُقيّم الحلقيّ الوسيط بترتيب تقييم معطيات cons في الإجراء list-of-values الوارد في 4.1.1 (انظر التمرين 4.1). ↩︎

  25. رأينا في 5.1 كيف نُنفّذ عمليّةً كهذه بآلة مسجّلاتٍ لا مكدس لها؛ إذ كانت حالة العمليّة تُخزَّن في مجموعةٍ ثابتةٍ من المسجّلات. ↩︎

  26. هذا التنفيذ للتعاوب الذيلي في ev-sequence نوعٌ من تقنيّة تحسينٍ معروفةٍ يستخدمها كثيرٌ من المصرّفات. فعند تصريف إجراءٍ ينتهي بنداء إجراء، يمكن استبدال النداء بقفزةٍ إلى نقطة دخول الإجراء المندىّ. وبناء هذه الاستراتيجيّة في المفسّر، كما فعلنا في هذا القسم، يُوفّر التحسين بصورةٍ موحّدةٍ في اللغة كلّها. ↩︎

  27. يمكننا تعريف no-more-exps? كما يلي: scheme (define (no-more-exps? seq) (null? seq)) ↩︎

  28. هذا ليس غشًّا حقًّا. ففي تنفيذٍ فعليٍّ مبنيٍّ من الصفر، كنّا سنستخدم مُقيّمنا ذا المتحكّم الصريح لتفسير برنامج Scheme يُجري تحويلاتٍ على مستوى المصدر، مثل cond->if، في مرحلة صياغةٍ تجري قبل التنفيذ. ↩︎

  29. نفترض هنا أنّ read ومختلف عمليّات الطباعة متاحةٌ كعمليّات آلةٍ أوّليّة، وهذا مفيدٌ لمحاكاتنا، لكنّه غير واقعيّ إطلاقًا في الممارسة. فهذه في الحقيقة عمليّات شديدة التعقيد. وفي الممارسة، كانت ستُنفّذ باستخدام عمليّات إدخالٍ وإخراجٍ منخفضة المستوى، مثل نقل محارف مفردةٍ إلى جهازٍ ومنه. ولدعم عمليّة get-global-environment نُعرّف scheme (define the-global-environment (setup-environment)) (define (get-global-environment) the-global-environment) ↩︎

  30. ثمّة أخطاء أخرى نودّ أن يتعامل معها المفسّر، لكنّها ليست بهذه البساطة. انظر التمرين 5.30. ↩︎

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

  32. للأسفّ، هذا هو الحال الطبيعيّ في أنظمة اللغات التقليديّة المبنيّة على المصرّفات، مثل C. ففي UNIX(tm) «يفرغ النظام لبَّه»، وفي DOS/Windows(tm) يصير كسولًا. أمّا Macintosh(tm) فيعرض صورة قنبلةٍ منفجرةٍ ويعرض عليك فرصة إعادة تشغيل الحاسوب—إن كنت محظوظًا. ↩︎

  33. هذا بيانٌ نظريّ. فنحن لا ندّعي أنّ مسارات البيانات الخاصّة بالمُقيّم مجموعةٌ مناسبةٌ أو فعّالةٌ بشكلٍ خاصّ لتكون مسارات حاسوبٍ عامّ الغرض. فهي مثلًا غير جيّدةٍ لتنفيذ حسابات النقاط العائمة عالية الأداء أو الحسابات التي تتلاعب بكثافةٍ بمتجهات البتّات. ↩︎

  34. في الواقع، بوسع الآلة التي تُشغّل الشيفرة المصرّفة أن تكون أبسط من آلة المفسّر، لأنّنا لن نستخدم المسجّلين exp وunev. فقد كان المفسّر يستخدمهما للاحتفاظ بقطعٍ من التعابير غير المُقيَّمة. أمّا مع المصرّف، فهذه التعابير تُبنى في الشيفرة المصرّفة التي ستُشغّلها آلة المسجّلات. ولغير هذا السبب، لا نحتاج إلى عمليّات الآلة التي تتعامل مع صياغة التعابير. لكنّ الشيفرة المصرّفة ستستخدم بعض عمليّات آلةٍ إضافيّة (لتمثيل كائنات الإجراءات المصرّفة) لم تظهر في آلة المُقيّم ذي المتحكّم الصريح. ↩︎

  35. لكن لاحظ أنّ مصرّفنا برنامج Scheme، وأنّ إجراءات الصياغة التي يستخدمها للتلاعب بالتعابير هي إجراءات Scheme الفعليّة المستخدمة مع المُقيّم الحلقيّ الوسيط. أمّا في المُقيّم ذي المتحكّم الصريح، فقد افترضنا أنّ عمليّات صياغةٍ مكافئةً متاحةٌ كعمليّاتٍ لآلة المسجّلات. (وبالطبع، عندما حاكينا آلة المسجّلات في Scheme، استخدمنا إجراءات Scheme الفعليّة في محاكاة آلة المسجّلات خاصّتنا.) ↩︎

  36. يستخدم هذا الإجراء ميزةً من Lisp تُسمّى الاقتباس الخلفيّ (backquote) (أو شبه الاقتباس (quasiquote)) وهي مفيدةٌ لبناء القوائم. فتسبيق قائمةٍ برمز الاقتباس الخلفيّ أشبه باقتباسها، إلّا أنّ أيّ شيءٍ في القائمة يُعلَّم بفاصلةٍ يُقيَّم. فمثلًا، إذا كانت قيمة linkage هي الرمز branch25، فإنّ التعبير scheme `((goto (label ,linkage))) ↩︎

  37. لا يمكننا ببساطةٍ استخدام الأسماء true-branch وfalse-branch وafter-if كما وردت أعلاه، لأنّ البرنامج قد يحوي أكثر من if واحد. فالمصرّف يستخدم الإجراء make-label لتوليد الأسماء. وMake-label يأخذ رمزًا كمعطىً ويُعيد رمزًا جديدًا يبدأ بالرمز المعطى. فمثلًا، كانت نداءات (make-label 'a) المتتالية تُعيد a1 وa2 وهكذا. ويمكن تنفيذ Make-label على نحوٍ يشبه توليد أسماء المتغيّرات الفريدة في لغة الاستعلام، كما يلي: scheme (define label-counter 0) (define (new-label-number) (set! label-counter (+ 1 label-counter)) label-counter) (define (make-label name) (string->symbol (string-append (symbol->string name) (number->string (new-label-number))))) ↩︎

  38. نحتاج إلى عمليّات آلةٍ لتنفيذ بنية بياناتٍ لتمثيل الإجراءات المصرّفة، تقابل البنيّة الخاصّة بالإجراءات المركّبة الموصوفة في 4.1.3: scheme (define (make-compiled-procedure entry env) (list 'compiled-procedure entry env)) (define (compiled-procedure? proc) (tagged-list? proc 'compiled-procedure)) (define (compiled-procedure-entry c-proc) (cadr c-proc)) (define (compiled-procedure-env c-proc) (caddr c-proc)) ↩︎

  39. في الحقيقة، نُشير إلى خطأٍ عندما لا يكون الهدف val والوصلة return، إذ إنّ المكان الوحيد الذي نطلب فيه وصلات return هو تصريف الإجراءات، واصطلاحنا أنّ الإجراءات تُعيد قيمها في val. ↩︎

  40. قد يبدو جعلُ مصرّفٍ يُنتج شيفرةً تعاوديّةً ذيليًّا فكرةً مباشرة. لكنّ معظم مصرّفات اللغات الشائعة، ومنها C وPascal، لا تفعل ذلك، ولذلك لا تستطيع هذه اللغات تمثيل العمليّات التكراريّة بالنداء الإجرائيّ وحده. وتكمُن صعوبة التعاوب الذيلي في هذه اللغات في أنّ تنفيذها تستخدم المكدس لتخزين معطيات الإجراءات ومتغيّراتها المحليّة فضلًا عن عناوين العودة. أمّا تنفيذات Scheme الموضحة في هذا الكتاب فتخزّن المعطيات والمتغيّرات في ذاكرةٍ تُجمَّع كانقالًا. والسبب في استخدام المكدس للمتغيّرات والمعطيات هو تفادي الحاجة إلى جمع القمامة في لغاتٍ لما كانت تتطلّبه لولا ذلك، ويُعتقد عمومًا أنّه أكفّ. وتستطيع مصرّفات Lisp المتطوّرة، في الواقع، استخدام المكدس للمعطيات دون أن تُفسد التعاوب الذيليّ. (انظر هانسون 1990 لوصفٍ له.) ثمّة جدلٌ أيضًا حول ما إذا كان تخصيص المكدس أكفّ فعلًا من جمع القمامة في المقام الأوّل، لكنّ التفاصيل يبدو أنّها مرتهنةٌ بدقائق دقيقة في بنية الحاسوب. (انظر آبل 1987 وميلر وروزاس 1994 لرأيين متعارضين في هذه المسألة.) ↩︎

  41. المتغيّر all-regs مربوطٌ بأسماء كلّ المسجّلات: scheme (define all-regs '(env proc val argl continue)) ↩︎

  42. لاحظ أنّ preserving ينادي append بثلاثة معطيات. ومع أنّ تعريف append المعروض في هذا الكتاب لا يقبل إلّا معطيين، فإنّ Scheme يوفّر قياسيًّا إجراء append يأخذ عددًا اعتباطيًّا من المعطيات. ↩︎

  43. لقد استخدمنا الرمز + نفسه هنا للدلالة على كلٍّ من إجراء لغة المصدر وعمليّة الآلة. وبشكلٍ عام، لن توجد مقابلةٌ واحدٌ بواحدٍ بين أوّليّات لغة المصدر وأوّليّات الآلة. ↩︎

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

  45. لا يصحّ هذا إذا سمحنا بتعريفاتٍ داخليّة، إلّا إذا استخرجناه بالمسح. انظر التمرين 5.43. ↩︎

  46. هذا هو التعديل المطلوب على البحث عن المتغيّرات إذا نُفّذت طريقة الاستخراج لإزالة التعريفات الداخليّة (التمرين 5.43). وسنحتاج إلى إزالة هذه التعريفات لكي تعمل العنونة المعجميّة. ↩︎

  47. لا يمكن استخدام العناوين المعجميّة للوصول إلى المتغيّرات في البيئة العامّة، لأنّ هذه الأسماء يمكن تعريفها وإعادة تعريفها تفاعليًّا في أيّ وقت. فمع استخراج التعريفات الداخليّة، كما في التمرين 5.43، تكون التعريفات الوحيدة التي يراها المصرّف هي التعريفات على المستوى الأعلى، وهي التي تعمل على البيئة العامّة. ولا يتسبّب تصريف تعريفٍ في إدخال الاسم المعرَّف في بيئة وقت التصريف. ↩︎

  48. وبالطبع، الإجراءات المصرّفة كالإجراءات المُفسَّرة مركّبةٌ (غير أوّليّة). واتّفاقًا مع المصطلحات المستخدمة في المُقيّم ذي المتحكّم الصريح، سنستخدم في هذا القسم «مركّب» بمعنى مُفسَّر (مقابلًا لمصرَّف). ↩︎

  49. وحيث إنّ آلة المُقيّم تبدأ الآن بـbranch، ينبغي لنا دائمًا تهيئة المسجّل flag قبل تشغيل آلة المُقيّم. ولتشغيل الآلة على حلقة القراءة-التقييم-الطباعة المعتادة، كان بوسعنا استخدام scheme (define (start-eceval) (set! the-global-environment (setup-environment)) (set-register-contents! eceval 'flag false) (start eceval)) ↩︎

  50. وبما أنّ الإجراء المصرّف كائنٌ قد يحاول النظام طباعته، نُعدّل أيضًا عمليّة طباعة النظام user-print (الواردة في 4.1.4) حتّى لا تحاول طباعة مكوّنات الإجراء المصرّف: scheme (define (user-print object) (cond ((compound-procedure? object) (display (list 'compound-procedure (procedure-parameters object) (procedure-body object) '))) ((compiled-procedure? object) (display ')) (else (display object)))) ↩︎

  51. وبوسعنا أن نفعل أفضل من ذلك بتمديد المصرّف للسماح للشيفرة المصرّفة بنداء الإجراءات المُفسَّرة. انظر التمرين 5.47. ↩︎

  52. مستقلًّا عن استراتيجيّة التنفيذ، نتحمّل عبئًا إضافيًّا كبيرًا إذا أصرَرنا على كشف الأخطاء التي تقع أثناء تنفيذ برنامج المستخدم والإشارة إليها، بدلًا من السّماح لها بإسقاط النظام أو إنتاج إجابات خاطئة. فمثلًا، يمكن كشف مرجع مصفوفةٍ خارج الحدود بالتحقّق من صلاحيّة المرجع قبل إجرائه. غير أنّ عبء التحقّق قد يبلغ أضعاف كلفة مرجع المصفوفة نفسه، وعلى المبرمج أن يوزن بين السرعة والأمان في تحديد مدى رغبته في مثل هذا التحقّق. وينبغي لمصرّفٍ جيّدٍ أن يكون قادرًا على إنتاج شيفرةٍ تتضمّن مثل هذه التحقّقات، وأن يتجنّب التحقّقات الزائدة، وأن يسمح للمبرمجين بالتحكّم في مدى فحوص الأخطاء ونوعها في الشيفرة المصرّفة. أمّا مصرّفات اللغات الشائعة، مثل C وC++، فلا تُدخل إلّا القليل جدًّا من عمليّات التحقّق من الأخطاء في الشيفرة العاملة، حتّى تجري الأمور بأسرع ما يمكن. ونتيجةً لذلك، يقع على عاتق المبرمجين تدبير التحقّق من الأخطاء صراحةً. ومؤسفٌ أنّ الناس يغفلون عن ذلك كثيرًا، حتّى في التطبيقات الحرجة حيث لا يكون هناك قيدٌ على السرعة. فبرامجهم تعيش حياةً سريعةً وخطرة. فمثلًا، استغلّت «الدودة» (Worm) الشهيرة التي شلّت الإنترنت عام 1988 فشل نظام التشغيل UNIX(tm) في التحقّق من أنّ مخزَن الإدخال قد فاض في عفريت finger. (انظر سبافورد 1989.) ↩︎

  53. وبالطبع، مع كلٍّ من استراتيجيّة التفسير واستراتيجيّة التصريف، يتحتّم علينا أيضًا أن نُنفّذ للآلة الجديدة تخصيص التخزين، والإدخال والإخراج، وكلّ العمليّات المتنوّعة التي أخذناها كـ«أوّليّات» في مناقشتنا للمُقيّم والمصرّف. وإحدى استراتيجيّات تقليل العمل هنا هي كتابة أكبر عددٍ ممكن من هذه العمليّات في Lisp ثمّ تصريفها للآلة الجديدة. وفي النهاية، يندرج كلّ شيءٍ في نواةٍ صغيرة (مثل جمع القمامة وآليّة تطبيق أوّليّات الآلة الفعليّة) تُكتب يدويًّا للآلة الجديدة. ↩︎

  54. تؤدّي هذه الاستراتيجيّة إلى اختباراتٍ ممتعةٍ لصحّة المصرّف، مثل التحقّق ممّا إذا كان تصريف برنامجٍ على الآلة الجديدة، باستخدام المصرّف المصرَّف، مطابقًا لتصريف البرنامج على نظام Lisp الأصليّ. وتتبّع مصدر الفروقات أمرٌ ممتعٌ لكنّه مُحبطٌ غالبًا، لأنّ النتائج حسّاسةٌ للغاية حتّى لأدقّ التفاصيل. ↩︎