ארכיטקטורת מפרש EOPL: הקבצים שיוצרים אותה
לפני שניגש לקוד השפה, חשוב להבין כיצד בנוי הפרויקט. כל מפרש בקורס מורכב מאוסף של קבצים בעלי תפקידים מוגדרים המרכיבים יחד את צינור העיבוד (Pipeline) משלב הקריאה ועד לתוצאה.
"-(5, 2)"
(diff-exp ...)
(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").
💡 מדריך למבחן: הוספת פיצ'ר חדש למפרש
כמעט כל שאלה במבחן דורשת הוספת פיצ'ר. סדר הפעולות הקבוע הוא:
- עדכון הגרמטיקה: הוסיפו שורה ל-
the-grammarב-lang.scmכדי שהמחשב יכיר את המילים השמורות וידע לבנות את הצומת (AST node). - (במידת הצורך) עדכון ערכים: אם הפיצ'ר מחזיר סוג נתון חדש (למשל רשימה), הוסיפו Variant ל-
expvalב-data-structures.scmיחד עם Extractor מתאים. - הסמנטיקה: הוסיפו סעיף ל-
cases expressionבתוך פונקצייתvalue-ofב-interp.scmהמגדיר מה הפיצ'ר עושה (אילו ביטויים הוא מעריך, איך הוא מרחיב סביבה, מה הוא מחזיר).
מבוא: השפה הבסיסית ביותר
שפת 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).
טיפול בשגיאות זמן ריצה: 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).
אתחול המפרש: 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))))))
המפרש ו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))))
- המפרש רואה
let-expומפרק אותו:var = 'x',exp1 = 5,body = -(x, 1). - מעריך את
exp1(המספר 5) בסביבה הנוכחית. התוצאה היא(num-val 5). - המהלך הקריטי: משתמש ב-
extend-envלייצר שכבת זיכרון חדשה (x שווה 5). - מעריך את ה-
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))))
השימוש ב-
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 בסדר הנכון בעזרת החצים, ולחצו על "בדוק פתרון".
פתרון ממן: המרות טיפוסים ולולאת do
שאלות ממן במפרשים בוחנות את היכולת שלנו להרחיב את השפה בפיצ'רים שקיימים בשפות מודרניות. כאן נצלול לפתרון של שתי שאלות עומק מהמטלות, כולל הבנת השאלה המקורית, הדוגמאות, וניתוח המעקב (Trace) של המפרש.
שאלה 1: מערכת המרת טיפוסים (Casting)
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-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 פונקציונלית
שפת PROC: הקבצים שיוצרים אותה
שפת PROC יורשת את המבנה משפת LET, אך מוסיפה את היכולת ליצור ולהפעיל פונקציות. השינויים המרכזיים מתבטאים בתוספת של ה-Closure והוספת הקריאה לפונקציות.
חלוקת הקבצים בשפת PROC
lang.scm- התווספו שני כללים חדשים לדקדוק:proc-exp(יצירת פונקציה) ו-call-exp(הפעלת פונקציה).data-structures.scm- הקובץ עבר שדרוג משמעותי:- התווסף ה-variant החדש ל-expval:
proc-val. - התווסף טיפוס נתונים חדש לגמרי:
procהמגדיר את הקלוז'ר (שומר את הפרמטר, גוף הפונקציה והסביבה השמורה).
- התווסף ה-variant החדש ל-expval:
interp.scm- התווספה לוגיקה ל-value-ofעבור יצירת פונקציות, והתווספה פונקציית העזר הקריטיתapply-procedureהאחראית על פתיחת הקלוז'ר והרצת הפונקציה בסביבתה השמורה.environments.scm- נותר כמעט ללא שינוי לעומת LET, שכן ניהול הזיכרון הבסיסי נשאר זהה (רשימות מקושרות).
💡 מדריך למבחן: הוספת פיצ'רים ב-PROC
כשמבקשים להרחיב את PROC (למשל Multi-parameter), הפוקוס שלכם יהיה כפול:
- ה-Closure (ב-data-structures): איך מבנה הפונקציה ישמור כעת מספר ארגומנטים במקום אחד? (במקום לשמור
symbol?נשמורlist-of symbol?). - ההפעלה (ב-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: פונקציות מסוג ראשון
שפת 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) |
הקלוז'ר וסקופ לקסיקלי מול דינמי
פונקציה היא לא סתם שורות קוד. כדי שהיא תזכור את המשתנים החיצוניים שסבבו אותה כשהיא נוצרה, היא חייבת להיות "ארוזה" במבנה מיוחד שנקרא סגור לקסיקלי (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)
בקרה:
שרשרת הסביבות ברגע חישוב גוף הפונקציה:
מנוע ההפעלה והרחבה לריבוי ארגומנטים
השינוי ב-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), כדי ללמוד כיצד ממופות רשימות הערכים לסביבה השמורה.
פתרון ממן: סביבה דינמית, קארינג ורפלקציה
מטלה זו בוחנת את ההבנה העמוקה ביותר של שפת 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)
מכיוון שב-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) של המפרש. במצב רגיל, תוכנית בשפת 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)
שפת 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 או רקורסיה מרובת פרמטרים). המיקוד יהיה בקבצי הסביבה:
- data-structures.scm: עדכון סביבת
extend-env-recשתחזיק רשימות של שמות, רשימות של פרמטרים ורשימות של גופים. - 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 החדשה |
פרדוקס הרקורסיה: הביצה והתרנגולת
שפת 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 רגיל נכשל ברקורסיה?
סביבות מעגליות ורקורסיה הדדית
הפתרון של ספר הקורס הוא גאוני ואלגנטי. במקום לנסות ליצור "קלוז'ר שמצביע על קלוז'ר" בזמן ההגדרה (מה שיוצר לולאה אינסופית שמקריסה את המחשב), משנים את מבנה הסביבה כך שתחזיק את "המתכון" להרכבת הפונקציה רק כשצריך אותה.
עדכון הסביבות (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) המאפשר רקורסיה:
🔁 מכונת הסריקה של הרשימות המקבילות (Mutual Recursion)
הדמיה של מנוע הרקורסיה ההדדית של letrec. עקבו אחר מציאת הפונקציה odd? מתוך 3 רשימות מקבילות ברגע ביצוע החיפוש בסביבה: (apply-env env 'odd?).
הקסם של 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)):
- כשהקוד מגיע לקריאה ל-
fact, הוא עושהvalue-ofעל המשתנה, מה שגורם לקריאה ל-apply-env. - החיפוש יורד בשרשרת ומגיע לשכבה
extend-env-recשבה שם הפונקציה הוא "fact". יש התאמה! - הקסם קורה: אנו מייצרים Closure. במקום לתת לו את
saved-env(שאינה כוללת את fact), אנו נותנים לו אתenvשל הרגע. הסביבה הזו היא בעצמה שכבת ה-extend-env-recשלfact. - ה-Closure נארז ומוחזר למפרש כערך חישובי (ExpVal).
- בתוך הגוף הרקורסיבי, הפונקציה מנסה שוב להפעיל את
fact. היא הולכת לסביבה השמורה שלה, ושוב נכנסת ל-apply-env, שמייצר שוב את ה-Closure במקום, וכך הלאה עד לתנאי העצירה!
לסיכום: LETREC מאפשרת רקורסיה על ידי בניית סביבה שמחכה שישאלו עליה, וכששואלים עליה, היא בונה ומחזירה קלוז'ר המכיל אותה עצמה.
שימו לב להשלכות הביצועים של טריק ה-JIT: בכל פעם שהפונקציה
fact קוראת לעצמה ברקורסיה, פקודת apply-env פוגשת את ה-extend-env-rec ומייצרת אובייקט Closure (כלומר proc-val) חדש לחלוטין בזיכרון! גישה זו שומרת על טוהר פונקציונלי (הסביבה לא משתנה לעולם – Immutable), אך היא בזבזנית מאוד בזיכרון לעומת שפות אימפרטיביות שמעדכנות מצביע יחיד (Pointers).
⚡ תרשים זרימה: יצירת Closure בזמן ריצה (JIT) ב-apply-env
✨ השלמת קוד תנאי העצירה - The JIT Puzzle
הבדל קריטי במפרש LETREC הוא השימוש בסביבה הנכונה לקלוז'ר מול הסביבה שבה ממשיכים את החיפוש. השלימו את הקוד ולחצו "בדוק פתרון".