שפת 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 = {Int, Bool}.
כלומר, ביטוי מחזיר רק שלם או בוליאני, ומשתנה שומר רק שלם או בוליאני.

💡 הכנה לבאות: למה ההפרדה קיימת?

ההפרדה הזו תהפוך לקריטית בשפות הבאות. למשל, בשפת PROC, פונקציה (Procedure) מצטרפת גם ל-ExpVal (כי ביטוי יכול להחזיר פונקציה) וגם ל-DenVal (כי משתנה יכול לשמור פונקציה).

מבנה הנתונים: עטיפת הערכים (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!")))))

📊 טבלת השוואה: שלבי המעבר מקוד המקור לערכי הריצה

שלב ייצוג מה הוא מייצג? טיפוס ב-Scheme דוגמה מעשית
Concrete / AST ייצוג של קוד המקור בזיכרון לאחר ניתוח תחבירי (עץ ביטויים). expression? (let-exp 'x (const-exp 5) (var-exp 'x))
Expressed Values (ExpVal) מה שביטויים מחזירים כאשר הם מוערכים (evaluate) על ידי value-of. expval? (num-val 5) או (bool-val #t)
Denoted Values (DenVal) הערכים שאנו קושרים למשתנים בתוך הסביבה (Environment). denval? ב-LET זהה ל-ExpVal: {Int, Bool}

🧩 סדנת בניית AST (Drag & Drop / Dropdowns)

הרכיבו את עץ הביטויים (AST) המתאים עבור קוד המקור הבא: let x = 5 in -(x, 1).

עץ הביטויים הנבנה:
צומת שורש (Root):
Scheme AST Output:
ממתין להרכבת השורש...
💡 בחר את צומת השורש הנכון כדי להתחיל בבנייה.
מפרש ה-LET

טיפול בשגיאות זמן ריצה: expval->num

אחת החובות המרכזיות של כל מפרש היא להבטיח שפעולות מתבצעות רק על טיפוסים חוקיים (Type Checking בזמן ריצה).

פונקציות חילוץ והגנה (Extractors)

כאשר אנחנו עוטפים ערך רגיל (DenVal) למבנה של ExpVal (למשל, הפיכת 5 ל-num-val(5)), עלינו לפרק אותו חזרה לפני ביצוע פעולה מתמטית כמו חיבור. אם הערך אינו מספר - נרצה להפסיק את הריצה ולזרוק שגיאה מפורשת. הקובץ המטפל בכך הוא לרוב data-structures.scm או סקריפט ייעודי של אובייקטי שגיאה.

;; expval->num : ExpVal -> Int
;; Extracts the number from a num-val, or reports an error.
(define expval->num
  (lambda (v)
    (cases expval v
      (num-val (num) num)
      (else (expval-extractor-error 'num v)))))

(define expval-extractor-error
  (lambda (variant value)
    (eopl:error 'expval-extractors 
                "Looking for a ~s, found ~s" variant value)))

💡 דגש למבחן (מנגנון Type-Safety דינמי)

בזמן בניית שפה, חובה להשתמש ב-expval->num במקום סתם לחלץ. זה מגן על המפרש מקריסה במקרה של תוכנית כמו: +(5, zero?(0)) בה מנסים לחבר מספר עם בוליאני.

⚠️ מעבדת הדיבאגינג: תפוס את השגיאה (Type Safety)

נסו להריץ את ביטוי המקור הבא המבצע חיסור בין מספר לבוליאני: let x = 5 in let y = zero?(0) in -(x, y).

interp.scm (diff-exp case):
(diff-exp (exp1 exp2)
(let ((val1 (value-of exp1 env))
(val2 (value-of exp2 env)))
(let ((num1 )
(num2 ))
(num-val (- num1 num2)))))
הפעילו עם חילוצים מתאימים או ללא חילוצים כלל.
Interpreter Output Console:
System idle. Press "Run" to evaluate the program.
STATUS: IDLE
מעטפת המפרש

אתחול המפרש: run & value-of-program

פונקציית value-of תמיד מצפה לקבל AST (העץ המנותח) וסביבה (Environment). אבל איך זה קורה בפועל מהרגע שהזנו טקסט?

הפונקציה העליונה - run

הפונקציה run משמשת כנקודת הכניסה (Entry point) למפרש כולו. היא מקבלת מחרוזת, מפעילה את הלקסר והפארסר (`scan&parse`) ומוסרת את ה-AST (מסוג Program) ל-value-of-program.

(define run
  (lambda (string)
    (value-of-program (scan&parse string))))

אתחול הסביבה - value-of-program

תוכנית שלמה (A-Program) מכילה בתוכה ביטוי אחד. התפקיד של value-of-program הוא לחלץ את הביטוי (Exp) מהתוכנית ולקרוא ל-value-of, תוך הזרקת הסביבה ההתחלתית (Initial Environment).

(define value-of-program 
  (lambda (pgm)
    (cases program pgm
      (a-program (exp1)
        (value-of exp1 (init-env))))))

;; init-env sets up initial bindings like x=10, v=5, i=1
(define init-env 
  (lambda ()
    (extend-env 'i (num-val 1)
      (extend-env 'v (num-val 5)
        (extend-env 'x (num-val 10)
          (empty-env))))))
שפת 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))))
💡 הערה ארכיטקטונית: Parallel Let מול Sequential Let
השימוש ב-map על רשימת הביטויים (המתבצע מול אותה סביבה נוכחית - env) יוצר התנהגות מקבילית (Parallel). זה אומר שאם נכתוב let x=1 y=x in ..., המשתנה y יקבל את הערך של x מהסביבה החיצונית, ולא את ה-1 שהרגע הגדרנו, כי x=1 עדיין לא נכנס לזיכרון. כדי לבנות let* (השמה סדרתית, שבה y היה מקבל 1), היינו חייבים לבצע רקורסיה שמרחיבה את הסביבה אחרי כל משתנה בנפרד.

📊 טבלת רכיבי אינטרפרטר LET: פונקציות מרכזיות ותפקידיהן

רכיב פונקציה מרכזית קלט פלט
ממשק run מחרוזת (קוד מקור) ערך (expval)
הערכה value-of expression + env expval
סביבה apply-env env + var expval (ערך המשתנה)
חילוץ ערכים expval->num / expval->bool expval number / boolean

🧩 פאזל הלוגיקה של value-of (סידור שורות קוד)

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

סדרו את השורות ובדקו את הפתרון.
מטלות שפת 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). קידום המשתנים נעשה במקביל על פי ערכם של המשתנים במצב הנוכחי.
do(
  <a 0 -(a,-4)>
  <b 7 -(b,a)>
  <c -(b,1) -(c,4)>
  [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 מוערך מחדש ומוחלף בערך של -(a, -4) ➔ לכן הופך ל-4.
  • משתנה b מוערך מחדש ומוחלף בערך של -(b, a) ➔ לכן הופך ל-7.
  • משתנה c מוערך מחדש ומוחלף בערך של -(c, 4) ➔ לכן הופך ל-2.

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

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

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

  • בדיקת תנאים: התנאי הראשון zero?(3) נכשל.
  • התנאי השני: 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))) ; הערכת כל הצעדים מול curr-env הנוכחית (מבטיח מקביליות!)
                   ; הסתרת הערכים הישנים באמצעות extend-env-multi וריצה מחודשת
                   (loop (extend-env-multi ids new-vals curr-env))))))))
      (loop init-env))))
                    
⚠️ הבטחת המקביליות בקוד: בפונקציית העזר eval-step-values (שמוזכרת אך לא ממומשת בקטע הקוד), המפרש מעריך את כל ביטויי הצעדים (Steps) מול ה-curr-env לפני שהוא קורא ל-extend-env-multi. הניתוק הזה – חישוב כל הערכים מראש ורק אז יצירת הסביבה החדשה – הוא מה שמונע ממשתנה אחד להשפיע על חברו באותה איטרציה.

🔄 תרשים זרימה: מחזור חיים של לולאת do פונקציונלית

אתחול סדרתי (eval-inits) בדיקת תנאים (check-conditions) אמת (True) עצירה והחזרת תוצאת התנאי שקר (False) חישוב צעדים במקביל בסביבה נוכחית הרחבת הסביבה עם extend-env-multi קריאה רקורסיבית
שפת 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

שם הקובץ מה התווסף/השתנה ב-PROC? תפקיד בארכיטקטורה תלויות ישירות
lang.scm נוספו proc-exp ו-call-exp הגדרת הלקסר והדקדוק (הפיכת מחרוזת ל-AST) -
data-structures.scm הוספת proc-val וטיפוס ה-procedure (ה-Closure) הגדרת טיפוסי הנתונים בזיכרון (הגדרת המבנה של ה-Closure) environments.scm
interp.scm מימוש dynamic lookup, בניית closure, וקריאה ל-apply-procedure מנוע הריצה הראשי המעריך ביטויים ומריץ את הקוד lang.scm, data-structures.scm
environments.scm ללא שינוי (ממשיך להשתמש בייצוג רשימות מקושרות לזיכרון) ניהול קשרי המשתנים (משמות לערכים בזיכרון) -
שפת 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

👑 השוואה: אזרחים מסוג ראשון (First-Class Citizens) בשפות תכנות

תכונה / יכולת אזרח מסוג ראשון (למשל: מספרים, פונקציות ב-PROC) אזרח מסוג שני (למשל: פונקציות ב-C) אזרח מסוג שלישי (למשל: אופרטורים בסיסיים +, -)
שמירה במשתנה כן! (למשל let f = proc(x) ...) רק דרך מצביעים עקיפים (Pointers) לא! אי אפשר לשמור + ישירות
העברה כארגומנט לפונקציה כן! (Higher-Order Functions) כן, באמצעות מצביעים לא!
החזרה כערך מפונקציה כן! (Currying / Factories) לא! (אי אפשר לייצר פונקציה חדשה דינמית ולהחזיר אותה) לא!
ייצוג ב-ExpVal (ערכי מפרש) proc-val(procedure) לא מיוצג ישירות ב-ExpVal כמשתנה ריצה מיוצג כחלק קשיח בקוד (Hardcoded AST)
שפת 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), אך הוא יוצר באגים מסוכנים של "לכידת משתנים" (Variable Capture). מתכנת שכותב פונקציה לא יכול להבטיח איך היא תתנהג, כי היא תלויה בשמות המשתנים המקריים שיהיו קיימים ברגע הקריאה לה. סקופ לקסיקלי מבטיח שהפונקציה אטומה וצפויה (Modular), למרות שהוא דורש זיכרון עודף לצורך שמירת הסביבה בקלוז'ר.

מבנה ה-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 מעבר ל-LET

רכיב ב-LET ב-PROC (הרחבה)
ערכים num-val, bool-val + proc-val (ערך פרוצדורלי)
ביטויים const, var, diff, zero?, if, let + proc-exp, call-exp
value-of 6 cases + 2 cases: proc → closure, call → apply
חדש: Closure (var, body, saved-env) — לוכדת את הסביבה

🔄 מנתב הסביבות (Environment Router) - ריבוי פרמטרים

השלימו את פונקציית apply-procedure לתמיכה בריבוי משתנים (Multi-Args), כדי ללמוד כיצד ממופות רשימות הערכים לסביבה השמורה.

interp.scm:
(define apply-procedure
(lambda (proc1 vals)
(cases proc proc1
(procedure (vars body saved-env)
(value-of body )
))))
מפת מיפוי פרמטרים (Environment Map):
vars (רשימת משתנים): '("x" "y" "z")
בחר מימוש בקוד משמאל כדי להפעיל את הנתב
vals (רשימת ערכים): '(num-val(10) num-val(20) num-val(30))
ממתין להשלמת הקוד...
שפת PROC (ממן)

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

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

💡 הכנה לבחינה: ממן 14 ושאלה 1 במבחן

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

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))))))
💡 הערה ארכיטקטונית על ניהול הזיכרון בכריכה דינמית:
במימוש שהוצג, טיפוס ה-procedure עדיין מחזיק את saved-env (ואנחנו פשוט בוחרים להתעלם ממנו ב-apply-procedure ולהשתמש ב-env החיצוני). כדי שהמפרש יהיה מושלם ארכיטקטונית, במימוש טהור ויעיל של כריכה דינמית אין שום סיבה לשמור את סביבת ההגדרה. היינו משנים את הקובץ data-structures.scm ומסירים לחלוטין את saved-env מתוך הקלוז'ר של proc-val, מה שהיה חוסך זיכרון רב.

💡 כוחה של סביבה דינמית: רקורסיה ללא 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)
💡 איך ה-Maker עובד מנטלית?
מכיוון שב-PROC פונקציה לא יכולה לקרוא לעצמה בשמה (השם טרם קיים בזיכרון), אנו מעבירים את הפונקציה כארגומנט לתוך עצמה תחת השם maker. מרגע שפונקציה מתקבלת כפרמטר, היא קיימת כחוק בסביבה הפנימית! הקריאה הרקורסיבית מתבצעת על ידי הפעלת הפרמטר על עצמו: (maker maker). זהו יישום מעשי של רעיון הנקרא Y-Combinator.

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

  • 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).

שבירת מחסום ההפשטה (Abstraction Barrier):

רפלקציה שוברת את "מחסום ההפשטה" (Abstraction Barrier) של המפרש. במצב רגיל, תוכנית בשפת PROC לא מודעת לקיומם של מבני הנתונים של ה-Interpreter (כמו num-val או bool-val שנכתבו ב-Scheme). מנגנון ה-Reflection מאפשר לתוכנית המקור "להציץ" אל מאחורי הקלעים של מנוע ההרצה שמריץ אותה, ולכן הוא דורש הוספת סוג חדש של תשובה (type-val) שלא מייצג נתון חישובי, אלא מידע על הנתון (Meta-data).

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.

🧮 תרשים זרימה: מעקב הרצה של Currying ו-Maker (כפל ב-4)

קריאה ל- (times4 3) הפעלה: ((makemult makemult) 3) x=3: -( ((M M) 2) , -4 ) x=2: -( ((M M) 1) , -4 ) x=1: -( ((M M) 0) , -4 ) x=0: returns 0 התוצאה: 12 -(8, -4)
שפת 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

שם הקובץ מה התווסף/השתנה ב-LETREC? תפקיד בארכיטקטורה סוג שינוי נדרש במבחן (הרחבות)
lang.scm הוספת דקדוק ה-letrec-exp הגדרת ביטוי רקורסיבי חדש הוספת משתנים מרובים (Mutual Recursion)
data-structures.scm הוספת extend-env-rec ב-datatype environment רישום "מתכון" הרקורסיה (שם, משתנה וגוף) שמירת רשימות מקבילות של שמות/משתנים/גופים
environments.scm הוספת מנגנון ה-JIT ב-apply-env היוצר closure במקום הלב של הרקורסיה: סגירת המעגל הזיכרוני חיפוש ברשימות באמצעות אינדקסים (JIT Lookup)
interp.scm פנייה ל-value-of עם סביבה רקורסיבית מורחבת הערכת ה-body הראשי של ה-letrec התאמה לקריאת extend-env-rec החדשה
שפת 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.

🥚 פרדוקס המעגליות: למה let רגיל נכשל ברקורסיה?

הגדרת Closure של fact saved-env: [x=3] (fact לא קיים כאן!) חיפוש עצמי ב-saved-env סביבת הגוף של fact fact -(n,1) ❌ שגיאה: fact לא מוגדר!
שפת 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.

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

מבנה הסביבה המורחב: נשתמש ברשימות עוקבות מקבילות (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)

🔁 מכונת הסריקה של הרשימות המקבילות (Mutual Recursion)

הדמיה של מנוע הרקורסיה ההדדית של letrec. עקבו אחר מציאת הפונקציה odd? מתוך 3 רשימות מקבילות ברגע ביצוע החיפוש בסביבה: (apply-env env 'odd?).

p-names (רשימת השמות):
0: "even?"
1: "odd?"
b-vars (רשימת הפרמטרים):
0: "x"
1: "x"
p-bodies (גופי הפונקציות):
0: (if (zero? x) true...)
1: (if (zero? x) false...)
לחצו "בצע צעד" כדי להתחיל את החיפוש הדינמי.
שפת 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-var p-name)
            
            ; אמת! חיפשו את הפונקציה הזו.
            ; ברגע זה בדיוק אנחנו מייצרים את ה-Closure במקום ואורזים אותו!
            ; שימו לב: אנחנו נותנים ל-Closure את env.
            ; ה-env הזה כולל את שכבת ה-extend-env-rec של עצמה! וכך נסגר המעגל.
            (proc-val (procedure b-var p-body env))
            
            ; שקר. חיפשו משהו אחר, נמשיך הלאה בסביבה השמורה.
            (apply-env saved-env search-var))))))

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

בואו נחזור לדוגמה שלנו עם העצרת (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 מאפשרת רקורסיה על ידי בניית סביבה שמחכה שישאלו עליה, וכששואלים עליה, היא בונה ומחזירה קלוז'ר המכיל אותה עצמה.

🐢 השלכות הביצועים של מנגנון ה-JIT:
שימו לב להשלכות הביצועים של טריק ה-JIT: בכל פעם שהפונקציה fact קוראת לעצמה ברקורסיה, פקודת apply-env פוגשת את ה-extend-env-rec ומייצרת אובייקט Closure (כלומר proc-val) חדש לחלוטין בזיכרון! גישה זו שומרת על טוהר פונקציונלי (הסביבה לא משתנה לעולם – Immutable), אך היא בזבזנית מאוד בזיכרון לעומת שפות אימפרטיביות שמעדכנות מצביע יחיד (Pointers).

תרשים זרימה: יצירת Closure בזמן ריצה (JIT) ב-apply-env

חישוב משתנה ב-apply-env האם extend-env-rec? לא (רגילה) חישוב רגיל או רקורסיה הלאה כן האם השם מחפש את הפונקציה? כן proc-val(procedure body env)

השלמת קוד תנאי העצירה - The JIT Puzzle

הבדל קריטי במפרש LETREC הוא השימוש בסביבה הנכונה לקלוז'ר מול הסביבה שבה ממשיכים את החיפוש. השלימו את הקוד ולחצו "בדוק פתרון".

environments.scm (extend-env-rec case):
(extend-env-rec (p-name b-var p-body saved-env)
(if (eqv? search-var p-name)
(proc-val (procedure b-var p-body ))
(apply-env search-var)))
פלט הדמיית ההרצה (Simulation Output):
Select options to test the recursive lookup environment rules.
בחר בשתי התיבות כדי להפעיל את הבדיקה.