שפת LET (פרק 3)

ארכיטקטורת מפרש EOPL: הקבצים שיוצרים אותה

לפני שניגש לקוד השפה, חשוב להבין כיצד בנוי הפרויקט. כל מפרש בקורס מורכב מאוסף של קבצים בעלי תפקידים מוגדרים המרכיבים יחד את צינור העיבוד (Pipeline) משלב הקריאה ועד לתוצאה.

Source Code "-(5, 2)"
מחרוזת טקסט רגילה
lang.scm scan&parse
ניתוח לקסיקלי ותחבירי
AST (diff-exp ...)
עץ ביטויים
interp.scm value-of
env
הערכת העץ (סמנטיקה)
ExpVal (num-val 3)
ערך סופי עטוף בקופסה

💡 סימולטור ארכיטקטורה אינטראקטיבי

לחצו על אחד השלבים בצינור העיבוד למעלה כדי לחשוף את תפקידו ומאחורי הקלעים שלו במפרש EOPL.

חלוקת התפקידים בקבצים

  • lang.scm - מכיל את הגדרות הלקסר והגרמטיקה (באמצעות SLLGen). מגדיר את כללי התחביר ואת שמות צומתי העץ התחבירי.
  • data-structures.scm - מגדיר את מבני הנתונים בעזרת define-datatype. מכיל את expval, פונקציות חולצים (Extractors), והגדרת טיפוסים כמו proc (פרוצדורות) בשפות מתקדמות.
  • environments.scm - מנהל את הזיכרון הלקסיקלי. מכיל פונקציות כמו extend-env ליצירת שכבת זיכרון חדשה ו-apply-env לשליפת ערך מהזיכרון.
  • interp.scm - הלב הפועם. מכיל את פונקציית value-of, שעוברת על העץ (באמצעות cases expression) ומחשבת את התוצאה עבור כל צומת.
  • top.scm - קובץ ההרצה המחבר הכל ומספק את פונקציית ה-(run "code").

💡 מדריך למבחן: הוספת פיצ'ר חדש למפרש

כמעט כל שאלה במבחן דורשת הוספת פיצ'ר. סדר הפעולות הקבוע הוא:

  1. עדכון הגרמטיקה: הוסיפו שורה ל-the-grammar ב-lang.scm כדי שהמחשב יכיר את המילים השמורות וידע לבנות את הצומת (AST node).
  2. (במידת הצורך) עדכון ערכים: אם הפיצ'ר מחזיר סוג נתון חדש (למשל רשימה), הוסיפו Variant ל-expval ב-data-structures.scm יחד עם Extractor מתאים.
  3. הסמנטיקה: הוסיפו סעיף ל-cases expression בתוך פונקציית value-of ב-interp.scm המגדיר מה הפיצ'ר עושה (אילו ביטויים הוא מעריך, איך הוא מרחיב סביבה, מה הוא מחזיר).
שפת LET (פרק 3)

מבוא: השפה הבסיסית ביותר

שפת LET מניחה את התשתית לקורס כולו. היא אינה שפת תכנות מלאה (אין בה פונקציות או לולאות), אלא פועלת כמו "מחשבון משוכלל". היא יודעת לחשב ביטויים מתמטיים, להריץ תנאים (if), והכי חשוב - לשמור משתנים מקומיים בעזרת let. בשפה זו, הכל קבוע (Immutable). מרגע שמשתנה הוגדר, לא ניתן לשנות את ערכו.

🎯 הפן הפדגוגי: מה לומדים ולמה?

  • למה הוספנו את זה? כדי להציב את מסד הנתונים וההתנהגות המינימליים ביותר של מפרש (שמירת ערכים קבועים, ביטויים ותנאים), מבלי להסתבך עם סביבות סבוכות (כמו פונקציות). זוהי נקודת ההתחלה המאפשרת לבנות את תשתית הלקסר והפארסר.
  • מה לומדים מזה? את ההבדל החשוב שבין ExpVal (הערך המוחזר מביטוי לאחר חישוב) לבין DenVal (הערך הנשמר בזיכרון של המשתנה בסביבה). בנוסף, מבינים כיצד AST מנותח בצורה רקורסיבית.

תחביר השפה (lang.scm)

הדקדוק של LET מגדיר מהם הביטויים החוקיים. שימו לב לשם הצומת שייווצר בעץ (למשל const-exp או let-exp).

; מספר בסיסי
(expression (number) const-exp)

; פעולת חיסור מתמטית
(expression
  ("-" "(" expression "," expression ")")
  diff-exp)

; תנאי
(expression
  ("if" expression "then" expression "else" expression)
  if-exp)

; שימוש במשתנה רגיל
(expression (identifier) var-exp)

; הגדרת משתנה חדש!
(expression
  ("let" identifier "=" expression "in" expression)
  let-exp)

מושגי יסוד: ExpVal מול DenVal

בספר (EOPL) תפגשו שני מושגים קריטיים של מפרשים:

  • Expressed Value (ExpVal): איזה סוג של ערכים יכול ביטוי (Expression) להחזיר כתוצאה חישובית?
  • Denoted Value (DenVal): איזה סוג של ערכים אפשר לשמור בתוך משתנה בסביבה (Binding)?

בשפת LET:
ExpVal = DenVal = {Number, Boolean}.
כלומר, ביטוי מחזיר רק מספר או בוליאני, ומשתנה שומר רק מספר או בוליאני.

מבנה הנתונים: עטיפת הערכים (data-structures.scm)

בגלל שפונקציית value-of שלנו צריכה להחזיר תמיד סוג אחד כללי של נתון, אנו עוטפים את המספרים והבוליאנים של סקים (Racket) בתוך קופסה שנקראת expval.

(define-datatype expval expval?
  ; קופסה למספר
  (num-val
    (value number?))
    
  ; קופסה לערך בוליאני
  (bool-val
    (boolean boolean?)))

; פונקציית חולץ (Extractor) לדוגמה:
(define expval->num
  (lambda (v)
    (cases expval v
      (num-val (num) num)
      (else (eopl:error 'expval->num "Expected number!")))))
שפת LET (פרק 3)

המפרש וMulti-Let

הלב הפועם של כל השפות בקורס הוא הפונקציה value-of. תפקידה הוא לקבל צומת של ה-AST וסביבה זיכרונית, ולחשב את התוצאה. בחלק זה גם נלמד כיצד להרחיב את השפה לתמיכה במספר השמות במקביל, שאלת מבחן קלאסית.

הלוגיקה הבסיסית ב-let-exp (הקמת סביבות)

בואו נבין בדיוק מה קורה בשורות האחרונות כשאנחנו כותבים בקוד שלנו: let x = 5 in -(x, 1)

; --- הלב של השפה: פקודת LET (מתוך interp.scm) ---
(let-exp (var exp1 body)
  ; 1. מחשבים את הערך של ההשמה (הצד הימני)
  (let ((val1 (value-of exp1 env)))
    ; 2. מריצים את הגוף (body) בתוך *סביבה מורחבת* המכילה את ההשמה החדשה!
    (value-of body 
      (extend-env var val1 env))))
  1. המפרש רואה let-exp ומפרק אותו: var = 'x', exp1 = 5, body = -(x, 1).
  2. מעריך את exp1 (המספר 5) בסביבה הנוכחית. התוצאה היא (num-val 5).
  3. המהלך הקריטי: משתמש ב-extend-env לייצר שכבת זיכרון חדשה (x שווה 5).
  4. מעריך את ה-body בתוך הסביבה החדשה.

הכנה למבחן: מימוש Multi-Let

בגרסה הבסיסית, let מגדיר משתנה בודד. אחת ההרחבות הנפוצות במבחן היא לאפשר כתיבת מספר הגדרות יחדיו: let x = 1 y = 2 in -(x, y). כיצד נממש זאת?

שלב 1: עדכון הגרמטיקה (lang.scm)

נשתמש במילת המפתח arbno (מספר שרירותי) כדי לדרוש 0 או יותר זוגות של (מזהה = ביטוי):

(expression
  ("let" (arbno identifier "=" expression) "in" expression)
  let-exp)

שימו לב: משום שהשתמשנו ב-arbno, הצומת let-exp ייצר כעת רשימה: identifier הופך ל-(list-of symbol?) ו-expression הופך ל-(list-of expression?).

שלב 2: עדכון המפרש (interp.scm)

מכיוון שקיבלנו רשימות של משתנים ורשימה של ביטויים (b-vars ו-b-exps), נשתמש ב-map כדי לחשב את כל הביטויים, ובפונקציית העזר extend-env* שמרחיבה את הסביבה עבור רשימות.

(let-exp (b-vars b-exps body)
  ; הפעלת value-of על כל רשימת הביטויים בעזרת map
  (let ((b-vals (map (lambda (exp) (value-of exp env)) b-exps)))
    ; הערכת הגוף בתוך סביבה שהורחבה בעזרת כל המשתנים וערכיהם יחדיו
    (value-of body
      (extend-env* b-vars b-vals env))))
מטלות שפת LET

פתרון ממן: המרות טיפוסים ולולאת do

שאלות ממן במפרשים בוחנות את היכולת שלנו להרחיב את השפה בפיצ'רים שקיימים בשפות מודרניות. כאן נצלול לפתרון של שתי שאלות עומק מהמטלות, כולל הבנת השאלה המקורית, הדוגמאות, וניתוח המעקב (Trace) של המפרש.

שאלה 1: מערכת המרת טיפוסים (Casting)

נוסח השאלה: נוסיף לשפת "ויהי" יכולת של ביצוע המרה (casting) לביטוי - הן המרה נרמזת והן מפורשת. להלן הדקדוק החדש:

Expression ::= <Type> (Expression)      cast-exp (typ exp1)
Type ::= int                            int-type ()
Type ::= bool                           bool-type ()

ההמרה פועלת כך:
- מספר לבוליאני: כל מספר שאינו 0 מומר ל-#t, ו-0 מומר ל-#f.
- בוליאני למספר: #t מומר ל-1, #f מומר ל-0.
- בנוסף, נשנה את ביטוי ה-if-exp כך שיבצע המרה נרמזת לביטוי הבוליאני שלו.

🎯 הפן הפדגוגי: מה המטרה כאן?

במקום שגיאות ריצה (Crashes) כשהמשתמש מכניס ערך מטיפוס לא צפוי, אנו לומדים כיצד ללמד את המפרש להתגמש ולנסות להמיר אותו. זוהי בדיוק ההתנהגות של שפות דינמיות כמו JavaScript (שבה if (1) עובד כמו if (true)). בנוסף, זהו תרגול מצוין של הוספת משפחה חדשה (Type) לדקדוק הלקסר.

ניתוח דוגמאות ההרצה ומעקב (Trace)

if 9 then 100 else 200

התוצאה: 100 (המרה נרמזת).

איך המפרש עובד: ה-`if-exp` שלנו קורא ל-value-of על 9. הוא מקבל num-val(9). במקום לזרוק שגיאה שזה לא בוליאני, מנגנון ה-Cases החדש שלנו מזהה שמדובר במספר שאינו 0, ולכן ממיר אותו אוטומטית ל-#t בזיכרון. לכן ה-if פונה לביטוי האמת (100).

-(100, <int>(zero?(0)))

התוצאה: 99 (המרה מפורשת).

איך המפרש עובד: פונקציית החיסור מנסה לחסר בין 100 לתוצאה של צד ימין. צד ימין הוא cast-exp לטיפוס int-type של zero?(0). הביטוי הפנימי מחזיר bool-val(#t). ה-cast-exp מקבל זאת, רואה שביקשנו int-type, וממיר את ה-#t למספר num-val(1). החיסור מתבצע: 100 פחות 1, והתוצאה 99.

מימוש טכני - הקוד שלנו

1. קובץ lang.scm - הוספת חוקי ה-Lexer וה-Parser:

(type ("int") int-type)
(type ("bool") bool-type)
(expression ("<" type ">" "(" expression ")") cast-exp)

2. קובץ interp.scm - מימוש cast-exp והמרה נרמזת ב-if-exp:

; בתוך פונקציית value-of:
(cast-exp (typ exp1)
  (let ((val (value-of exp1 env)))
    (cases type typ
      (int-type ()
        (cases expval val
          (num-val (num) val)
          (bool-val (bool) (if bool (num-val 1) (num-val 0)))
          (else (eopl:error 'value-of "Cannot cast to int: ~s" val))))
      (bool-type ()
        (cases expval val
          (bool-val (bool) val)
          (num-val (num) (if (zero? num) (bool-val #f) (bool-val #t)))
          (else (eopl:error 'value-of "Cannot cast to bool: ~s" val)))))))

; שינוי if-exp להמרה נרמזת:
(if-exp (exp1 exp2 exp3)
  (let ((val1 (value-of exp1 env)))
    (let ((is-true (cases expval val1
                     (bool-val (b) b)
                     (num-val (n) (not (zero? n))) ; המרה נרמזת!
                     (else (eopl:error 'if "Condition must be bool or num")))))
      (if is-true (value-of exp2 env) (value-of exp3 env)))))

שאלה 2: לולאת do מתקדמת

נוסח השאלה: נוסיף לשפת "ויהי" ביטוי חדש מסוג לולאת do על פי הדקדוק הבא:

Expression ::= do ( { < Identifier Expression Expression > }+ { [ Expression Expression ] }+ )
do-exp (ids inits steps bools results)

- המשתנים מאותחלים באופן סדרתי ("אתחול של משתנה יכול להכיר את המשתנים שכבר אותחלו לפניו").
- בכל מחזור הלולאה נבדקים התנאים הבוליאניים לפי סדר. הראשון שאמת עוצר את הלולאה ומחזיר את תוצאתו.
- אם אין תנאי אמת, המשתנים מקודמים בצעדים (Steps). קידום המשתנים נעשה במקביל על פי ערכם של המשתנים במצב הנוכחי.

🎯 הפן הפדגוגי: מנגנון לולאות פונקציונליות

זוהי שאלת הדגל של הקורס! היא מלמדת אותנו את ההבדל הקריטי בין סדרתי (Sequential) ל-מקבילי (Parallel) במפרשים. המשתנים באתחול נוצרים בשרשרת, אבל בקידום (הצעדים) כולם מחושבים יחד לפני העדכון. בנוסף, השאלה מדגימה איך לממש "לולאה" שרצה לנצח ללא שום פעולת set! פיזית, אלא אך ורק על ידי קריאה רקורסיבית לסביבה חדשה (Lexical Shadowing) שמסתירה את הערכים הישנים.

מעקב זרימה לדוגמת המטלה (Trace)

do(
  <a 0 4>
  <b 7 -(a,2)>
  <c -(b,1) -(a,b)>
  [zero? (b) -(a,c)]
  [zero? (-(c,-2)) -(a,5)]
)

שלב האתחול (סדרתי):

  • a מקבל 0.
  • b מקבל 7 (הוא לא תלוי ב-a באתחול).
  • c מקבל -(b,1). כיוון שזה סדרתי, c רואה את b, ולכן הוא מקבל -(7,1) = 6.

איטרציה 1 (סביבה: a=0, b=7, c=6):

  • בדיקת תנאים: התנאי הראשון zero?(7) נכשל. התנאי השני zero?(-(6,-2)) -> zero?(8) נכשל. הלולאה ממשיכה!
  • חישוב צעדים (במקביל, לפי סביבה נוכחית):
  • ל-a נוסיף 4 ➔ מ-0 ל-4.
  • ל-b נוסיף -(0,2) = -2 ➔ מ-7 ל-5.
  • ל-c נוסיף -(0,7) = -7 ➔ מ-6 ל-1-.

איטרציה 2 (סביבה חדשה: a=4, b=5, c=-1):

  • בדיקת תנאים: התנאי הראשון zero?(5) נכשל. התנאי השני zero?(-(-1,-2)) -> zero?(1) נכשל. הלולאה ממשיכה.
  • חישוב צעדים (במקביל):
  • ל-a נוסיף 4 ➔ מ-4 ל-8.
  • ל-b נוסיף -(4,2) = 2 ➔ מ-5 ל-7.
  • ל-c נוסיף -(4,5) = -1 ➔ מ-1- ל-2-.

איטרציה 3 (סביבה חדשה: a=8, b=7, c=-2):

  • בדיקת תנאים: התנאי הראשון zero?(7) נכשל.
  • התנאי השני: zero?(-(-2,-2)) -> zero?(0)אמת!
  • הלולאה נעצרת ומחזירה את תוצאת התנאי השני: -(a,5), ומכיוון ש-a הוא 8 ➔ מוחזר הערך 3!.

מימוש טכני - הקוד שלנו

קובץ lang.scm - SLLGen יארוז את הרשימות המקבילות אוטומטית:

(expression
  ("do" "("
    (arbno "<" identifier expression expression ">")
    (arbno "[" expression expression "]")
  ")")
  do-exp)

קובץ interp.scm - פונקציות העזר והלולאה הראשית ב-value-of:

; פונקציית עזר לאתחול סדרתי. שימו לב שהיא מעדכנת את ה-env ככל שמתקדמים.
(define eval-inits
  (lambda (ids inits env)
    (if (null? ids)
        env
        (let ((val (value-of (car inits) env)))
          (eval-inits (cdr ids) (cdr inits) (extend-env (car ids) val env))))))

; מנוע הלולאה:
(do-exp (ids inits steps bools results)
  (let ((init-env (eval-inits ids inits env)))
    (letrec
      ((loop
         (lambda (curr-env)
           (let ((res (check-conditions bools results curr-env)))
             (if res
                 (car res) ; עטפנו את התוצאה ב-list במידה והוחזר #f (כדי להבדיל מכשלון בתנאי)
                 (let ((new-vals (eval-step-values ids steps curr-env)))
                   ; הסתרת הערכים הישנים באמצעות extend-env-multi וריצה מחודשת
                   (loop (extend-env-multi ids new-vals curr-env))))))))
      (loop init-env))))
שפת PROC (פרק 3)

שפת PROC: הקבצים שיוצרים אותה

שפת PROC יורשת את המבנה משפת LET, אך מוסיפה את היכולת ליצור ולהפעיל פונקציות. השינויים המרכזיים מתבטאים בתוספת של ה-Closure והוספת הקריאה לפונקציות.

חלוקת הקבצים בשפת PROC

  • lang.scm - התווספו שני כללים חדשים לדקדוק: proc-exp (יצירת פונקציה) ו-call-exp (הפעלת פונקציה).
  • data-structures.scm - הקובץ עבר שדרוג משמעותי:
    • התווסף ה-variant החדש ל-expval: proc-val.
    • התווסף טיפוס נתונים חדש לגמרי: proc המגדיר את הקלוז'ר (שומר את הפרמטר, גוף הפונקציה והסביבה השמורה).
  • interp.scm - התווספה לוגיקה ל-value-of עבור יצירת פונקציות, והתווספה פונקציית העזר הקריטית apply-procedure האחראית על פתיחת הקלוז'ר והרצת הפונקציה בסביבתה השמורה.
  • environments.scm - נותר כמעט ללא שינוי לעומת LET, שכן ניהול הזיכרון הבסיסי נשאר זהה (רשימות מקושרות).

💡 מדריך למבחן: הוספת פיצ'רים ב-PROC

כשמבקשים להרחיב את PROC (למשל Multi-parameter), הפוקוס שלכם יהיה כפול:

  1. ה-Closure (ב-data-structures): איך מבנה הפונקציה ישמור כעת מספר ארגומנטים במקום אחד? (במקום לשמור symbol? נשמור list-of symbol?).
  2. ההפעלה (ב-interp): ב-apply-procedure תצטרכו להשתמש ב-extend-env* כדי לייצר סביבה מורחבת עבור מספר משתנים במקביל רגע לפני הרצת הפונקציה.
שפת PROC (פרק 3)

מבוא שפת PROC: פונקציות מסוג ראשון

שפת PROC (קיצור של Procedure) עושה את הקפיצה המשמעותית ביותר בקורס – היא הופכת את השפה מ"מחשבון" לשפת תכנות אמיתית. היא עושה זאת באמצעות רעיון פילוסופי עמוק: פונקציות הן אזרחים מסוג ראשון (First-Class Citizens).

🎯 הפן הפדגוגי: מה לומדים ולמה?

  • למה הוספנו את זה? כדי לאפשר שימוש חוזר בקוד (Code Reuse) והפשטה פרוצדורלית. פונקציות מאפשרות לנו לארוז לוגיקה תחת שם ולהריץ אותה מתי שנרצה, מה שחוסך קוד כפול ומאפשר בניית תוכניות מורכבות.
  • מה לומדים מזה? את הכוח שבהחזקת פונקציה במשתנה (כמו כל משתנה מספרי אחר), את ההפרדה העמוקה שבין הגדרת הפונקציה (Abstraction) שמתבצעת בזמן ההשמה, לבין הפעלת הפונקציה (Application) שמתבצעת מאוחר יותר. בנוסף, נלמד כיצד סביבה (Closure) נשמרת.

תוספות לדקדוק (lang.scm)

בשפת PROC, אנחנו לא קוראים לזה פונקציה אלא proc. הוספנו בסך הכל שני כללי תחביר:

; 1. יצירת פונקציה (Abstraction)
; מקביל ל-lambda בסקים. יש לה פרמטר אחד וגוף.
(expression
  ("proc" "(" identifier ")" expression)
  proc-exp)
  
; 2. הפעלת פונקציה (Application / Call)
; התחביר הוא פשוט סוגריים. הביטוי השמאלי הוא הפונקציה (operator) 
; והביטוי הימני הוא הארגומנט (operand).
(expression
  ("(" expression expression ")")
  call-exp)

הקפיצה ב-ExpVal ו-DenVal

אם פונקציה יכולה להישמר במשתנה ולחזור כתוצאה של חישוב, זה אומר שמערכת הטיפוסים שלנו משתנה דרסטית!

  • ExpVal: {Number, Boolean, Procedure}
  • DenVal: {Number, Boolean, Procedure}

כלומר, מעכשיו פונקציית apply-env עשויה להחזיר לנו פונקציה שלמה מתוך קופסת proc-val, ונוכל להעביר פונקציות כארגומנטים לפונקציות אחרות (Higher-Order Functions).

איך הקוד נראה בשפת PROC?

בואו נראה איך המתכנת כותב קוד בשפה שלנו. מאחר ואין לשפה מנגנון לשמירת פונקציות בצורה גלובלית, משתמשים ב-let כדי לתת לפונקציה שם:

% מגדירים פונקציה שמקבלת x ומחסרת ממנו 1. 
% ושומרים אותה במשתנה בשם f.
let f = proc (x) -(x, 1) 
in 
   % לאחר מכן, מפעילים אותה על המספר 10
   (f 10)

% התוצאה: 9
שפת PROC (פרק 3)

הקלוז'ר וסקופ לקסיקלי מול דינמי

פונקציה היא לא סתם שורות קוד. כדי שהיא תזכור את המשתנים החיצוניים שסבבו אותה כשהיא נוצרה, היא חייבת להיות "ארוזה" במבנה מיוחד שנקרא סגור לקסיקלי (Closure). זה מוביל לדיון החשוב ביותר בקורס לגבי ניהול משתנים (Scoping).

הבעיה הקלאסית: באיזה משתנה נשתמש?

התבוננו בקוד הבא שכתוב בשפת PROC. שאלה זו (על הטיותיה) מופיעה כמעט בכל מבחן:

let x = 3 
in let f = proc (z) -(z, x)  % הפונקציה משתמשת ב-x (משתנה חופשי!)
   in let x = 4              % כאן x השתנה ל-4.
      in (f 10)              % הפעלת הפונקציה.

השאלה הגדולה: כשהפונקציה רצה בסוף ומחשבת -(z, x) (כלומר 10 פחות x), באיזה x היא תשתמש? ב-3 או ב-4?

Lexical Scoping (לקסיקלי)

התוצאה: 7.

הפונקציה מצלמת את הסביבה ברגע הגדרתה. מכיוון שכשהיא נוצרה (בשורה 2) הערך של x בסביבה היה 3, ה-Closure שמר את 3. זה המודל התקני בו שפות מודרניות (ו-PROC) משתמשות.

Dynamic Scoping (דינמי)

התוצאה: 6.

הפונקציה מתעלמת מסביבת ההגדרה ובודקת את הסביבה ברגע ההפעלה. ברגע ההפעלה (בשורה 4), העדכון האחרון בזיכרון (Call Stack) קבע כי x שווה 4 ולכן זה הערך שיחושב.

מבנה ה-Closure ב-EOPL (data-structures.scm)

כדי לאפשר Lexical Scoping הלכה למעשה, שפת PROC אורזת את הפונקציה יחד עם הסביבה השמורה:

(define-datatype proc proc?
  (procedure
    (var symbol?)           ; הפרמטר (למשל 'z')
    (body expression?)      ; עץ ה-AST של גוף הפונקציה
    (saved-env environment?))) ; הסביבה השמורה!! זה הצילום מסך!

הדמיה השוואתית: סקופ לקסיקלי מול דינמי (Lexical vs Dynamic Scoping) הדמיה אינטראקטיבית

ראו כיצד מנוע ההרצה מחפש משתנים בכל אחד מהמודלים עבור הקוד:
let x = 3 in let f = proc(z) -(z, x) in let x = 4 in (f 10)

בקרה:
לחצו על "בצע צעד" כדי לראות את שלבי הרצת הקוד ומעקב הסביבות.
שרשרת הסביבות ברגע חישוב גוף הפונקציה:
שפת PROC (פרק 3)

מנוע ההפעלה והרחבה לריבוי ארגומנטים

השינוי ב-interp.scm מחולק לשני שלבים: יצירת ה-Closure (צילום הסביבה), ופתיחתו להרצה (apply-procedure). לאחר מכן, נראה כיצד מרחיבים את השפה לריבוי ארגומנטים - שאלה פופולרית מאוד במבחנים.

יצירה והפעלה במפרש

; --- שלב 1: הגדרת הפונקציה (proc-exp) ---
; כשהמחשב קורא את הטקסט "proc (x) ...", הוא לא מריץ את הגוף!
; הוא אורז את ה-env הנוכחי בתוך ה-Closure (שלב הצילום)
(proc-exp (var body)
  (proc-val (procedure var body env)))
  
; --- שלב 2: קריאה לפונקציה (call-exp) ---
(call-exp (rator rand)
  ; משיגים את הקלוז'ר (proc) ואת הערך של הארגומנט (arg)
  (let ((proc (expval->proc (value-of rator env)))
        (arg (value-of rand env)))
    ; מפעילים בעזרת מנוע היישום
    (apply-procedure proc arg)))

; --- מנוע היישום: פותח את הקלוז'ר! ---
(define apply-procedure
  (lambda (proc1 val)
    (cases proc proc1
      (procedure (var body saved-env)
        ; מריצים את הגוף בתוך הסביבה ה*שמורה*!
        ; המשתנה (var) יקבל את הערך (val), והכל מולבש מעל הזיכרון ההיסטורי.
        (value-of body 
          (extend-env var val saved-env))))))

הכנה למבחן: מימוש מולטי-ארגומנטים (Multi-parameter PROC)

בגרסה הרגילה, הפונקציה מקבלת פרמטר אחד. במבחנים נדרש פעמים רבות להרחיב לכתיבה בסגנון: proc (x, y, z) -(x, -(y, z)) וקריאה מתאימה (f 1 2 3).

שינויי קוד מרכזיים להרחבה זו:

  • בגרמטיקה (lang.scm): נשתמש ב-separated-list וב-arbno.
    ("proc" "(" (separated-list identifier ",") ")" expression) proc-exp
    ("(" expression (arbno expression) ")") call-exp
  • ב-data-structures.scm (טיפוס proc):
    (vars (list-of symbol?)) במקום var בודד.
  • ב-interp.scm (call-exp):
    כיוון שיש לנו rands (רשימה של ארגומנטים), נחשב את כולם בעזרת map:
    (args (map (lambda (rand) (value-of rand env)) rands))
  • ב-apply-procedure:
    שימוש בפונקציית extend-env* שמרחיבה עבור רשימת משתנים ורשימת ערכים במקביל:
    (extend-env* vars args saved-env)
שפת PROC (ממן)

פתרון ממן: סביבה דינמית, קארינג ורפלקציה

מטלה זו בוחנת את ההבנה העמוקה ביותר של שפת PROC באמצעות 3 שינויים ארכיטקטוניים מרתקים: שינוי מודל הזיכרון, מעקף מגבלות שפה באמצעות טכניקות מבוססות פונקציות בלבד, והוספת יכולת לראות "פנימה" אל תוך הטיפוסים בזמן ריצה.

1 כריכה דינמית ורקורסיה טבעית

במודל הרגיל של שפת PROC אנו משתמשים בכריכה לקסיקלית (Lexical Scoping) - פונקציה תמיד זוכרת מאיפה היא באה. היא לוקחת איתה בתוך ה-Closure את הסביבה שבה היא הוגדרה.

בשאלה זו התבקשנו לשנות את המפרש לעבוד בכריכה דינמית (Dynamic Scoping) - פונקציה מתעלמת מהמקום שבו נולדה, ופועלת לפי הסביבה של אתר הקריאה (Call-site) ברגע ההפעלה שלה.

דוגמה והרצה ידנית

let a=3
in let p = proc (x) -(x,a)
   in let a=5
      in -(a, (p 2))
  • בכריכה לקסיקלית (התנהגות רגילה): הפונקציה p מצלמת את הסביבה [a=3]. כשנקרא ל-(p 2) היא תחפש את a ותמצא 3. החישוב: 2 - 3 = -1. התוצאה הסופית: 5 - (-1) = 6.
  • בכריכה דינמית: הפונקציה מתעלמת מ-[a=3]. כשאנו קוראים לה מתוך הסביבה [a=5], ה-a שבגופה יקבל 5! החישוב: 2 - 5 = -3. התוצאה הסופית: 5 - (-3) = 8.

שינויי הקוד ב-interp.scm

כל מה שצריך לעשות הוא להעביר את סביבת אתר הקריאה (env) מה-call-exp ישר לתוך apply-procedure במקום להשתמש ב-saved-env.

; שלב א: קריאה לפונקציה מזהה את הסביבה
(call-exp (rator rand)
  (let ((proc (expval->proc (value-of rator env)))
        (arg (value-of rand env)))
    ; מעבירים את ה-env כפרמטר שלישי!
    (apply-procedure proc arg env)))

; שלב ב: פתיחת הפונקציה מתבססת עליו
(define apply-procedure
  (lambda (proc1 val env)
    (cases proc proc1
      (procedure (var body saved-env)
        ; מרחיבים את הסביבה הדינמית (env) במקום saved-env
        (value-of body (extend-env var val env))))))

💡 כוחה של סביבה דינמית: רקורסיה ללא Letrec!

בסעיף ב' של השאלה נדרשנו לכתוב פונקציית פיבונצ'י. בגלל שאנחנו בכריכה דינמית, רקורסיה עובדת באופן טבעי עם let בלבד. מדוע? משום שברגע שגוף הפונקציה מתחיל לרוץ, הוא מחפש את שמו של המשתנה בסביבת הקריאה - ושם הפונקציה כבר מוגדרת!

let fib = proc (n)
            if zero?(-(n, 1))
            then 1
            else if zero?(-(n, 2))
                 then 1
                 else -((fib -(n, 1)), -(0, (fib -(n, 2))))
in (fib 5)

* שימו לב להמרת חיבור לחיסור כפול: x + y מבוטא בשפת PROC כ--(x, -(0, y)).

2 קארינג (Currying) ורקורסיית Maker

שפת PROC הסטנדרטית מאפשרת רק ארגומנט אחד לפונקציה, ואין בה רקורסיה מובנית (ללא letrec). בשאלה זו אנו לומדים כיצד לעקוף את שתי המגבלות הללו: מעבר למספר ארגומנטים מבוצע באמצעות Currying (פונקציה שמחזירה פונקציה), ורקורסיה מבוצעת באמצעות העברת הפונקציה לעצמה (טכניקת Maker).

סעיף א: בדיקת זוגיות משותפת

פונקציית same-parity הבודקת אם שני מספרים בעלי אותה זוגיות ב-Currying מלא. הקוד מבוסס על רקורסיית ה-Maker עבור is-even.

; ה-Maker בונה בדיקת זוגיות ברקורסיה עמוקה
let is-even-maker = proc (maker)
                      proc (n)
                        if zero?(n)
                        then zero?(0) ; true
                        else if zero?(-(n, 1))
                             then zero?(1) ; false
                             else ((maker maker) -(n, 2))
                             
in let is-even = proc (n) ((is-even-maker is-even-maker) n)

   ; Currying לקבל 2 משתנים (x, y) 
   in let same-parity = proc (x)
                          proc (y)
                            if (is-even x)
                            then (is-even y)
                            else if (is-even y) then zero?(1) else zero?(0)
                            
      in ((same-parity 4) 4) ; יחזיר #t

סעיף ב: הרצת תוכנית Maker

תוכנית המממשת כפל ב-4 בצורה רקורסיבית מתוחכמת בעזרת Maker.

let makemult = proc (maker)
                 proc (x)
                   if zero? (x) then 0
                   else -(((maker maker) -(x,1)) , -4)
in let times4 = proc (x) ((makemult makemult) x)
   in (times4 3)

מעקב ההרצה מתבצע מהבסיס למעלה:

  • x=0: returns 0
  • x=1: returns -(((M M) 0) , -4) = -(0, -4) = 4
  • x=2: returns -(((M M) 1) , -4) = -(4, -4) = 8
  • x=3: returns -(((M M) 2) , -4) = -(8, -4) = 12

ולכן הערך המוחזר של התוכנית הוא 12.

סעיף ג: סכום בעזרת Currying ו-Maker

המטלה דורשת סכום מספרים 1 עד n. הנוסחה הקלאסית מבוצעת על ידי Accumulator (צובר) שעובר כפרמטר נוסף באמצעות Currying. כך מיישמים פעולה שדורשת שני ארגומנטים טבעיים (רקורסיה מעולה ונקייה!):

; מחשב סכום מ-n ל-0 בעזרת משתנה צובר (acc) 
let sum-maker = proc (maker)
                  proc (n)
                    proc (acc)
                      if zero?(n)
                      then acc
                      else (((maker maker) -(n, 1)) -(acc, -(0, n)))

in let f = proc (n) (((sum-maker sum-maker) n) 0)
   in (f 4) ; יחזיר 10 

3 רפלקציה בזמן ריצה (Reflection)

בדרך כלל, למשתנים בזמן ריצה אין זהות, אלא רק ערך (ExpVal). בפיצ'ר ה-Reflection אנו נותנים לתוכנית יכולת "לשאול" משתנים על הטיפוס שלהם בעזרת פעולת get-type. הפעולה מחזירה סוג חדש של תשובה, שאותה ניתן לבדוק. הדבר מאפשר לפונקציות לשנות את התנהגותן לפי הטיפוס שמוזן להן (Runtime Type Checking).

1. התחביר (lang.scm)

(expression
  ("get-type" "(" identifier ")")
  get-type-exp)

(expression
  ("isBool?" "(" expression ")")
  is-bool?-exp)

(expression
  ("isNum?" "(" expression ")")
  is-num?-exp)

(expression
  ("isProc?" "(" expression ")")
  is-proc?-exp)

2. מבנה נתונים (data-structures.scm)

אנו מוסיפים סוג תשובה חדש ל-ExpVal בשם type-val.

(define-datatype type-rep type-rep?
  (num-type)
  (bool-type)
  (proc-type))

(define-datatype expval expval?
  (num-val (value number?))
  (bool-val (boolean boolean?))
  (proc-val (proc proc?))
  (type-val (t type-rep?)))

3. מנוע ההפעלה (interp.scm)

(get-type-exp (id)
  (let ((val (apply-env env id)))
    (cases expval val
      (num-val (num) (type-val (num-type)))
      (bool-val (bool) (type-val (bool-type)))
      (proc-val (proc) (type-val (proc-type)))
      (type-val (t) (eopl:error '...)))))

(is-num?-exp (ex)
  (let ((val (value-of ex env)))
    (cases expval val
      (type-val (t)
        (cases type-rep t
          (num-type () (bool-val #t))
          (else (bool-val #f))))
      (else (bool-val #f)))))
; * בדומה מממשים את is-bool? ו-is-proc?

מעקב על תוכנית המבחן המסכמת

let a = 10
in let p = proc (w) -(w,a)
   in let q = proc (something)
                let t = get-type(something)
                in if isNum?(t) then (p something)
                   else if isBool?(t) then if something then 500 else 800
                   else (something 50)
      in (q zero?(a))
  • ההפעלה החיצונית: קריאה ל-q עם הערך של zero?(10). התוצאה היא #f, ולכן something בסביבה של q נשמר כערך בוליאני שקר.
  • זיהוי הטיפוס (Reflection): הפקודה get-type(something) שולפת את הערך, מזהה שזהו bool-val ומחזירה מבנה נתונים חדש - type-val(bool-type), אשר נשמר בתוך t.
  • ניתוב דינמי: התנאי isNum?(t) נכשל כיוון שמדובר בטיפוס בוליאני ולא מספרי. התנאי isBool?(t) מצליח.
  • הכרעה סופית: אנו נכנסים לענף ה-then ומבצעים את התנאי if something then 500 else 800. מכיוון ש-something בעצמו הוא ערך שקר, מוחזר המספר 800.
שפת LETREC (פרק 3)

שפת LETREC: הקבצים שיוצרים אותה

שפת LETREC מציגה לראשונה את הפתרון ללולאות מבוססות פונקציה דרך רקורסיה. השינוי המהותי ביותר בשפה מתרחש תחת מנוע הזיכרון (הסביבה).

חלוקת הקבצים בשפת LETREC

  • lang.scm - התווסף הדקדוק של letrec-exp המאפשר הגדרת פונקציה.
  • data-structures.scm - המהפך של השפה: טיפוס environment קיבל את הצורה extend-env-rec. במקום לשמור ערכים שמחושבים מראש, סביבה זו שומרת את המתכון ליצירת הפונקציה (שם הפונקציה, שם הפרמטר והגוף).
  • environments.scm - כאן טמון הקסם של השפה: בתוך apply-env נוסף תנאי למקרה של extend-env-rec שמייצר את ה-Closure בזמן אמת.
  • interp.scm - תהליך ההערכה (value-of) פשוט מוסיף את סביבת המתכון כשהוא נתקל ב-letrec-exp ופונה להרצת ה-body הראשי.

💡 מדריך למבחן: הוספת פיצ'רים ב-LETREC

עיקר שאלות המבחן יעסקו בהרחבת הרקורסיה (כמו Mutual Recursion או רקורסיה מרובת פרמטרים). המיקוד יהיה בקבצי הסביבה:

  1. data-structures.scm: עדכון סביבת extend-env-rec שתחזיק רשימות של שמות, רשימות של פרמטרים ורשימות של גופים.
  2. environments.scm: עדכון מנגנון ה-JIT בתוך apply-env כך שיחפש באינדקס ספציפי בתוך הרשימות הללו על מנת לבנות את הקלוז'ר הנכון.
שפת LETREC (פרק 3)

פרדוקס הרקורסיה: הביצה והתרנגולת

שפת PROC היא נהדרת, אבל יש לה חיסרון קטלני אחד: אי אפשר לכתוב בה רקורסיה (Recursion). פונקציה לא יכולה לקרוא לעצמה. שפת LETREC פותחה אך ורק כדי לפתור את הפרדוקס הזיכרוני הזה ולספק לנו שפה שלמה לחלוטין מבוססת LISP.

🎯 הפן הפדגוגי: מה לומדים ולמה?

  • למה הוספנו את זה? כדי לתמוך ברקורסיה ובמנגנון קריאה עצמית. ללא רקורסיה, השפה שלנו חסרת יכולת לבצע חזרתיות (לולאות) והיא חלשה מדי.
  • מה לומדים מזה? כיצד מתגברים על בעיית המעגליות של משתנה שצריך להכיר את עצמו כדי להיווצר, אך כדי להיווצר הוא חייב להיות מוכר קודם. נלמד לייצג סביבות מעגליות באמצעות קשירה בזמן ריצה (JIT Binding).

למה רקורסיה נכשלת ב-PROC?

נניח והיינו מנסים לכתוב פונקציית עצרת (Factorial) בשפת PROC הקיימת:

% הגדרנו את fact כמשתנה שאמור להחזיק פונקציה
let fact = proc (n) 
             if zero?(n) then 1 else *(n, (fact -(n,1)))
in 
   (fact 5)

מה יקרה מאחורי הקלעים? המפרש יקרוס. למה?

  • המפרש מתחיל מימין. הוא רואה proc (n) ... ויוצר Closure.
  • הוא מצלם את הסביבה באותו רגע ושומר אותה ב-saved-env.
  • אבל, באותו רגע, המשתנה fact עדיין לא קיים בסביבה!! השם fact נכנס לזיכרון רק אחרי שהצד הימני סיים את החישוב כולו.
  • כאשר הפונקציה תופעל ותנסה לעשות את הקריאה הרקורסיבית (fact -(n,1)), הפונקציה apply-env תחפש את fact בתוך ה-saved-env שלה (מזמן הצילום), לא תמצא אותו, ותזרוק שגיאת Unbound Variable.
שפת LETREC (פרק 3)

סביבות מעגליות ורקורסיה הדדית

הפתרון של ספר הקורס הוא גאוני ואלגנטי. במקום לנסות ליצור "קלוז'ר שמצביע על קלוז'ר" בזמן ההגדרה (מה שיוצר לולאה אינסופית שמקריסה את המחשב), משנים את מבנה הסביבה כך שתחזיק את "המתכון" להרכבת הפונקציה רק כשצריך אותה.

עדכון הסביבות (data-structures.scm)

אנו מוסיפים צורה שלישית ל-environment. סביבה מסוג extend-env-rec אינה מחזיקה expval כמו סביבה רגילה, אלא שומרת את ההוראות (שם, פרמטר, גוף).

(define-datatype environment environment?
  (empty-env)
  (extend-env ...)
  
  ; הסביבה המעגלית מיועדת רק לפונקציות רקורסיביות:
  (extend-env-rec
    (p-name symbol?)      ; שם הפונקציה (fact)
    (b-var symbol?)       ; פרמטר (n)
    (p-body expression?)  ; גוף (AST)
    (saved-env environment?)))

השינוי ב-interp.scm

הטיפול ב-letrec-exp פשוט מכניס את סביבת המתכון הזו מתחת להערכת הגוף הראשי. הפונקציה טרם נוצרה!

(letrec-exp (p-name b-var p-body letrec-body)
  (value-of letrec-body
    ; בתוך סביבה מורחבת מסוג רקורסיבי
    (extend-env-rec p-name b-var p-body env)))

הכנה למבחן: רקורסיה הדדית (Mutual Recursion)

במבחן מבקשים לעיתים לתמוך בהגדרת מספר פונקציות רקורסיביות יחד בבלוק אחד, אשר יכולות לקרוא אחת לשנייה (למשל פונקציית even? הקוראת ל-odd? והפוך). זה מוכר כ-extend-env-rec* או ריבוי הצהרות letrec.

מבנה הסביבה המורחב: נשתמש ברשימות עוקבות מקבילות (Parallel Lists) במקום במשתנה בודד:

(extend-env-rec*
  (p-names (list-of symbol?))
  (b-vars (list-of symbol?))
  (p-bodies (list-of expression?))
  (saved-env environment?))

כיצד apply-env עובד עם רשימות אלו?

החיפוש ייעשה בעזרת פונקציית עזר למציאת האינדקס של הפונקציה המבוקשת בתוך רשימת p-names. אם הפונקציה נמצאה באינדקס i, נשלוף את הפרמטר והגוף מאותו האינדקס (באמצעות list-ref על b-vars ו-p-bodies) ונרכיב מהם את הקלוז'ר בו במקום.

תרשים מעגל הסביבות של LETREC הסבר חזותי

כשפונקציה רקורסיבית מוגדרת בתוך letrec, המפרש מייצר סביבה מיוחדת מסוג extend-env-rec. בעת חיפוש שם הפונקציה ב-apply-env, נוצר קלוז'ר שסביבתו השמורה היא הסביבה הזו עצמה, ובכך נוצר מעגל (Circular Reference) המאפשר רקורסיה:

סביבה מורחבת רקורסיבית: E1 (extend-env-rec)
שם הפונקציה (p-name) "fact"
הערך המוחזר: Closure (פרוצדורה)
פרמטר (b-var) n
גוף (p-body) if zero?(n) ...
סביבה שמורה (saved-env) E1 (מצביע לעצמו!)
↓ saved-env (הסביבה החיצונית)
E0 (empty-env or base-env)
שפת LETREC (פרק 3)

הקסם של apply-env: יצירה בזמן אמת

מתי הפונקציה נוצרת בפועל ואיך היא סוף סוף מכירה את עצמה? הכל קורה ברגע החיפוש! (Just-In-Time Evaluation). זהו הטריק המבריק ביותר של שפות תכנות פונקציונליות.

עדכון אלגוריתם החיפוש (environments.scm)

הוספנו מקרה שלישי ל-cases בתוך הפונקציה apply-env האגדית:

(define apply-env
  (lambda (env search-var)
    (cases environment env
      (empty-env () ...)
      (extend-env (...) ...)
      
      ; הגענו לשכבה מעגלית.
      ; נבדוק האם המשתנה שמחפשים הוא הפונקציה שלנו:
      (extend-env-rec (p-name b-var p-body saved-env)
        (if (eqv? search-sym p-name)
            
            ; אמת! חיפשו את הפונקציה הזו.
            ; ברגע זה בדיוק אנחנו מייצרים את ה-Closure במקום ואורזים אותו!
            ; שימו לב: אנחנו נותנים ל-Closure את env.
            ; ה-env הזה כולל את שכבת ה-extend-env-rec של עצמה! וכך נסגר המעגל.
            (proc-val (procedure b-var p-body env))
            
            ; שקר. חיפשו משהו אחר, נמשיך הלאה בסביבה השמורה.
            (apply-env saved-env search-sym))))))

הלוגיקה (סגירת המעגל)

בואו נחזור לדוגמה שלנו עם העצרת (fact) ונעקוב מה קורה כשהיא קוראת לעצמה (fact -(n,1)):

  1. כשהקוד מגיע לקריאה ל-fact, הוא עושה value-of על המשתנה, מה שגורם לקריאה ל-apply-env.
  2. החיפוש יורד בשרשרת ומגיע לשכבה extend-env-rec שבה שם הפונקציה הוא "fact". יש התאמה!
  3. הקסם קורה: אנו מייצרים Closure. במקום לתת לו את saved-env (שאינה כוללת את fact), אנו נותנים לו את env של הרגע. הסביבה הזו היא בעצמה שכבת ה-extend-env-rec של fact.
  4. ה-Closure נארז ומוחזר למפרש כערך חישובי (ExpVal).
  5. בתוך הגוף הרקורסיבי, הפונקציה מנסה שוב להפעיל את fact. היא הולכת לסביבה השמורה שלה, ושוב נכנסת ל-apply-env, שמייצר שוב את ה-Closure במקום, וכך הלאה עד לתנאי העצירה!

לסיכום: LETREC מאפשרת רקורסיה על ידי בניית סביבה שמחכה שישאלו עליה, וכששואלים עליה, היא בונה ומחזירה קלוז'ר המכיל אותה עצמה.

שפת LEXADDR (פרק 3.6)

כתובות לקסיקליות: הקבצים שיוצרים אותה

הארכיטקטורה משתנה מהותית. קוד המקור עובר כעת שלב מקדים של תרגום לפני שהוא רץ. במקום Interpreter בלבד, יש לנו כעת מערכת משולבת של "קומפיילר" ומפרש קל משקל.

lang.scm scan&parse
translator.scm translation-of
שלב הסטטי (Senv)
interp.scm value-of
nameless-env
סביבת זמן הריצה הפשוטה

חלוקת הקבצים המעודכנת

  • translator.scm (קובץ חדש) - קובץ הליבה החדש של השלב הסטטי. המודול עובר על העץ המקורי ומרכיב עץ חדש (Nameless AST) בעזרת סביבה סטטית (`Senv`) שמחשבת את המיקומים (הכתובות).
  • data-structures.scm - הקובץ התפצל לשניים: סביבה סטטית (`extend-senv` המחזיקה רק שמות), וסביבת זמן ריצה (`nameless-environment`) שהפכה מרשימה מקושרת מורכבת לרשימת סקים פשוטה (`list-of expval`). גם ה-Closure כבר לא שומר את שם המשתנה שלו.
  • interp.scm - הפך לרזה ומהיר יותר. מתעסק אך ורק בהשמת ערכים על רשימה שטוחה ושליפה לפי אינדקס.

💡 מדריך למבחן: הוספת פיצ'רים בכתובות לקסיקליות

הוספת פיצ'ר כאן דורשת להעביר אותו בכל הצינור:

  1. lang.scm: הוספת התחביר פעמיים! פעם למקור ופעם לעץ ה-Nameless.
  2. translator.scm: כתיבת תנאי המתרגם את הפעולה מצומת מילולי לצומת נטול שמות (תוך עדכון או חיפוש ב-Senv).
  3. interp.scm: כתיבת כלל החישוב בפועל לצומת נטול השמות שנוצר ב-Nameless AST.
שפת LEXADDR (פרק 3.6)

כתובות לקסיקליות: קומפילציה ותרגום

עד כה, סביבת הזיכרון שלנו עבדה באמצעות חיפוש שמות (Strings / Symbols) על פני שרשרת. זהו תהליך ארוך ולא יעיל הדורש $O(d)$ זמן, כאשר $d$ הוא העומק הלקסיקלי. בפרק זה, אנו מחלקים את העבודה לשלב של "קומפילציה" (תרגום מראש) ושלב ריצה, ובכך נפטרים משמות המשתנים כליל!

🎯 הפן הפדגוגי: מה לומדים ולמה?

  • למה הוספנו את זה? כדי לפתור את חוסר היעילות של חיפוש מילולי בזיכרון. זהו השלב המקשר בין קוד מקור אנושי לבין קוד מכונה/קוד ביניים (Bytecode) יעיל. הרי במעבד אין שמות למשתנים, יש רק כתובות.
  • מה לומדים מזה? את ההפרדה הקריטית בין זמן קומפילציה (Static Analysis) לזמן ריצה (Runtime). אנו לומדים כיצד ניתוח לקסיקלי סטטי מאפשר לנו לדעת מראש בדיוק היכן משתנה יימצא בזיכרון, וכך להמיר חיפוש מחרוזות לגישה ב-$O(1)$ באמצעות אינדקס של מערך.

איך זה עובד? (המרת שמות לכתובות)

בזמן הקומפילציה אנו מעבירים את ה-AST המקורי דרך מודול שנקרא translator.scm. הוא עובר על הקוד יחד עם סביבה דמה הנקראת הסביבה הסטטית (Senv). הסביבה הסטטית שומרת רק את שמות המשתנים (ללא ערכם, שעדיין לא ידוע), ומטרתה לחשב את העומק של כל משתנה.

הצומת הרגיל var-exp(var) מתורגם בעץ התחבירי החדש לצומת nameless-var-exp(n) המכיל אינדקס מספרי בלבד.

; קוד המקור (עם שמות אנושיים)
let x = 3 in let y = 4 in -(x, y)

; לאחר התרגום (AST של שפת Nameless)
; שימו לב: השמות x ו-y הוסרו מה-let ומאופרציית החיסור לחלוטין!
(nameless-let-exp 3 
  (nameless-let-exp 4 
    (diff-exp (nameless-var-exp 1) (nameless-var-exp 0))))

המשתנה הפנימי ביותר y מקבל את האינדקס 0 (האחרון שהוכנס לזיכרון והקרוב ביותר לסקופ), והמשתנה x מקבל את האינדקס 1.

מחשב כתובות לקסיקליות אינטראקטיבי הדמיה אינטראקטיבית

עמדו עם העכבר או לחצו על המשתנים שבתוך פעולת החיסור בשורה התחתונה כדי לראות כיצד מתבצע התרגום הסטטי לאינדקסים (Nameless Variables):

let x = 37
let y = 11
let z = 5
in - ( x , z )
חישוב מול הסביבה הסטטית (Senv):
index 0 (Innermost) z
index 1 y
index 2 (Outermost) x
הציבו את העכבר מעל x או z בקוד משמאל כדי לראות את חישוב הכתובת הלקסיקלית שלו.

קוד התרגום המרכזי (translator.scm)

המנגנון שעובר על העץ ובאופן שיטתי מחליף כל הופעה של שם בכתובת המחושבת מהסביבה הסטטית.

(define translation-of
  (lambda (exp senv)
    (cases expression exp
      
      ; קריאה למשתנה מתורגמת לאינדקס המספרי בעזרת הסביבה הסטטית
      (var-exp (var)
        (nameless-var-exp
          (apply-senv senv var)))
          
      ; הגדרת המשתנה (let) משמיטה את שם המשתנה מהצומת החדש
      ; ומרחיבה את הסביבה הסטטית עבור בדיקת הגוף הפנימי (body)
      (let-exp (var exp1 body)
        (nameless-let-exp
          (translation-of exp1 senv)            
          (translation-of body
            (extend-senv var senv))))
            
      ; אותו הדבר בדיוק מתרחש בפונקציה (proc)!
      (proc-exp (var body)
        (nameless-proc-exp
          (translation-of body
            (extend-senv var senv))))
            
      ...)))
שפת LEXADDR (פרק 3.6)

המפרש נטול השמות: Nameless Interpreter

לאחר שלב התרגום, אנו מקבלים עץ מסוג Nameless-program. עץ זה מועבר לפונקציה value-of שונה לחלוטין. השינוי המהפכני כאן מתרחש בניהול הזיכרון – הסביבה הופכת לרשימה פשוטה של ערכים, ללא כל קשר לטקסט המקורי. מהירות הריצה עולה דרמטית.

סביבת Nameless-env פשוטה ויעילה

בקובץ data-structures.scm, הסביבה (Environment) היא כעת בסך הכל רשימה שטוחה של expval. השליפה מתבצעת בעזרת הפקודה המובנית list-ref שמגיעה במהירות לאיבר ה-n.

; מבנה הסביבה
(define nameless-environment?
  (lambda (x) ((list-of expval?) x)))

; הרחבה - פשוט שמים את הערך בראש הרשימה! (cons)
(define extend-nameless-env
  (lambda (val nameless-env)
    (cons val nameless-env)))

; שליפה - גישה ישירה לפי אינדקס n המקומפל
(define apply-nameless-env
  (lambda (nameless-env n)
    (list-ref nameless-env n)))

גם ה-Closure עבר דיאטה

טיפוס הפרוצדורה המקומפל (Nameless) אינו צריך יותר לשמור את שם הפרמטר שלו (b-var). הפרמטר פשוט יקבל את האינדקס 0 בעת כניסה לגוף הפונקציה בזמן ריצה.

(define-datatype proc proc?
  (procedure
    ; שימו לב! אין כאן (var symbol?) בשדות!!
    (body expression?)
    (env nameless-environment?)))

פונקציית value-of במפרש המקומפל

כך נראית התוצאה הסופית בקובץ interp.scm. מנוע ההרצה הפך להיות יעיל, רזה, ומשולל התעסקות עם מחרוזות מילוליות.

(define value-of
  (lambda (exp nameless-env)
    (cases expression exp
      
      ; הגישה למשתנה קוראת ישר לאינדקס מהסביבה
      (nameless-var-exp (n)
        (apply-nameless-env nameless-env n))

      ; השמת ה-let פשוט מוסיפה את הערך המחושב (val)
      ; להיות האיבר הראשון ברשימת הסביבה
      (nameless-let-exp (exp1 body)
        (let ((val (value-of exp1 nameless-env)))
          (value-of body
            (extend-nameless-env val nameless-env))))

      ; יצירת הפונקציה לוקחת רק את הגוף והסביבה (ללא משתנים)
      (nameless-proc-exp (body)
        (proc-val
          (procedure body nameless-env)))
          
      ...)))