© כל הזכויות על המבחנים והשאלות שמורות לאוניברסיטה הפתוחה
אני יודע שהספר ארוך, מסובך ובאנגלית, ואף אחד לא באמת קורא אותו. אבל בעיקר בעקבות המבחן האחרון (2026), נראה שיש צורך בהכרה לפחות בסיסית של הספר והמבנה שלו, מה שיכול לחסוך הרבה זמן ומאמץ במבחן בעזרה של התמצאות ומציאת קטעי קוד במהירות. בחלק שלפניכם יש הסבר מהיר על מבנה הספר שכדאי להכיר, ובנוסף את כל קטעי הקוד המלאים מהספר ואיפה הם מופיעים, שמומלץ להעתיק לפי הצורך למדריך למידה על מנת שבמבחן יהיה ניתן להתמצא בקלות.
לפניכם פירוט תמציתי של מבנה הספר והנושאים המרכזיים. לחצו על כל פרק כדי להרחיב.
צוללים פנימה:
תארו לעצמכם שאתם מנסים להסביר לילד איך לבנות מגדל אינסופי מלגו. אתם לא יכולים להסביר לו על כל קובייה וקובייה עד אינסוף, נכון? במקום זה, אתם נותנים לו שני חוקים פשוטים: הראשון - איך שמים את הקובייה הראשונה על הרצפה (מקרה הבסיס). השני - אם כבר יש לך מגדל, איך מוסיפים לו בדיוק עוד קובייה אחת למעלה (צעד האינדוקציה). ברגע שהילד מבין את שני החוקים האלה, הוא יכול לבנות מגדל בכל גובה שירצה!
זה בדיוק מה שלומדים בפרק 1: איך להגדיר קבוצות ענק של נתונים (כמו רשימות ארוכות או עצים של מידע) בעזרת שפת ה-BNF (Backus-Naur Form) המאפשרת הגדרה רקורסיבית מבוססת כללים.
הפרק גם מסביר את מושג הרקורסיה על נתונים (כמו פונקציות שרצות על רשימות ועצים כגון list-length ו-nth-element), כבסיס לחשיבה של בניית קוד מקטעי בסיס. זהו הבסיס שעליו נשען כל עולם מדעי המחשב והתכנות, והוא קריטי להבנת כל הפרקים הבאים.
צוללים פנימה:
דמיינו שאתם נוהגים ברכב. אתם לוחצים על דוושת הגז, והרכב מאיץ. אתם לא צריכים לדעת בדיוק כמה דלק מוזרק למנוע, איך הבוכנות זזות, או איך תיבת ההילוכים מעבירה את הכוח לגלגלים. יש לכם 'ממשק' פשוט (הדוושה), והמכניקה הפנימית 'מוסתרת' מכם.
בפרק 2 אנחנו לומדים בדיוק את זה - הפשטת נתונים (Data Abstraction). המטרה היא להגדיר מידע לפי הממשק (Interface) שלו, ולא לפי הייצוג שלו בזיכרון. למשל, ניישם סביבות משתנים (Environments) בדרכים שונות - פעם כרשימות צמודות (a-lists) ופעם כפונקציות המייצגות זיכרון.
בנוסף, והכי חשוב - לומדים על תהליך ה-Parsing: איך הופכים טקסט רגיל שהמתכנת מקליד (כמו מחרוזת של קוד) לעץ תחביר מופשט (AST) בעזרת הכלים הפנימיים שלנו. העץ הזה הוא המבנה שהמפרש שלנו יקרא ויריץ.
צוללים פנימה (חומר מרכזי במבחן!):
זהו הפרק שבו אנחנו בונים את ליבת המפרש שלב אחר שלב:
let x = 5 in +(x, 3).בנוסף, הפרק עוסק רבות בהבדלי Scoping (לקסיקלי מול דינמי - כלומר מה קורה אם פונקציה לוקחת משתנים מסביבת היצירה שלה, לעומת סביבת הקריאה שלה) וכן לומדים על Lexical Addresses. הטריק של הכתובות הלקסיקליות מאפשר למפרש לוותר לגמרי על חיפוש שמות של משתנים בזיכרון, ובמקום זאת לתרגם כל משתנה לקואורדינטה מספרית (Nameless Variables), מה שמשפר משמעותית את המהירות.
צוללים פנימה (חומר מרכזי במבחן!):
עד עכשיו, התוכנות שלנו היו 'טהורות' (Functional). ברגע שהגדרנו ש-X שווה 5, הוא נשאר 5. בפרק 4 אנחנו מכניסים לעולם שלנו את מימד ה'שינוי' וניהול מצב - אנחנו מוסיפים למחשב 'זיכרון' גלובלי (Store). תחשבו על הזיכרון כמו על שורת תאי דואר שאפשר להחליף את הפתק שבתוכם.
newref), קורא את תוכנו (deref), ומחליף את הערך (setref). כמו מצביעים (Pointers) ב-C.set), והמערכת הולכת לתא הנכון ומעדכנת אותו - כמו ב-Java או Python.while, פקודות בלוק begin-end והדפסות מפורשות (print).הפרק נחתם בנושא הקריטי של שיטות שונות להעברת מידע לפונקציות: אנחנו חוקרים את ההבדלים בין Call by Reference (העברת המצביע לזיכרון עצמו), Call by Value (העתקת הערך), Call by Name (החלפת הביטוי כמו מאקרו) ו-Call by Need (הערכה עצלה שזוכרת את התוצאה למניעת חישובים חוזרים - שידוע בשם Memoization).
צוללים פנימה:
דמיינו שאתם קוראים ספר נהדר, אבל לפתע הטלפון מצלצל. מה תעשו? תשימו סימניה בספר כדי לזכור בדיוק איפה עצרתם, תלכו לענות לטלפון, וכשתסיימו תחזרו בדיוק לאותה נקודה. "סימניה" כזו במדעי המחשב נקראת 'המשכיות' (Continuation) - היא שומרת את המצב המדויק של התוכנית ואת השאלה "מה נשאר לי לעשות כשאסיים את המשימה הנוכחית?".
בפרק הזה אנחנו משכתבים את כל המפרש שלנו כך שיעבוד עם הסימניות האלו. למה שנעשה דבר כזה מסובך? התשובה נגלית מיד: כשיש שליטה מלאה על 'הסימניות', אפשר לשלוט על בקרת הזרימה באופן מוחלט!
אנו לומדים לבנות פונקציות שמבטלות את בעיית גלישת המחסנית (Stack Overflow) בטכניקת Trampolining. בנוסף, אם קורית שגיאה חמורה, אפשר לקפוץ מיד לסימניה שמטפלת בשגיאות (מה שנקרא Exceptions עם try-catch).
הקפיצה הגדולה בסוף הפרק היא בניית Threads (תהליכונים) - המערכת מנהלת מספר משימות במקביל על ידי מעבר סימניות מהיר ביניהן בעזרת קוצב זמן (Time Slicer) המקצה תור של משימות למעבד ומדמה מקביליות מלאה.
צוללים פנימה:
בפרק 5 למדנו לכתוב את המפרש שלנו בסגנון CPS. בפרק 6 אנחנו לוקחים צעד קדימה: במקום שהמפרש יהיה כתוב ב-CPS, אנחנו בונים מתרגם (Translator) שלוקח כל תוכנית רגילה בשפת המקור (כמו LETREC) וממיר אותה אוטומטית לתוכנית המקבילה לה ב-CPS.
זהו שלב מרכזי בקומפילציה מודרנית: כשהתוכנית מומרת ל-CPS, סדר הביצוע הופך למוחלט ומפורש (ללא תלות בסדר ההערכה של שפת האם), ואין קריאות שחוזרות (Returns) אלא רק קריאות זנב (Tail Calls).
הפרק מציג את תהליך ההמרה (CPS-IN / CPS-OUT) שמטפל בכל סוגי הביטויים, תוך הפרדה בין ביטויים פשוטים (Simple Expressions) שניתן לחשב מיד, לביטויים מורכבים הדורשים המתנה ויצירת Continuation חדש. לבסוף מוצגות אופטימיזציות למניעת יצירת Continuations מיותרים לקוד נקי ויעיל יותר.
צוללים פנימה:
איך המחשב יודע שאי אפשר לחבר תפוח עם המספר 5? בעזרת מערכת טיפוסים (Types)! בפרק זה אנחנו לומדים איך המחשב בודק שהקוד שלנו הגיוני סטטית - לפני שהוא בכלל מנסה להריץ אותו. זה כמו בודק דקדוק אוטומטי לתוכנה שעוצר באגים עוד בשלב כתיבת הקוד.
תחילה, אנחנו מלמדים את המפרש את שפת CHECKED - תוכניות שבהן המתכנת הצהיר במפורש על סוגי הנתונים שלו ("המשתנה הזה הוא int", "הפונקציה הזו מחזירה bool"). המפרש עובר על ה-AST ומוודא שהטיפוסים כולם חוקיים ותואמים.
אבל הקסם האמיתי מגיע בחלק השני של הפרק, שפת INFERRED: אנחנו בונים מערכת ש"מסיקה" לבד את סוגי הנתונים (Type Inference). המערכת מסתכלת על הקוד, מרכיבה אלפי משוואות אילוצים (למשל, "אם איקס מחובר ל-5, אז איקס חייב להיות מספר!"), ופותרת אותן בעצמה באמצעות אלגוריתם מפורסם בשם האחדה (Unification) וסביבות החלפה חכמות (Substitutions). כך המתכנת לא כותב כמעט אף טיפוס, והמחשב עדיין מוודא שאין טעויות.
צוללים פנימה:
כשבונים תוכנות ענקיות אי אפשר לכתוב הכל בקובץ אחד ענק. יש לחלק את העבודה למחלקות שונות, וכל צוות בונה 'מודול' (קובייה עצמאית של קוד עם אחריות מוגדרת).
אבל איך הצוותים עובדים ביחד מבלי להרוס אחד לשני? הפתרון הוא ממשקים (Interfaces). כל מודול מפרסם החוצה רק את החוזה שלו (מה שנקרא Module Declaration) - למשל "יש לי פונקציה שעושה חיבור", אבל מסתיר לחלוטין את *איך* הוא עשה את זה (המימוש הפנימי - Module Implementation). זה מאפשר לשנות ולשדרג את המודול שלנו מבלי לפחד שזה ישבור חלקים אחרים במערכת.
בפרק אנחנו מוסיפים לשפה יכולת להגדיר מודולים, לאכוף את ההסתרות בעזרת סביבות מיוחדות, להגדיר טיפוסים אטומים לחלוטין שמסווים את צורתם המקורית (Opaque Types), ואפילו לחקור כיצד מודול יכול לקבל מודול אחר כפרמטר!
צוללים פנימה:
במקום לחשוב על תוכנות כרשימה של פקודות חישוב משעממות, תכנות מונחה עצמים (OOP) מציע פרדיגמה חדשה: לחשוב על התוכנה כעל עולם מלא בישויות (אובייקטים). כל אובייקט מחזיק נתונים משל עצמו ופעולות משלו (מתודות/Methods).
בפרק האחרון אנחנו מלמדים את המפרש שלנו את שפת היצורים הזו מאפס - אנחנו בונים מערכת שבה אפשר להגדיר תבניות (מחלקות/Classes) של אובייקטים, ולאפשר להם לרשת (Inheritance) תכונות ופונקציות זה מזה (למשל 'כלב ים' יורש מהתבנית 'חיה כללית').
אנו גם מפענחים לעומק מונחים מתקדמים ומורכבים כגון ניתוב שיטות דינמי (Method Dispatch) - איך המערכת יודעת בדיוק איזו מתודה לבצע כשאנחנו מבקשים מאובייקט פעולה בעולם של ירושות מסובכות, ומה המשמעות העמוקה של המילה self בסביבה (המאפשרת לאובייקט לקרוא לפעולות של עצמו) וכן בניית מתודות super.
הקוד מגדיר פונקציה רקורסיבית בסיסית בשם in-S? הבודקת האם מספר טבעי נתון שייך לקבוצה S (קבוצת הכפולות של 3). המטרה כאן היא להמחיש כיצד מתרגמים הגדרה מתמטית אינדוקטיבית של קבוצה לקוד ב-Scheme הפועל מלמעלה למטה (Top-Down).
דפוס זה קריטי להבנת תנאי עצירה והקטנת הבעיה. שימו לב לשימוש בבדיקת החסם התחתון (>= (- n 3) 0) שמונעת גלישה לרקורסיה אינסופית. במבחן, כאשר נדרש לכתוב סניפט עזר רקורסיבי, תמיד יש לחשוב "מה קורה אם הפרמטר חורג מהתחום?" ולטפל בכך, בדיוק כמו ההחזרה של #f כאן.
in-S? : N -> Boolממשק הפונקציה: מקבלת מספר טבעי (N) ומחזירה ערך בוליאני (Bool). חשוב לרשום חתימה זו כדי לשמור על סדר בפתרונות במבחן.
usage: (in-S? n) = #t if n is in S, #f otherwise
(define in-S?קישור השם `in-S?` לפונקציה שמוגדרת מיד בהמשך.
(lambda (n)תחילת הגדרת הפונקציה המקבלת פרמטר יחיד n. ב-Scheme כל פונקציה היא למעשה ביטוי lambda.
(if (zero? n) #tתנאי עצירה ראשון (חיובי): מקרה הבסיס. 0 הוא כפולה של 3 (0*3), לכן מחזירים אמת. ה-Predicate zero? מאוד נפוץ בקוד הספר לבדיקת איברים ריקים או אפס.
(if (>= (- n 3) 0)מניעת לולאה אינסופית: לפני ביצוע הצעד האינדוקטיבי, מוודאים שהמספר לאחר החיסור לא יהיה שלילי. זה חשוב כי הצעד האינדוקטיבי מוגדר רק על מספרים טבעיים (N).
(in-S? (- n 3))צעד אינדוקטיבי (קריאה רקורסיבית): "מייצרים" בעיה קטנה יותר על ידי חיסור 3 מ-n. הפונקציה בודקת אם (n-3) בקבוצה S.
#f))))תנאי עצירה שני (שלילי): אם המספר הופך לשלילי, משמע n לא היה כפולה של 3, ולכן נחזיר שקר (False).
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
הפונקציה list-length מקבלת רשימה ומחזירה את מספר האיברים (אורך) שבה. היא מדגימה מעבר רקורסיבי קלאסי על רשימה ב-Scheme: טיפול במקרה של רשימה ריקה, ופירוק הרשימה לאיבר הראשון ולשאר הרשימה (בעזרת cdr) במקרה הרקורסיבי.
זוהי תבנית בסיסית שחוזרת על עצמה המון - "תבנית הסריקה של רשימה". כל פעם שמבקשים מכם לרוץ על רשימה ולבצע פעולה מצטברת (כמו סכימה, ספירה או בניית רשימה חדשה), תשתמשו במבנה ה-if הזה עם null? ובקריאה הרקורסיבית עם cdr. סניפט זה שימושי מאוד כקטע עזר לשאלות מורכבות יותר.
list-length : List -> Intממשק: מקבלת רשימה של איברים מכל סוג (List) ומחזירה מספר שלם (Int) המייצג את מספר האיברים.
usage: (list-length lst) = the length of lstדוגמת שימוש או הצהרת כוונות לתיעוד.
(define list-lengthהגדרת הפונקציה בסביבה הגלובלית תחת השם list-length.
(lambda (lst)פונקציה אנונימית המקבלת פרמטר אחד, lst, שמייצג את הרשימה שלנו.
(if (null? lst)תנאי העצירה: אם הרשימה ריקה. null? מחזיר אמת רק אם האובייקט הוא הרשימה הריקה ().
0אורך של רשימה ריקה הוא אפס. זהו מקרה הבסיס שממנו הבנייה חוזרת אחורה בעץ הרקורסיה.
(+ 1 (list-length (cdr lst))))))צעד האינדוקציה: אורך הרשימה שווה לאורך של "שאר הרשימה" (cdr lst) ועוד 1 (עבור האיבר הראשון שהורדנו כעת).
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
הפונקציה nth-element סורקת רשימה באופן רקורסיבי על מנת לשלוף את האיבר שנמצא באינדקס n (כאשר האינדקס מתחיל מ-0). במידה והרשימה נגמרת לפני שהגענו לאינדקס המבוקש (כלומר, הרשימה קצרה מדי), מופעלת פונקציית העזר report-list-too-short שזורקת שגיאה בצורה מסודרת עם ההודעה המתאימה באמצעות המנגנון המובנה של eopl.
הדפוס החשוב כאן הוא שילוב של שני תנאי עצירה: האחד למקרה קצה של קלט לא תקין (רשימה ריקה לפני שהגענו לאינדקס), והשני למקרה ההצלחה (האינדקס התאפס). בנוסף, זהו המפגש הראשון עם פונקציית eopl:error, שהיא פונקציית חובה כאשר אנו מממשים מפרשים ומעוניינים לדווח על שגיאות תחביר או שגיאות זמן ריצה של המפרש (כגון משתנה לא מוגדר).
(define nth-elementהגדרת הפונקציה המרכזית שתפקידה להחזיר את האיבר ה-n ברשימה.
(lambda (lst n)פונקציה המקבלת שני פרמטרים: רשימה lst ומספר טבעי n המייצג את האינדקס.
(if (null? lst)תנאי עצירה ראשון (שגיאה): במידה והרשימה התרוקנה משמע חיפשנו אינדקס מחוץ לגבולות הרשימה.
(report-list-too-short n)קריאה לפונקציית העזר לדיווח שגיאה עם הערך הנותר של n (כדי לדעת בכמה הרשימה הייתה קצרה מדי).
(if (zero? n)תנאי עצירה שני (הצלחה): מצאנו את האיבר! (ספרנו לאחור מ-n עד 0).
(car lst)האיבר הנוכחי שעליו אנו עומדים (car) הוא האיבר המבוקש. נחזיר אותו.
(nth-element (cdr lst) (- n 1))))))הצעד הרקורסיבי: מתקדמים לאיבר הבא (cdr lst) ומקטינים את האינדקס המבוקש ב-1.
(define report-list-too-shortהגדרת פונקציית עזר לטיפול מסודר בשגיאות, מפרידה את הלוגיקה של השגיאה מהלוגיקה המרכזית.
(lambda (n)מקבלת את הערך n שנותר כדי לדווח בכמה הרשימה הייתה קצרה.
(eopl:error 'nth-elementקריאה לפונקציית השגיאה של הספר. הפרמטר הראשון הוא סמל ('nth-element) המציין את מקור השגיאה. דפוס קבוע וחשוב במפרשים!
"List too short by ~s elements.~%" (+ n 1))))מחרוזת השגיאה הכוללת פורמט. ~s משמש להזרקת הערך (במקרה זה n+1), ו-~% מבצע ירידת שורה.
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
הפונקציה remove-first מקבלת סמל (Symbol) ורשימה, ומחזירה רשימה חדשה שבה המופע הראשון של הסמל הוסר. אם הסמל אינו מופיע ברשימה, היא מחזירה העתק של הרשימה המקורית.
זוהי דוגמה מצוינת לפונקציה שבונה רשימה חדשה (Constructing a List) ולא רק סורקת אותה. השילוב של cons בצעד הרקורסיבי הוא טכניקה בסיסית: אנחנו שומרים את האיבר הנוכחי (car los) ומשרשרים אותו להמשך הרשימה שעליה מתבצעת הרקורסיה. אם מצאנו את האיבר שאנו רוצים למחוק - אנו מחזירים את שאר הרשימה (cdr los) בלי ה-cons, ובכך "מדלגים" עליו.
(define remove-firstהגדרת הפונקציה המרכזית.
(lambda (s los)פונקציה המקבלת סמל s (אותו נרצה להסיר) ורשימת סמלים los (list of symbols).
(if (null? los)תנאי העצירה: אם הגענו לסוף הרשימה (או שהיא הייתה ריקה מלכתחילה).
'()נחזיר את הרשימה הריקה. שימו לב לשימוש בגרש '() במקום סתם (). מונע שגיאת הערכה.
(if (eqv? (car los) s)תנאי העצירה של ההצלחה: אם האיבר הנוכחי ברשימה זהה לסמל שאנחנו מחפשים (באמצעות eqv?).
(cdr los)אם מצאנו אותו, נחזיר את שאר הרשימה מבלי להוסיף את האיבר הנוכחי! זה מה שגורם לו "להימחק". וחשוב לא פחות - אנו מפסיקים כאן את הרקורסיה (ולכן המופעים הבאים לא יימחקו).
(cons (car los) (remove-first s (cdr los)))))))הצעד הרקורסיבי: אם זה לא הסמל שלנו, נרצה לשמור אותו. לכן אנו עושים cons לאיבר הנוכחי עם התוצאה של הקריאה הרקורסיבית על שאר הרשימה.
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
הפונקציה בודקת האם משתנה מסוים (var) מופיע באופן חופשי (כלומר, אינו קשור ע"י הגדרת lambda שמסתירה אותו) בתוך ביטוי תחשיב למבדא (exp). היא מפרקת את הביטוי ל-3 מקרים לפי הגדרת ה-LcExp: משתנה (Symbol), ביטוי Lambda, או אפליקציה של פונקציה על משתנה (AppExp).
זהו סניפט חובה! הוא מלמד שתי טכניקות קריטיות: הראשונה היא סריקת עץ תחביר באופן ידני בעזרת car, cadr, caddr במקום להשתמש ב-cases של `define-datatype` (טכניקה שנראה הרבה בפרקים הראשונים). השנייה היא ההבנה של הצללה (Shadowing) וקשירה (Binding). מקרה הלמבדא מראה במפורש איך משתנה מוגדר כחופשי רק אם הוא מופיע בגוף הלמבדא וגם הוא לא המשתנה שהלמבדא כרגע מגדירה.
(define occurs-free?שם הפונקציה. מסתיימת בסימן שאלה מכיוון שהיא פרדיקט - מחזירה אמת או שקר.
(lambda (var exp)מקבלת את המשתנה שאנחנו מחפשים var ואת הביטוי שבו אנו סורקים exp.
(condשימוש ב-cond (בדומה ל-switch) שמתאים למימוש לפי ה-BNF של LcExp שיש לו 3 אפשרויות.
((symbol? exp) (eqv? var exp))מקרה 1: משתנה (VarExp). אם הביטוי הוא סתם סמל, משמע הוא משתנה בפני עצמו. האם הוא חופשי? כן, אם הוא שווה במקרה למשתנה שאנחנו מחפשים.
((eqv? (car exp) 'lambda)מקרה 2: ביטוי הלמבדא (LambdaExp). אנו מזהים אותו בכך שהאיבר הראשון ברשימה הוא הסמל 'lambda'.
(andכדי ש-var יופיע חופשי בתוך הלמבדא, שני דברים חייבים לקרות (ולכן AND):
(not (eqv? var (car (cadr exp))))תנאי א: המשתנה המוגדר בלמבדא (נמצא ב-cadr exp, שהוא רשימת הפרמטרים, לוקחים ממנו את הראשון עם car) אינו המשתנה שאנו מחפשים. אם הוא כן, אז הוא הופך ל"קשור" (Bound) ואז var אינו חופשי כאן!
(occurs-free? var (caddr exp))))תנאי ב: וגם, המשתנה var ממשיך להופיע באופן חופשי בתוך גוף הלמבדא (שנמצא במיקום השלישי caddr exp).
(elseמקרה 3: הפעלת פונקציה (AppExp). אם זה לא משתנה בודד ולא למבדא, זה חייב להיות אפליקציה של פונקציה על ארגומנט - ביטוי של שני איברים למשל (rator rand).
(orהמשתנה חופשי ב-AppExp אם הוא חופשי או בפונקציה (rator) או בארגומנט (rand). (ולכן OR).
(occurs-free? var (car exp))קריאה רקורסיבית לחלק השמאלי של הביטוי (הפונקציה עצמה).
(occurs-free? var (cadr exp)))))))קריאה רקורסיבית לחלק הימני (הארגומנט שמועבר).
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
הפונקציה מיועדת לעבור על רשימה (S-list) שיכולה להכיל גם תתי-רשימות (מבנה עץ) ולהחליף כל מופע של סמל old בסמל new. מכיוון שאיבר ברשימה יכול להיות רשימה בעצמו (לפי ה-BNF של S-exp), נדרשות שתי פונקציות שמבצעות "רקורסיה הדדית" (Mutual Recursion): אחת לטיפול ברשימה השלמה, והשנייה לטיפול בביטוי הבודד.
דפוס הרקורסיה ההדדית (Mutual Recursion) נפוץ מאוד בהמשך הקורס, בייחוד בשלב בו מפרשים מפרידים בין value-of-program, value-of, ועזרים אחרים הקוראים זה לזה. בנוסף, הקוד הזה חושף כיצד נכון להתמודד עם עצים ולא רק עם רשימות שטוחות - במקרה כזה, כאשר עושים cons לביטוי, ה-car שלו בעצמו מחושב באופן רקורסיבי.
(define substהפונקציה שמטפלת ב"רשימה השלמה" (slist).
(lambda (new old slist)מקבלת את הערך החדש, הערך הישן להחלפה, והרשימה שעליה אנו רצים.
(if (null? slist)תנאי עצירה: הרשימה ריקה.
'()נחזיר רשימה ריקה.
(consבנייה מחדש של הרשימה על ידי שרשור הטיפול באיבר הראשון אל הטיפול בשאר הרשימה.
(subst-in-s-exp new old (car slist))טיפול באיבר הראשון (שיכול להיות בעצמו רשימה). אנו "זורקים" את העבודה עליו לפונקציה האחות!
(subst new old (cdr slist))))))רקורסיה להמשך הרשימה.
(define subst-in-s-expהפונקציה שמטפלת באיבר בודד (S-exp). יכול להיות סמל או תת-רשימה.
(lambda (new old sexp)מקבלת איבר בודד (sexp).
(if (symbol? sexp)מקרה 1 - עלה (סמל): אם האיבר הוא סמל, אנחנו פשוט צריכים לבדוק אם להחליפו.
(if (eqv? sexp old) new sexp)אם הסמל שווה לישן, נחזיר את החדש. אחרת, נחזיר את הסמל כפי שהוא (ללא שינוי).
(subst new old sexp))))מקרה 2 - ענף (תת-רשימה): אם האיבר אינו סמל, הוא בהכרח רשימה (על פי הגדרת BNF). לכן, נחזיר את העבודה לפונקציית האם `subst` שתעבור על תת-הרשימה הזו. כך נוצרת הרקורסיה ההדדית!
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
הפונקציה מקבלת רשימה ומחזירה רשימה של זוגות (רשימות בנות שני איברים), שבה כל זוג מכיל את האינדקס של האיבר (מתחיל ב-0) ואת האיבר עצמו. המטרה כאן היא להדגים שימוש בפונקציית מעטפת (Wrapper function) ובפונקציית עזר עם פרמטר נוסף למעקב אחר "מצב" (State) - האינדקס הנוכחי.
זוהי טכניקה חיונית: העברת מידע נוסף דרך הרקורסיה (Accumulator / State variable). לעיתים תכופות במבחן מבקשים לשנות מבנה עץ או רשימה בהתאם לעומק, למיקום, או לסביבה. הפתרון הקבוע הוא ליצור פונקציית עזר שמקבלת את הפרמטר הנוסף (כאן זהו ה-n), ולעטוף אותה בפונקציה ראשית שמתחילה את הריצה עם ערך התחלתי (למשל 0).
(define number-elements-fromפונקציית העזר שמבצעת את העבודה בפועל. היא יודעת מאיזה אינדקס להתחיל.
(lambda (lst n)מקבלת את הרשימה ואת המספר הנוכחי n (שמייצג את האינדקס).
(if (null? lst) '()תנאי עצירה: רשימה ריקה מחזירה רשימה ריקה.
(consבונים מחדש את הרשימה:
(list n (car lst))האיבר החדש הוא רשימה קטנה המכילה את האינדקס הנוכחי ואת איבר המקור.
(number-elements-from (cdr lst) (+ n 1))))))קריאה רקורסיבית: ממשיכים לשאר הרשימה (cdr) ומגדילים את מונה האינדקס (n+1).
(define number-elementsהפונקציה הראשית ("פונקציית מעטפת"). היא זו שחושפים למשתמש כי הוא לא אמור לדעת שצריך להתחיל מ-0.
(lambda (lst)מקבלת רק את הרשימה עצמה.
(number-elements-from lst 0)))קוראת לפונקציית העזר ומאתחלת את המונה ל-0.
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
הפונקציה מקבלת רשימה של מספרים שלמים ומחזירה את סכומם. היא מהווה דוגמה בסיסית ופשוטה לרקורסיה מצטברת: אם הרשימה ריקה, הסכום הוא 0. אחרת, הסכום הוא המספר הראשון ברשימה ועוד סכום שאר הרשימה.
בדומה ל-list-length, זהו עוד דפוס חובה של סריקת רשימה, אך הפעם המיקוד הוא בפעולת הקיפול (Fold/Reduce). במקום (+ 1 ...) אנו משתמשים ב-(+ (car loi) ...) כדי לצבור ערכים מתוך איברי הרשימה. דפוס כזה יכול להופיע בשאלות שדורשות צבירה של ערכים (כמו סכימה של משתנים, איסוף מחרוזות וכו').
(define list-sumהגדרת הפונקציה המרכזית.
(lambda (loi)פונקציה המקבלת פרמטר אחד: loi (ראשי תיבות של List Of Integers).
(if (null? loi)תנאי עצירה: האם הרשימה ריקה?
0אם כן, סכום של אפס איברים הוא 0. זהו ה-Identity (איבר יחידה) של פעולת החיבור.
(+ (car loi)אם לא, לוקחים את האיבר הנוכחי שעליו אנו עומדים, ומחברים אותו...
(list-sum (cdr loi))))))...לתוצאת הסכימה הרקורסיבית של שאר הרשימה.
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
בדומה ל-list-sum, גם כאן סוכמים מספרים, אך הפעם הם מאוחסנים במערך (Vector) ולא ברשימה מקושרת. כיוון שלמערך אין car ו-cdr, אנו משתמשים באינדקסים (ספירה לאחור). הפונקציה הראשית משתמשת ב-let כדי לשמור את אורך המערך, ופונקציית העזר עוברת עליו מהסוף להתחלה.
זהו הסניפט המרכזי שמדגים שימוש ב-let להגדרת משתנים מקומיים (Local binding). הקריאה (let ((var val)) body) מאפשרת לחשב את vector-length פעם אחת בלבד ולשמור את התוצאה במשתנה n, במקום לקרוא לפונקציה הזו שוב ושוב. בנוסף, מודגמת כאן הגישה לאיברי מערך באמצעות vector-ref.
(define partial-vector-sumפונקציית העזר הרקורסיבית, המקבלת את המערך ואת האינדקס הנוכחי n שעליו היא סוכמת.
(lambda (von n)von הוא Vector Of Numbers. n הוא האינדקס הנוכחי שממנו סורקים לאחור.
(if (zero? n)תנאי עצירה: האם הגענו לאינדקס 0 (התחלת המערך)?
(vector-ref von 0)אם כן, נחזיר את האיבר במקום ה-0. vector-ref ניגש לאיבר במערך לפי אינדקס.
(+ (vector-ref von n)אחרת, נחבר את האיבר במקום ה-n (הנוכחי)...
(partial-vector-sum von (- n 1))))))...עם סכום המערך מהאינדקס הקודם (n-1). הרקורסיה יורדת עד ל-0.
(define vector-sumהפונקציה הראשית החשופה למשתמש.
(lambda (von)מקבלת רק את המערך.
(let ((n (vector-length von)))שימוש ב-let: מגדירים משתנה מקומי n שערכו הוא אורך המערך, כדי לא לחשבו שוב ושוב. אורך המערך הוא מעבר לאינדקס האחרון.
(if (zero? n)אם המערך ריק לחלוטין (אורכו 0)...
0נחזיר 0 כסכום.
(partial-vector-sum von (- n 1))))))אם לא, נזמן את פונקציית העזר כאשר האינדקס ההתחלתי הוא n-1 (האיבר האחרון).
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
הקוד מדגים את עיקרון הפשטת הנתונים (Data Abstraction). הפונקציה plus מוגדרת בעזרת פונקציות הממשק (is-zero?, successor, predecessor) מבלי לדעת כלל איך מספרים מיוצגים בזיכרון! מתחת לזה יש שני מימושים שונים לממשק: Unary Representation (בסיס 1, באמצעות רשימות של #t) ו-Scheme Number Representation (המספרים הרגילים של שפת סקים).
פרק 2 מתמקד בהפרדה בין הממשק (Interface) לבין הייצוג (Implementation). קוד המשתמש בממשק (Client code, כמו plus) לא ישתנה גם אם נחליף את המימוש מתחתיו ממערך למחסנית, או במקרה הזה - מ-Unary למספרים רגילים. זהו דפוס קריטי כשמבקשים ממכם במבחן להחליף ייצוג (למשל סביבה ממערך לרשימה מקושרת): פונקציות שמשתמשות ב-apply-env לא אמורות להשתנות!
(define plusפונקציית החיבור - לא יודעת איך המספר מיוצג! קוד Client טהור.
(lambda (x y)מקבלת שני מספרים x, y.
(if (is-zero? x)תנאי עצירה מופשט: משתמשת בפונקציית הממשק is-zero?.
yאם x הוא אפס, התוצאה היא y (איבר יחידה).
(successor (plus (predecessor x) y)))))רקורסיה: עוקב של החיבור בין קודמו של x לבין y. הכל דרך הממשק!
(define zero (lambda () '()))אפס מיוצג כרשימה ריקה.
(define is-zero? (lambda (n) (null? n)))בדיקת אפס היא בדיקת null.
(define successor (lambda (n) (cons #t n)))עוקב הוא הוספת 'אמת' לרשימה.
(define predecessor (lambda (n) (cdr n)))קודם הוא הסרת 'אמת' (חיתוך הרשימה).
(define zero (lambda () 0))אפס הוא פשוט המספר 0.
(define is-zero? (lambda (n) (zero? n)))משתמש ב-zero? של השפה.
(define successor (lambda (n) (+ n 1)))עוקב הוא הוספת 1.
(define predecessor (lambda (n) (- n 1)))קודם הוא הפחתת 1.
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
זהו מימוש של סביבה (Environment) בעזרת פרוצדורות (פונקציות). בגישה זו, סביבה היא פונקציה שמקבלת משתנה ומחזירה את הערך שלו. סביבה ריקה היא פשוט פונקציה שתמיד זורקת שגיאה (כי אין בה כלום). סביבה מורחבת (extend-env) היא פונקציה שעוטפת את הסביבה הקודמת (Closures).
זהו אחד מהרעיונות הכי קשים לעיכול אך החשובים ביותר בקורס - ייצוג נתונים על ידי פונקציות. פונקציית apply-env לא באמת צריכה לחפש משהו; היא פשוט מפעילה את הסביבה (שהיא כאמור, פונקציה בעצמה) עם המשתנה. ההבנה איך lambda שומרת איתה את saved-var ואת saved-env בזכרון (קלוז'ר) היא קריטית לשאלות תיאורטיות על סביבות במבחן.
(define empty-envסביבה ריקה בייצוג פרוצדורלי.
(lambda ()בנאי שמחזיר... סביבה! (שבייצוג זה, היא פונקציה).
(lambda (search-var)זוהי הסביבה: פונקציה שממתינה לקבל משתנה לחפש.
(report-no-binding-found search-var))))בסביבה ריקה תמיד ניכשל בחיפוש.
(define extend-envהרחבת סביבה - בניית סביבה חדשה שמכירה משתנה חדש.
(lambda (saved-var saved-val saved-env)מקבלת את המשתנה החדש, הערך שלו, ואת הסביבה הישנה.
(lambda (search-var)מחזירה פונקציה חדשה (סביבה חדשה). הודות למנגנון הקלוז'ר, היא "זוכרת" את משתני המעטפת (saved-var, saved-val, saved-env).
(if (eqv? search-var saved-var)כשישאלו אותה על משתנה, קודם כל היא תבדוק אם זה המשתנה שלה.
saved-valאם כן - הצלחה! מחזירים את הערך.
(apply-env saved-env search-var)))))אם לא - היא "מעבירה את הטלפון" לסביבה הישנה (הרקורסיה קורית כאן).
(define apply-envשליפת ערך מסביבה כלשהי.
(lambda (env search-var)מקבלת את הסביבה והמשתנה לחפש.
(env search-var)))כיוון שהסביבה היא פשוט פונקציה שמקבלת משתנה - כל מה שצריך לעשות זה להפעיל אותה!
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
זהו מימוש של סביבה (Environment) בעזרת define-datatype של שפת EOPL. בניגוד למימוש הפרוצדורלי שמייצג סביבה כפונקציה סגורה, כאן הסביבה היא עץ נתונים מפורש (AST) בעל שני סוגי צמתים: empty-env ו-extend-env. פונקציית apply-env משתמשת ב-cases כדי לפרק את המבנה ולסרוק אותו כדי למצוא משתנה.
הייצוג הזה הוא הנפוץ ביותר בקורס ובמבחנים (פרקים 3 ואילך תמיד משתמשים בו במפרשים הסטנדרטיים). עליכם לדעת להוסיף סביבות חדשות (למשל, extend-env-rec עבור למבדא רקורסיבית בפרק 3.4), ולהוסיף את המקרה התואם בתוך ה-cases של apply-env. שימו לב להפרדה - extend-env בונה את מבנה הנתונים, ורק ב-apply-env מתבצעת הבדיקה בפועל!
(define-datatype environment environment?הגדרת טיפוס נתונים חדש בשם environment, ופונקציית הזיהוי שלו (פרדיקט) environment?.
(empty-env)המקרה הראשון - סביבה ריקה ללא שדות.
(extend-envהמקרה השני - סביבה מורחבת.
(bvar symbol?)המשתנה השמור, חייב להיות מסוג symbol.
(bval expval?)הערך השמור, חייב להיות מסוג expval.
(saved-env environment?)))הסביבה הקודמת, חייבת להיות מסוג environment (עץ רקורסיבי!).
(define apply-envהפונקציה המבצעת סריקה וחילץ ערך מסביבה.
(lambda (env search-var)מקבלת סביבה כלשהי ומשתנה לחפש.
(cases environment envאנו משתמשים ב-cases כדי "לפתוח" את הקופסה של הסביבה ולראות איזה סוג היא.
(empty-env ()מקרה 1: אם זו סביבה ריקה (אין לה פרמטרים, אז הסוגריים ריקים).
(report-no-binding-found search-var))כמובן שלא נמצא את המשתנה, נדווח על שגיאה.
(extend-env (bvar bval saved-env)מקרה 2: אם זו סביבה מורחבת, נחלץ מתוכה את המשתנה שלה, הערך שלה, והסביבה שעליה היא יושבת.
(if (eqv? search-var bvar)נבדוק אם המשתנה שמצאנו כאן זהה לזה שאנו מחפשים.
bvalמצאנו! נחזיר את הערך bval.
(apply-env saved-env search-var))))))לא מצאנו! נמשיך לחפש ברקורסיה על הסביבה הקודמת (saved-env).
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
זהו אותו האלגוריתם של occurs-free? שראינו בפרק 1, אלא שהפעם הוא כתוב ללא ידיעה של ייצוג העץ. במקום להשתמש ב-car וב-cadr שמניחים שהעץ הוא רשימה של סמלים, אנו משתמשים בפונקציות זיהוי (Predicates: var-exp?, lambda-exp?) ובפונקציות חילוץ (Extractors: var-exp->var, lambda-exp->body).
שוב מודגם כוחה של הפשטת הנתונים. אם בעתיד נחליט לשנות את הדרך שבה ביטויי הלמבדא מיוצגים בזיכרון (למשל, בעזרת define-datatype), האלגוריתם עצמו - התנאים הלוגיים לבדיקת הופעה חופשית - לא ישתנה כלל! במבחנים שדורשים לשנות ייצוג, זכרו לעדכן את פונקציות העזר (Constructors, Extractors, Predicates) ולא לגעת בקוד המשתמש בהן.
(define occurs-free?שם הפונקציה (גרסת הממשק המופשט).
(lambda (search-var exp)מקבלת את המשתנה לחפש ואת הביטוי לבדוק בו.
(condשימוש ב-cond כיוון שיש לנו כמה אפשרויות ל-exp לפי הדקדוק.
((var-exp? exp)מקרה 1: משתנה. שימו לב לשימוש בפרדיקט של הממשק!
(eqv? search-var (var-exp->var exp)))אנו שולפים את שם המשתנה באמצעות פונקציית החילוץ var-exp->var ומשווים.
((lambda-exp? exp)מקרה 2: ביטוי למבדא. בדיקה בעזרת פרדיקט lambda-exp?.
(andהתנאים לחופשיות בתוך למבדא לא השתנו:
(not (eqv? search-var (lambda-exp->bound-var exp)))1. שזה לא המשתנה הקשור (הפרמטר של הלמבדא שמחולץ ע"י lambda-exp->bound-var).
(occurs-free? search-var (lambda-exp->body exp))))2. ושהוא אכן חופשי בגוף הלמבדא (שמחולץ ע"י lambda-exp->body).
(elseמקרה 3: הפעלת פונקציה (Application). זה הדבר היחיד שנשאר.
(orחופשי אם הוא חופשי באחד מצידי ההפעלה:
(occurs-free? search-var (app-exp->rator exp))שולפים את הפונקציה המופעלת עם app-exp->rator.
(occurs-free? search-var (app-exp->rand exp)))))))שולפים את הארגומנט עם app-exp->rand.
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
זהו מימוש של עץ תחביר מופשט (Abstract Syntax Tree - AST) עבור ביטויי תחשיב הלמבדא (Lambda Calculus) באמצעות המאקרו define-datatype שמסופק על ידי הספר. ההגדרה יוצרת אוטומטית פונקציות בנאי (Constructors) כמו var-exp, lambda-exp, פונקציית זיהוי כללית lc-exp?, ומאפשרת שימוש ב-cases כדי לפרק את העץ בהמשך.
זוהי אחת ההגדרות החשובות בקורס! כמעט כל מפרש שתיבנו מבוסס על סוג כזה של AST. שימו לב למבנה: שם הטיפוס, הפרדיקט הכללי, ואז רשימה של גרסאות (Variants). לכל גרסה יש שם, ורשימה של שדות (Fields) עם פרדיקטים שמוודאים את סוגם (למשל, symbol? או בדיקה רקורסיבית lc-exp?). תצטרכו לדעת להרחיב את זה (למשל להוסיף if-exp) במבחן!
(define-datatype lc-exp lc-exp?הגדרת הטיפוס lc-exp, ופרדיקט הזיהוי lc-exp?
(var-expגרסה 1: ביטוי משתנה. מייצג קריאה למשתנה.
(var symbol?))יש לו שדה אחד בלבד: השם שלו, וחייב להיות מסוג סמל (symbol).
(lambda-expגרסה 2: ביטוי למבדא (הגדרת פונקציה).
(bound-var symbol?)שדה ראשון: המשתנה הקשור (הפרמטר של הפונקציה).
(body lc-exp?))שדה שני: גוף הפונקציה. שימו לב - זה חייב להיות ביטוי רקורסיבי מסוג lc-exp!
(app-expגרסה 3: הפעלת פונקציה (Application).
(rator lc-exp?)Operator (או rator) - הפונקציה המופעלת. חייב להיות ביטוי.
(rand lc-exp?)))Operand (או rand) - הארגומנט שמועבר לפונקציה. חובה שיהיה ביטוי.
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
אחרי שהגדרנו את lc-exp בעזרת define-datatype, אנו מממשים שוב את occurs-free? - הפעם באמצעות המאקרו cases. ה-cases בודק מאיזה סוג (Variant) הביטוי שקיבלנו, ואוטומטית מפרק אותו למרכיביו וקושר אותם למשתנים שאנחנו מספקים.
זוהי דוגמת הזהב של הקורס לתכנות מונחה-תחביר (Syntax-Directed Programming) על עצי AST. התבנית הזו (שימוש ב-cases עם כל האפשרויות של טיפוס הנתונים) תופיע כמעט בכל שורה שתכתבו במבחנים בהם תממשו מפרשים. שימו לב לחוסר הצורך להשתמש בפרדיקטים (var-exp?) או חלצנים (lambda-exp->body) - ה-cases עושה הכל עבורנו בבת אחת! תבנית זו עוזרת להימנע משגיאות גישה.
(define occurs-free?שם הפונקציה (גרסת cases).
(lambda (search-var exp)שוב, מקבלת משתנה וביטוי לבדיקה.
(cases lc-exp expהמפתח! אנחנו אומרים: "פתח את exp כעץ מסוג lc-exp".
(var-exp (var)מקרה 1: משתנה. אנחנו "תופסים" את השם שלו ומכנים אותו var.
(eqv? var search-var))פשוט בודקים האם הם זהים.
(lambda-exp (bound-var body)מקרה 2: פונקציה. "תופסים" את שם הפרמטר ל-bound-var, ואת גוף הפונקציה ל-body.
(andהתנאים זהים, אבל הקוד הרבה יותר נקי ואלגנטי!
(not (eqv? search-var bound-var))לא המשתנה הקשור...
(occurs-free? search-var body)))...וכן מופיע באופן חופשי בגוף.
(app-exp (rator rand)מקרה 3: הפעלה. "תופסים" את הפונקציה ל-rator ואת הארגומנט ל-rand.
(orחופשי באחד מצידי ההפעלה.
(occurs-free? search-var rator)חופשי בצד הפונקציה המופעלת?
(occurs-free? search-var rand))))))או חופשי בארגומנט המועבר?
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
זהו הפרסר (Parser) הראשון שלכם! הוא לוקח ביטוי בסיסי בשפת סקים (קונקרטי - Concrete Syntax) והופך אותו לעץ התחביר המופשט (AST) שהגדרנו. כלומר, הוא ממיר רשימות וסמלים ל-var-exp, lambda-exp ו-app-exp.
במבחנים שדורשים לשנות את תחביר השפה (למשל "הוסף פקודת לולאה while לשפה"), ייתכן ותצטרכו לעדכן גם את הפרסר. שימו לב איך הוא סורק את הנתון: קודם בודק אם הוא סמל, אחרת אם הוא זוג (רשימה), ואז בודק את האיבר הראשון ברשימה כדי לזהות האם זו מילת מפתח (כמו lambda) או סתם הפעלת פונקציה.
(define parse-expressionשם הפונקציה.
(lambda (datum)מקבלת datum (מידע קונקרטי, רשימה/סמל).
(condבודקת את סוג המידע:
((symbol? datum)אם זה רק סמל בודד (למשל 'x')...
(var-exp datum))...אז נבנה עץ מסוג var-exp ששומר את הסמל.
((pair? datum)אם זו רשימה (יש לה car ו-cdr)...
(if (eqv? (car datum) 'lambda)נבדוק אם האיבר הראשון הוא המילה השמורה 'lambda.
(lambda-expאם כן, ניצור עץ מסוג lambda-exp.
(car (cadr datum))הפרמטר נמצא ברשימה בתוך הרשימה (ה-cadr של התחביר), לכן נשלוף את ה-car שלו.
(parse-expression (caddr datum)))ונפרסר ברקורסיה את הגוף! (האבר השלישי).
(app-expאם זו רשימה אבל המילה הראשונה אינה lambda, זו הפעלת פונקציה. נבנה עץ app-exp.
(parse-expression (car datum))נפרסר ברקורסיה את הפונקציה המופעלת (rator).
(parse-expression (cadr datum)))))ונפרסר ברקורסיה את הארגומנט (rand).
(else (report-invalid-concrete-syntax datum)))))אם זה לא סמל ולא רשימה, התחביר שגוי.
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
התהליך ההפוך לפרסר! מקבל עץ תחביר מופשט (AST) מסוג lc-exp ובונה ממנו חזרה ביטוי "קונקרטי" - רשימות וסמלים שאפשר להדפיס למסך ולקרוא בקלות.
שוב, שימוש קלאסי ב-cases על העץ. בניגוד ל-parse שמשתמש ב-cond כי הקלט הוא רשימה פשוטה, ה-unparse משתמש ב-cases כי הקלט שלו הוא מבנה נתונים מפורש מסוג lc-exp. שימו לב לאופן שבו מרכיבים חזרה את הרשימות בעזרת הפונקציה list.
(define unparse-lc-expשם הפונקציה.
(lambda (exp)מקבלת ביטוי מסוג lc-exp.
(cases lc-exp expפותחים את העץ לפי הסוגים שלו.
(var-exp (var)אם זה משתנה...
var)נחזיר פשוט את הסמל עצמו (כמו 'x).
(lambda-exp (bound-var body)אם זו למבדא...
(list 'lambda (list bound-var)נרכיב רשימה שמתחילה במילה 'lambda', ואז רשימה של המשתנה הקשור...
(unparse-lc-exp body)))ואז נפעל ברקורסיה על הגוף.
(app-exp (rator rand)אם זו הפעלת פונקציה...
(listניצור רשימה של שני איברים:
(unparse-lc-exp rator)האיבר הראשון הוא ה-unparse של הפונקציה.
(unparse-lc-exp rand))))))האיבר השני הוא ה-unparse של הארגומנט.
✨ העבר את העכבר מעל שורות הקוד לקבלת הסבר ממוקד
הגדרת ה-BNF (Backus-Naur Form) של השפה הבסיסית הראשונה שלנו: שפת LET. השפה כוללת מספרים, פעולת חיסור, בדיקת אפס, תנאי (if-then-else), משתנים והכרזת משתנים מקומיים בעזרת ביטוי let. ה-BNF מגדיר בצורה פורמלית אילו מחרוזות נחשבות לתוכנית חוקית בשפה.
תחביר ה-BNF הוא החוזה של השפה. מרבית שאלות המבחן דורשות הוספת תכונה חדשה (למשל סוג לולאה או אופרטור), והשלב הראשון הוא תמיד כתיבת ה-BNF המעודכן. כל שורת סמנטיקה כאן (מצד ימין ל-::=) תהפוך בהמשך ל-Variant בתוך הגדרת ה-AST, ולכן יש חפיפה מלאה ביניהם. הבנת ה-BNF היא הבסיס לפרסור.
Program ::= Expressionתוכנית היא פשוט ביטוי אחד גדול
Expression ::= Numberקבוע מספרי
Expression ::= -(Expression , Expression)פעולת חיסור
Expression ::= zero? (Expression)בדיקת אפס
Expression ::= if Expression then Expression else Expressionתנאי מלא
Expression ::= Identifierקריאה למשתנה
Expression ::= let Identifier = Expression in Expressionיצירת סביבה מקומית חדשה
כאן אנו מגדירים את הטיפוסים הבסיסיים שהשפה יודעת לעבוד איתם. ב-LET, ערכים מבוטאים (Expressed values - מה שביטוי מחזיר) וערכים מצוינים (Denoted values - מה שנשמר בתוך הסביבה) הם זהים: שלמים ובוליאניים. הפונקציות מספקות ממשק בנאי וחולץ לאלו.
שאלות רבות מבקשות להוסיף טיפוס נתונים חדש לשפה (כגון רשימות, מבנים או מחרוזות). עליכם לדעת לעדכן את Expressed Values כדי שהשפה תוכל להחזיר אותם מחישובים, ואת Denoted Values אם תרצו לשמור אותם במשתנים. ההבחנה בין השניים הופכת משמעותית בשפות מתקדמות יותר כמו EXPLICIT-REFS.
ExpVal = Int + Boolערך מבוטא (תוצאת חישוב) יכול להיות מספר או בוליאני
DenVal = Int + Boolערך מצוין (הנשמר בסביבה) זהה לערך המבוטא בשפה זו
num-val : Int -> ExpVal
bool-val : Bool -> ExpVal
expval->num : ExpVal -> Int
expval->bool : ExpVal -> Bool
אלו הם חוקי הסמנטיקה המגדירים מתמטית כיצד מתבצעת הערכת ביטויים. הפונקציה value-of תמיד מקבלת ביטוי ואת הסביבה הנוכחית (ρ). עבור קבוע, מחזירים מספר. עבור משתנה, מחפשים בסביבה. עבור פעולות, מחלצים, מפעילים ועוטפים שוב.
בכל פעם שתוסיפו פקודה חדשה לשפה, תתבקשו לכתוב עבורה את הכלל הסמנטי התיאורטי (למשל במשוואות מתמטיות) ואז לתרגם אותו לקוד Scheme בפונקציה value-of. הכלל החשוב הוא שאי אפשר להפעיל אופרטורים של Scheme (כמו - או +) ישירות על ExpVal, אלא יש לחלץ את הערך הפנימי באמצעות Extractors (כמו expval->num).
(value-of (const-exp n) ρ) = (num-val n)קבוע מחזיר את המספר כערך מבוטא
(value-of (var-exp var) ρ) = (apply-env ρ var)משתנה מחפש את הערך בסביבה
(value-of (diff-exp exp1 exp2) ρ)
= (num-val
(-
(expval->num (value-of exp1 ρ))
(expval->num (value-of exp2 ρ))))הערכת שני הביטויים, חילוץ המספרים, חיסור, ועטיפה מחדש
תרגום ישיר של ה-BNF מעמוד 60 לקוד Scheme בעזרת define-datatype. כל שורה ב-BNF מקבלת כאן ייצוג מבני בעץ התחביר. השדה expression? מוודא רקורסיבית שהבנים של כל צומת הם גם כן תתי-ביטויים חוקיים.
זהו השלד של כל מפרש שתכתבו או תשנו במבחן. כאשר מבקשים להוסיף print-exp או לולאת while-exp לשפה, עליכם לגשת קודם כל ל-AST ולהוסיף את המקרה (Variant) המתאים. שימו לב לטיפוסי השדות: קבועים הם number?, שמות משתנים הם symbol?, ותתי-ביטויים הם תמיד expression?.
(define-datatype expression expression?עץ התחביר לכל הפקודות בשפת LET
(const-exp (num number?))
(diff-exp (exp1 expression?) (exp2 expression?))
(zero?-exp (exp1 expression?))
(if-exp (exp1 expression?) (exp2 expression?) (exp3 expression?))
(var-exp (var symbol?))
(let-exp (var symbol?) (exp1 expression?) (body expression?)))
המימוש המלא של ערכי השפה expval. הוא מגדיר מבנה נתונים שיכול לעטוף מספרים וערכים בוליאניים. הפונקציות expval->num ו-expval->bool מספקות שכבת הגנה שמחלצת את הערך הפנימי אך זורקת שגיאה מסודרת אם הטיפוס אינו תואם למה שציפינו.
זהו מנגנון Type Checking דינאמי בסיסי. במבחן לעיתים תתבקשו להרחיב את הטיפוסים, למשל לאפשר ל-expval להכיל רשימה כדי לממש פעולות כמו cons ו-car. במקרה כזה, עליכם להוסיף מקרה חדש (למשל list-val) ב-define-datatype, ולכתוב פונקציית מחלץ expval->list שתבדוק אותו בתוך ה-cases.
(define-datatype expval expval?ערך מבוטא יכול להיות מספר או בוליאני
(num-val (num number?))
(bool-val (bool boolean?)))
(define expval->numחילץ המספר מתוך ערך מבוטא (או זריקת שגיאה)
(lambda (val)
(cases expval val
(num-val (num) num)
(else (report-expval-extractor-error 'num val)))))
(define expval->boolחילץ הבוליאני מתוך ערך מבוטא (או זריקת שגיאה)
(lambda (val)
(cases expval val
(bool-val (bool) bool)
(else (report-expval-extractor-error 'bool val)))))
הפונקציה run משמשת כנקודת הכניסה למפרש. היא מקבלת מחרוזת קוד, מפעילה עליה את scan&parse לקבלת AST, ומעבירה אותו ל-value-of-program. פונקציה זו מחלצת את הביטוי הראשי מהתוכנית וקוראת ל-value-of עם הסביבה ההתחלתית (init-env).
בדרך כלל במבחנים הפונקציות האלו לא משתנות הרבה, אלא אם כן התוכנית עצמה עוברת שינוי דרמטי (למשל, התוכנית מורכבת מרשימת הצהרות ולא מביטוי בודד, כמו בשפת MODULES). תבנית זו עוזרת להבין את "שרשרת הפיקוד" של המפרש: טקסט → עץ → חישוב סמנטי.
(define runנקודת הכניסה. מפרסרת מחרוזת ל-AST, ומריצה את התוכנית.
(lambda (string)
(value-of-program (scan&parse string))))
(define value-of-programמריצה תוכנית (שמכילה ביטוי ראשי אחד) על סביבה התחלתית (init-env).
(lambda (pgm)
(cases program pgm
(a-program (exp1)
(value-of exp1 (init-env))))))
זהו לב המפרש! הפונקציה המרכזית value-of עוברת באמצעות cases על כל אחד מסוגי הביטויים האפשריים בעץ התחביר (AST). היא מבצעת את החישוב בפועל ומחזירה ערך (expval).
קוד זה הוא הלחם והחמאה של המבחן. כשמתבקשים להוסיף תכונה חדשה (כגון לולאה, אופרטור מיוחד או הדפסה), רוב העבודה מסתכמת בהוספת ה-clause המתאים ל-cases בתוך value-of. חשוב להקפיד לזכור לקרוא ל-value-of רקורסיבית על תתי-הביטויים, ולמשוך את הערכים (עם expval->num וכדומה) לפני הפעלת פעולות של סקים, ולעטוף בחזרה ב-num-val / bool-val בסוף.
(define value-ofלב המפרש! מעריכה ביטוי בסביבה נתונה.
(lambda (exp env)
(cases expression expמפרקים את עץ התחביר בעזרת cases
(const-exp (num) (num-val num))קבוע: מחזירים כ-num-val
(var-exp (var) (apply-env env var))משתנה: שולפים מהסביבה
(diff-exp (exp1 exp2)חיסור:
(let ((val1 (value-of exp1 env))
(val2 (value-of exp2 env)))
(let ((num1 (expval->num val1))
(num2 (expval->num val2)))
(num-val (- num1 num2)))))מעריכים צדדים, מחלצים, מחסרים ב-Scheme, ואורזים חזרה.
(zero?-exp (exp1)בדיקת אפס: מעריכים, מחלצים, ובודקים zero?.
(let ((val1 (value-of exp1 env)))
(let ((num1 (expval->num val1)))
(if (zero? num1)
(bool-val #t)
(bool-val #f)))))
(if-exp (exp1 exp2 exp3)תנאי: תנאי העצירה של הרקורסיה (branching).
(let ((val1 (value-of exp1 env)))
(if (expval->bool val1)
(value-of exp2 env)
(value-of exp3 env))))
(let-exp (var exp1 body)משתנה מקומי: מעריכים את exp1, ואז מעריכים את body בתוך סביבה מורחבת (extend-env)!
(let ((val1 (value-of exp1 env)))
(value-of body (extend-env var val1 env)))))))
השפה הופכת משפת LET לשפת PROC. המשמעות היא הוספת פונקציות מסדר ראשון (First-class functions) כך שפרוצדורות הופכות להיות ערכים (Values) שניתן לשמור במשתנים, להחזיר מפונקציות אחרות ולהעביר כארגומנטים.
השינוי המשמעותי כאן הוא הוספת Proc לטיפוסי הערכים: ExpVal = Int + Bool + Proc. מרגע זה, כל פעם שמשנים או מתעסקים עם משתנים שיכולים להיות פונקציות, עלינו לזכור שהפונקציה נשמרת כסגור (Closure), והיא ערך לכל דבר ועניין.
ExpVal = Int + Bool + Procמעכשיו גם פרוצדורה היא ערך מבוטא!
DenVal = Int + Bool + Procהיא גם יכולה להישמר בסביבה.
Expression ::= proc (Identifier) Expressionהגדרה של פונקציה (מומר ל-proc-exp ב-AST)
Expression ::= (Expression Expression)הפעלה של פונקציה (מומר ל-call-exp ב-AST)
כללי הסמנטיקה התיאורטיים שמסבירים כיצד מתבצעת יצירת פונקציה (proc-exp) והפעלתה (call-exp). הדבר המהותי ביותר הוא שהגדרת הפונקציה אורזת בתוכה את הסביבה בזמן ההגדרה (ליצירת closure).
ההבדל בין Lexical Scope (חוקי ב-Scheme/PROC) לבין Dynamic Scope טמון בדיוק בנוסחה הזו. אם נדרש לממש Lexical Scoping רגיל, הפונקציה שומרת את ρ (הסביבה הנוכחית בעת הגדרתה). ללא שמירת ρ ושימוש בסביבה חדשה בזמן ההפעלה, נקבל סקופ דינאמי (נושא שיעלה בהמשך הקורס).
(value-of (proc-exp var body) ρ)
= (proc-val (procedure var body ρ))כלל הסמנטיקה: הגדרת פונקציה יוצרת ערך שמכיל את גוף הפונקציה ואת הסביבה שבה הוגדרה (Closure).
(value-of (call-exp rator rand) ρ)
= (apply-procedure
(expval->proc (value-of rator ρ))
(value-of rand ρ))כלל הסמנטיקה להפעלה: מעריכים את הפונקציה ואת הארגומנט, ואז מפעילים.
כאן אנו מממשים את הכלל התיאורטי מקודם: אובייקט ה-Closure נשמר בזיכרון בעזרת מבנה נתונים proc הכולל את שם הפרמטר (bvar), את גוף הפונקציה (body) ואת הסביבה השמורה (saved-env). בנוסף, הפונקציה apply-procedure מגדירה כיצד לפתוח את ה-Closure ולרוץ בתוכו עם הערך שהוזן לארגומנט.
אם מבקשים במבחן לשנות את התנהגות הפונקציות - למשל לאפשר פונקציות שמקבלות מספר ארגומנטים משתנה, או לאפשר סקופ דינאמי (ללא saved-env) - זה בדיוק המקום לבצע את השינוי. חשוב להכיר הן את הגישה מבוססת מבני הנתונים (define-datatype) והן את הגישה הפרוצדורלית.
(define-datatype proc proc?סגור (Closure) - מכיל את שם המשתנה, גוף הפונקציה, והכי חשוב: הסביבה שבה הוגדרה הפונקציה.
(procedure
(bvar symbol?)
(body expression?)
(saved-env environment?)))
(define apply-procedureאיך מפעילים פונקציה?
(lambda (proc1 val)
(cases proc proc1
(procedure (var body saved-env)
(value-of body (extend-env var val saved-env))))))מריצים את גוף הפונקציה (body) עם סביבה מורחבת (saved-env ביחד עם המשתנה החדש).
;; ייצוג פרוצדורלי אלטרנטיבי של סגור:אפשר לממש closure גם כפונקציית סקים (lambda) שלוקחת רק val!
(define procedure
(lambda (var body env)
(lambda (val)
(value-of body (extend-env var val env)))))
(define apply-procedure
(lambda (proc1 val)
(proc1 val)))
הכנסת פונקציות לתוך לב המפרש (value-of). ראשית, expval מורחב כדי לתמוך ב-proc-val. לאחר מכן, בפונקציה הראשית, הגדרת פונקציה (proc-exp) פשוט נארזת ל-Closure יחד עם הסביבה הנוכחית. לעומתה, קריאה לפונקציה (call-exp) דורשת להעריך את שני הצדדים: את שם הפונקציה כדי לקבל את ה-Closure, ואת הארגומנט כדי לקבל את הערך, ואז להפעיל ביניהם apply-procedure.
שאלות העוסקות בסביבות או בהעברת משתנים תמיד מסתמכות על השורות הללו. חשוב להבין שב-proc-exp הקוד של הגוף לא מורץ! הוא רק נשמר. הריצה קורית רק ב-call-exp. אם תתבקשו לממש פונקציה עם כמה ארגומנטים (Multi-parameter), כאן תצטרכו לשנות את ה-let שיחשב את כל הארגומנטים ברשימה.
(define-datatype expval expval?
(num-val (value number?))
(bool-val (boolean boolean?))
(proc-val (proc proc?))הוספת פרוצדורה כסוג ערך לגיטימי בשפה)
(define expval->proc
(lambda (v)
(cases expval v
(proc-val (proc) proc)
(else (expval-extractor-error 'proc v)))))
(proc-exp (var body)הגדרת פונקציה מומרת מיד ל-closure על ידי קריאה ל-procedure יחד עם הסביבה השמורה.
(proc-val (procedure var body env)))אורזים את הפונקציה יחד עם הסביבה הנוכחית בתוך proc-val.
(call-exp (rator rand)הפעלת פונקציה מעריכה תחילה את שם הפונקציה ואת הארגומנט, ולאחר מכן מפעילה.
(let ((proc (expval->proc (value-of rator env))מעריכים את צד שמאל - אמור לצאת פרוצדורה.)
(arg (value-of rand env)מעריכים את הארגומנט בתוך הסביבה הנוכחית.))
(apply-procedure proc arg)))מפעילים את הפרוצדורה על הארגומנט באמצעות הסביבה השמורה בה.
בשפת PROC הרגילה אי אפשר לכתוב פונקציות רקורסיביות, כי הפונקציה לא מכירה את שמה שלה ולא יכולה לקרוא לעצמה מהסביבה שבה היא נוצרה. שפת LETREC פותרת בעיה זו. כאן מוגדר התחביר החדש המאפשר הגדרה של פונקציה רקורסיבית.
Expression ::= letrec Identifier (Identifier) = Expression in Expressionהתחביר: letrec name(var) = body in letrec-body
(letrec-exp (p-name b-var p-body letrec-body))המבנה המוזרק אל עץ התחביר (AST).
זהו הכלל הסמנטי שמאפשר רקורסיה. כאשר מריצים letrec-exp, המפרש מעריך את הגוף המרכזי (letrec-body) בתוך סביבה מיוחדת (extend-env-rec). סביבה זו שומרת בתוכה את פרטי הפונקציה במקום סתם ערך (כמו ב-extend-env רגיל), כך שכשיחפשו אותה, היא תיצור עבורנו את ה-closure הדרוש באופן מחזורי.
במבחנים שדורשים multi-letrec (מספר פונקציות רקורסיביות המוגדרות ביחד), הפונקציה extend-env-rec מקבלת רשימות של שמות במקום שם בודד. זו הבנה בסיסית חשובה: הרקורסיה נפתרת ברמת הסביבה ולא ברמת ערכי ה-expval.
(value-of (letrec-exp proc-name bound-var proc-body letrec-body) env)כאשר המפרש מגיע להגדרת letrec
= (value-of letrec-body
(extend-env-rec proc-name bound-var proc-body env))מריצים את הקוד המרכזי (letrec-body) תחת סביבה מיוחדת שיודעת להחזיר את הפונקציה עצמה במידת הצורך.
מעקב סמנטי תיאורטי (Trace) המדגים כיצד משוערך ביטוי letrec. המעקב מראה כיצד הערכת letrec הופכת מיד לקריאה של גוף ה-letrec (ה-body) תחת סביבה חדשה ρ1.
תרשימי זרימה כאלה כמעט ולא מופיעים במבחנים של שפת EOPL באופן מעשי (חשיבות נמוכה). המטרה היא פשוט לראות איך הפונקציה double מחולצת מתוך הסביבה החדשה, ואיך היא מכילה שוב מצביע אל אותה הסביבה בדיוק, ליצירת רקורסיה.
(value-of (letrec-exp 'double 'x ...) ρ0)תחילת הערכת פונקציה רקורסיבית
= (value-of (call-exp (var-exp 'double) (const-exp 6)) ρ1)
= (apply-procedure
(expval->proc (value-of (var-exp 'double) ρ1))
(value-of (const-exp 6) ρ1))
= (apply-procedure (expval->proc (apply-env ρ1 'double)) 6)
המשך המעקב התיאורטי מהעמוד הקודם. מראה כיצד הקריאה לפונקציה מרחיבה את הסביבה עם הארגומנט (למשל, [x=6] על גבי ρ1).
שוב, פירוט יתר כזה פחות נפוץ במבחנים. המטרה היא להמחיש שמשתנה האיטרציה של הרקורסיה (x) מתווסף "על גבי" הסביבה שמכילה את הפונקציה (double), ולכן כשהפונקציה תקרא לעצמה עם ערך חדש היא תמצא קודם את ה-x החדש, ואז תרד רמה ל-double.
= (apply-procedure (procedure 'x proc-body ρ1) 6)
= (value-of proc-body (extend-env 'x 6 ρ1))
= (value-of proc-body [x=6]ρ1)
= (value-of (if-exp ...) [x=6]ρ1)
...ממשיכים את הצעדים עד עצירת הרקורסיה.
הרחבת מבנה הנתונים environment כדי לתמוך בסביבות של פונקציות רקורסיביות (extend-env-rec). סביבה כזו לא שומרת ערך מוכן, אלא שומרת את החלקים להרכבת הפונקציה (שם הפונקציה, פרמטר וגוף) ואת הסביבה החיצונית (saved-env).
חובה לדעת את המבנה הזה. במבחן לעיתים נדרש להוסיף סביבה מסוג שונה (למשל סביבה שמעבירה משתנים By Reference) או לשנות את extend-env-rec כך שיתמוך ברשימה של פונקציות שמכירות אחת את השנייה (Mutually recursive). במקרה כזה, נחליף את (p-name symbol?) ב-(p-names (list-of symbol?)) וכדומה.
(define-datatype environment environment?
(empty-env)
(extend-env
(bvar symbol?)
(bval expval?)
(saved-env environment?))
(extend-env-rec
(p-name symbol?)
(b-var symbol?)
(p-body expression?)
(saved-env environment?))סביבה רקורסיבית מיוחדת ששומרת פונקציה!)
זהו הלב של מנגנון הרקורסיה (ה"קסם"). פונקציית החיפוש apply-env מורחבת לחפש ב-extend-env-rec. כאשר מחפשים את הפונקציה והשם תואם ל-p-name, המפרש מייצר Closure חדש על המקום! והסביבה השמורה ב-Closure היא env - שהיא סביבת ה-letrec בעצמה.
כל שינוי במבנה הסביבות (למשל הוספת Multi-letrec, או סביבה שקופה) גורר שינוי ב-apply-env. ה"מעגל הבלתי סופי" של רקורסיה מושג בזכות השורה: (proc-val (procedure b-var p-body env)). שימו לב שה-env כאן מתייחס לסביבה כולה (המכילה את עצמה), ולא ל-saved-env שחיצוני לה!
(define apply-env
(lambda (env search-sym)
(cases environment env
(empty-env ()
(report-no-binding-found search-sym))
(extend-env (saved-var saved-val saved-env)
(if (eqv? saved-var search-sym)
saved-val
(apply-env saved-env search-sym)))
(extend-env-rec (p-name b-var p-body saved-env)מצאנו סביבה רקורסיבית!
(if (eqv? search-sym p-name)אם המשתנה שמחפשים הוא שם הפונקציה שלנו:
(proc-val (procedure b-var p-body env))אנו יוצרים closure תוך שימוש בסביבה env (הסביבה הרקורסיבית בעצמה), וזה "הטריק".
(apply-env saved-env search-sym))))))
דוגמה המדגימה בעיית Scoping קלאסית. בפונקציה f = proc(y) -(y,x), לאיזה x אנו מתייחסים? EOPL משתמש ב-Lexical Scoping (Static Scope), שבו ערכו של x נקבע לפי המקום שבו הפונקציה "כתובה", ולכן x שווה ל-3 (למרות שכאשר אנו מפעילים את הפונקציה, x שווה 4!).
נושא ה-Scope חוזר המון!
1. Lexical Scoping (סטטי) = מתסכל על x איפה שהפונקציה נוצרה (מיוצג ע"י Closure ששומר סביבה ρ).
2. Dynamic Scoping (דינמי) = מסתכל על x איפה שהפונקציה נקראה (מיוצג ללא Closure, אלא הערכת הגוף בתוך הסביבה הנוכחית של הקורא).
let x = 3
in let f = proc (y) -(y,x)
in let x = 4
in (f 5)בדוגמה זו: למי x שייך בתוך f? ב-Lexical Scoping הערך יהיה 3. ב-Dynamic Scoping הערך יהיה 4.
ייצוג גרפי להבנת Lexical Scoping. כל בלוק (let או proc) יוצר "תיבה". כאשר נדרש משתנה (למשל x), מחפשים אותו בתיבה הנוכחית, ואם אין - הולכים לתיבה העוטפת אותה, כלפי חוץ.
כלי חשיבתי מצוין כשקוראים שאלות על סקופים במבחן! ציירו לכם קופסאות. קופסת ה-proc נוצרת בתוך התיבה של let x=3, ולכן ה-x שהיא רואה הוא בתיבה של 3 ולא בתיבה הפנימית של ה-let x=4, שנמצאת למטה.
+----------------------------------+
| let x = 3 |
| in +---------------------------+ |
| | let f = proc (y) -(y,x) | |
| | in +--------------------+ | |
| | | let x = 4 | | |
| | | in (f 5) | | |
| | +--------------------+ | |
| +---------------------------+ |
+----------------------------------+מבנה בלוקים המדגים את הסביבה הלקסיקלית המקומיות
מעקב סמנטי מפורט המדגים כיצד value-of שולף את הסביבה הסטטית מה-closure. זה מדגים בבירור כי x מתפרש כ-3 ולא כ-4. תרגיל זה מוכיח את הדיאגרמה מהעמוד הקודם.
רמת פירוט כזו פחות הכרחית במבחן מעשי, אבל חשוב להבין את המנגנון. המבחן בדרך כלל יבקש מכם להגיד מה תהיה התוצאה של תוכנית (כמו הדוגמה בעמוד 87) בהינתן Scoping כזה או אחר.
(value-of (call-exp (var-exp 'f) (const-exp 5)) [x=4, f=..., x=3]ρ)
= (apply-procedure
(expval->proc (value-of (var-exp 'f) [x=4, f=..., x=3]ρ))
(value-of (const-exp 5) [x=4, f=..., x=3]ρ))
= (apply-procedure (procedure 'y -(y,x) [x=3]ρ) 5)שימו לב: הפרוצדורה נשלפה מהסביבה ויש לה סביבה שמורה משלה שבה x=3!
= (value-of -(y,x) [y=5, x=3]ρ)
= 2
דוגמה לתוכנית עם קופסאות מקוננות עמוקות יותר. מדגימה שוב את עיקרון החיפוש הלקסיקלי - מחפשים משתנה קודם בסקופ הכי פנימי ויוצאים החוצה עד שמוצאים הגדרה.
הדוגמה מהווה בסיס לרעיון ה-Shadowing: אם יש x פנימי, ה-x החיצוני יותר מוסתר (Shadowed). זהו הבסיס המנטלי למעבר למשתנים נטולי שם (nameless) שיוצגו בהמשך.
let x = 3
in let y = 4
in let proc (x)
let z = 5
in -(x, -(y,z))מערכת של קופסאות מקוננות שבה x הפנימי מסתיר (Shadows) את x החיצוני.
זהו שלב מרכזי בהתפתחות המפרש. במקום לשמור שמות של משתנים בזיכרון בזמן ריצה ולחפש אותם עם apply-env, אפשר "לתרגם" את כל המשתנים לאינדקסים לפני הריצה. אינדקס מייצג "כמה קופסאות צריך לקפוץ למעלה כדי למצוא את המשתנה". שיטה זו, שנקראת de Bruijn Indices (אינדקסי דה-ברויֶין), פותרת את בעיית ה-Shadowing בצורה אלגנטית והופכת את המפרש להרבה יותר מהיר.
שאלות על תרגום קוד (Lexical Addressing) נפוצות במבחן. כדי לתרגם משתנה לאינדקס (Nameless), התחילו מהשימוש בו ו"טפסו" החוצה בקוד. כל let או proc בדרך הוא קפיצה של +1. כך %0 משמעותו "המשתנה שהוגדר ממש מעלי" ו-%2 משמעותו "המשתנה שהוגדר 3 רמות מעלי".
let x = 3
in let y = 4
in let proc (x)
let z = 5
in -(x, -(y,z))תוכנית עם שמות רגילים
%lexical translation:מתרגמים באופן סטטי למספרים:
let 3
in let 4
in proc
let 5
in -(%1, -(%3,%0))%1 זה x הפנימי (יוצאים החוצה מה-let של z), %3 זה y (יוצאים מ-z, מ-proc, מ-y), %0 זה z (באותה רמה).
מעקב שמדגים מה היתרון הגדול באינדקסים: הסביבה (Environment) הופכת מרשימה מורכבת של מפתח-ערך, למערך (או רשימה) פשוט של ערכים בלבד!
כאשר תתבקשו לכתוב סביבה בשפת Nameless במבחן (למשל, לרשום את ρ בנקודה מסוימת בתוכנית), זכרו שהיא נראית כמו (3, 4, ...). הערך החדש ביותר (המקומי ביותר, זה שבאינדקס 0) תמיד נדחף להתחלה (צד שמאל) של הרשימה.
(value-of
(call-exp (nameless-proc-exp ...) (const-exp 5))
(3 4))סביבה שמכילה רק ערכים: 3 (באינדקס 0) ו-4 (באינדקס 1)
= (apply-procedure
(procedure -(%1, -(%3,%0)) (3 4))
5)
= (value-of -(%1, -(%3,%0)) (5 3 4))הארגומנט 5 נדחף לראש הסביבה. כעת אינדקס 0 הוא 5, אינדקס 1 הוא 3, אינדקס 2 (שבמקור היה %3) הוא 4.
כאן אנו לומדים על גישה חדשה למפרשים. במקום להריץ ישירות את קוד המקור, אנו מבצעים תרגום מקדים (Translation). התוכנית המקורית עם המשתנים עוברת המרה לתוכנית שוות-ערך לחלוטין אך המשתמשת באינדקסי de Bruijn. רק לאחר מכן, התוכנית המומרת תועבר למפרש נטול שמות (Nameless Interpreter) שיריץ אותה ביעילות רבה יותר.
הבנה ש-Translation קורה שלב אחד לפני ה-Interpretation (value-of). במבחן ייתכן שתידרשו להוסיף תכונה (למשל, לולאת for). אם אנחנו עובדים עם מתרגם, תצטרכו לכתוב איך ה-for מתורגם ב-translation-of בנוסף לאיך הוא מחושב ב-value-of.
> (translation-of-program
(scan&parse
"let x=3 in let y=4 in -(x,y)"))קריאה למתרגם הסטטי על קוד מקור
#(struct:a-program
#(struct:nameless-let-exp
#(struct:const-exp 3)
#(struct:nameless-let-exp
#(struct:const-exp 4)
#(struct:diff-exp
#(struct:nameless-var-exp 1)
#(struct:nameless-var-exp 0)))))שימו לב לעץ המוחזר: במקום let-exp או var-exp יש גרסאות "nameless" עם מספרים (1 ו-0) במקום שמות.
הגדרת סוגי ביטויים (Expressions) חדשים בעץ התחביר שנועדו להחליף את הביטויים הרגילים בסיום התרגום. nameless-var-exp מחליף משתנה שמי, nameless-let-exp מחליף בלוק let וכו'. הביטויים החדשים אינם שומרים מזהים (Identifiers), אלא רק עמדות יחסיות (Lexical Addresses / Numbers).
שימו לב ל-nameless-proc-exp: הוא אינו מקבל שום משתנה מקושר (bound var) ב-AST! פשוט מעבירים אליו את ה-body. כנ"ל nameless-let-exp שלא זקוק לדעת איך קראו למשתנה המאותחל.
Expression ::= %lexref number
nameless-var-exp (num)משתנה חסר שם שמיוצג על ידי עומק השיוך (Lexical Address) שלו בסביבה.
Expression ::= %let Expression in Expression
nameless-let-exp (exp1 body)בלוק let חסר שם, לא צריך לדעת את שם המשתנה המקומי כי המשתנים בפנים מתייחסים אליו דרך ה-Lexical Address.
Expression ::= %lexproc Expression
nameless-proc-exp (body)הגדרת פרוצדורה ללא ציון שם הפרמטר שלה. הארגומנט שיוזן לה ימוקם באינדקס 0.
כדי שנוכל לתרגם שמות למספרים, עלינו לנהל סביבה בזמן התרגום (זמן קומפילציה). זוהי הסביבה הסטטית (Static Environment). סביבה זו שומרת רק שמות (ללא ערכים), ומטרתה היחידה היא לגלות עבור כל משתנה "כמה קופסאות עלינו לצאת" (Lexical Depth) כדי למצוא אותו, ולהפוך זאת לאינדקס de Bruijn מתאים.
הפונקציה החשובה פה היא apply-senv המחשבת את האינדקס. אם אנו מוצאים את המשתנה בתא הראשון ברשימה נחזיר 0. אחרת, נמשיך הלאה ונחזיר את הערך ועוד 1, וכך הלאה. אם ביקשו במבחן לשנות את האינדקסים (למשל להתחיל מ-1 במקום מ-0), כאן השינוי אמור להתבצע.
(define empty-senv
(lambda () '()))
(define extend-senv
(lambda (var senv)
(cons var senv)))הרחבת סביבה פשוטה השומרת אך ורק את שם המשתנה החדש (בראש הרשימה).
(define apply-senv
(lambda (senv var)
(cond
((null? senv) (report-unbound-var var))
((eqv? var (car senv)) 0)
(else
(+ 1 (apply-senv (cdr senv) var))))))סופר כמה צעדים עלינו לצעוד (Lexical Depth) עד שאנו מוצאים את המשתנה. התוצאה היא ה-de Bruijn Index שלו.
נקודת הכניסה של המתרגם. ממש כפי שלמפרש יש value-of-program, למתרגם יש translation-of-program. הוא מקבל תוכנית (AST רגיל), מכין סביבה סטטית התחלתית (המכילה את המשתנים הגלובליים המובנים, כמו i, v ו-x), ומעביר את גוף התוכנית לתרגום רקורסיבי.
שימו לב ש-init-senv ו-init-env חייבים להיות מסונכרנים! אם הוספנו למפרש משתנה מובנה חדש (למשל פונקציה print) ב-init-env, חובה להוסיף אותה גם ב-init-senv כדי שהמתרגם יכיר את השם ולא יזרוק שגיאת "משתנה לא מוגדר".
(define translation-of-program
(lambda (pgm)
(cases program pgm
(a-program (exp1)
(a-program
(translation-of exp1 (init-senv)))))))מתרגמים את הביטוי הראשי תוך שימוש בסביבה סטטית מותחלת
(define init-senv
(lambda ()
(extend-senv 'i
(extend-senv 'v
(extend-senv 'x
(empty-senv))))))סביבה שמכירה מראש את i (אינדקס 0), v (אינדקס 1), ו-x (אינדקס 2) בסביבה ההתחלתית.
זהו לב מנגנון התרגום (Figure 3.16). הפונקציה translation-of עוברת על ה-AST המקורי. כאשר היא נתקלת במשתנה, היא מחשבת את הכתובת הלקסיקלית שלו על ידי חיפוש ב-senv, ומייצרת nameless-var-exp. כאשר היא נתקלת ב-let או proc, היא מרחיבה את הסביבה הסטטית עבור תרגום הגוף הפנימי.
חשוב מאוד להבין את ההבדל בין translation-of ל-value-of. מתרגם מייצר קוד חדש (AST חדש), בעוד מפרש מייצר ערך (ExpVal). לכן בתרגום של let למשל מחזירים עץ מסוג nameless-let-exp, ולא מעריכים את גוף ה-let!
(define translation-of
(lambda (exp senv)
(cases expression exp
(const-exp (num) (const-exp num))
(var-exp (var)
(nameless-var-exp
(apply-senv senv var)))ממירים משתנה שמי למשתנה חסר שם שמכיל את אינדקס המרחק שלו (de Bruijn Index).
(zero?-exp (exp1)
(zero?-exp (translation-of exp1 senv)))
(let-exp (var exp1 body)
(nameless-let-exp
(translation-of exp1 senv)
(translation-of body (extend-senv var senv))))מתרגמים את הגוף תחת סביבה סטטית מורחבת המכילה את שם המשתנה החדש (var) בעמדה 0.
(proc-exp (var body)
(nameless-proc-exp
(translation-of body (extend-senv var senv))))בדומה ל-let, מתרגמים את הפונקציה כאשר הארגומנט שלה מעשיר את הסביבה הסטטית. שמה של הפונקציה נשמט!
...)))
הגדרת הפעולות הבסיסיות (Interface) לסביבה נטולת שמות, הכוללות empty-nameless-env, extend-nameless-env ו-apply-nameless-env. בנוסף, מוגדר מבנה חדש לפרוצדורות (Closures): בניגוד לעבר, גוף הפרוצדורה (body) אינו מכיל מזהים שמיים, והפרוצדורה זוכרת סביבה נטולת שמות.
שימו לב להבדל הקריטי: Closure של שפה רגילה שומר var (שם הארגומנט) ו-env. Closure של שפת Nameless שומר רק את ה-body ו-nameless-env. הוא לא צריך את שם הארגומנט כי ב-apply-procedure פשוט נוסיף את הערך כאיבר ה-0 לסביבה.
(define-datatype proc proc?
(procedure
(body expression?)
(saved-env nameless-environment?))שימו לב שחסר כאן ה-var (שם הארגומנט) שהיה קיים במפרש הרגיל. כל הארגומנטים מיוצגים פשוט כאינדקס!)
מימוש יעיל ביותר של הסביבה בזמן ריצה: היא אינה דורשת חיפושי מחרוזות. הסביבה היא פשוט רשימה (List) של ערכים. פונקציית apply-nameless-env מבצעת גישה יעילה (באמצעות list-ref). בנוסף, apply-procedure מריצה את הפרוצדורה על ידי דחיפת הארגומנט לראש רשימת הערכים השמורה ב-closure.
זכרו ש-list-ref בסביבה נטולת שמות פותר את בעיית ה-O(n) בחיפוש מחרוזות, אך עדיין לוקח O(n) כדי להגיע לאינדקס עמוק برשימה מקושרת (למרות שפעולות pointer מהירות יותר מהשוואת מחרוזות). בסביבות אמיתיות משתמשים במערכים לקבלת O(1).
(define nameless-environment?
(lambda (x)
((list-of expval?) x)))הסביבה היא רשימת ערכים פשוטה.
(define apply-nameless-env
(lambda (nenv n)
(list-ref nenv n)))גישה ישירה לאינדקס באמצעות list-ref המובנה של Scheme! פשוט ויעיל.
(define apply-procedure
(lambda (proc1 val)
(cases proc proc1
(procedure (body saved-env)
(value-of body (extend-nameless-env val saved-env)))הארגומנט נדחף למקום ה-0 ברשימת הערכים השמורה.))))
זהו לב המפרש החדש (Figure 3.18). אנו מגדירים כיצד value-of מעריך את ה-AST חסר השמות. זהו מפרש נקי ויעיל: הוא אינו עוסק יותר בניהול שמות משתנים. nameless-var-exp ניגש מיד לסביבה, ו-nameless-let-exp רק מחשב ערך ודוחף אותו לסביבה באמצעות extend-nameless-env. הריצה הסופית מהירה משמעותית.
זהו הקוד המלא עבור המפרש של שפת חסרת השמות. כדאי מאוד להבין איך עובד value-of בגרסה הזו לעומת הגרסה הרגילה: אין שימוש ב-apply-env עם מחרוזת, אלא גישה מיידית לסביבה נטולת השמות.
(define value-of
(lambda (exp nenv)
(cases expression exp
(const-exp (num) (num-val num))
(nameless-var-exp (n)
(apply-nameless-env nenv n))שליפה מיידית מבוססת מספר n
(nameless-proc-exp (body)
(proc-val (procedure body nenv)))
(call-exp (rator rand)
(let ((proc (expval->proc (value-of rator nenv)))
(arg (value-of rand nenv)))
(apply-procedure proc arg)))
(nameless-let-exp (exp1 body)
(let ((val (value-of exp1 nenv)))
(value-of body
(extend-nameless-env val nenv))))מעריכים את צד ימין, ודוחפים לסביבה.
...)))
הגדרת המשוואות הסמנטיות (Domain Equations) לשפה החדשה, הכוללת מנגנון של State (זיכרון / חנות). אנו מכניסים מושג חדש בשם Store השומר מיפוי של מיקומים (References) לערכים. בנוסף, ערך מסוג Ref מצטרף כסוג חדש של ExpVal.
זהו הבסיס לכל פרק 4. במבחן עליכם לזכור שההבדל העיקרי הוא שהסביבה (Env) ממפה משתנים למיקומים בזיכרון (Ref), והחנות (Store) ממפה מיקומים לערכים בפועל (ExpVal). זה מאפשר למשתנים שונים להצביע לאותו מקום!
ExpVal = Int + Bool + Proc + Ref(ExpVal)מעתה, הצבעה למקום בזיכרון (Ref) נחשבת לערך חוקי בשפה שניתן להחזיר מביטוי.
DenVal = ExpVal
Store = Listof(ExpVal)מבנה הנתונים השומר את מצב התוכנית (State), והוא מעודכן ומועבר לכל אורך הריצה.
המחשה לכוחה של שפה עם State. בדוגמה זו אנו מייצרים תוכנית שמתנהגת כמו 'מונה' (Counter). הפונקציה g מעלה את המונה ב-1 בכל פעם שהיא נקראת, וקוראת את הערך המעודכן. מכיוון שהסביבה (Environment) היא לקסיקלית והזיכרון (Store) הוא חיצוני ל-Scope, כל קריאה מעדכנת את אותו תא זיכרון.
דוגמה קלאסית לשאלות של "מה יודפס?". תוכניות כאלו מחזירות ערך שונה בכל קריאה למרות שהארגומנט נשאר זהה (Loss of Referential Transparency). במקרה זה, הקריאה הראשונה מחזירה 1, השנייה מחזירה 2, ולכן -(1, 2) מחזיר -1.
let g = let counter = newref(0)יצירת תא בזיכרון המאותחל לאפס. ה-let החיצוני מתבצע פעם אחת.
in proc (dummy)
beginמבנה begin מאפשר ביצוע רצף פקודות המשנות מצב ומחזיר את הערך של האחרונה.
setref(counter, -(deref(counter), -1));עדכון הזיכרון (העלאה ב-1).
deref(counter)קריאת הזיכרון (והחזרת ערכו).
end
in let a = (g 11)
in let b = (g 11)
in -(a,b)התוצאה אינה 0, אלא -1. מכיוון שבקריאה הראשונה (g 11) מחזיר 1, ובשנייה מחזיר 2.
מכיוון שערך מסוג Ref הוא ערך חוקי בשפה, ניתן לאחסן הפניות (Pointers) בתוך הזיכרון עצמו. דוגמה זו מראה יצירת "מצביע למצביע" (Pointer to Pointer).
בדומה לשפת C, ניתן לייצר שרשראות של מצביעים. x שומר ref. כשעושים deref(x) מקבלים את ה-ref הפנימי. זה שימושי בעיקר להבנת המגבלות והכוח של EXPLICIT-REFS.
let x = newref(newref(0))x מחזיק הפניה למיקום l1, שבתוכו יש הפניה למיקום l2, שבתוכו הערך 0.
in begin
setref(deref(x), 11);שולף את ההפניה ל-l2 (דרך קריאת l1 ב-deref), ומעדכן את מיקום l2 בערך 11.
deref(deref(x))
end
כאשר מוסיפים חנות (Store) לשפה, הסמנטיקה משתנה מ-(value-of exp env) = val ל-Store-Passing Style. כלומר, כל הערכה לוקחת סביבה ρ וחנות σ, ומחזירה גם ערך וגם חנות מעודכנת. כאן רואים איך כללים בסיסיים כמו הערכת משתנים או חיסור חייבים להעביר את ה-Store הלאה ("Threading the store").
זה הבסיס לאיך שפותרים שאלות של הוספת Features לשפה עם Store. חובה להראות שכל תת-ביטוי מחזיר את ה-Store שלו (σ1), והביטוי הבא חייב להשתמש ב-Store הזה (ולא ב-Store המקורי σ) כדי לראות את השינויים!
(value-of (const-exp n) ρ σ) = (num-val(n), σ)קבוע אינו משנה את החנות, היא מוחזרת כפי שהיא.
(value-of (var-exp var) ρ σ) = (apply-env ρ var, σ)
(value-of (diff-exp exp1 exp2) ρ σ)
= let (val1, σ1) = (value-of exp1 ρ σ)
in let (val2, σ2) = (value-of exp2 ρ σ1)
in (num-val(val1 - val2), σ2)החנות מועברת "בשרשרת": תחילה ל-exp1 שמייצר חנות מעודכנת σ1, שמועברת ל-exp2 שמייצר חנות סופית σ2.
הרחבת התחביר של השפה כך שתכלול פקודות לניהול זיכרון מפורש (Explicit): יצירת תא בזיכרון (newref), קריאה מתא קיים (deref), וכתיבה לתא (setref). בנוסף, מתווסף מבנה ה-begin המאפשר לבצע רצף פעולות המשנות מצב ולהחזיר את התוצאה של האחרונה.
יש לדעת לקרוא קוד שמשתמש במילים אלו. newref מייצר Ref(val), deref מצפה ל-Ref(val) ומחזיר val, ו-setref מצפה ל-Ref(val) ומשנה את תוכנו ב-Store. begin נחוץ כי בלעדיו לא ניתן היה לשרשר פקודות בשפה פונקציונלית.
Expression ::= begin Expression {; Expression}* end
begin-exp (exp1 exps)מבנה השלם (AST) של begin הכולל את הביטוי הראשון ורשימה של שאר הביטויים.
Expression ::= newref (Expression)
newref-exp (exp1)יצירת תא חדש.
Expression ::= deref (Expression)
deref-exp (exp1)קריאה מתא קיים.
Expression ::= setref (Expression , Expression)
setref-exp (exp1 exp2)כתיבה לתא קיים (exp1 מכיל את ההפניה, exp2 מכיל את הערך החדש).
ההגדרה הסמנטית המדויקת של פקודות הזיכרון בסגנון Store-Passing. שימו לב כי newref ו-setref מחזירות חנות חדשה המעודכנת עם השינוי (σ2, σ3), בעוד deref רק מבצע שליפה ממנה ללא שינוי, ולכן מחזיר הלאה את אותה החנות (σ1).
במבחנים מבקשים לעיתים קרובות להגדיר פקודות חדשות שמשנות את ה-Store (למשל, החלפה של ערכים swap או השמה כפולה). השתמשו בתבנית הזו! חשבו את הערכים קודם, העבירו את ה-Store מחישוב לחישוב, ובסוף צרו Store חדש באמצעות פעולות אבסטרקטיות של הזיכרון (כמו setref הסמנטי) והחזירו אותו כחלק מהזוג (ערך, חנות).
(value-of (newref-exp exp1) ρ σ)
= let (val1, σ1) = (value-of exp1 ρ σ)
in let (l, σ2) = (newref σ1 val1)
in (ref-val(l), σ2)מחשבים את ערך הביטוי, מקצים מקום בזיכרון, ומחזירים את ההפניה (ref-val) יחד עם החנות המורחבת.
(value-of (deref-exp exp1) ρ σ)
= let (val1, σ1) = (value-of exp1 ρ σ)
in (deref σ1 val1, σ1)מחזירים את הערך השמור בחנות במיקום ההפניה val1, ללא שינוי החנות.
(value-of (setref-exp exp1 exp2) ρ σ)
= let (val1, σ1) = (value-of exp1 ρ σ)
in let (val2, σ2) = (value-of exp2 ρ σ1)
in let σ3 = (setref σ2 val1 val2)
in (num-val(23), σ3)מחשבים מיקום וערך חדש. מעדכנים את החנות (מייצר את σ3). מוסכמה ב-EOPL היא להחזיר ערך שרירותי (כמו 23) מפעולת setref.
בניגוד לסמנטיקה המתמטית (Store-Passing) שבה ה-Store הועבר כאובייקט משורשר, במפרש בפועל אנו משתמשים בגישה פרגמטית יותר: ה-Store הוא משתנה גלובלי ב-Scheme (באמצעות set!). לכן, value-of-program חייב קודם כל לאתחל אותו דרך initialize-store! לפני הרצת כל תוכנית.
הבנה ארכיטקטונית חשובה: EOPL בוחרת לממש את הזיכרון בעזרת הזיכרון (State) של שפת המארח (Scheme) במקום לשרשר אותו פונקציונלית. זה מפשט מאוד את כתיבת ה-value-of, שכן אין צורך להחזיר זוגות של (val, store) מכל פונקציה. זה גם אומר שאם לא נקרא ל-initialize-store!, התוכניות יזלגו מידע אחת לשנייה!
(define value-of-program
(lambda (pgm)
(initialize-store!)מאפס ומכין את הזיכרון הגלובלי לפני כל הרצת תוכנית, למניעת "זליגת" מצב מריצות קודמות.
(cases program pgm
(a-program (exp1)
(value-of exp1 (init-env))))))
הגדרת המבנה הגלובלי ששומר את החנות (Figure 4.1). ה-Store מיושם בצורה הפשוטה ביותר (Naive) - כרשימה (List) פשוטה. מצביע (Reference) הוא בסך הכל מספר שלם המייצג את האינדקס באותה רשימה. initialize-store! משתמשת ב-set! של Scheme כדי לאתחל משתנה גלובלי זה.
חשוב להכיר כיצד הממשק הבסיסי של Store נראה. ההחלטה לממש Store כרשימה היא לא יעילה (זמן O(n) לכתיבה ולקריאה), אך היא נוחה להדגמת הרעיון. אם מבקשים לממש Store יעיל יותר במבחן (למשל, Vector), תצטרכו לעדכן את הפונקציות פה.
(define empty-store
(lambda () '()))
(define the-store 'uninitialized)משתנה גלובלי שמייצג את החנות לאורך כל זמן הריצה.
(define get-store
(lambda () the-store))
(define initialize-store!
(lambda ()
(set! the-store (empty-store))))שימוש ב-set! פנימי של Scheme כדי לשנות את מצב החנות לרשימה ריקה.
(define reference?
(lambda (v)
(integer? v)))מצביע בזיכרון מיוצג באופן פשוט וקומפקטי על ידי מספר שלם (האינדקס ברשימה).
הפונקציות שמבצעות בפועל את המניפולציה על הזיכרון הגלובלי, שמיוצג כרשימה. newref משרשרת אלמנט חדש לסוף הרשימה (append) ומחזירה את האינדקס שלו (שהוא אורך הרשימה הנוכחי). deref קוראת בעזרת list-ref. setref! היא המורכבת ביותר: מכיוון שרשימות ב-Scheme הן Immutable, היא בונה רשימה חדשה לגמרי שבה הוחלף רק האלמנט במיקום הספציפי (O(n)), ומחליפה את ה-Store הגלובלי בה.
תתבקשו לא פעם לשנות את המימוש של Store (למשל, הוספת Garbage Collector פשוט, או הרחבה למערך רב-מימדי). הבנת הלוגיקה של setref-inner היא קריטית: היא עוברת ברקורסיה על ה-Store עד שהיא מגיעה לאינדקס, יוצרת Cell חדש עם הערך החדש, ומעתיקה את כל שאר התאים. שימו לב שהזיכרון לעולם אינו מצטמצם במודל הנאיבי הזה.
(define newref
(lambda (val)
(let ((next-ref (length the-store)))האינדקס הבא הוא בדיוק אורך הרשימה הנוכחי.
(set! the-store (append the-store (list val)))מוסיפים את הערך החדש לסוף הזיכרון.
next-ref)))
(define deref
(lambda (ref)
(list-ref the-store ref)))
(define setref!
(lambda (ref val)
(set! the-store
(letrec
((setref-inner
(lambda (store1 ref1)
(cond
((null? store1)
(report-invalid-reference ref the-store))
((zero? ref1)
(cons val (cdr store1)))הגענו למיקום המבוקש: מחליפים את ראש הרשימה (car) בערך החדש (val) ומחברים להמשך (cdr).
(else
(cons
(car store1)
(setref-inner
(cdr store1) (- ref1 1))))))))
(setref-inner the-store ref)))))
שילוב הפקודות בתוך מנוע ההערכה value-of (מתוך Figure 4.3). מכיוון שהשתמשנו בזיכרון גלובלי, הכללים הללו נראים פשוטים בהרבה מהכללים המתמטיים: אנחנו מעריכים את הביטויים הפנימיים בסביבה (env), ואז קוראים לפונקציות ה-API של ה-Store הגלובלי (newref, deref, setref!), ללא צורך בהעברה מפורשת של ה-Store פנימה והחוצה כפי שעשינו בסמנטיקה.
תבנית זו היא התבנית שאיתה תעבדו כמעט בכל תוספת של שפה פקודתית. שימו לב לחלוקה: הפעולה (expval->ref v1) חשובה כי ה-Store לא מצפה לקבל אוביקט ExpVal כרפרנס אלא את המספר (האינדקס) עצמו. במקרה של setref-exp, מקובל להחזיר ערך דמה כלשהו, בספר נבחר 23.
(cases expression exp
...
(newref-exp (exp1)
(let ((v1 (value-of exp1 env)))
(ref-val (newref v1))))יוצרים הצבעה חדשה בזיכרון ועוטפים אותה בסוג ref-val המוגדר כ-ExpVal.
(deref-exp (exp1)
(let ((v1 (value-of exp1 env)))
(let ((ref1 (expval->ref v1)))
(deref ref1))))מחלצים את מיקום ההצבעה מהערך, ושולפים מתוכו את המידע דרך deref.
(setref-exp (exp1 exp2)
(let ((ref (expval->ref (value-of exp1 env))))
(let ((val2 (value-of exp2 env)))
(begin
(setref! ref val2)קריאה ל-setref! שמעדכנת את הזיכרון הגלובלי באמצעות set! פנימי.
(num-val 23))))))החזרת ערך סרק מכיוון שפקודת השמה לא אמורה לשמש כביטוי המחזיר מידע.
שפת IMPLICIT-REFS מייצגת את המודל המקובל בשפות תכנות מודרניות (כמו C, Java, Python). כאן מתרחש שינוי סמנטי עמוק: הפניות (References) אינן עוד ערכים שהמתכנת יכול לגעת בהם כישות נפרדת בשפה (אין Ref ב-ExpVal). במקום זאת, כל שם משתנה בסביבה הלקסיקלית מחזיק הפניה לזיכרון (לכן DenVal = Ref(ExpVal)).
ההבדל בין EXPLICIT ל-IMPLICIT הוא שאלת מבחן קלאסית. ב-EXPLICIT הסביבה ממפה שמות ל-ExpVal. ב-IMPLICIT הסביבה ממפה שמות למיקומים בזיכרון (DenVal=Ref(ExpVal)). כלומר ב-IMPLICIT, עצם יצירת המשתנה (למשל ב-let) כוללת הקצאת מקום ב-Store באופן אוטומטי.
ExpVal = Int + Bool + Proc
DenVal = Ref(ExpVal)מה שהסביבה ממפה אליו. כל שם משתנה מצביע ישירות לתא בזיכרון.
Store = Listof(ExpVal)כמו קודם, החנות שומרת את הערכים הממשיים.
הסרנו מהתחביר את הפעולות newref, deref ו-setref מכיוון שהמפרש יבצע אותן באופן אוטומטי (מאחורי הקלעים). במקומן, הוספנו פקודת השמה אחת המוכרת מכל שפת תכנות: set x = exp.
תזכרו: ב-IMPLICIT אי אפשר לייצר תא בזיכרון שלא מקושר ישירות למשתנה (כמו בדוגמת הפוינטרים הקודמת). ברמת ה-AST, הפעולה assign-exp מקבלת שם משתנה (var) ולא ביטוי שיש לחשב, בגלל שהיא ניגשת ישירות לסביבה כדי למצוא את המיקום.
Expression ::= set Identifier = Expression
assign-exp (var exp1)ביטוי AST מסוג assign-exp. הוא יאתר את מיקום המשתנה בזיכרון, ויכתוב לתוכו את ערך הביטוי.
הקוד בשפת היעד הרבה יותר נקי מ-EXPLICIT-REFS. אין יותר deref(x) המכוער, פשוט כותבים x. וכדי לשנות את ערכו, פשוט כותבים set x = .... התנהגות זו מרגישה למתכנת טבעית, בדומה ל-Java או Python.
זכרו ש-let x = 0 יוצר תא חדש בזיכרון. set x = -(x, -1) קורא את התא, מחשב, ואז מעדכן את אותו התא עם התוצאה החדשה. הקריאה ל-mut מבצעת את פעולת ה-set.
let x = 0יצירת משתנה. מאחורי הקלעים מתבצע newref בזיכרון הגלובלי, והסביבה תמפה את x להפניה (Ref) זו.
in let mut = proc (d) set x = -(x, -1)שימוש במשתנה. הקריאה ל-x מתרגמת ל-deref, וה-set מתרגם ל-setref על מיקום הזיכרון של x.
in begin
(mut 0);
(mut 0);
x
end
ההגדרה המתמטית מראה איך מתבצע ה"קסם": כאשר המפרש מעריך משתנה רגיל (var-exp), הוא אינו מחזיר פשוט את מה שבסביבה. הוא שולף את ההצבעה מהסביבה, ומייד מפעיל עליה deref כדי להחזיר את הערך האמיתי השמור בחנות.
זהו לב השינוי בין שתי השפות מבחינת הסמנטיקה של var-exp. שימו לב לחתימה הכפולה: הסביבה מופעלת על המשתנה (לקבלת המיקום l), ואז ה-Store מופעל על המיקום l. החנות עצמה לא משתנה בחיפוש משתנה ולכן מוחזרת כפי שהיא σ.
(value-of (var-exp var) ρ σ)
= (deref σ (apply-env ρ var), σ)שלב 1: (apply-env ρ var) מחזיר את מיקום הזיכרון l.
שלב 2: (deref σ l) מחזיר את הערך במיקום זה מתוך החנות.
בשפת IMPLICIT-REFS, ההשמה (set) מעדכנת את התא הקיים אליו מצביע המשתנה. מצד שני, בעת קריאה לפונקציה (call-exp), הארגומנט מוקצה לתא חדש לגמרי בזיכרון, ומועבר לפונקציה. התנהגות זו נקראת Call-by-Value, מכיוון שמשתני הפונקציה הם עותקים מקומיים עצמאיים לחלוטין בזיכרון, וכל שינוי להם לא ישפיע על המשתנים שבחוץ.
שימו לב שב-assign-exp אנחנו לא מקצים כתובת חדשה, אלא דורסים בעזרת setref את הכתובת הישנה l שיש למשתנה. לעומת זאת, ב-call-exp אנחנו קוראים ל-newref, שמייצר תא זיכרון חדש l עבור הערך. הבחנה זו מהותית להבנת מודל הזיכרון!
(value-of (assign-exp var exp1) ρ σ)
= let (val, σ1) = (value-of exp1 ρ σ)
in let l = (apply-env ρ var)מוצאים את המיקום l של המשתנה var.
in (num-val(27), (setref σ1 l val)כותבים לחנות את הערך המחושב באותו מיקום l בדיוק.)
(value-of (call-exp rator rand) ρ σ)
= let (proc, σ1) = (value-of rator ρ σ)
in let (val, σ2) = (value-of rand ρ σ1)
in let (l, σ3) = (newref σ2 val)ב-Call-by-Value אנו מקצים מיקום *חדש* l עבור הארגומנט בזיכרון (העותק המקומי), ומעבירים אותו פנימה לפרוצדורה.
in (apply-procedure proc l σ3)
תרגום הסמנטיקה התיאורטית לקוד אמיתי (כחלק מ-value-of). בגלל שאנחנו במודל נאיבי ב-Scheme (שבו ה-Store מנוהל גלובלית כמשתנה Scheme נסתר), ההשמה היא פשוט קריאה ל-setref!. קודם שולפים את המצביע למשתנה עם apply-env, ואז כותבים לתוכו.
קוד המפרש כאן משתמש ב-State הגלובלי (אין העברת store כפרמטר), ולכן קריאה ל-setref! מבצעת Side-Effect אמיתי בסביבת הריצה. לכן ביטוי ההשמה גם מחזיר סתם מספר אקראי (27) כי התוצאה שלו חסרת משמעות, רק התוצר לוואי חשוב.
(assign-exp (var exp1)
(begin
(setref!
(apply-env env var)
(value-of exp1 env))מחשבים את exp1 ושומרים במיקום שהסביבה מכירה עבור var.
(num-val 27)))
ב-IMPLICIT-REFS, כל הצהרת משתנה חדש מקצה באופן אוטומטי תא בזיכרון (Store). ב-let ובזמן הפעלת procedure, ה-extend-env מקבל לא את הערך עצמו, אלא את המצביע (Ref) שהוחזר מ-newref! זהו הבדל ארכיטקטוני עצום.
כאשר שואלים "היכן נוצרים Ref ב-IMPLICIT-REFS?", התשובה היא בדיוק בשני המקומות האלו (וב-rec). הסביבה כעת ממפה תמיד משתנים ל-Ref, אז כל קריאה ל-extend-env עוטפת את הערך ב-newref.
(let-exp (var exp1 body)
(let ((v1 (value-of exp1 env)))
(value-of body
(extend-env var (newref v1) env))))מרחיבים את הסביבה כך שהשם var יצביע למיקום החדש שהוקצה ב-Store!
(define apply-procedure
(lambda (proc1 val)
(cases proc proc1
(procedure (var body saved-env)
(value-of body
(extend-env var (newref val) saved-env))))))באופן דומה, משתנה הפונקציה (var) מוצמד להקצאת זיכרון טרייה המכילה את הארגומנט (val) - מודל ה-Call-by-Value בשיאו.
במפרש הישן, extend-env-rec דרש תחשיב מסובך כי הפונקציה הייתה צריכה להכיר את עצמה בתוך ה-Environment. ב-IMPLICIT-REFS זה פשוט בהרבה: כיוון שכל משתנה הוא מצביע לזיכרון, ברגע שמאתרים את שם הפונקציה ב-apply-env, אנחנו פשוט בונים את ה-closure ומקצים לו מקום חדש בזיכרון (newref).
זהו "טריק" אלגנטי שחוסך את הצורך ליצור משתני דמה או מבני נתונים מעגליים בזמן יצירת הסביבה. ההקצאה קורית רק כשמישהו אשכרה מנסה לשלוף את הפונקציה הרקורסיבית (בזמן ריצה, בתוך apply-env). שימו לב שהסביבה שנארזת ב-closure היא env (דהיינו הרקורסיבית כולה).
(define apply-env
(lambda (env search-sym)
(cases environment env
...
(extend-env-rec (p-name b-var p-body saved-env)
(if (eqv? search-sym p-name)
(newref
(proc-val (procedure b-var p-body env)))מייצר תא זיכרון חדש עבור הפרוצדורה בזמן השליפה! הסביבה שנשמרת בתוך ה-closure היא ה-env הרקורסיבי המלא.
(apply-env saved-env search-sym))))))
הרחבת השפה לתמיכה במבני נתונים הניתנים לשינוי (Mutable Data Structures), בדומה ל-set-car! ו-set-cdr! בשפת Scheme. המשוואות הסמנטיות מגדירות ש-MutPair אינו שומר את הערכים עצמם, אלא שומר צמד של הצבעות (References) לזיכרון!
חשוב לזכור את המשוואה: MutPair = Ref(ExpVal) x Ref(ExpVal). כשאנחנו משנים צד אחד של הזוג, איננו יוצרים זוג חדש, אלא פשוט ניגשים לאחת משתי הכתובות וכותבים לשם מידע חדש (שינוי Destructive). הסביבה ממפה את שם הזוג לכתובת שמצביעה על מבנה ה-MutPair הזה.
ExpVal = Int + Bool + Proc + MutPair
MutPair = Ref(ExpVal) x Ref(ExpVal)צמד שמורכב משתי כתובות בזיכרון.
DenVal = Ref(ExpVal)
כיצד נממש את MutPair? בקלות! כאשר אנו יוצרים זוג בעזרת make-pair, אנו פשוט מבקשים מהזיכרון שתי הקצאות חדשות (newref). כעת, אובייקט ה-pair רק שומר בתוכו את מספרי הכתובות. קריאה משמאל מתורגמת ל-deref של הכתובת השמאלית, וכתיבה לשמאל מתורגמת ל-setref! של אותה כתובת.
המימוש הזה נחשב ל"נאיבי" מכיוון שהוא מניח קיומו של מערך זיכרון פשוט המנוהל בקוד Scheme החיצוני (ללא Store-Passing בסמנטיקה). שימו לב ש-make-pair מבצע שתי קריאות עצמאיות ל-newref ולכן הזוג בעצם מחזיק שתי הפניות נפרדות (לא מחייב שיהיו רציפות במודל התיאורטי שלנו).
(define make-pair
(lambda (val1 val2)
(let ((ref1 (newref val1)))
(let ((ref2 (newref val2)))
(mutpair ref1 ref2)))מייצרים שני תאים בזיכרון, ואורזים את מספרי התאים למבנה הנתונים mutpair.))
(define left
(lambda (p)
(cases mutpair p
(a-pair (ref1 ref2)
(deref ref1)שולפים את הכתובת השמאלית וקוראים את תוכנה.))))
(define setleft
(lambda (p val)
(cases mutpair p
(a-pair (ref1 ref2)
(setref! ref1 val)כותבים לכתובת השמאלית בזיכרון הגלובלי.))))
עד עכשיו, כאשר העברנו משתנה לפונקציה (Call-by-Value), המפרש יצר עותק מלא בזיכרון. Call-by-Reference הוא מודל שונה לגמרי (נפוץ ב-C++ עם &): אם הארגומנט הוא משתנה קיים, אנו מעבירים לפונקציה את ההצבעה המקורית שלו. לכן, אם הפונקציה מעדכנת את הארגומנט שלה, גם המשתנה המקורי מחוץ לפונקציה משתנה!
שאלות פלט של תוכנית לפי אסטרטגיית קריאה נפוצות מאוד. תזכרו: אם התוכנית רצה תחת Call-by-Value התוצאה של a תישאר 3 (כי x היה רק עותק שלו). ב-Call-by-Reference התוצאה של a הופכת ל-4 (כי x הוא אליאס ל-a עצמו).
let p = proc (x) set x = 4מבצע שינוי לארגומנט המקומי x
in let a = 3
in begin
(p a);מכיוון שאנו ב-Call-By-Reference, המשתנה המקומי x מקבל את אותה כתובת זיכרון כמו a! ההשמה למעלה מעדכנת את הזיכרון המשותף.
a
endהתוצאה כאן תהיה 4, ולא 3 כפי שהיה ב-Call-By-Value!
במקום סתם לקרוא ל-value-of על הארגומנט ב-call-exp, אנו מפעילים את הפונקציה הייעודית value-of-operand. פונקציה זו בודקת: אם הארגומנט הוא משתנה (var-exp), אל תחלץ את ערכו! רק תחזיר את הכתובת שלו (apply-env). אם הוא משהו אחר (כמו ביטוי מתמטי x + 1), חשב אותו והקצה עבורו כתובת חדשה (newref).
מפתח ההבדל לעומת Implicit Refs רגיל (שהוא Call-by-Value) נמצא כאן. ב-Call-by-Reference var-exp יחזיר את מיקום הזיכרון הקיים, ולכן לא נשתמש ב-newref. שימו לב ש-apply-procedure מקבל את הכתובת val (שהיא פשוט l) וקורא ישירות ל-extend-env בלי לעטוף אותה שוב, כי הכתובת כבר מוכנה!
(define value-of-operand
(lambda (exp env)
(cases expression exp
(var-exp (var) (apply-env env var))מפתח ההבדל! במידה והארגומנט הוא משתנה קיים, מחזירים רק את ה-Ref שלו מהסביבה, ללא יצירת עותק.
(else
(newref (value-of exp env))))))אם הארגומנט הוא ביטוי רגיל (נניח 5+3), מחשבים אותו ומקצים עבורו תא חדש.
(define apply-procedure
(lambda (proc1 val)
(cases proc proc1
(procedure (var body saved-env)
(value-of body
(extend-env var val saved-env))))))כעת val הוא תמיד מיקום (Ref), בין אם מצביע מקורי או חדש. אנחנו מכניסים אותו ישירות ל-Environment.
בשפות רגילות (Call-by-Value), הארגומנטים לפונקציה מחושבים מראש לפני הקריאה. ב-Call-by-Name (המכונה גם חישוב עצל / Lazy Evaluation), הארגומנט אינו מחושב מראש אלא מועבר כביטוי, ויחושב רק אם וכאשר הפונקציה תנסה להשתמש בו בפועל. זה חוסך חישובים מיותרים ויכול לאפשר עבודה עם מבני נתונים אינסופיים.
דוגמת מבחן קלאסית: מה יקרה אם הארגומנט מכיל לולאה אינסופית או שגיאה, אבל הפונקציה מתעלמת ממנו? ב-Call-by-Value התוכנית תיתקע/תקרוס עוד לפני הכניסה לפונקציה. ב-Call-by-Name התוכנית תרוץ בהצלחה, כי הביטוי לעולם לא יחושב.
let f = proc (x) 11פונקציה זו מתעלמת לחלוטין מהפרמטר x, ותמיד מחזירה 11.
in (f (f (f 3)))ב-Call-by-Value חישוב זה ידרוש 3 הפעלות. ב-Call-by-Name, המפרש מעביר לפונקציה f החיצונית את הביטוי "(f (f 3))" בלי לחשב אותו. הפונקציה מחזירה מייד 11 והארגומנט נזרק מבלי שחושב מעולם!
כדי להעביר ביטוי מבלי לחשב אותו מראש, עלינו לארוז את עץ התחביר שלו (AST) ביחד עם הסביבה שבה הוא נוצר (כדי שיכיר את המשתנים שהיו קיימים באותו רגע ב-Scope שלו). אריזה זו (ביטוי + סביבה) נקראת Thunk (תאנק), והיא מאוחסנת בזיכרון במקום ערך רגיל.
זכרו את המשוואה DenVal = Ref(ExpVal + Thunk). פירוש הדבר הוא שבסביבה (Environment), כל משתנה מצביע לכתובת בזיכרון. התא בזיכרון יכול להכיל או ערך שכבר חושב (ExpVal, למשל אחרי השמה), או ביטוי מוקפא מסוג Thunk.
ExpVal = Int + Bool + Proc
DenVal = Ref(ExpVal + Thunk)שימו לב: הסביבה ממפה כעת לתאים בזיכרון שיכולים להכיל או ערך ממשי (ExpVal) או ביטוי מוקפא שממתין לחישוב (Thunk).
Thunk = Exp x EnvThunk שקול מבחינה מתמטית למכפלה הקרטזית של ביטוי וסביבה. זהו בדיוק המידע הדרוש כדי לחשב אותו בעתיד.
כאשר מעבירים ארגומנט לפונקציה ב-Call-by-Name (דרך value-of-operand), אנו לא קוראים ל-value-of. במקום לחשב, אנחנו לוקחים את הביטוי (exp) והסביבה (env), עוטפים אותם כאובייקט מסוג a-thunk ומקצים לו מקום חדש בזיכרון ב-Store (בעזרת newref). ה-Thunk נותר שם בהקפאה עד שמישהו באמת יזדקק לו.
שימו לב להתייחסות ל-var-exp. גם ב-Call-by-Name, אם הארגומנט המועבר הוא בעצמו משתנה (למשל f(x)), אנחנו לא עוטפים אותו ב-Thunk חדש, אלא מעבירים את ה-Reference המקורי שלו (כמו ב-Call-by-Reference). זוהי התנהגות סטנדרטית שמייעלת את המפרש, וגורמת למשתנה להתנהג כ-alias!
(define value-of-thunk
(lambda (th)
(cases thunk th
(a-thunk (exp1 saved-env)
(value-of exp1 saved-env)מחשב את הביטוי תחת הסביבה שנשמרה בזמן יצירת ה-Thunk.))))
(define value-of-operand
(lambda (exp env)
(cases expression exp
(var-exp (var) (apply-env env var))
(else
(newref (a-thunk exp env))במקום קריאה ל-value-of - אורזים את הביטוי (AST) והסביבה ל-a-thunk, ושמים אותו בזיכרון.))))
אז מתי מתבצע החישוב בפועל ב-Call-by-Name? רק כשהמפרש מנסה לקרוא את הערך של המשתנה (כלומר מגיע ל-var-exp בעץ, למשל בזמן פעולת חיבור). הוא שולף את התוכן מהזיכרון. אם מדובר בערך שכבר הוערך במלואו (expval?) הוא פשוט מוחזר. אם זהו Thunk מוקפא – הוא "מופשר" ומחושב באותו הרגע (באמצעות קריאה ל-value-of-thunk, שבסוף מריצה value-of עם הסביבה שנשמרה).
ב-Call-by-Name, בכל פעם שפונקציה קוראת למשתנה שקשור ל-Thunk, היא תחשב אותו מחדש לגמרי! אם משתנה מופיע 3 פעמים בגוף הפונקציה, ה-Thunk שלו (שתהליך הערכתו עלול להיות כבד) יחושב 3 פעמים נפרדות, כי התוצאה שלו לא נשמרת (אין Memoization).
(cases expression exp
...
(var-exp (var)
(let ((ref1 (apply-env env var)))
(let ((w (deref ref1)))
(if (expval? w)
w
(value-of-thunk w))בודק מה הוצאנו מהזיכרון. אם זה ערך מוגמר (למשל כי ביצענו השמה), פשוט מחזיר. אחרת, מעריך את ה-Thunk בעזרת value-of-thunk!)))
החיסרון ב-Call-by-Name טהור הוא שאם פונקציה משתמשת בארגומנט 5 פעמים, ה-Thunk יחושב מחדש 5 פעמים! גישת Call-by-Need (המשמשת את שפת Haskell) פותרת זאת: בפעם הראשונה שה-Thunk מחושב, אנחנו שומרים את התוצאה בחזרה לתוך הזיכרון (בעזרת setref!). בפעמים הבאות יישלף רק הערך המחושב ללא מאמץ.
ההבדל היחיד בין By-Name ל-By-Need מתבטא בעת קריאת המשתנה. ההקצאה היא זהה (עוטפים ב-Thunk). אך כאשר אנו מחשבים את הערך, אנו מבצעים "Memoization" דורסני כדי שהחישוב הבא יהיה מיידי. זהו מנגנון חיסכון עצום באסטרטגיות Lazy Evaluation.
(cases expression exp
...
(var-exp (var)
(let ((ref1 (apply-env env var)))
(let ((w (deref ref1)))
(if (expval? w)
w
(let ((val1 (value-of-thunk w)))
(begin
(setref! ref1 val1)
val1))זהירות: Memoization! חישבנו את ה-thunk לערך (val1), שמרנו אותו בחזרה לתא הזיכרון (ref1), והחזרנו. בפעם הבאה שנקרא למשתנה var, הוא יחזיר את הערך ישירות מ-(expval? w) ולא יגיע לכאן!)))))
הספר מדגים את המעבר מפונקציה ששומרת הקשר (Context) - עצרת הקלאסית, שבה צריך לחכות לתוצאת הרקורסיה כדי לכפול ב-n - אל פונקציה מסוג Tail-Recursive. בגרסת ה-iter, משתמשים באוגר (Accumulator - a) כדי לשמור את המצב, ולכן הקריאה הרקורסיבית היא הפעולה האחרונה ללא פעולות המתנה אחריה. זוהי ההקדמה למושג ההמשכיות (Continuation).
קריאת זנב (Tail Call) אומרת שהפונקציה אינה צריכה "לחזור" אחורה לקוד שהפעיל אותה על מנת לסיים את החישוב. הקומפיילר או המפרש יכולים פשוט "לזרוק" את סביבת הפונקציה הקוראת ולעבור הלאה, מה שמונע חריגות Stack Overflow.
(define fact
(lambda (n)
(if (zero? n) 1 (* n (fact (- n 1)))קריאה רקורסיבית רגילה. פעולת הכפל ממתינה לחזרת הרקורסיה. זה בונה "מחסנית" (Call Stack).)))
(define fact-iter
(lambda (n)
(fact-iter-acc n 1)))
(define fact-iter-acc
(lambda (n a)
(if (zero? n)
a
(fact-iter-acc (- n 1) (* n a))קריאת זנב (Tail Call)! הפונקציה קוראת לעצמה כפעולה האחרונה, כאשר האוגר 'a' צובר את ההכפלות. אין צורך לשמור מחסנית.)))
תזכורת למבנה המפרש הרגיל (Direct-Style) שהכרנו. במפרש כזה, הערכת zero?-exp דורשת המתנה: אנחנו קוראים ל-value-of, ממתינים שזה יחזור עם val1, ורק אז ממשיכים ל-expval->num ולבסוף if. זוהי בדיוק ההמתנה שמייצרת Call Stack עמוק ומונעת טיפול בחריגים (Exceptions) או קפיצות בתוכנית (Control Flow).
בסגנון Direct Style המפרש מנצל את המחסנית של שפת האם (למשל Scheme במקרה זה) כדי לזכור מה נשאר לו לעשות אחרי ההמתנה (במקרה זה, להפעיל zero? על התוצאה). כלומר, המפרש עצמו תלוי בתמיכה של שפת האם בהמתנות וסביבות.
(define value-of
(lambda (exp env)
(cases expression exp
(const-exp (num) (num-val num))
(var-exp (var) (apply-env env var))
(proc-exp (var body)
(proc-val (procedure var body env)))
(letrec-exp (p-name b-var p-body letrec-body)
(value-of letrec-body (extend-env-rec p-name b-var p-body env)))
(zero?-exp (exp1)
(let ((val1 (value-of exp1 env)))קריאה "עמוקה" המייצרת תלות והמתנה. המפרש ממתין לתוצאה כדי להמשיך בשורות הבאות.
(let ((num1 (expval->num val1)))
(if (zero? num1) (bool-val #t) (bool-val #f)))))
...)))
כאן מתחיל הטרנספורם ל-CPS! כל פונקציית הערכה מקבלת פרמטר חדש: cont. זהו אובייקט "ההמשכיות" (Continuation) שמתאר מה צריך לעשות עם התוצאה במקום להחזיר אותה למעלה. בתחילת התוכנית מעבירים לה את (end-cont) (ההמשכיות שאומרת "הדפס סיום והחזר ללקוח"). הפונקציה apply-cont מפעילה את ההמשכיות על הערך.
מעתה ואילך, המפרש לא מבצע שום return! תמיד נקרא לפונקציה אחרת (בין אם זה המשך הערכה ל-value-of/k או הפעלה של apply-cont). זה אומר שאנו שומרים את ההקשר בעצמנו כחלק מהמבנה cont ולא תלויים במחסנית (Call Stack) של שפת היעד.
(define value-of-program
(lambda (pgm)
(cases program pgm
(a-program (exp1)
(value-of/k exp1 (init-env) (end-cont))קריאה למפרש החדש (value-of/k) עם ה-Continuation ההתחלתי. ה-k מרמז על "Kontinuation" כנהוג.))))
(define apply-cont
(lambda (cont val)
(cases continuation cont
(end-cont ()
(begin
(eopl:printf "End of computation.~%")הפעולה האחרונה לחלוטין שמבצעת התוכנית לאחר שחישבה את הערך הסופי.
val))
...)))
במקרים פשוטים כמו מספר (const), משתנה (var) או פרוצדורה (proc), הערך ידוע לנו מייד (אין צורך בחישובים מורכבים נוספים בשרשרת שלהם). במקום לבצע return (להחזיר את הערך לקורא הקודם), אנו פשוט מפעילים את ההמשכיות cont שקיבלנו על הערך החדש באמצעות apply-cont! זוהי גישת "אל תחזור אליי, תמשיך הלאה".
שימו לב לכלל הברזל של CPS: כשהערך כבר פשוט ומוכן, מעבירים אותו ל-apply-cont. כשעדיין צריך לחשב משהו בעץ התחביר, קוראים ל-value-of/k (כמו שנראה בהמשך). אף פונקציה לא מחזירה ערך ישירות.
(define value-of/k
(lambda (exp env cont)
(cases expression exp
(const-exp (num) (apply-cont cont (num-val num))אין המתנה. לוקחים את ההמשכיות 'cont' ומפעילים אותה מיד על הערך 'num-val num'.)
(var-exp (var) (apply-cont cont (apply-env env var)))
(proc-exp (var body)
(apply-cont cont (proc-val (procedure var body env))))
...)))
גם letrec-exp הוא ביטוי פשוט ב-CPS. כל מה שאנחנו צריכים לעשות זה להרחיב את הסביבה (באופן מיידי, בעזרת extend-env-rec שאינו דורש חישובי מפרש מורכבים), ואז פשוט לקרוא ל-value-of/k עבור גוף ה-letrec עם הסביבה המורחבת. להמשכיות של הגוף אנחנו פשוט מעבירים את ה-cont המקורי שקיבלנו! זוהי קריאת זנב (Tail Call) מושלמת.
כשאין צורך לעבד את התוצאה של ביטוי (למשל כאן: התוצאה של letrec היא בדיוק התוצאה של ה-body שלו), לא יוצרים Continuation חדש! פשוט "מעבירים את הלפיד" ומעבירים את ה-cont הנוכחי לביטוי הפנימי.
(define value-of/k
(lambda (exp env cont)
(cases expression exp
...
(letrec-exp (p-name b-var p-body letrec-body)
(value-of/k letrec-body
(extend-env-rec p-name b-var p-body env)
cont)קריאת זנב: אנחנו מעבירים לביטוי הפנימי את 'cont' בשלמותו. המפרש לא צריך לחכות לתוצאה כאן.)
...)))
בניגוד ל-letrec, ב-zero?-exp אנחנו קודם צריכים לחשב את התוצאה של הפנימי (exp1), ורק אז לבדוק אם היא אפס. מכיוון שאסור לנו להמתין! אנחנו שולחים את exp1 למפרש value-of/k ומעבירים לו המשכיות חדשה מסוג zero1-cont, שיושבת בתוכה המשכיות המקור cont.
זהו דפוס הפעולה המרכזי של יצירת Continuations חדשות! כשמגיע הערך ל-zero1-cont (דרך פונקציית העזר apply-cont), הוא מבצע את פעולת הבדיקה zero? ורק אז קורא להמשכיות השמורה מבחוץ.
(define value-of/k
(lambda (exp env cont)
(cases expression exp
...
(zero?-exp (exp1)
(value-of/k exp1 env
(zero1-cont cont))שולח את exp1 להערכה, ואומר לו: "כשתסיים, תעביר את התוצאה ל-zero1-cont שמחכה לך".)
...)))
(define apply-cont
(lambda (cont val)
(cases continuation cont
...
(zero1-cont (saved-cont)
(apply-cont saved-cont
(bool-val
(zero? (expval->num val))))מבצע את הפעולה שהוקפאה: ממיר למספר, בודק אם שווה לאפס, ומעביר את התשובה ל-saved-cont (ההמשכיות החיצונית).)
...)))
ביטוי let דורש תהליך דו-שלבי. תחילה עלינו לחשב את exp1 (הערך שאליו יקשר המשתנה var). רק לאחר מכן נוכל להרחיב את הסביבה ולחשב את ה-body. כדי שלא נמתין ל-exp1, אנחנו פשוט מריצים אותו דרך value-of/k ומשגרים לו המשכיות מסוג let-exp-cont.
שימו לב לכל הפרטים שההמשכיות let-exp-cont "אורזת" בתוכה: המשתנה שיצרנו (var), גוף הלולאה לחשב אחר כך (body), הסביבה שעדיין צריכה הרחבה (env), ומי שמחכה לתוצאה בסוף (cont). זו הדרך שבה אנחנו שומרים מצב בלי Call Stack אמיתי.
(define value-of/k
(lambda (exp env cont)
(cases expression exp
...
(let-exp (var exp1 body)
(value-of/k exp1 env
(let-exp-cont var body env cont))חשב את exp1, וכשתסיים - תפעיל את 'let-exp-cont' שבו שמרנו את 'var', את 'body', את הסביבה הנוכחית ואת ה-cont המקורי.)
...)))
כאשר הערך של exp1 (שהיה בתוך ה-let) כבר מוכן, הוא מגיע לכאן לתוך פרמטר ה-val. כעת הגיע הזמן להשלים את ה-let: להרחיב את הסביבה (עם הערך החדש שסוף סוף זמין) ולחשב את ה-body. שימו לב שאנחנו מעבירים לו את saved-cont המקורי.
העברת ה-saved-cont בסוף אומרת: "תוצאת ה-body תהיה התוצאה הסופית של כל ביטוי ה-let!". המפרש שוב מבצע קריאת זנב מושלמת ולא ממתין להערכת ה-body בעצמו.
(define apply-cont
(lambda (cont val)
(cases continuation cont
...
(let-exp-cont (var body saved-env saved-cont)
(value-of/k body
(extend-env var val saved-env)
saved-cont)מחשב את ה-body עם הסביבה המורחבת. הקריאה הזו מחליפה את ההמתנה הקודמת שהייתה למפרש!)
...)))
ביטוי תנאי. אנחנו חייבים קודם לחשב את exp1 (התנאי) לפני שנוכל לבחור אם להריץ את exp2 או exp3. כרגיל, ב-CPS אנחנו לא ממתינים לתנאי אלא יוצרים if-test-cont שמתעורר כשהתנאי מוכן. ברגע שהוא מוכן (ב-apply-cont), הוא בודק את ערך האמת ומריץ את אחד מהענפים יחד עם ה-saved-cont המקורי.
שימו לב להבדל הקריטי ממפרש רגיל: המפרש הרגיל מבצע את בחירת הענפים בתוך הפונקציה value-of, ב-CPS הבחירה מתבצעת לחלוטין מתוך פונקציית apply-cont של ההמשכיות, שנוצרה מבעוד מועד.
(define value-of/k
(lambda (exp env cont)
(cases expression exp
...
(if-exp (exp1 exp2 exp3)
(value-of/k exp1 env
(if-test-cont exp2 exp3 env cont))מחשב קודם כל את התנאי, ושולח אותו להמשכיות ייעודית ששומרת את שני הענפים לבחירה עתידית.)
...)))
(define apply-cont
(lambda (cont val)
(cases continuation cont
...
(if-test-cont (exp2 exp3 saved-env saved-cont)
(if (expval->bool val)
(value-of/k exp2 saved-env saved-cont)
(value-of/k exp3 saved-env saved-cont))"קבלת ההחלטה". רק עכשיו מתבצעת הבדיקה על 'val', ומורץ ההמשך המתאים בתוספת ההמשכיות המקורית.)
...)))
ישנן שתי דרכים לממש Continuations. דרך אחת היא בעזרת פונקציות Scheme טהורות (Procedural Representation). בגישה זו, כל cont הוא בעצם פונקציה שמקבלת ערך (val) ומפעילה את הקוד שהוקפא. לדוגמה, end-cont היא פונקציה שמדפיסה טקסט ומחזירה. פונקציית apply-cont פשוט מפעילה את הפרוצדורה. זוהי הגישה המופשטת ביותר.
הבנת הגישה הפרוצדורלית עוזרת להבין מה המשכיות בעצם עושה (מחכה לתוצאה ומריצה שאר חישוב). למרות הפשטות, במפרשים שיוצרים במבחן נשתמש בגישת מבני-הנתונים (DataType) כדי שהמפרש יהיה קל יותר למעקב ולאגירה.
(define-datatype continuation continuation?
(a-cont
(procedure procedure?)))
(define apply-cont
(lambda (cont val)
(cases continuation cont
(a-cont (procedure)
(procedure val)פשוט מפעילים את הפונקציה (ששמורה בתוך המבנה) עם הערך שסופק!))))
(define end-cont
(lambda ()
(a-cont
(lambda (val)
(begin
(eopl:printf "End of computation.~%")
val))פונקציה זו היא ה-"המשכיות העליונה". כשהיא רצה, היא מסיקה את הריצה.)))
(define zero1-cont
(lambda (saved-cont)
(a-cont
(lambda (val)
(apply-cont saved-cont
(bool-val
(zero? (expval->num val))))))))
הגישה השנייה לייצוג Continuations היא בעזרת מבני נתונים מפורשים. במקום פונקציות מסתוריות שזוכרות משתנים, אנו מגדירים טיפוס continuation שלכל מצב בו ("אחרי תנאי", "אחרי משתנה ראשון בהשמה" וכו') יש Record משלו השומר במפורש את כל מה שנחוץ להמשך (סביבה, משתנים, וההמשכיות הבאה).
זוהי הגישה המקובלת לבניית מפרשים בקורס (ואף כתיבת קוד C המממש אותם). שימו לב שכל Continuation שומר saved-cont. בכך, במקום מחסנית ביצועים (Call Stack) של מערכת ההפעלה, יצרנו מחסנית מפורשת משלנו (Explicit Stack) באמצעות רשימה מקושרת של מבני נתונים!
(define-datatype continuation continuation?
(end-cont)
(zero1-cont
(saved-cont continuation?))
(let-exp-cont
(var identifier?)
(body expression?)
(saved-env environment?)
(saved-cont continuation?))
(if-test-cont
(exp2 expression?)
(exp3 expression?)
(saved-env environment?)
(saved-cont continuation?)כל המשכיות מחזיקה מצביע ל-saved-cont. זהו בעצם "רשימה מקושרת" של משימות עתידיות שמהוות את ה-Call Stack המפורש שלנו!))
איך מבצעים ב-CPS חיסור של שני ביטויים (או כל פעולה בינארית אחרת)? מחשבים את הראשון, שולחים אותו ל-diff1-cont שמחשב את השני (תוך אגירת התוצאה של הראשון), ששולח אותו ל-diff2-cont שסוף סוף מבצע את חישוב החיסור בפועל וקורא להמשכיות המקורית (saved-cont).
תבנית זו קריטית להבנה! כאשר יש מספר תתי-ביטויים להעריך (כמו exp1 ו-exp2), אנו "משרשרים" Continuations: ההמשכיות הראשונה שומרת את הטקסט של exp2 ומריצה אותו, והשנייה כבר מכילה את התוצאה הסופית של exp1 ויכולה לסיים את הפעולה. זהו "Thread" חישוב שמתבצע בטור.
(define value-of/k
(lambda (exp env cont)
(cases expression exp
...
(diff-exp (exp1 exp2)
(value-of/k exp1 env
(diff1-cont exp2 env cont))שלב 1: התחל לחשב את האיבר הראשון (exp1).)
...)))
(define apply-cont
(lambda (cont val)
(cases continuation cont
...
(diff1-cont (exp2 saved-env saved-cont)
(value-of/k exp2 saved-env
(diff2-cont val saved-cont))שלב 2: exp1 חזר אל 'val'. התחל לחשב את exp2, ושמור את הערך של exp1 (val) להמשך.)
(diff2-cont (val1 saved-cont)
(let ((num1 (expval->num val1))
(num2 (expval->num val)))
(apply-cont saved-cont
(num-val (- num1 num2))))שלב 3: כעת גם exp2 סיים (וחזר ל-'val'). אנו מבצעים את החיסור בין val1 (שהוקפא) ל-val.)
...)))
בדיוק כמו ב-diff-exp, קריאה לפונקציה מחייבת לחשב קודם את ה-rator (מי הפונקציה לקרוא לה), ולאחר מכן את ה-rand (הארגומנט). זה מבוצע על ידי כך ששולחים את rator לחישוב עם rator-cont שמחכה לסיום הפונקציה ואז מחשב את הארגומנט.
שוב מדובר בשרשור של Continuations כדי להתמודד עם שערוך של שני ביטויים (Operator ו-Operand) לפני הפעולה עצמה. שימו לב שאנחנו עוברים מ-value-of/k (על ה-rator) ל-rator-cont (שמפעיל את rand) ולבסוף ל-rand-cont.
(define value-of/k
(lambda (exp env cont)
(cases expression exp
...
(call-exp (rator rand)
(value-of/k rator env
(rator-cont rand env cont))שלח את הפונקציה (rator) לחישוב. כשהיא תחזור, נטפל ב-rand.)
...)))
(define apply-cont
(lambda (cont val)
(cases continuation cont
...
(rator-cont (rand saved-env saved-cont)
(value-of/k rand saved-env
(rand-cont val saved-cont))הפונקציה חזרה (כעת שמורה ב-val). נשלח את rand לחישוב, ונעביר את 'val' להמשכיות.)
...)))
כאשר גם ה-rand חזר מחישוב, אנו מגיעים ל-rand-cont. בשלב זה אנחנו כבר יודעים גם מה הפונקציה וגם מה הארגומנט, ואנחנו פשוט מפעילים אותה עם apply-procedure/k. במקום ש-apply-procedure/k תחזיר אלינו ערך, אנחנו פשוט מעבירים לה את saved-cont.
שימו לב: מכיוון שאנו מעבירים את ה-saved-cont ישירות לתוך הפרוצדורה, אנחנו מבצעים כאן Tail Call Optimization באופן טבעי. הפרוצדורה (כשתסיים) תפעיל את ההמשכיות החיצונית ישירות, מבלי שנצטרך לחזור ל-rand-cont.
(define apply-cont
(lambda (cont val)
(cases continuation cont
...
(rand-cont (val1 saved-cont)
(let ((proc (expval->proc val1)))
(apply-procedure/k proc val saved-cont)שולחים את הפונקציה לריצה, ואומרים לה: "העבירי את התוצאה הסופית ישירות ל-saved-cont המקורי!". זוהי ליבת ה-Tail Call Optimization האוטומטית.))
...)))
(define apply-procedure/k
(lambda (proc1 val cont)
(cases proc proc1
(procedure (var body saved-env)
(value-of/k body
(extend-env var val saved-env)
cont)))))
גרסה כללית יותר של ה-CPS שיודעת לתמוך בפונקציות שמקבלות כמה ארגומנטים (ולא רק אחד). כאן ה-call-exp מעביר רשימה של ארגומנטים (rands). ה-rator-cont מחשב את הראשון ומעביר ל-rands-cont, שמשורשר על עצמו בעזרת פונקציית עזר שאוספת את שאר הארגומנטים ברקורסיה של CPS.
זה בעיקר ממחיש את המורכבות בניהול רשימות ב-CPS. כל איבר ברשימה דורש "המשכיות" משלו שמחכה לאיבר הבא. לעיתים משתמשים בפונקציות עזר כמו map/k לביצוע הפעולה הזו באופן אלגנטי יותר.
(define value-of/k
(lambda (exp env cont)
(cases expression exp
...
(call-exp (rator rands)
(value-of/k rator env
(rator-cont rands env cont)))
...)))
(define apply-cont
(lambda (cont val)
(cases continuation cont
...
(rator-cont (rands saved-env saved-cont)
(if (null? rands)
(apply-procedure/k (expval->proc val) '() saved-cont)אם הפונקציה לא קיבלה ארגומנטים, אפשר פשוט להפעיל אותה מיידית.
(value-of/k (car rands) saved-env
(rands-cont (cdr rands) '() val saved-env saved-cont))אם יש, מחשבים את הראשון (car), ושומרים את השאר (cdr) בתוך המשכיות איסוף rands-cont.))
...)))
למרות ש-CPS הוא Tail-Recursive, שפת הבסיס (כמו Scheme, C או Python) עדיין שומרת Call Stack עבור כל קריאה לפונקציה! כדי למנוע קריסה בתוכניות ארוכות, משתמשים בטכניקת Trampoline. במקום שהמפרש יקרא ישירות לפונקציה הבאה, הוא מחזיר אובייקט (Bounce). לולאת ה-Trampoline הראשית מקבלת את ה-Bounce ומריצה אותו מהתחלה – כך שהמחסנית מתרוקנת ומתמלאת תמיד באותו גובה! זה מאפשר רקורסיה אינסופית בטוחה (Tail Call Optimization ברמת השפה).
Trampoline מאפשר "לרמות" את שפת האם שלא תומכת ב-Tail Call Optimization בעצמה. במקום לקרוא לפונקציה בתוך פונקציה עד שנקרוס, אנחנו משהים את הקריאה, מחזירים אותה החוצה אל ה-Trampoline, והוא זה שמבצע את הקריאה מהבסיס הריק של המחסנית.
(define-datatype bounce bounce?
(a-bounce
(exp1 expression?)
(env environment?)
(cont continuation?)))
(define trampoline
(lambda (bounce1)
(if (bounce? bounce1)
(cases bounce bounce1
(a-bounce (exp1 env cont)
(trampoline (value-of/k exp1 env cont))מחשב צעד אחד (value-of/k), שיחזיר bounce חדש, ושוב מפעיל עליו את ה-trampoline (בלולאה/קריאת זנב טהורה).))
bounce1)))
(define value-of-program
(lambda (pgm)
(cases program pgm
(a-program (exp1)
(trampoline
(value-of/k exp1 (init-env) (end-cont)))תוכנית מתחילה עכשיו דרך ה-trampoline שתנהל את הריצה עד לקבלת ערך סופי שאינו Bounce.))))
המחשת סינטקס בשפת היעד עבור מנגנון זריקת שגיאות (Exceptions). השפה תומכת ב-try-catch ו-raise. בדוגמה זו מחפשים אינדקס במערך. אם הרשימה מתרוקנת מבלי שנמצא האיבר, זורקים שגיאה 99 עם פקודת raise. בלוק ה-try עוטף את הקריאה, ובלוק ה-catch תופס את השגיאה (אם נזרקה) ומחזיר -1 במקום להקריס את התוכנית כולה.
ברמת שפת היעד זה נראה פשוט כמו בכל שפה. הקסם הוא כיצד המפרש מתמודד עם פקודת raise. ב-Direct Style היינו זקוקים למנגנוני שפה ייעודיים. אך עם CPS, נוכל לממש זאת באופן טבעי באמצעות התעלמות משארית ה-Continuations (זריקת המחסנית)!
letrec
index (n, lst) =
if null?(lst)
then raise 99עוצר מיד את הריצה, זורק את כל ההמשכיות הנוכחית ומחפש באופן דינמי את ה-try שעטף את הריצה.
else if zero?(-(car(lst), n))
then 0
else -((index n cdr(lst)), -1)
in try (index 5 list(2, 7, 1, 9, 5, 8))
catch (x) -1מגדיר "המשכיות הצלה" (handler). אם מישהו יזרוק שגיאה בתוך ה-try, הערך שלו ייכנס ל-x ויוחזר -1.
כאשר המפרש נתקל ב-try-exp, הוא שולח את גוף ה-try (ה-exp1) לחישוב, ומעביר לו המשכיות מיוחדת מסוג try-cont המכילה את ה-handler ואת ההמשכיות המקורית. זה בעצם "רושם" נקודת חזרה לשעת חירום.
כאשר הוא נתקל ב-raise-exp, הוא שולח את ערך השגיאה לחישוב עם המשכיות מסוג raise1-cont שיודעת שתפקידה הוא לזרוק את הערך.
זהו יישום כוחו של CPS! try רק מוסיף אלמנט ל"מחסנית" (ה-Continuations). פקודת ה-raise תשתמש אחר כך במבנה הזה כדי לחפש אחורה.
(define value-of/k
(lambda (exp env cont)
(cases expression exp
...
(try-exp (exp1 var handler-exp)
(value-of/k exp1 env
(try-cont var handler-exp env cont))מחשב את הקוד הרגיל. ההמשכיות try-cont 'זוכרת' שאם הכל בסדר, פשוט ממשיכים רגיל. אם תהיה שגיאה, נחזור לפה.)
(raise-exp (exp1)
(value-of/k exp1 env
(raise1-cont cont))מחשב את הערך שצריך לזרוק כשגיאה.)
...)))
מה קורה כשמגיעים לסיום מוצלח של בלוק try (כלומר, הגוף פעל בהצלחה והחזיר ערך ל-try-cont)? פשוט מתעלמים מה-handler המצוי ב-try-cont ומעבירים את התוצאה הלאה ל-saved-cont המקורי.
ומה קורה כשמגיעים לסיום חישוב הערך בתוך פקודת raise (כלומר, מגיעים ל-raise1-cont)? קוראים לפונקציית העזר הקסומה apply-handler שמחפשת את ה-try-cont המתאים כדי לטפל בשגיאה!
אם התוכנית סיימה את בלוק ה-try בהצלחה, היא "קופצת" מעל ההאנדלר וממשיכה רגיל. אם התרחשה שגיאה (raise), העבודה האמיתית מועברת ל-apply-handler.
(define apply-cont
(lambda (cont val)
(cases continuation cont
...
(try-cont (var handler-exp saved-env saved-cont)
(apply-cont saved-cont val)סיום תקין של ה-try! הכל עבר בשלום. מתעלמים לחלוטין מה-handler וממשיכים ל-saved-cont עם התוצאה שיצאה (val).)
(raise1-cont (saved-cont)
(apply-handler val saved-cont)ערך השגיאה (val) מחושב ומוכן. עכשיו אנחנו זורקים אותו לאוויר, ושולחים את apply-handler לחטט אחורה ב-Call Stack (saved-cont) ולמצוא לו Handler!)
...)))
זהו לב מנגנון ה-Exceptions (Stack Unwinding). הפונקציה בודקת את ההמשכיות הנוכחית: אם זו try-cont, היא מריצה את ה-handler שלו! אם זו כל המשכיות אחרת (למשל zero1-cont או if-test-cont), היא זורקת אותה לפח (לא מריצה אותה!), ופשוט בודקת את ה-saved-cont שלה במקום! כך היא מקלפת את המחסנית אחורה עד שהיא מוצאת try. התכונה הזו אפשרית רק בזכות CPS!
ההבנה ש"זריקת המחסנית" (Stack Unwinding) היא פשוט פעולת התעלמות סדרתית מה-Continuations עד שמוצאים אחד שמתאים היא קריטית. כל עוד לא נתקלנו ב-try-cont, אנחנו ממשיכים לקרוא רקורסיבית ל-apply-handler עם ה-saved-cont, מה שמוחק לחלוטין את הצעד המקורי מזיכרון הריצה.
(define apply-handler
(lambda (val cont)
(cases continuation cont
(try-cont (var handler-exp saved-env saved-cont)
(value-of/k handler-exp
(extend-env var val saved-env)
saved-cont)בינגו! מצאנו בלוק try שתופס את השגיאה. מריצים את ה-handler-exp שלו תחת ההמשכיות שאחרי ה-try (כאילו כלום לא קרה).)
(end-cont ()
(report-uncaught-exception))
(zero1-cont (saved-cont)
(apply-handler val saved-cont)לא try. מתעלמים מה-zero1 וממשיכים "לקלף" את ההמשכיות הבאה שנמצאת ב-saved-cont.)
(let-exp-cont (var body saved-env saved-cont)
(apply-handler val saved-cont))
(if-test-cont (exp2 exp3 saved-env saved-cont)
(apply-handler val saved-cont))
...)))
שפת THREADS מוסיפה תמיכה בתהליכונים (Threads). הפקודה spawn מקבלת ביטוי (לרוב קריאה לפונקציה), מתחילה להריץ אותו כחוט ביצוע מקביל ברקע (על ידי הוספתו ל-Ready Queue במפרש), ומיד מחזירה מספר מזהה (Thread ID), מבלי לחכות שהתהליכון יסיים!
ההבדל בין spawn(foo) ל-foo() הוא עצום. הקריאה ל-spawn לא ממתינה לתוצאה – היא רק "רושמת" את ההרצה העתידית אצל הסדרן (Scheduler). התהליכון הנוכחי ממשיך מייד לקו הבא בתוכנית.
let x = 2
in letrec
foo(n) = if zero?(n)
then x
else -((foo -(n,1)), -2)
in spawn(foo(4))יוצר תהליכון חדש שיחשב את (foo 4), ומוסיף אותו לתור הסדרן. ה-Main Thread ממשיך הלאה מיד!
דוגמה פשוטה להפעלת תהליכונים במקביל: ה-producer מדפיס מספרים, וה-consumer גם מדפיס מספרים (במינוס). הפונקציה wait משמשת כהשהייה (כמו Sleep), כדי לבזבז "טיקים" (Ticks) ולגרום לסדרן (Scheduler) לקחת מהם את זכות הריצה ולהעביר אותה לתהליכון השני בגלל Time Slice שנגמר!
דוגמאות מסוג זה נועדו להמחיש בעיות של Race Conditions ותלות בסדרן (Scheduler). שימו לב שזמן ההמתנה שנקבע ב-wait משנה לחלוטין את סדר ההדפסות. זה מדגים Preemptive Multitasking – הפסקת הריצה נכפית עליהם כשזמנם קצוב.
letrec
wait(k) = if zero?(k) then 0 else (wait -(k, 1))פונקציית 'בזבוז זמן'. סופרת אחורה כדי לבזבז פעולות חישוב.
producer(n) =
if zero?(n)
then print("producer finished")
else begin
print(n);
(wait 5);גורמת לסדרן לבצע החלפת הקשר (Context Switch) אל הצרכן.
(producer -(n, 1))
end
consumer(n) =
if zero?(n)
then print("consumer finished")
else begin
print(-(-(n, 0)));
(wait 14);
(consumer -(n, 1))
end
in begin
spawn((producer 4));מייצר תהליכון משנה עבור המפיק.
(consumer 4)מריץ את הצרכן בתהליכון הראשי במקביל.
end
הסדרן (Scheduler) מנהל את כל התהליכונים. כאן אנו מגדירים את ה-State הגלובלי שלו: the-ready-queue (תור התהליכונים שמחכים לרוץ), max-time-slice (כמה "טיקים" כל תהליכון מקבל ברצף לכל היותר), ו-time-remaining (כמה זמן נשאר לתהליכון שרץ כרגע לפני שניקח לו את התור).
זהו מימוש של מערכת הפעלה קטנה בתוך המפרש. המשתנים הגלובליים האלו הם הלב של מנגנון תזמון התהליכונים. כשהמונה (time-remaining) יורד לאפס, מתבצעת החלפת הקשר (Context Switch).
(define the-ready-queue 'uninitialized)
(define the-final-answer 'uninitialized)
(define max-time-slice 'uninitialized)
(define time-remaining 'uninitialized)
(define initialize-scheduler!
(lambda (ticks)
(set! the-ready-queue (empty-queue))
(set! the-final-answer 'uninitialized)
(set! max-time-slice ticks)
(set! time-remaining max-time-slice)מאתחל את מונה הטיקים המקסימלי לכל תהליכון.))
איך הסדרן מריץ את התהליכון הבא בתור? הפונקציה run-next-thread שולפת את התהליכון הראשון (first-ready-thread) מתוך תור ה-Ready. לאחר מכן, היא מחדשת לו את זמן ה-Time Slice שלו למקסימום, ומפעילה אותו! זוהי גישת Round Robin קלאסית (תזמון מעגלי הוגן).
לשים לב שכל "תהליכון" מיוצג במפרש בתור Thunk (פונקציה ללא ארגומנטים: (lambda () ...)). כאשר הסדרן שולף אותו מהתור, הוא פשוט קורא לו בעזרת (first-ready-thread), וזה ממשיך את ריצת התוכנית מאותה נקודה בה נעצר!
(define place-on-ready-queue!
(lambda (th)
(set! the-ready-queue
(enqueue the-ready-queue th)מכניס את התהליכון לסוף התור, כך שהוא יחכה בסבלנות לסיבוב שלו.)))
(define run-next-thread
(lambda ()
(if (empty? the-ready-queue)
the-final-answer
(dequeue the-ready-queue
(lambda (first-ready-thread other-ready-threads)
(set! the-ready-queue other-ready-threads)
(set! time-remaining max-time-slice)מעניק לתהליכון הנשלף את מלוא הטיקים המותרים לו לריצה רצופה.
(first-ready-thread)מפעיל אותו. כיוון שתהליכון הוא Thunk (פונקציה ללא ארגומנטים המכילה המשכיות), הפעלתה ממשיכה את ריצתו בדיוק מאיפה שעצר!)))))
איך המפרש יודע מתי לעצור תהליכון? אנו מוסיפים לוגיקה בכל פעם ש-apply-cont מופעל (כלומר, בכל צעד חישוב קטן). אם פג הזמן של התהליכון (time-expired?), אנחנו שומרים את המצב הנוכחי כפונקציה (Thunk) שמוכנסת לסוף התור, ואומרים לסדרן להריץ את הבא. אם לא פג, מורידים את הטיימר וממשיכים!
היופי כאן הוא שהחלפת הקשר אפשרית אך ורק תודות ל-CPS שהופך את מצב התוכנית לאובייקט שאפשר 'להקפיא'. ה-Thunk שנוצר (lambda () (apply-cont cont val)) אוזר בתוכו את ההמשכיות ואת הערך של התהליכון.
(define apply-cont
(lambda (cont val)
(if (time-expired?)
(begin
(place-on-ready-queue!
(lambda () (apply-cont cont val)))נגמר הזמן! מקפיאים את הריצה לתוך Thunk שדוחף את ה-cont וה-val, ושמים בתור. זהו לב ה-Context Switch המקדים (Preemptive).
(run-next-thread)מעבירים את השליטה לסדרן.)
(begin
(decrement-timer!)כל צעד חישוב (apply-cont) עולה "טיק" אחד.
(cases continuation cont
(end-main-thread-cont ()
(set-final-answer! val)
(run-next-thread))
(end-subthread-cont ()
(run-next-thread)אם תהליכון משנה סיים את משימתו (מבלי שנגמר זמנו), מיד מריצים את הבא בתור.)
...)))))
כאשר מספר תהליכונים ניגשים לאותו משתנה (כמו x), עלול להיווצר Race Condition (מצב תחרות). הפתרון בשפת THREADS הוא שימוש ב-Mutex (מנעול). לפני שניגשים לקטע הקריטי, קוראים ל-wait-for-mutex. בסיום, משחררים עם signal-mutex. זה מבטיח שרק תהליכון אחד משנה את x בכל רגע נתון.
זוהי דוגמת ה-Hello World של סנכרון תהליכונים. הוספת מנעולים (Mutexes) היא השלב הבא במפרש שלנו אחרי הוספת תהליכונים. חשוב להבין שמיוטקס הוא אובייקט שמנוהל על ידי המפרש ומסוגל להקפיא תהליכונים אם הוא כבר תפוס.
let m = newmutex()יצירת אובייקט מנעול חדש במערכת.
in let x = 0
in letrec
wait(k) = if zero?(k) then 0 else (wait -(k, 1))
incr_x(id) =
begin
wait-for-mutex(m);נעילת המיוטקס. אם תהליכון אחר תפס אותו, התהליכון הזה יכנס לתור המתנה בתוך המיוטקס.
set x = -(x, -1);
print(x);
signal-mutex(m)שחרור המיוטקס. יעיר את התהליכון הראשון שמחכה בתור שלו.
end
in begin
spawn((incr_x 100));
spawn((incr_x 200));
spawn((incr_x 300))
end
המנעול שלנו, Mutex, הוא אובייקט בעל שני שדות המצביעים לזיכרון ה-Store הכללי: שדה בוליאני closed? שמתאר אם המנעול כרגע נעול, ושדה wait-queue, שהוא תור פנימי נפרד מתור הסדרן הראשי, בו יחכו תהליכונים שמנסים לקחת את המנעול אך מצאו שהוא תפוס!
המיוטקס הוא בעצם State בזיכרון, ולכן השדות שלו נשמרים כ-References. ההפרדה בין "תור הסדרן" (תהליכונים שמוכנים לרוץ) לבין "תור הממתינים למיוטקס" (תהליכונים חסומים) היא קריטית להבנת מערכות הפעלה.
(define-datatype mutex mutex?
(a-mutex
(ref-to-closed? reference?)
(ref-to-wait-queue reference?)))
(define new-mutex
(lambda ()
(a-mutex
(newref #f)מאתחלים כמנעול פתוח (לא תפוס).
(newref (empty-queue))מאתחלים תור ממתינים ריק למנעול זה.)))
wait-for-mutex: אם המנעול סגור, הוא דוחף את התהליכון שמנסה להיכנס (ה-th הנוכחי) לתור הממתינים הפנימי של המנעול! הוא פשוט ישכב שם (ולא יחזור לסדרן הראשי) עד שישחררו אותו. אם פתוח, הוא נועל מיד וממשיך.
signal-mutex: אם משחררים את המנעול כשיש מישהו שמחכה בתור שלו, שולפים את הראשון בתור וזורקים אותו לתור הסדרן הראשי (Ready Queue) כדי שיתחיל לרוץ! אם התור ריק, פשוט פותחים את המנעול.
זהו לב מנגנון הסנכרון. כש-wait נכשל, התהליכון "נרדם" – הוא מוצא מתור ה-Ready ומועבר לתור ה-Wait של המיוטקס. כש-signal מופעל, הוא "מעיר" תהליכון אחד על ידי החזרתו לתור ה-Ready.
(define wait-for-mutex
(lambda (m th)
(cases mutex m
(a-mutex (ref-to-closed? ref-to-wait-queue)
(if (deref ref-to-closed?)
(setref! ref-to-wait-queue
(enqueue (deref ref-to-wait-queue) th))סגור! התהליכון 'th' נדחף לתור הממתינים של המיוטקס, והוא מפסיק להתקיים בתור הסדרן הראשי. הוא מוקפא.
(begin
(setref! ref-to-closed? #t)
(th)פתוח! סוגרים, והתהליכון ממשיך לרוץ מיד.))))))
(define signal-mutex
(lambda (m th)
(cases mutex m
(a-mutex (ref-to-closed? ref-to-wait-queue)
(let ((closed? (deref ref-to-closed?))
(wait-queue (deref ref-to-wait-queue)))
(if closed?
(if (empty? wait-queue)
(setref! ref-to-closed? #f)
(dequeue wait-queue
(lambda (first-waiting-th other-waiting-ths)
(place-on-ready-queue! first-waiting-th)יש מחכים! שולפים את הראשון, שמים אותו בתור הסדרן הראשי (שיחזור לחיים), ומשאירים את המנעול סגור (כי הוא תפס אותו הרגע).
(setref! ref-to-wait-queue other-waiting-ths))))
#f)
(th))))))
הדגמה פשוטה של המרת תוכנית Scheme רגילה ל-CPS. במקום שפונקציית fact תחזיר ערך בעצמה בעזרת return, היא מקבלת פונקציית-המשך (cont) וקוראת לה עם התוצאה כשהיא מסיימת.
זוהי דוגמת מבוא קלאסית ל-CPS ברמת הפונקציה (ולא ברמת המפרש כפי שראינו קודם). שימו לב שההמשכיות (lambda (val) (cont (* n val))) מכילה את השלב הבא של החישוב (הכפלה) ושומרת את n בסביבתה (Closure).
(define fact
(lambda (n)
(fact/k n (lambda (val) val))קריאה התחלתית עם המשכיות הזהות (מחזירה פשוט את הערך).))
(define fact/k
(lambda (n cont)
(if (zero? n)
(cont 1)תנאי העצירה קורא להמשכיות עם הערך 1.
(fact/k (- n 1)
(lambda (val)
(cont (* n val)))ההמשכיות העתידית: "כשתסיים את (- n 1) ותחזיר val, תכפיל את n ב-val ותקרא להמשכיות המקורית".))))
כדי לא להיות תלויים ב-Closures (סביבות פנימיות של פונקציות) במימוש שלנו, אנו עושים Defunctionalization - הפיכת פונקציות ההמשכיות למבני נתונים מפורשים מסוג continuation. כל סוג המשכיות מקבל Constructor (כמו fact1-cont). הפונקציה apply-cont מפרשת אותם, מבצעת את הפעולה, וממשיכה הלאה.
זהו יישום של עקרון ההמרה מ-Closure למבנה נתונים. כל משתנה חופשי שהיה בתוך ה-Closure (כמו n בפונקציית fact) הופך להיות שדה במבנה הנתונים (saved-n). זו הדרך שבה מפרש מנהל Continuations בפועל.
(define-datatype continuation continuation?
(end-cont)
(fact1-cont
(n integer?)
(cont continuation?)))
(define apply-cont
(lambda (cont val)
(cases continuation cont
(end-cont ()
(begin
(eopl:printf "End of computation.~%")
val))
(fact1-cont (saved-n saved-cont)
(apply-cont saved-cont
(* saved-n val))מבצע את הפעולה (הכפלה ב-n) ועובר להמשכיות הבאה.))))
במקום להעביר משתנים דרך ארגומנטים לפונקציות (n, cont, val), אנו מגדירים אותם כמשתנים גלובליים, ומתייחסים אליהם כאל אוגרים (Registers) במעבד אמיתי. כל קריאה לפונקציה הופכת בעצם להשמות באוגרים (set!) ואז קפיצה לפונקציה ללא ארגומנטים (Thunk).
זהו עוד צעד בדרך להפוך קוד עילי לשפת מכונה. אוגרים הם הזיכרון המהיר ביותר ואין להם תקורה של קריאה לפונקציה. זה ממחיש כיצד CPS מאפשר למדל ארכיטקטורת חומרה של ממש.
(define n 'uninitialized)
(define cont 'uninitialized)
(define val 'uninitialized)
(define fact/k
(lambda ()
(if (zero? n)
(begin
(set! val 1)מכניס את התוצאה לאוגר val.
(apply-cont)קופץ להמשכיות (שלוקחת את הערכים מהאוגרים).)
(begin
(set! cont (fact1-cont n cont))
(set! n (- n 1))
(fact/k)הקריאה הרקורסיבית היא קריאה ללא ארגומנטים!))))
כדי לוודא שהמחסנית לא תגדל בכלל (כי Scheme עשויה לשמור מסגרות קריאה גם אם לא צריך), אנו מחזירים את הפונקציה הבאה שאמורה לרוץ (כאובייקט procedure) אל לולאת ה-trampoline, והיא זו שמפעילה אותה ברמת ה-Top-Level.
זוהי טכניקה שימושית במיוחד בשפות שאינן תומכות ב-Tail Call Optimization. הטרמפולינה 'מקפיצה' כל קריאה לפונקציה חזרה ללולאה הראשית במקום להעמיק את מחסנית הקריאות.
(define fact/k
(lambda ()
(if (zero? n)
(begin
(set! val 1)
apply-cont)
(begin
(set! cont (fact1-cont n cont))
(set! n (- n 1))
fact/kמחזיר את הפונקציה עצמה (ללא סוגריים), כך שה-Trampoline יפעיל אותה מבחוץ.))))
(define trampoline
(lambda (bounce)
(if (procedure? bounce)
(trampoline (bounce))מפעיל את התהליכון/פונקציה, ותופס את ה-Bounce הבא שחוזר.
bounce)))
פונקציית עצרת הייתה קלה כי יש רק קריאה רקורסיבית אחת בכל פעם. בפיבונאצ'י יש שתיים! לכן ב-CPS אנו קוראים ל-fib/k הראשון עם המשכיות שמקבלת את val1, ובתוכה קוראים ל-fib/k השני עם המשכיות שמקבלת val2, ואז מחברים אותם וקוראים ל-cont המקורי. זהו "שיטוח" מלא של עץ הקריאות!
זו דוגמה הממחישה את הקושי בכתיבת CPS ידנית - כל קריאה לפונקציה מחייבת יצירת Closure חדש. סדר הביצוע (קודם צד שמאל ואז צד ימין) הופך להיות מפורש (Explicit) לחלוטין ואין מקום לאי בהירות.
(define fib/k
(lambda (n cont)
(if (< n 2)
(cont 1)
(fib/k (- n 1)
(lambda (val1)
(fib/k (- n 2)
(lambda (val2)
(cont (+ val1 val2))))))שרשור (Nesting) של המשכיות: חכה ל-fib(n-1), ואז חכה ל-fib(n-2), ואז חבר אותם!)))
המרת משפט cond ל-CPS: אם ענף מסוים מחזיר תוצאה פשוטה, קוראים להמשכיות k עם התוצאה. אם הענף מכיל קריאה (Tail Call) לפונקציה אחרת, מעבירים את ההמשכיות k בתור פרמטר לאותה פונקציה. אין צורך לייצר Closure חדש!
זה ממחיש איך Tail Calls ב-CPS לא דורשים בניית המשכיות חדשה. ה-k פשוט מועבר הלאה (Pass-through). בגלל שכל הקריאות הן בעמדת זנב, המערכת לא צוברת היסטוריה במחסנית.
;; Source:
(define f
(lambda (x y)
(cond
((zero? x) 0)
((zero? y) (f (- x 1) (- y 1)))
(else (f x (- y 1))))))
;; CPS:
(define f/k
(lambda (x y k)
(cond
((zero? x) (k 0)עטוף את הפלט בהמשכיות.)
((zero? y) (f/k (- x 1) (- y 1) k)העבר את k כארגומנט לפונקציה המומרת הבאה.)
(else (f/k x (- y 1) k)))))
זהו המפרש לשפה שכבר הומרה אוטומטית ל-CPS (שפת ה-Target, הנקראת cps-out-exp). בשפה זו אין צורך ב-apply-cont מוסתר, כי ההמשכיות מועברת כארגומנט רגיל! המפרש מתייחס לביטויים כפשוטים (שלא יכולים להיתקע) או מורכבים (כמו call-exp).
שימו לב שבכל קריאה לפונקציה (call-exp), ה-apply-procedure נמצא בעמדת הזנב! המפרש אף פעם לא חוזר מערך כדי לחשב משהו אחר. הכל מתבצע כקפיצות קדימה, מה שמבטיח אי-גדילה של המחסנית.
(define value-of-cps
(lambda (exp env)
(cases cps-out-exp exp
(cps-simple-exp->exp (simple)
(value-of-simple-exp simple env))
(cps-let-exp (var exp1 body)
(let ((val (value-of-simple-exp exp1 env)))
(value-of-cps body
(extend-env var val env))))
(cps-call-exp (rator rands)
(let ((rator-proc
(expval->proc (value-of-simple-exp rator env)))
(rand-vals
(map
(lambda (simple)
(value-of-simple-exp simple env))
rands)))
(apply-procedure rator-proc rand-vals)קריאה ללא כל חזרה (Tail call טהור).))
...)))
פונקציית עזר קטנה למתרגם (Translator). כאשר יש לנו תוצאה פשוטה, אנחנו רוצים "לשלוח" אותה אל ההמשכיות, שזה בעצם לייצר עץ תחביר (AST) מסוג cps-call-exp בו הפונקציה היא ההמשכיות והארגומנט הוא התוצאה שלנו.
בניית מתרגם דורשת יצירה של עצי תחביר חדשים מתוך ישנים. הפעולה של "החזרת ערך" הופכת לקריאה להמשכיות (קריאה לפונקציה). make-send-to-cont מפשטת את יצירת ה-AST הזה.
(define make-send-to-cont
(lambda (k-exp simple-exp)
(cps-call-exp k-exp (list simple-exp))יוצר קריאה אסטית המייצגת הפעלה של k על הפלט.))
כאשר מתרגמים יצירת פונקציה (proc-exp), חייבים להוסיף לה פרמטר חדש בסוף הרשימה (k%00), שזהו משתנה ההמשכיות! הפונקציה המומרת תשלח ל-k%00 את התוצאה של הגוף שלה. כאשר קוראים לפונקציה (call-exp), הופכים את הקריאה לכזו שכוללת את ההמשכיות הנוכחית באמצעות פונקציית עזר.
זהו לב השינוי בחתימת הפונקציות ב-CPS: כל lambda (x) הופכת ל-lambda (x k%00). הבנה זו חיונית אם תתבקשו במבחן להמיר קוד ידנית ל-CPS או להבין איך המתרגם עובד.
(define cps-of-exp
(lambda (exp cont-exp)
(cases expression exp
...
(proc-exp (vars body)
(make-send-to-cont cont-exp
(cps-proc-exp (append vars (list 'k%00))מוסיף פרמטר קסם "k%00" לכל פונקציה!
(cps-of-exp body (cps-var-exp 'k%00)))))
(call-exp (rator rands)
(cps-of-call-exp rator rands cont-exp))
...)))
כדי לתרגם call-exp, צריך קודם לוודא שהפונקציה עצמה (rator) וכל הארגומנטים שלה (rands) הם ביטויים פשוטים בעזרת cps-of-exps. רק אז בונים קריאת CPS אחת בסוף השרשרת, ומוסיפים את cont-exp (ההמשכיות) כארגומנט האחרון של הקריאה!
הדגש הוא שאי אפשר פשוט "לקרוא לפונקציה" ב-CPS אם הארגומנטים מורכבים (כמו קריאות לפונקציות אחרות). צריך לחלץ (To let-bind) את כל החישובים המורכבים החוצה, כך שהפונקציה והארגומנטים יהיו כולם ערכים אטומיים (פשוטים) בזמן הקריאה.
(define cps-of-call-exp
(lambda (rator rands cont-exp)
(cps-of-exps (cons rator rands)מחשב מראש (על ידי בניית מבני CPS מקוננים) את ה-rator וה-rands כדי שכולם יהיו אטומים.
(lambda (simples)
(cps-call-exp
(car simples)
(append (cdr simples) (list cont-exp))מרכיב את הקריאה הסופית עם cont-exp בתור הארגומנט האחרון!)))))
פונקציית cps-of-exps מבטיחה שכל הארגומנטים בקריאה לפונקציה יהיו "פשוטים" (Simple). היא סורקת את הרשימה ומוצאת את הביטוי המסובך הראשון. ואז היא בונה המשכיות (Closure) שמקבלת את התוצאה שלו למשתנה חדש (var), ובתוך ההמשכיות ממשיכה לסרוק ולהמיר את שאר הרשימה, כשהיא מחליפה את הביטוי המסובך ב-var-exp אטומי!
לוגיקה מעניינת המייצרת "קינון" (Nesting) של let/המשכיות עבור כל ארגומנט מורכב ברשימה, משמאל לימין. המנגנון הזה הופך קריאה כמו f(g(x), h(y)) ל: "חשב את g(x) ושמור ב-var1, ואז חשב את h(y) ושמור ב-var2, ואז קרא ל-f(var1, var2)".
(define cps-of-exps
(lambda (exps builder)
(let cps-of-rest ((exps exps))
(let ((pos (list-index
(lambda (exp)
(not (inp-exp-simple? exp))מוצאת את הביטוי הראשון שאינו "אטום" (שדורש חישוב מורכב).)
exps)))
(if (not pos)
(builder (map cps-of-simple-exp exps))
(let ((var (fresh-identifier 'var)))
(cps-of-exp
(list-ref exps pos)
(cps-proc-exp (list var)
(cps-of-rest
(list-set exps pos (var-exp var))))יוצרת המשכיות שתתפוס את התוצאה למשתנה "var", ומחליפה את הביטוי המסובך במשתנה זה להמשך הסריקה.)))))))
תרגום ישיר של "ביטויים פשוטים" (כאלה שלא יכולים להתפצל או לחכות לקריאות לפונקציות אחרות) לשפת ה-Target (הקרויה cps-out-exp). ההמרה היא 1:1, חוץ מפונקציות (proc-exp) שבהן נוסיף ארגומנט המשכיות (k%00), וממשיכים לתרגם את הגוף.
ביטויים פשוטים (Simple Expressions) כוללים קבועים, משתנים, פעולות פרימיטיביות על פשוטים, והגדרות פונקציה. התכונה המרכזית שלהם היא שהחישוב שלהם מסתיים "מיד" ולא מצריך מעבר של שליטה.
(define cps-of-simple-exp
(lambda (exp)
(cases expression exp
(const-exp (num) (cps-const-exp num))
(var-exp (var) (cps-var-exp var))
(diff-exp (exp1 exp2)
(cps-diff-exp
(cps-of-simple-exp exp1)
(cps-of-simple-exp exp2)))
(zero?-exp (exp1)
(cps-zero?-exp
(cps-of-simple-exp exp1)))
(proc-exp (ids exp)
(cps-proc-exp (append ids (list 'k%00))
(cps-of-exp exp (cps-var-exp 'k%00))))
(sum-exp (exps)
(cps-sum-exp
(map cps-of-simple-exp exps)))
(else
(report-invalid-exp-to-cps-of-simple-exp exp)))))
הפונקציה cps-of-exp מקבלת ביטוי ואת ההמשכיות (AST) שאליה הוא אמור להחזיר את התוצאה (cont-exp). במקרה של ביטויים אטומיים (פשוטים) כמו מספרים או משתנים, פשוט משדרים אותם אל ההמשכיות בעזרת make-send-to-cont!
זוהי נקודת הכניסה העיקרית למתרגם ה-CPS. היא מדגימה כיצד מופרדים הביטויים: פשוטים (Const, Var, Proc) "נשלחים" מיד להמשכיות שמחכה להם (היות וחישובם אינו מצריך זמן המתנה), וביטויים מורכבים יותר (שאר ה-cases) נשלחים לפונקציות עזר.
(define cps-of-exp
(lambda (exp cont-exp)
(cases expression exp
(const-exp (num) (make-send-to-cont cont-exp (cps-const-exp num))שולח את המספר להמשכיות. קריאת CPS פשוטה.)
(var-exp (var) (make-send-to-cont cont-exp (cps-var-exp var)))
(proc-exp (vars body)
(make-send-to-cont cont-exp
(cps-proc-exp (append vars (list 'k%00))
(cps-of-exp body (cps-var-exp 'k%00)))))
(zero?-exp (exp1)
(cps-of-zero?-exp exp1 cont-exp))
(diff-exp (exp1 exp2)
(cps-of-diff-exp exp1 exp2 cont-exp))
(sum-exp (exps)
(cps-of-sum-exp exps cont-exp))
...)))
המשך של cps-of-exp. כאן מטפלים במבני זרימת שליטה (Control Flow) מורכבים יותר כמו if, let וקריאות לפונקציות. עבור כל אחד מאלו נדרשת פונקציית עזר (כגון cps-of-if-exp או cps-of-call-exp) שדואגת לפרק את המבנים ולהעביר את ההמשכיות לחלקים הנכונים בגוף הביטוי.
ההפרדה בין ביטויים פשוטים למורכבים היא לב האלגוריתם של תרגום ל-CPS. ביטויים פשוטים מקבלים את ההמשכיות ומופעלים עליה (חלק 1). ביטויים מורכבים דורשים "הרכבה מחדש" של סדר הפעולות, ולכן מועברים לטיפול מעמיק שמייצר Continuation חדש.
... ; continuation of cps-of-exp
(if-exp (exp1 exp2 exp3)
(cps-of-if-exp exp1 exp2 exp3 cont-exp))
(let-exp (var exp1 body)
(cps-of-let-exp var exp1 body cont-exp))
(letrec-exp (p-names b-varss p-bodies letrec-body)
(cps-of-letrec-exp
p-names b-varss p-bodies letrec-body cont-exp))
(call-exp (rator rands)
(cps-of-call-exp rator rands cont-exp)))))
כאשר מתרגמים תוכנית שלמה ל-CPS, הביטוי הראשי מטופל כמו רשימת ארגומנטים (דרך cps-of-exps, כאילו הוא ארגומנט יחיד לפונקצית ההדפסה). התוצאה הסופית הופכת ל-simple-exp. זוהי ההמשכיות העליונה ביותר (Top-level).
ההמשכיות הראשונית (Initial Continuation) של תוכנית מומרת, בעצם לא מקבלת קריאה מפורשת ל-CPS-call. הערך המוחזר מהתוכנית הופך לביטוי "פשוט" (אטומי) שאותו האינטרפרטר פשוט מעריך ומדפיס למסך, ובכך מסתיים התהליך!
(define cps-of-program
(lambda (pgm)
(cases program pgm
(a-program (exp1)
(cps-a-program
(cps-of-exps (list exp1)
(lambda (new-args)
(simple-exp->exp (car new-args))))התוכנית הראשית לא נשלחת להמשכיות, אלא הערך המוחזר שלה מוצג ישירות.)))))
פרק 7 עוסק במערכת טיפוסים (Type Checking). כאן אנו מגדירים את העץ המופשט (AST) המייצג טיפוסים: ישנם טיפוסים פרימיטיביים כמו int-type ו-bool-type, ויש טיפוס מורכב לפונקציות: proc-type המורכב מטיפוס הארגומנט שמתקבל וטיפוס הערך המוחזר.
ייצוג טיפוסי פונקציות כעץ חשוב מאוד להבנת חתימות. לדוגמה, טיפוס כמו int -> (bool -> int) מיוצג כעץ שבו התוצאה היא בעצמה proc-type. תצטרכו להכיר את המבנה הזה בעת בניית Type Checker או שאלות על אינפרנציה של טיפוסים (Type Inference).
(define-datatype type type?
(int-type)
(bool-type)
(proc-type
(arg-type type?)
(result-type type?))טיפוס פונקציה. למשל: פונקציה שמקבלת int ומחזירה bool (int -> bool).)
בשפה שמבצעת בדיקת טיפוסים קשיחה (Static Type Checking), המתכנת חייב להצהיר על הטיפוסים באופן מפורש בקוד (Type Annotations). כל הגדרת פונקציה (proc) חייבת לכלול את טיפוס הארגומנט, ופונקציה רקורסיבית (letrec) מכריזה בנוסף גם על טיפוס התוצאה המוחזרת שלה!
שגיאה נפוצה במבחנים היא לשכוח להוסיף את חתימת הטיפוס לתחביר. זכרו: כאשר עובדים עם שפה כמו CHECKED (ולא שפה עם Inference או דינמית), אי אפשר לכתוב proc (x), חייבים לכתוב proc (x : int). ב-letrec צריך לתת את חתימת התוצאה: letrec int f(x:int) = ...
;; The programmer must now declare types explicitly:
Expression ::= proc (Identifier : Typeלמשל: proc (x : int)) Expression
proc-exp (var ty body)
Expression ::= letrec Typeטיפוס התוצאה המוחזרת מהרקורסיה Identifier (Identifier : Typeטיפוס הארגומנט) = Expression in Expression
letrec-exp (p-result-type p-name b-var b-var-type p-body letrec-body)
במערכת טיפוסים סטטית, השלב הקריטי הוא לוודא שהטיפוס המצופה זהה לטיפוס בפועל. פונקציית העזר check-equal-type! בודקת שוויון בין עצים של טיפוסים (למשל, ש-int זהה ל-int, ושאם ציפינו ל-bool לא קיבלנו עץ של proc-type). אם אין שוויון, נזרקת שגיאת טיפוסים במקום!
שגיאת טיפוסים קורית לפני שהקוד רץ (אנחנו בשלב ה-Type Checker). אם במבחן מבקשים מכם להוסיף פעולה חדשה לשפה, כמעט תמיד תצטרכו להשתמש ב-check-equal-type! כדי לוודא שהארגומנטים שניתנו לפעולה מתאימים (למשל, הפעולה zero? תצפה שטיפוס הארגומנט יהיה int).
(define check-equal-type!
(lambda (ty1 ty2 exp)
(if (not (equal? ty1 ty2))בודק שוויון עמוק - אם הם לא זהים לחלוטין, זו שגיאה!
(report-unequal-types ty1 ty2 exp)
#t)))
כאשר ישנה שגיאה בהתאמת הטיפוסים, אנו לא רוצים להדפיס למתכנת את מבנה העץ המסורבל (כמו (proc-type (int-type) (bool-type))). לכן יש פונקציית עזר, type-to-external-form הממירה את עץ הטיפוס חזרה לייצוג טקסטואלי וקריא, בשימוש בחץ ->.
תצוגה חיצונית לעומת פנימית היא נושא שחוזר בספר (למשל ביצירת ערכי expval). זהו דפוס קלאסי בבניית אינטרפרטרים וקומפיילרים - לייצג נתונים בצורה אחת לצורך אלגוריתמים פנימיים (עצים), ולהחזיר למשתמש צורה ידידותית.
(define report-unequal-types
(lambda (ty1 ty2 exp)
(eopl:error 'check-equal-type!
"Types didn't match: ~s != ~a in~%~a"
(type-to-external-form ty1)ממיר את הטיפוס לתצוגה קריאה.
(type-to-external-form ty2)
exp)))
(define type-to-external-form
(lambda (ty)
(cases type ty
(int-type () 'int)
(bool-type () 'bool)
(proc-type (arg-type result-type)
(list
(type-to-external-form arg-type)
'->מדפיס את החץ המסורתי שמייצג פונקציות.
(type-to-external-form result-type))))))
זוהי הפונקציה המרכזית (המקבילה ל-value-of) שמחשבת את הטיפוס של הקוד במקום את הערך שלו. זוהי "הרצה יבשה" בשלב הקומפילציה. לדוגמה, עבור diff-exp, בודקים ששני הצדדים הם מטיפוס int, ומחזירים שכל הביטוי הוא int. ב-if-exp, בודקים שהתנאי הוא bool וששני הענפים מחזירים בדיוק את אותו טיפוס (טיפוס תואם).
כל הוספת פעולה לשפה במבחן תחייב אתכם לעדכן את type-of: קודם כל לקרוא רקורסיבית ל-type-of על הארגומנטים, ואז לוודא בעזרת check-equal-type! שהם מהטיפוס הנדרש, ולבסוף להחזיר את הטיפוס של הפעולה עצמה.
(define type-of
(lambda (exp tenv)
(cases expression exp
(const-exp (num) (int-type))
(var-exp (var) (apply-tenv tenv var))סביבת Type Environment (TENV) שומרת טיפוסים, לא ערכים!
(diff-exp (exp1 exp2)
(let ((ty1 (type-of exp1 tenv))
(ty2 (type-of exp2 tenv)))
(check-equal-type! ty1 (int-type) exp1)וידוא שהצד השמאלי הוא מספר!
(check-equal-type! ty2 (int-type) exp2)
(int-type)))
(zero?-exp (exp1)
(let ((ty1 (type-of exp1 tenv)))
(check-equal-type! ty1 (int-type) exp1)
(bool-type)בדיקת אפס מחזירה בוליאני.))
(if-exp (exp1 exp2 exp3)
(let ((ty1 (type-of exp1 tenv))
(ty2 (type-of exp2 tenv))
(ty3 (type-of exp3 tenv)))
(check-equal-type! ty1 (bool-type) exp1)
(check-equal-type! ty2 ty3 exp)וידוא קריטי ששני ענפי ה-if מחזירים טיפוס זהה (למשל לא יכול להיות שאחד מחזיר int והשני bool).
ty2))
...)))
חלק 2 של type-of מטפל בפונקציות וקריאות להן. בהצהרת פונקציה (proc-exp), הטיפוס של המשתנה מוצהר ונוסף לסביבת הטיפוסים (TENV), ואז מחושב טיפוס הגוף. בקריאה (call-exp), מוודאים שמה שמנסים להפעיל הוא אכן מטיפוס פונקציה, ושהטיפוס המועבר מתאים בדיוק לטיפוס שהפונקציה מבקשת. ב-letrec מוודאים שגוף הפונקציה באמת מחזיר את טיפוס התוצאה שהוצהר.
סביבת הטיפוסים (TENV - Type Environment) עובדת בדיוק כמו סביבת ערכים. היא ממפה שם של משתנה לטיפוס שלו (במקום לערך שלו). כל כניסה לסקופ חדש מרחיבה את TENV, כדי לאפשר וידוא טיפוסים למשתנים הקשורים בסקופ זה.
... ; type-of continued
(let-exp (var exp1 body)
(let ((exp1-type (type-of exp1 tenv)))
(type-of body
(extend-tenv var exp1-type tenv))))
(proc-exp (var var-type body)
(let ((result-type
(type-of body
(extend-tenv var var-type tenv))מוסיפים את המשתנה לסביבת הטיפוסים ומחשבים את טיפוס הפלט של הגוף.))
(proc-type var-type result-type)מרכיבים את הטיפוס השלם של הפונקציה (arg -> result).))
(call-exp (rator rands)
(let ((rator-type (type-of rator tenv))
(rand-type (type-of (car rands) tenv)))
(cases type rator-type
(proc-type (arg-type result-type)
(begin
(check-equal-type! arg-type rand-type (car rands))החלק החשוב ביותר: וידוא שסוג הארגומנט בקריאה מתאים בדיוק לסוג שהפונקציה מצפה לו.
result-type))
(else
(report-rator-not-a-proc-type rator-type rator)))))
(letrec-exp (p-result-type p-name b-var b-var-type p-body letrec-body)
(let ((tenv-for-letrec-body
(extend-tenv p-name
(proc-type b-var-type p-result-type)
tenv)מוסיפים לסביבה את הפונקציה הרקורסיבית עצמה עם הטיפוס המוצהר שלה, כדי שיהיה אפשר לקרוא לה בתוך הגוף שלה.))
(let ((p-body-type
(type-of p-body
(extend-tenv b-var b-var-type tenv-for-letrec-body))))
(check-equal-type! p-body-type p-result-type p-body)מוודאים שהגוף של הפונקציה באמת תואם להבטחה שהמתכנת הצהיר עליה ב-p-result-type.
(type-of letrec-body tenv-for-letrec-body)))))))
בשפת INFERRED, התחביר דומה לשפת CHECKED, אבל עכשיו מותר להשמיט את סוגי הנתונים! אפשר לכתוב פשוט ? במקום הסוג (למשל proc(x : ?)). המפרש ייאלץ "להסיק" (Infer) לבד מה אמור להיות הטיפוס של x על סמך אופן השימוש בו בהמשך הקוד.
הסקת טיפוסים מקלה על המתכנת אך מסבכת את המפרש. אם המתכנת שם ? (שנקרא no-type), האינטרפרטר יחליף אותו במשתנה טיפוס (TVar) חדש שישמש כנעלם במערכת משוואות הטיפוסים (Unification Algorithm).
;; Types can be omitted! The interpreter will infer them.
Optional-type ::= ?
no-type ()
Optional-type ::= Type
a-type (ty)
Expression ::= proc (Identifier : Optional-typeטיפוס הארגומנט הפך לאופציונלי, ויכול להיות ?) Expression
proc-exp (var o-type body)
כאשר המפרש נתקל בטיפוס לא ידוע (?), הוא מקצה לו "משתנה טיפוס" (Type Variable) טרי, למשל tvar15. המשתנה הזה משמש כנעלם במשוואה מתמטית. מאוחר יותר ה-Unifier ינסה לפתור את המערכת ולמצוא ערך מתאים לנעלם הזה.
הבנת tvar-type קריטית למעקב אחרי עבודת ה-Unifier. הפונקציה fresh-tvar-type עושה שימוש באפקטי לוואי (State) עם set! על משתנה sn פנימי כדי לייצר מזהים ייחודיים שאינם חוזרים על עצמם לעולם במהלך הריצה של התוכנית.
(define-datatype type type?
...
(tvar-type
(serial-number integer?))טיפוס חדש המייצג נעלם שטרם נפתר, מיוצג ע"י מספר סידורי.)
;; When we encounter a '?' (no-type), we generate a fresh type variable:
(define fresh-tvar-type
(let ((sn 0))
(lambda ()
(set! sn (+ sn 1))מייצר משתנים חדשים באופן ייחודי (tvar1, tvar2, tvar3...).
(tvar-type sn))))
פונקציית apply-one-subst מהווה פעולה מתמטית קלאסית: לקחת עץ-טיפוס קיים המכיל משתנים (כמו tvar), ולהחליף במדויק כל מופע של המשתנה tvar הספציפי בטיפוס אחר (ty0) שהתגלה כפתרון שלו. זה כמו להציב x=5 במשוואה מתמטית מורכבת.
פעולת ההצבה עובדת בצורה רקורסיבית מלאה. אם יש עץ מורכב כגון פונקציה המחזירה פונקציה tvar1 -> (int -> tvar1), הפונקציה תכנס ותחליף כל המופעים של tvar1 במקביל, בכל ענפי העץ. הבנת המנגנון חיונית למעקב במבחן אחרי תהליך מציאת הטיפוסים.
;; Replaces all occurrences of tvar (like tvar15) with a specific type ty0.
(define apply-one-subst
(lambda (ty0 tvar ty)
(cases type ty
(int-type () (int-type))
(bool-type () (bool-type))
(proc-type (arg-type result-type)
(proc-type
(apply-one-subst ty0 tvar arg-type)מבצע את ההצבה באופן רקורסיבי על צד שמאל של הפונקציה (ארגומנט).
(apply-one-subst ty0 tvar result-type)))
(tvar-type (sn)
(if (equal? ty (tvar-type tvar))אם מצאנו בדיוק את המשתנה שאנו מחפשים...
ty0נחליף אותו בטיפוס החדש (ty0)!
ty)))))
האלגוריתם שומר "מילון הצבות" (Substitution Table). כאשר נרצה לדעת מהו הטיפוס הסופי של ביטוי כלשהו (כדי להציג למשתמש למשל), אנחנו נחיל עליו את כל ההצבות מהמילון בו-זמנית: נחפש כל tvar שנמצא בעץ-הטיפוס בתוך המילון, ונחליף אותו בפתרון שנמצא עבורו.
המילון הוא בעצם רשימה של זוגות (tvar . type). שימו לב שההצבה לא מתבצעת רק פעם אחת, אלא באופן מלא - אם במילון tvar1 הוא tvar2, ובמילון tvar2 הוא int, אז tvar1 יהפוך בסוף ל-int.
;; A substitution is a list of pairs: ((tvar1 . type1) (tvar2 . type2) ...)
(define apply-subst-to-type
(lambda (ty subst)
(cases type ty
(int-type () (int-type))
(bool-type () (bool-type))
(proc-type (t1 t2)
(proc-type
(apply-subst-to-type t1 subst)
(apply-subst-to-type t2 subst)))
(tvar-type (sn)
(let ((tmp (assoc ty subst)חיפוש המשתנה במילון ההצבות כדי לראות אם יש לו כבר פתרון ידוע.))
(if tmp
(cdr tmp)אם יש פתרון - נחזיר את הפתרון.
ty))))))
כאשר האלגוריתם מגלה פתרון חדש למשתנה (למשל, מסיק ש-tvar15 = int), הוא חייב לעדכן את המילון. אבל הוא לא יכול פשוט להוסיף את ההצבה החדשה לראש הרשימה! הוא חייב לעבור על כל ההצבות הישנות במילון ולעדכן גם אותן בהתאם לממצא החדש! (למשל אם יש במילון ישן tvar5 = (tvar15 -> bool), הוא יעודכן כעת ל-tvar5 = (int -> bool)).
זוהי נקודת כשל נפוצה של סטודנטים בסימולציות ידניות במבחן. כשמצאתם הצבה חדשה, עליכם להציב אותה אחורה בתוך כל ההצבות שכבר מצאתם, כדי לשמור על עקביות. פעולה זו שקולה ל-back-substitution באלגברה לינארית.
(define empty-subst (lambda () '()))
(define extend-subst
(lambda (subst tvar ty)
(cons
(cons tvar ty)הוספת הזוג החדש (ההצבה החדשה) לראש המילון.
(map
(lambda (p)
(let ((oldlhs (car p))
(oldrhs (cdr p)))
(cons oldlhs
(apply-one-subst ty tvar oldrhs)קריטי: החלפת המשתנה החדש שהתגלה בכל צדדי ה"ימין" של שאר המשוואות הקיימות.)))
subst))))
זהו לב מערכת ההסקה! אלגוריתם ה-Unifier מקבל משוואה בסגנון "טיפוס 1 אמור להיות שווה לטיפוס 2" (למשל כי הם שני הצדדים של תנאי if), ומנסה למצוא את ההצבות שיגרמו להם להיות זהים.
1. אם שניהם כבר זהים, מצוין - לא צריך הצבות חדשות.
2. אם צד אחד הוא משתנה, מוסיפים הצבה שמקשרת את המשתנה לצד השני (אחרי שמוודאים ב-no-occurrence? שאין לולאה).
3. אם שניהם פונקציות, מפעילים את ה-unifier קודם על הארגומנטים, ואז על התוצאות, וממזגים את סך ההצבות.
זהו לב ליבו של Unification! תמיד מנסים לפרק טיפוסים מורכבים (כמו פונקציות) למרכיבים שלהם, ולאחד אותם חלק-חלק, תוך כדי שמעבירים ומוסיפים הצבות ממקום למקום. אם נתקלים בסתירה מוחלטת (למשל int מול bool), זורקים שגיאה.
(define unifier
(lambda (ty1 ty2 subst exp)
(let ((ty1 (apply-subst-to-type ty1 subst))
(ty2 (apply-subst-to-type ty2 subst)))
(cond
((equal? ty1 ty2) subst)
((tvar-type? ty1)
(if (no-occurrence? ty1 ty2)בדיקת Occurrence: אי אפשר לפתור משוואה כמו X = X -> int, כי X מכיל את עצמו! זה נקרא רקורסיית טיפוסים אלא אם יש לנו Recursive Types.
(extend-subst subst ty1 ty2)
(report-no-occurrence-violation ty1 ty2 exp)))
((tvar-type? ty2)
(if (no-occurrence? ty2 ty1)
(extend-subst subst ty2 ty1)
(report-no-occurrence-violation ty2 ty1 exp)))
((and (proc-type? ty1) (proc-type? ty2))
(let ((subst (unifier
(proc-type->arg-type ty1)
(proc-type->arg-type ty2)
subst exp)קודם משווים (ומעדכנים הצבות) של הארגומנטים...))
(let ((subst (unifier
(proc-type->result-type ty1)
(proc-type->result-type ty2)
subst exp)...ואז משווים את טיפוסי ההחזרה בעזרת ההצבות החדשות שהתקבלו!))
subst)))
(else (report-unification-failure ty1 ty2 exp))))))
הפונקציה הזו מונעת לולאה אין-סופית בטיפוסים. נניח שיש לנו את המשוואה X = (int -> X). אי אפשר להציב את X בעצמו כי זה ייצור עץ טיפוסים אינסופי עמוק של (int -> (int -> (int -> ...))). הפונקציה בודקת האם המשתנה tvar מופיע בכלל בתוך הטיפוס ty. אם כן - ההצבה נדחית כדי למנוע קרש (Crash) של המפרש.
זו שגיאה נפוצה לנסות לאחד (Unify) טיפוס עם טיפוס המכיל אותו בעצמו, וזה יגרור כשלון (Failure) בבדיקת הטיפוסים של הקוד. במבחן, אם אתם נתקלים במשוואה מעגלית כזו, דעו שהסקת הטיפוסים נכשלת (Type Error).
;; Checks if 'tvar' appears anywhere inside 'ty'.
(define no-occurrence?
(lambda (tvar ty)
(cases type ty
(int-type () #t)
(bool-type () #t)
(proc-type (arg-type result-type)
(and
(no-occurrence? tvar arg-type)בודק באופן רקורסיבי על צד הארגומנט.
(no-occurrence? tvar result-type)))
(tvar-type (sn)
(not (equal? tvar ty))אם מצאנו את המשתנה עצמו - מחזירים שקר (כלומר יש מופע - אסור לעשות Unify).))))
פונקציית עזר פשוטה שהופכת Optional-type ל-type איתו המפרש יכול לעבוד ולבצע חישובים. אם המפתח הגדיר טיפוס במפורש - משתמשים בו כמו שהוא. אם הוא השאיר "?", אנחנו מייצרים עבורו משתנה-טיפוס (tvar) חדש וטרי, כדי שהאלגוריתם יוכל להתייחס אליו כנעלם ולהסיק אותו.
זהו "שער הכניסה" למערכת הסקת הטיפוסים. כל פעם שהמפרש נתקל במשתנה או פרמטר ללא טיפוס מוצהר, נוצר משתנה חדש. אתם חייבים לדעת לעקוב אחרי המשתנים שנוצרים, בדרך כלל ממספרים אותם כ- t1, t2, t3...
;; If the user provided a type, use it. If not, generate a fresh variable!
(define otype->type
(lambda (otype)
(cases optional-type otype
(no-type () (fresh-tvar-type)מייצר tvar חדש במקרה של "?".)
(a-type (ty) tyמחזיר את הטיפוס המפורש שהמתכנת רשם.))))
זוהי הרחבה של פונקציית type-to-external-form המאפשרת להדפיס למסך משתני-טיפוס (tvars) שלא נפתרו. היא משרשרת את המילה tvar והמספר הסידורי שלו ליצירת סימבול קריא, כמו tvar5 במקום להציג אובייקטים מסובכים למשתמש.
שום דבר קריטי למבחן עצמו מעבר להבנת המושג "תצוגה חיצונית" של הנתונים לעומת הייצוג הפנימי שלהם במפרש (ה-AST).
;; We can display type variables as either their raw ID or a readable letter like "t23".
(define type-to-external-form
(lambda (ty)
(cases type ty
... ; int-type, bool-type, proc-type as before
(tvar-type (sn)
(string->symbol
(string-append
"tvar"
(number->string sn))הופך את המספר 15 לסימבול 'tvar15.)))))
בשפת INFERRED, הפונקציה type-of חייבת להחזיר שני דברים בו-זמנית: גם את הטיפוס שמצאה, וגם את מילון ההצבות (subst) המעודכן, שהרי כל צעד יכול לגלות הצבות חדשות! מכיוון שב-Scheme קשה להחזיר שני ערכים, אנחנו יוצרים סוג נתונים מיוחד בשם an-answer שעוטף את שניהם יחד.
מבנה התשובה (answer) הוא קריטי. בכל פעם שאתם כותבים כלל טיפוס (Typing Rule) ל-INFERRED, עליכם לקרוא ל-type-of, לחלץ ממנה את הטיפוס ואת המילון המעודכן, לבצע unifier אם צריך כדי לאמת, ולהחזיר את המילון החדש ביחד עם הטיפוס של הפעולה כולה.
;; In INFERRED, type-of returns TWO things: the Type, and the updated Substitution dictionary!
;; We simulate this by having type-of return an 'answer' datatype containing both.
(define-datatype answer answer?
(an-answer
(type type?)
(subst substitution?)))כך אנחנו מחזירים "שני ערכים" יחד בתוך אובייקט אחד.
(define type-of
(lambda (exp tenv subst)
(cases expression exp
(const-exp (num) (an-answer (int-type) subst))
(zero?-exp (exp1)
(cases answer (type-of exp1 tenv subst)
(an-answer (ty1 subst1)
(let ((subst2 (unifier ty1 (int-type) subst1 exp)מבקשים מה-unifier להשוות את ty1 ל-int-type, מה שייתכן ויוליד משוואות חדשות ל-subst2!))
(an-answer (bool-type) subst2)))))
...)))
ב-diff, קודם מחשבים את exp1, דורשים שיהיה int, ומעבירים את ההצבות שנוצרו (subst1.5) לחישוב של exp2! ההעברה השרשורית הזאת של subst מבטיחה ששאר התוכנית מכירה את הטיפוסים שהתגלו עד עכשיו.
ב-if, אנחנו מוודאים שהתנאי בוליאני, מנתחים את ה-then ואת ה-else (כל אחד מקבל את מילון ההצבות של קודמו), ובסוף עושים Unify על ty2 ו-ty3 כדי להבטיח ששני הענפים מחזירים טיפוס זהה.
שימו לב להעברת השרביט! subst1 עובר ל-unifier ויוצא כ-subst1.5. ה-subst1.5 הזה נכנס לבדיקה של צד ימין. זו בדיוק הדרך שבה מידע על משתנים מועבר מצד אחד של התוכנית אל הצד השני במהלך ה-Type Inference.
... ; type-of continued
(diff-exp (exp1 exp2)
(cases answer (type-of exp1 tenv subst)
(an-answer (ty1 subst1)
(let ((subst1.5 (unifier ty1 (int-type) subst1 exp1)מכריחים את צד שמאל להיות int ומעדכנים את המילון))
(cases answer (type-of exp2 tenv subst1.5)קריטי: משתמשים במילון המעודכן subst1.5 כדי שחישוב exp2 יכיר את ההצבות החדשות שנוצרו!
(an-answer (ty2 subst2)
(let ((subst2.5 (unifier ty2 (int-type) subst2 exp2)))
(an-answer (int-type) subst2.5))))))))
(if-exp (exp1 exp2 exp3)
(cases answer (type-of exp1 tenv subst)
(an-answer (ty1 subst1)
(let ((subst2 (unifier ty1 (bool-type) subst1 exp1)))
(cases answer (type-of exp2 tenv subst2)
(an-answer (ty2 subst3)
(cases answer (type-of exp3 tenv subst3)
(an-answer (ty3 subst4)
(let ((subst5 (unifier ty2 ty3 subst4 exp)כאן אנחנו מייצרים משוואה חדשה: הטיפוס של ענף אז חייב להיות שווה לטיפוס של ענף אחרת!))
(an-answer ty2 subst5))))))))))
ב-proc, הטיפוס נכנס דרך otype->type. אם המתכנת רשם סוג, הוא יומר אליו ויוזן ל-TENV. אם הוא לא רשם (השאיר ?), יוזן משתנה טיפוס (tvar) חדש שייבדק בתוך ה-body. ההנחה היא שהשימוש במשתנה בתוך גוף הפונקציה יכפה עליו טיפוס בהמשך!
ב-let אנחנו מחשבים את exp1 כדי לקבל את ty1, ואז מכניסים אותו ל-TENV תחת השם var כדי להמשיך להריץ את ה-body. שימו לב שבניגוד ל-value-of ששומר ערכים בסביבה, type-of שומר רק את הטיפוסים.
... ; type-of continued
(var-exp (var)
(an-answer (apply-tenv tenv var) subst))
(let-exp (var exp1 body)
(cases answer (type-of exp1 tenv subst)
(an-answer (ty1 subst1)
(type-of body (extend-tenv var ty1 tenv) subst1))))
(proc-exp (var otype body)
(let ((arg-type (otype->type otype)מכניס משתנה חדש במידה ולא נרשם טיפוס))
(cases answer (type-of body
(extend-tenv var arg-type tenv)מוסיף ל-TENV את הארגומנט בטיפוס שלו (שיכול להיות גם Tvar!)
subst)
(an-answer (result-type subst1)
(an-answer
(proc-type arg-type result-type)
subst1)))))
היופי של המערכת מתגלה ב-call-exp: אנחנו יודעים ש-rator אמור להיות פונקציה. אז אנחנו מייצרים משתנה טיפוס טרי חדש שנקרא result-type, ומאלצים ב-Unifier ש-rator-type יהיה תואם למבנה rand-type -> result-type. זה מה שיגרום למערכת לפתור אחורה ולהבין איזה טיפוס ה-rator מחזיר!
ב-letrec מוודאים שגוף ה-letrec באמת מחזיר את p-result-type כפי שהבטיח (או לפי מה שהוסק).
טריק יצירת הנעלם החדש עבור תוצאת הפונקציה (result-type) בקריאה לפונקציה (call) הוא המפתח ליכולת ההסקה. במבחן, אם יש קריאה לפונקציה לא-ידועה עם פרמטר int, המערכת מייד תוסיף משוואה "הפונקציה הלא ידועה הזו = int -> T_חדש".
... ; type-of continued
(call-exp (rator rands)
(let ((result-type (fresh-tvar-type)אנחנו לא יודעים מה הפונקציה תחזיר, אז אנחנו "ממציאים" משתנה שייצג את התוצאה שלה!))
(cases answer (type-of rator tenv subst)
(an-answer (rator-type subst1)
(cases answer (type-of (car rands) tenv subst1)
(an-answer (rand-type subst2)
;; דורשים שסוג הפונקציה יהיה תואם למבנה (arg -> result-type)
(let ((subst3
(unifier rator-type
(proc-type rand-type result-type)
subst2 exp)מאלצים את rator-type להתאים לתבנית (rand-type -> result-type). כך יתגלה מהו הטיפוס האמיתי של הפונקציה!))
(an-answer result-type subst3))))))))
(letrec-exp (p-result-otype p-name b-var b-var-otype p-body letrec-body)
(let ((p-result-type (otype->type p-result-otype))
(p-arg-type (otype->type b-var-otype)))
(let ((tenv-for-letrec-body
(extend-tenv p-name
(proc-type p-arg-type p-result-type)
tenv)))
(cases answer (type-of p-body
(extend-tenv b-var p-arg-type tenv-for-letrec-body)
subst)
(an-answer (p-body-type subst1)
(let ((subst2 (unifier p-body-type p-result-type subst1 p-body)מוודאים שהטיפוס בפועל של הגוף תואם לטיפוס המוצהר של הרקורסיה.))
(type-of letrec-body tenv-for-letrec-body subst2))))))))))
הוספת תמיכה במודולים לשפה. מודול מוגדר בעזרת שם, Interface (המגדיר אילו משתנים זמינים ל"ייצוא" ומה הטיפוס שלהם - מעין "חוזה" / Signatures), ו-ModuleBody (שמכיל את ההגדרות וקוד המימוש בפועל). כדי לגשת למשתנה מתוך מודול קיים, משתמשים בתחביר השליפה המיוחד from m1 take x.
מודולים הם הדרך לסגור קוד (Encapsulation). מודול בנוי מהגדרות (ממשק) ויישום (גוף). בפרק 8 אנחנו רואים איך מודולים מרחיבים גם את מערכת הטיפוסים (כדי לבדוק שהממשק תואם לגוף) וגם את המפרש בזמן ריצה.
Program ::= (ModuleDefn)* Expression
a-program (m-defs body)
ModuleDefn ::= module Identifier interface Interface body ModuleBody
a-module-definition (m-name expected-iface m-body)
Interface ::= [ (Declaration)* ]
simple-iface (decls)
Declaration ::= Identifier : Type
val-decl (var-name ty)
ModuleBody ::= [ (Definition)* ]
defns-module-body (defns)
Definition ::= Identifier = Expression
val-defn (var-name exp)
Expression ::= from Identifier take Identifierלמשל: from m1 take x. שולף את x מתוך מודול m1.
qualified-var-exp (m-name var-name)
איך מודול מיוצג בזיכרון בזמן ריצה? התשובה האלגנטית: כסביבה (Environment) משל עצמו! ה-typed-module הוא פשוט מעטפת המכילה environment של המשתנים הפנימיים שלו.
כדי לתמוך בכך, הסביבה הגלובלית שודרגה עם extend-env-with-module, המאפשרת לאחסן בתוכה רשומות המפנות למודולים שלמים לצד משתנים רגילים.
מודולים לא דורשים מנגנון זיכרון מורכב משלהם. הם ממחזרים את ה-Environment הקיים! מודול הוא פשוט סביבה פנימית השמורה כערך תחת שם המודול בסביבה החיצונית יותר.
;; A typed module is just an environment containing its internal bindings
(define-datatype typed-module typed-module?
(simple-module
(bindings environment?)))
;; The environment can now hold both standard variables (val) and modules!
(define-datatype environment environment?
(empty-env)
(extend-env
(bvar symbol?)
(bval expval?)
(saved-env environment?))
(extend-env-with-module
(m-name symbol?)
(m-val typed-module?)
(saved-env environment?))הרחבת הסביבה הכללית עם מודול שלם, כך ששם המודול (למשל m1) יצביע לאובייקט ה-module.)
חיפוש מודול בסביבה הוא בדיוק כמו חיפוש משתנה רגיל, אך עם שינוי קטן: הוא מחפש רק ברשומות מסוג extend-env-with-module. אם הוא נתקל במשתנה רגיל (ב-extend-env), הוא מתעלם ממנו וממשיך הלאה ברקורסיה, משום שאנחנו מחפשים במפורש מודול ולא ערך פשוט.
ההפרדה הזו אומרת שיכולים להיות לנו משתנה ומודול בעלי אותו השם, ולא יקרה התנגשות בזמן החיפוש (למרות שבפועל מונעים זאת). פונקציית החיפוש "מדלגת" על מה שלא רלוונטי לה.
(define lookup-module-name-in-env
(lambda (m-name env)
(cases environment env
(empty-env ()
(eopl:error 'lookup-module-name-in-env
"No binding for module ~s" m-name))
(extend-env (bvar bval saved-env)
(lookup-module-name-in-env m-name saved-env)מתעלם ממשתנה רגיל וממשיך לחפש בשרשרת הסביבות.)
(extend-env-with-module (m-name1 m-val saved-env)
(if (eqv? m-name1 m-name)
m-val
(lookup-module-name-in-env m-name saved-env))))))
כאשר רשום בקוד התחביר הייעודי from m1 take x, המפרש מבצע פעולה דו-שלבית. בשלב א', הוא שולף את המודול m1 מהסביבה הגלובלית דרך lookup-module-name-in-env. בשלב ב', הוא פותח את אובייקט המודול, שולף ממנו את הסביבה הפנימית המכילה את bindings, ומבצע apply-env רגיל על x מתוך הסביבה הפנימית של המודול בלבד!
הבנת התהליך הדו-שלבי הזה עוזרת להתמודד עם שאלות הרחבה על מודולים פנימיים (Nested Modules), בהן תצטרכו לשלוף מודול מתוך מודול לפני שתשלפו משתנה.
(define lookup-qualified-var-in-env
(lambda (m-name var-name env)
(let ((m-val (lookup-module-name-in-env m-name env)שלב א': שולף את המודול מהסביבה הכללית))
(cases typed-module m-val
(simple-module (bindings)
(apply-env bindings var-name)שלב ב': שולף את המשתנה מתוך הסביבה הפנימית של המודול!)))))
בתוכנית עם מודולים (a-program), קודם כל המערכת עוברת על כל הצהרות המודולים במערך (m-defs). היא מעריכה כל מודול - כלומר מריצה את הקוד הפנימי שלו כדי לבנות את הסביבה שלו, מוסיפה אותו לסביבה הגלובלית, וממשיכה למודול הבא. רק אחרי שכל המודולים נטענו ל-Environment העדכני (env), ה-value-of רץ סוף סוף על body הראשי של התוכנית.
רקורסיה של add-module-defns-to-env פירושה שמודול מוקדם זמין למודולים מאוחרים יותר (lexical scope)! מודול מספר 3 מכיר ויכול לגשת למודול מספר 1. זה פרט חשוב מאוד כשמעבירים למבחן.
(define value-of-program
(lambda (pgm)
(cases program pgm
(a-program (m-defs body)
(let ((env (add-module-defns-to-env m-defs (empty-env))בונה סביבה גלובלית שכוללת את כל המודולים (וכל מודול מכיר את המודולים שהוגדרו לפניו!).))
(value-of body env))))))
(define add-module-defns-to-env
(lambda (defns env)
(if (null? defns)
env
(cases module-definition (car defns)
(a-module-definition (m-name iface m-body)
(add-module-defns-to-env
(cdr defns)
(extend-env-with-module
m-name
(value-of-module-body m-body env)
env))מעריך את המודול הנוכחי, שומר אותו בסביבה, וממשיך ברקורסיה למודול הבא!)))))
בתוך המודול, איך בונים את הסביבה הפנימית שלו? עוברים שורה-שורה (על כל val-defn). עבור כל הגדרה: מעריכים את הביטוי (מחפשים את val של exp), ומרחיבים את הסביבה. שימו לב: הסביבה המורחבת (new-env) מועברת הלאה ברקורסיה, ולכן משתנים שהוגדרו מוקדם יותר במודול זמינים לשימוש בשורות הבאות (Lexical Scope של ה-body של המודול)!
עקרון ה-Nested Environment (סביבות מקוננות). כל משתנה עוטף את זה שבא לפניו. בסוף מקבלים שרשרת extend-env שמכילה את כולם ומוכנסת לתוך ה-simple-module.
;; Runs the code inside the module to generate its environment
(define value-of-module-body
(lambda (m-body env)
(cases module-body m-body
(defns-module-body (defns)
(simple-module
(defns-to-env defns env))))))
;; Evaluates all definitions inside the module one by one
(define defns-to-env
(lambda (defns env)
(if (null? defns)
(empty-env)
(cases definition (car defns)
(val-defn (var exp)
(let ((val (value-of exp env)))
(let ((new-env (extend-env var val env)))
;; Recursive call builds the nested environment!
(extend-env var val
(defns-to-env (cdr defns) new-env))סביבה שמכילה את הערך החדש, ובתוכה את שאר הערכים שיחושבו אחריה!)))))))
כמו שסביבת הריצה קיבלה extend-env-with-module, סביבת הטיפוסים (TENV) מקבלת extend-tenv-with-module. אלא שכאן המודול לא שומר את ה-Environment שלו, אלא שומר את ה-Interface שלו! (ההצהרות). חיפוש משתנה var-name בתוך מודול m-name מחפש בהצהרות.
;; The Type Checker also needs its own environment for Modules!
(define-datatype type-environment type-environment?
(empty-tenv)
(extend-tenv
(bvar symbol?)
(bval type?)
(saved-tenv type-environment?))
(extend-tenv-with-module
(name symbol?)
(interface interface?)
(saved-tenv type-environment?))סביבת טיפוסים של מודול = הממשק (Interface) שלו!)
(define lookup-qualified-var-in-tenv
(lambda (m-name var-name tenv)
(let ((iface (lookup-module-name-in-tenv tenv m-name)))
(cases interface iface
(simple-iface (decls)
(lookup-variable-name-in-decls var-name decls)מחפש את הטיפוס של המשתנה בתוך ההצהרות של ה-Interface.)))))
זהו לב בדיקת הטיפוסים של מודולים! עבור כל מודול אנחנו מוצאים את ה-Interface בפועל שלו (actual-iface), בודקים שהוא מספק לפחות את מה שה-Interface המצופה דורש (בעזרת <:-iface), ואם כן - שומרים ב-TENV את ה-Expected Interface. זה אומר שגם אם המודול מייצא פונקציה חשאית, אי אפשר לגשת אליה מבחוץ כי היא לא ב-Expected Interface!
;; The Type Checker evaluates the module declarations and bodies, making sure everything matches!
(define type-of-program
(lambda (pgm)
(cases program pgm
(a-program (module-defns body)
(type-of body
(add-module-defns-to-tenv module-defns (empty-tenv)))))))
(define add-module-defns-to-tenv
(lambda (defns tenv)
(if (null? defns)
tenv
(cases module-definition (car defns)
(a-module-definition (m-name expected-iface m-body)
(let ((actual-iface (interface-of m-body tenv)מסיק לבד מה הממשק האמיתי של המודול מהקוד שלו))
(if (<:-iface actual-iface expected-iface tenv)בודק אם הממשק האמיתי עומד בדרישות של הממשק המוצהר. האם actual מתנהג כמו expected? ; Does actual satisfy expected?
;; If yes, add the EXPECTED interface to the TENV, not the actual one! (Encapsulation)
(let ((new-tenv (extend-tenv-with-module m-name expected-iface tenv)הכנסת הממשק *המוצהר* לסביבה, מה שמסתיר פונקציות עזר פנימיות.))
(add-module-defns-to-tenv (cdr defns) new-tenv))
(report-module-doesnt-satisfy-iface m-name expected-iface actual-iface))))))))
פונקציית interface-of לוקחת module-body (גוף המודול) והופכת אותו ל-simple-iface. איך? היא עוברת על כל ההגדרות בו, מוצאת את הטיפוס שלהם בעזרת type-of, ויוצרת רשימה של הצהרות (Declarations).
(define interface-of
(lambda (m-body tenv)
(cases module-body m-body
(defns-module-body (defns)
(simple-iface
(defns-to-decls defns tenv))))))
;; Goes through each definition, finds its type, and adds it as a declaration (val-decl) to the interface.
(define defns-to-decls
(lambda (defns tenv)
(if (null? defns)
'()
(cases definition (car defns)
(val-defn (var-name exp)
(let ((ty (type-of exp tenv)מחשב את הטיפוס של משתנה בתוך המודול))
(let ((new-env (extend-tenv var-name ty tenv)))
(cons
(val-decl var-name ty)יוצר Declaration חדש לממשק: השם והטיפוס.
(defns-to-decls (cdr defns) new-env)))))))))
איך בודקים שממשק אחד מתאים לממשק אחר? זה קורה באמצעות מנגנון שדומה ל-Subtyping: עבור כל הצהרה בממשק המצופה (expected), חייבת להיות הצהרה תואמת בעלת אותו שם ואותו טיפוס בממשק בפועל (actual). לממשק ה-actual מותר להכיל משתנים נוספים (אקסטרה), אבל אסור שיהיה חסר לו משהו מה-expected! (ההשוואה מאפשרת גם סדר שונה של משתנים).
;; <:-iface checks if 'iface1' (actual) is a subtype of 'iface2' (expected)
(define <:-iface
(lambda (iface1 iface2 tenv)
(cases interface iface1
(simple-iface (decls1)
(cases interface iface2
(simple-iface (decls2)
(<:-decls decls1 decls2 tenv)))))))
;; We iterate over decls2 (the expected declarations).
;; For every expected declaration, it MUST exist in decls1 (actual), and the types MUST match.
;; It's okay if decls1 has EXTRA declarations that decls2 doesn't mention!
(define <:-decls
(lambda (decls1 decls2 tenv)
(cond
((null? decls2) #t) ; All expected decls were satisfied
((null? decls1) #f) ; Expected decls left, but actual is empty (Missing binding)
(else
(let ((name1 (decl->name (car decls1)))
(name2 (decl->name (car decls2)))
(type1 (decl->type (car decls1)))
(type2 (decl->type (car decls2))))
(if (eqv? name1 name2)
(and
(equal? type1 type2)
(<:-decls (cdr decls1) (cdr decls2) tenv))
(<:-decls (cdr decls1) decls2 tenv)אם השמות שונים, פשוט מדלגים ובודקים את המשתנה הבא ב-decls1! כך מאפשרים ש-decls1 יכיל דברים שאין ב-decls2.))))))
השפה משתדרגת מ-SIMPLE-MODULES ל-OPAQUE-TYPES. כעת, מודולים יוכלו לא רק לייצא פונקציות ומשתנים, אלא גם להגדיר טיפוסים חדשים משלהם! מודול יכול לחשוף טיפוס כ-transparent (כולם יודעים שהטיפוס "צבע" הוא בעצם int), או כ-opaque (סודי ואטום: אף אחד בחוץ לא יודע שזה int, ולכן אי אפשר לעשות עליו + 1 בטעות).
שילוב של opaque יוצר Abstraction אבסולוטי. כל הפעולות שנוגעות לטיפוס האניגמטי הזה יצטרכו להתבצע אך ורק על ידי פונקציות שהמודול עצמו מייצא! זהו העקרון של Abstract Data Types (ADT) בפעולה.
;; OPAQUE-TYPES introduces defining custom types inside modules!
Type ::= Identifier
named-type (name)
Type ::= from Identifier take Identifier
qualified-type (m-name t-name)לדוגמה: (from mybool take t) כדי לגשת לטיפוס שמוגדר במודול אחר.
Declaration ::= transparent Identifier = Typeשקוף: אומר למשתמש שמדובר בכינוי (Alias) לטיפוס הקיים.
transparent-type-decl (name ty)
Declaration ::= opaque Identifierאטום: מסתיר את המימוש! אם נשתמש בו, לא נוכל לעשות עליו פעולות של int למרות שמתחת לפני השטח הוא int.
opaque-type-decl (name)
Definition ::= type Identifier = Type
type-defn (name ty)
מכיוון שהוספנו לסינטקס טיפוסי כינוי כגון named-type (למשל כינוי t בתוך המודול) ו-qualified-type (למשל from m1 take t), התעורר צורך בפונקציה שתפתח ותפרוס את הכינויים האלו עד שתגיע לטיפוס הבסיס (כמו int או bool). זה בדיוק מה ש-expand-type עושה: היא ממשיכה להרחיב עד שהיא מוצאת סוג פרימיטיבי, או עד שהיא נתקעת בטיפוס opaque שאותו אסור להמשיך להרחיב (כי המבנה הפנימי שלו מוסתר).
תמיד כשמשווים בין טיפוסים בשפה שתומכת ב-Aliases (כינויים), חייבים להריץ עליהם expand-type לפני ההשוואה, כדי לוודא שאיננו דוחים בטעות שני טיפוסים שהם בעצם זהים אבל נכתבו בשמות שונים.
;; Because a type 't' can just be an alias for 'int', we need a function that 'unfolds' the type until it hits a primitive type or an opaque type.
(define expand-type
(lambda (ty tenv)
(cases type ty
(int-type () (int-type))
(bool-type () (bool-type))
(proc-type (arg-type result-type)
(proc-type
(expand-type arg-type tenv)אם זה proc, מרחיבים גם את הארגומנטים...
(expand-type result-type tenv)))
(named-type (name)
(lookup-type-name-in-tenv tenv name)מסתכל ב-TENV מה הטיפוס שמוסתר מאחורי השם.)
(qualified-type (m-name t-name)
(lookup-qualified-type-in-tenv m-name t-name tenv)))))
זהו השדרוג של <:-decls מ-SIMPLE-MODULES שבודק האם ממשק אחד מקיים ממשק אחר. כעת, לפני השוואה ישירה של סוגי הטיפוסים, המערכת מריצה עליהם קודם כל equiv-type?, אשר מבצע הרחבה (expand) כדי לגלות את סוג הבסיס. חשוב במיוחד: כאשר היא מאשרת הצהרה מהממשק, היא מרחיבה את tenv עם אותה הצהרה, כך שמשתנים מאוחרים יותר יוכלו להשתמש בטיפוס שאושר לפני רגע.
זה מראה למה הסביבה בבדיקת טיפוסים של מודול היא מצטברת! אם שורה ראשונה ב-Interface היא הגדרת transparent type t = int, השורה השנייה שיכולה להיות val x : t תהיה חוקית כי בודק הטיפוסים זוכר את t מהצעד הקודם.
;; We use expand-type in <:-decls!
;; When comparing two types in an interface (like expected vs actual), we expand them FIRST to see what they really mean.
(define <:-decls
(lambda (decls1 decls2 tenv)
(cond
...
(else
(let ((name1 (decl->name (car decls1)))
(name2 (decl->name (car decls2))))
(if (eqv? name1 name2)
(and
;; Compare expanded types instead of raw types!
(equiv-type? (decl->type (car decls1))
(decl->type (car decls2))
tenv)שימוש ב-equiv-type? שמרחיב את שניהם ואז משווה.
(<:-decls (cdr decls1) (cdr decls2)
;; Must extend tenv with the actual declaration before continuing
(extend-tenv-with-decl (car decls1) tenv)קריטי: משתנים בהמשך הרשימה עשויים להיות תלויים במשתנה שכעת בדקנו (למשל הגדרנו טיפוס t ואז משתנה מטיפוס t).))
(<:-decls (cdr decls1) decls2 tenv)))))))
איך בודק הטיפוסים מסיק את הממשק האמיתי של מודול מקוד המקור (ה-Body) שלו בלבד? עבור משתנים רגילים, הוא הופך אותם ל-val-decl. עבור הגדרת טיפוס (כמו type t = int), פונקציית ההמרה (defns-to-decls) תמיד הופכת את ההגדרה הפנימית ל-transparent. מדוע? כי המודול מודע לעצמו: הקוד הפנימי תמיד יודע מה המימוש של הטיפוס! ההפיכה ל-opaque היא אשליה הקיימת רק למראית עין כלפי חוץ (אם צוינה במפורש ב-Interface החיצוני).
בתוך המודול, הטיפוסים תמיד transparent! רק כשמשווים את הממשק שהוסק עם הממשק המוצהר, אפשר "לשנמך" את השקיפות ל-opaque כלפי שאר התוכנית.
;; equiv-type? checks if two types are actually the same after expansion
(define equiv-type?
(lambda (ty1 ty2 tenv)
(equal?
(expand-type ty1 tenv)
(expand-type ty2 tenv))))
;; defns-to-decls now handles `type` definitions!
(define defns-to-decls
(lambda (defns tenv)
(if (null? defns)
'()
(cases definition (car defns)
...
(type-defn (name ty)
(let ((new-env (extend-tenv-with-type name (expand-type ty tenv) tenv)))
(cons
;; A 'type' definition in the body ALWAYS becomes a 'transparent' declaration in the interface!
(transparent-type-decl name ty)מבפנים - כל טיפוס הוא תמיד שקוף עבור הקוד שבתוך המודול!
(defns-to-decls (cdr defns) new-env))))))))
זוהי הפונקציה שבודקת האם הצהרה בודדת מהממשק-בפועל (decl1) מספקת וממלאת את החוזה של הצהרה מהממשק-המוצהר (decl2).
החוק החשוב ביותר כאן: ממשק-בפועל שמספק טיפוס transparent (כלומר, חושף מה מסתתר מאחורי השם) ממלא בהצלחה דרישה לטיפוס opaque בממשק-המוצהר (דרישה להסתיר את המידע)!
זה קורה משום שמימוש שמראה יותר מידע (שקיפות) נחשב תמיד ל-Subtype תקין של חוזה שדורש פחות מידע (אטימות). המודול יכול פשוט "ללבוש חליפה אטומה" ולהסתיר את עצמו לפי דרישת החוזה. לעומת זאת, מודול אטום לא יכול בשום אופן לספק דרישה ל-transparent.
;; <:-decl checks if actual declaration satisfies expected declaration
(define <:-decl
(lambda (decl1 decl2 tenv)
(or
(and
(val-decl? decl1)
(val-decl? decl2)
(equiv-type? (decl->type decl1) (decl->type decl2) tenv))
(and
(transparent-type-decl? decl1)
(transparent-type-decl? decl2)
(equiv-type? (decl->type decl1) (decl->type decl2) tenv))
(and
(transparent-type-decl? decl1)
(opaque-type-decl? decl2)המודול משקף את הטיפוס, אבל ה-interface דורש שיהיה אטום - זה תקין! המודול פשוט "מתלבש" בחליפה אטומה.) ; A transparent type can satisfy an opaque requirement!
(and
(opaque-type-decl? decl1)
(opaque-type-decl? decl2)))))
השפה משתדרגת מ-OPAQUE-TYPES ל-PROC-MODULES! כעת המערכת מאפשרת ליצור מודולים מיוחדים הנקראים "Functors". פונקטור הוא למעשה "פונקציה של מודולים": הוא מקבל כפרמטר מודול אחד, ומחזיר מודול חדש המבוסס עליו.
הממשק שלו (proc-iface) מתאר את זה בדיוק כמו טיפוס פונקציה רגילה: (פרמטר : ממשק נדרש) => ממשק התוצאה.
ההרחבה של expand-iface מוכיחה שכאשר פונקטור רץ, הממשק שמוחזר ממנו "מכיר" את מה שהוכנס אליו. לכן בעת הרחבת ה-result-iface, המודול שסופק כפרמטר (כולל הטיפוסים הפנימיים שלו) מוכנס לסביבה.
;; A parameterized module (Functor) takes a module as input and returns a module!
Interface ::= ((Identifier : Interface) => Interface)
proc-iface (param-name param-iface result-iface)ממשק (A => B) כמו פונקציה רגילה, אבל עובד על מודולים שלמים.
;; Expanding a functor's interface just means expanding its parameter's interface and its result's interface.
(define expand-iface
(lambda (m-name iface tenv)
(cases interface iface
(simple-iface (decls)
(simple-iface
(expand-decls m-name decls tenv)))
(proc-iface (param-name param-iface result-iface)
(proc-iface
param-name
(expand-iface param-name param-iface tenv)מרחיב את הממשק של הפרמטר המבוקש
(expand-iface m-name result-iface
(extend-tenv-with-module
param-name
(expand-iface param-name param-iface tenv)
tenv)מרחיב את הממשק של התוצאה, כשהפרמטר כבר ידוע וקיים בסביבה (למשל, כדי להשתמש בטיפוס t שהמודול-פרמטר מספק)))))))
נוספו שלוש אפשרויות לגוף מודול כדי לתמוך בפונקטורים: (1) הגדרת גוף שהוא בעצמו פונקציה שמקבלת מודול (proc-module-body). (2) גוף שהוא פשוט שם של מודול קיים (var-module-body). (3) קריאה להפעלת פונקטור על מודול ארגומנט (app-module-body).
בזיכרון, פונקטור מיוצג בתור proc-module - שזה בעצם Closure מיוחד למודולים! הוא שומר את שם הפרמטר, את גוף הפונקטור, ואת הסביבה הנוכחית בה הוא הוגדר.
פונקטורים משתמשים ב-Lexical Scoping בדיוק כמו פונקציות רגילות. הם זוכרים את הסביבה בה הם הוגדרו (למשל מודולים אחרים או משתנים שהיו זמינים באותו זמן), וישתמשו בה כשהם יורצו.
ModuleBody ::= module-proc (Identifier : Interface) ModuleBody
proc-module-body (m-name m-type m-body)
ModuleBody ::= Identifier
var-module-body (m-name)
ModuleBody ::= (Identifier Identifier)
app-module-body (rator rand)לדוגמה: module M = (F X), מפעיל את פונקציית המודול F על המודול X.
;; In memory, a functor is a 'proc-module' containing the parameter name, its expected body, and the environment it was defined in (Lexical Scoping!).
(define-datatype typed-module typed-module?
(simple-module (bindings environment?))
(proc-module
(bvar symbol?)
(body module-body?)
(saved-env environment?))בדיוק כמו proc רגיל בשפה: סוגר (Closure) שמחכה שיפעילו אותו!)
איך המפרש מריץ Functors בפועל? כשהוא נתקל ב-proc-module-body הוא יוצר את ה-Closure (מודול שעדיין לא רץ). כשהוא נתקל ב-app-module-body, הוא מחפש בסביבה את מודול ה-Functor ואת המודול הארגומנט שיועבר אליו.
אז, הוא שולף את הסביבה השמורה של ה-Closure, מוסיף לתוכה את מודול הארגומנט תחת השם של הפרמטר, ובתוך הסביבה המעודכנת הזו מריץ את גוף הפונקטור (value-of-module-body).
המנגנון פה זהה לחלוטין ל-apply-procedure של פונקציות רגילות. פונקטור הוא פשוט פונקציה שמקבלת מודול במקום משתנה רגיל, ומחזירה מודול. לכן, הכללים של Scope נשארים זהים למשתנים רגילים!
;; How do we run module code that defines or uses functors?
(define value-of-module-body
(lambda (m-body env)
(cases module-body m-body
(defns-module-body (defns)
(simple-module
(defns-to-env defns env)))
(var-module-body (m-name)
(lookup-module-name-in-env m-name env))
(proc-module-body (m-name m-type m-body)
(proc-module m-name m-body env)יצירת ה-Closure. הגוף עדיין לא מורץ!) ; Returns the functor (Closure)
(app-module-body (rator rand)
(let ((rator-val (lookup-module-name-in-env rator env))
(rand-val (lookup-module-name-in-env rand env)))
(cases typed-module rator-val
(proc-module (bvar body saved-env)
;; To apply the functor, we run its body, extending its saved environment with the argument module!
(value-of-module-body body
(extend-env-with-module bvar rand-val saved-env))הרצת גוף הפונקציה (המודול) תחת הסביבה שנשמרה עם תוספת הארגומנט שסופק!)
(else
(eopl:error 'value-of-module-body
"Attempt to apply non-parameterized module ~s" rator))))))))
איך בודקים סטטית את ההגדרה של proc-module-body (הפונקטור עצמו)? בודק הטיפוסים פועל כמו בהגדרת פונקציה מטיפוס: הוא מוסיף לסביבת הטיפוסים הסטטית (tenv) את מודול הארגומנט המובטח (יחד עם הממשק שלו m-type). ואז, בסביבה החדשה שכוללת את הפרמטר, הוא מריץ interface-of כדי להסיק מה הממשק שיוחזר מהגוף!
התוצאה של כל התהליך הזה היא יצירת ממשק מסוג proc-iface.
בדיקת טיפוסים של פונקטור מניחה "דמה" של מודול ארגומנט כדי לבדוק שגוף הפונקטור אכן מייצר מודול תקין בהינתן שהפרמטר עומד בחוזה (ב-Interface שלו).
;; Type-checking Functors is just like type-checking regular procs!
(define interface-of
(lambda (m-body tenv)
(cases module-body m-body
(defns-module-body (defns)
(simple-iface
(defns-to-decls defns tenv)))
(var-module-body (m-name)
(lookup-module-name-in-tenv tenv m-name))
(proc-module-body (m-name m-type m-body)
(let ((expanded-m-type (expand-iface m-name m-type tenv)))
(let ((new-tenv (extend-tenv-with-module m-name expanded-m-type tenv)מוסיפים את הפרמטר לסביבה כדי שיהיה זמין בזמן הבדיקה של הגוף.))
(let ((result-iface (interface-of m-body new-tenv)מסיקים מה הממשק שיוחזר כתוצאה מהרצת המודול הזה.))
;; Returns a proc-iface (A => B)
(proc-iface m-name expanded-m-type result-iface)החזרת ממשק מסוג Functor))))
...
ואיך בודקים הפעלה של Functor (app-module-body)? בדיוק כמו שמפעילים פונקציה: בודקים תחילה שהמודול המופעל (rator) הוא אכן פונקטור (מטיפוס proc-iface).
לאחר מכן, מריצים את <:-iface כדי לוודא שהמודול שהועבר כארגומנט ממלא במלואו את הממשק שהפונקטור דורש (param-iface).
אם כן, התוצאה היא הממשק שהפונקטור הבטיח להחזיר (result-iface).
שלב קריטי כאן הוא rename-in-iface: הפונקטור מגדיר את result-iface שלו במונחים של שם הפרמטר שלו (למשל תלוי בטיפוס t מתוך מודול X). כשמפעילים אותו עם מודול אמיתי Y, צריך לשנות את שמות הטיפוסים בתוצאה כך שיצביעו על Y. זה אומר שטיפוסים הם תלויי-הקשר!
... ; interface-of continued
(app-module-body (rator rand)
(let ((rator-iface (lookup-module-name-in-tenv tenv rator))
(rand-iface (lookup-module-name-in-tenv tenv rand)))
(cases interface rator-iface
(simple-iface (decls)
(eopl:error 'interface-of
"Attempt to apply non-parameterized module ~s" rator))
(proc-iface (param-name param-iface result-iface)
;; Does the argument module satisfy the parameter requirements?
(if (<:-iface rand-iface param-iface tenv)בודק אם המודול שסיפקנו עונה על הדרישות של ה-Functor!
(rename-in-iface result-iface param-name rand)משנה בתוך התוצאה את שם הפרמטר לשם של המודול האמיתי שסיפקנו, כדי שהטיפוסים יהיו מדויקים בממשק המוחזר! ; Yes! Return the result interface (with the param name swapped out)
(report-module-doesnt-satisfy-iface rand param-iface rand-iface)))))))))
אנו מתחילים את פרק 9 עם שפת CLASSES שמכניסה תכנות מונחה עצמים קלאסי. היא מאפשרת הגדרת מחלקות שיורשות זו מזו (extends), הגדרת שדות פנימיים למחלקה (field), והגדרת מתודות (method).
הפקודות החדשות כוללות את new (ליצירת אובייקט חדש), send (לשליחת הודעה/הפעלת מתודה על אובייקט), super (פניה מפורשת למתודת האב כדי להתגבר על דריסה) ו-self.
כל פעולה ב-OOP (כמו מתודות או יצירת אובייקט) ממשיכה להיות ביטוי (Expression) שמחזיר ערך בסוף. תכנות מונחה עצמים בספר EOPL מתבסס ברובו על העברת הודעות (Message Passing), בדומה לשפת Smalltalk או Ruby.
;; A program now contains a list of class declarations followed by an expression.
Program ::= (ARBITRARY-NUMBER-OF ClassDecl) Expression
;; A class has a name, inherits from a superclass (or 'object'), defines fields, and methods.
ClassDecl ::= class Identifier extends Identifier
(ARBITRARY-NUMBER-OF field Identifier)
(ARBITRARY-NUMBER-OF MethodDecl)
a-class-decl (class-name super-name field-names method-decls)
;; Methods have a name, parameters, and a body.
MethodDecl ::= method Identifier (COMMA-SEPARATED-LIST-OF Identifier) Expression
a-method-decl (method-name vars body)
;; Object-oriented expressions
Expression ::= new Identifier (COMMA-SEPARATED-LIST-OF Expression)
new-object-exp (class-name rands)יצירת אובייקט והפעלת מתודת initialize.
Expression ::= send Expression Identifier (COMMA-SEPARATED-LIST-OF Expression)
method-call-exp (obj-exp method-name rands)קריאה למתודה (שליחת הודעה). מפעיל Dynamic Dispatch.
Expression ::= super Identifier (COMMA-SEPARATED-LIST-OF Expression)
super-call-exp (method-name rands)קריאה למתודה ממחלקת האב כדי למנוע דריסה.
Expression ::= self
self-exp ()התייחסות לאובייקט הנוכחי.
התוספות למפרש (value-of) כדי לתמוך ב-OOP:
1. self ו-super אינם מילות מפתח קסומות, אלא פשוט משתנים נסתרים בסביבה (%self ו-%super)!
2. הפעלת מתודה (send) מחפשת מתודה החל מהמחלקה שהאובייקט משתייך אליה בזמן ריצה (Dynamic Dispatch).
3. פעולת ה-new מייצרת אובייקט בזיכרון, ואז אוטומטית שולחת אליו הודעת initialize (קריאה לבנאי).
שימו לב להבדל העצום בין send ל-super: בעוד ש-send מתחיל את חיפוש המתודה מלמטה (ממחלקת האובייקט האמיתי בזיכרון), קריאה ל-super חייבת להתחיל לחפש את המתודה החל מהמחלקה שמעל למחלקה שבה אנו רצים עכשיו (הסופר-קלאס הסטטי שלנו, שנשמר ב-%super), ולא מהמחלקה של %self! אחרת ניכנס ללולאה אינסופית.
;; How we evaluate the new OOP constructs:
(define value-of
(lambda (exp env)
(cases expression exp
...
(self-exp ()
(apply-env env '%self)'self' is just a hidden variable in the environment!)
(method-call-exp (obj-exp method-name rands)
(let ((args (eval-rands rands env))
(obj (value-of obj-exp env)))
(apply-method (find-method (object->class-name obj) method-name)מתחיל לחפש את המתודה החל מהמחלקה שאליה שייך האובייקט (מתחיל מלמטה). זה ה-Dynamic Dispatch!
obj
args)))
(super-call-exp (method-name rands)
(let ((args (eval-rands rands env))
(obj (apply-env env '%self)))
(apply-method (find-method (apply-env env '%super) method-name)מתחיל לחפש את המתודה החל מהסופר-קלאס שנשמר בסביבה.
obj
args)))
(new-object-exp (class-name rands)
(let ((args (eval-rands rands env))
(obj (new-object class-name)))
;; Call 'initialize' method on the newly created object
(apply-method (find-method class-name 'initialize)
obj
args)כל אובייקט מריץ את מתודת ה-initialize מיד לאחר יצירתו.
obj))))) ; Return the created object
איך נראה אובייקט פיזית בזיכרון בזמן ריצה? האובייקט לא שומר עותקים של המתודות שלו! כדי לחסוך בזיכרון, הוא שומר רק את שם המחלקה שלו (כדי שנוכל למצוא בסביבת המחלקות הכללית אילו מתודות אפשר להפעיל עליו) ורשימה של הפניות למשתני זיכרון (references) המייצגים את הערכים הנוכחיים של השדות שלו (Fields).
ההפרדה הזו (שדות באובייקט, מתודות במחלקה) מסבירה למה קריאה למתודה חייבת לעבור דרך שם המחלקה (Dynamic Dispatch) - המתודות משותפות לכל האובייקטים מאותה מחלקה! האובייקט הוא רק "שק" של State (שדות).
;; An object in memory is remarkably simple:
;; It just needs to know its class name (so we can find its methods)
;; and it needs a list of memory references to its fields.
;; Methods are NOT stored inside the object! They are stored in the class.
(define-datatype object object?
(an-object
(class-name symbol?)מזהה לאיזו מחלקה שייך. זה מאפשר את ה-Dynamic Dispatch!
(fields (list-of reference?))מערך השדות של האובייקט (כולל שדות שהורשו ממחלקות האב).))
;; Helper to get the class name from an object
(define object->class-name
(lambda (obj)
(cases object obj
(an-object (class-name fields)
class-name))))
כאשר יוצרים אובייקט (new-object), מוקצה לו זיכרון (על ידי newref) לכל השדות במחלקה הנוכחית וגם במחלקות האב שלה (ירושה של שדות). התאים מתחילים ריקים (uninitialized-field).
וכאשר מריצים מתודה (apply-method), הסביבה (Environment) נבנית בזהירות: בונים סביבה ריקה לחלוטין (שבירת ה-Lexical Scope הרגיל!), ועליה מרכיבים אך ורק את %self, %super, הפניות לשדות האובייקט (כדי שהמתודה תוכל לשנות אותם), ואת הפרמטרים שהועברו.
מתודות ב-CLASSES סותרות את חוקי ה-Closure הרגילים! הן לא רצות בסביבה בה המחלקה הוגדרה (הן לא שומרות "Saved Environment"). במקום זאת, הן תמיד רצות בסביבה טרייה המבוססת על השדות של %self.
;; new-object allocates the object and its fields in memory
(define new-object
(lambda (class-name)
(an-object
class-name
(map
(lambda (field-name)
(newref (list 'uninitialized-field field-name))מייצר תא זיכרון ריק (לפני ה-initialize) עבור כל שדה (Field) של המחלקה.)
(class->field-names (lookup-class class-name))))))
;; apply-method executes the method body
(define apply-method
(lambda (m self args)
(cases method m
(a-method (vars body super-name field-names)
(value-of body
;; The method runs in an environment containing the arguments...
(extend-env vars args
;; ...and the object's fields...
(extend-env-refs field-names (object->fields self)מחבר את שמות השדות שמופיעים בקוד (מחלקה א') להפניות הזיכרון האמיתיות שקיימות באובייקט self!
;; ...and 'super'...
(extend-env (list '%super) (list super-name)
;; ...and 'self'.
(extend-env (list '%self) (list self)
(empty-env)מתודות רצות בסביבה ריקה למעט מה שהוגדר למעלה! כלומר אין "Lexical Scope" אל מחוץ למחלקה.)))))))))
מכיוון שהמתודות אינן נשמרות באובייקטים עצמם, היכן הן מאוחסנות? בתוך רשימה גלובלית הנקראת the-class-env (סביבת המחלקות הסטטית). סביבה זו שומרת את ההגדרה המלאה (a-class) של כל מחלקה בתוכנית: מי מחלקת האב שלה, מהם שמות כל השדות שלה (רשימה שטוחה שכוללת ירושה), וסביבת המתודות שלה (מיפוי בין שם מתודה לגוף המתודה).
שמירת השדות כרשימת שמות בלבד קריטית, כי בזיכרון של האובייקט (Snippet 160) יש לנו רק הפניות אנונימיות. בעזרת רשימת השמות מהמחלקה, אנחנו יכולים להתאים לכל משתנה גישה לתא הנכון בזיכרון.
;; The global class environment stores all class definitions.
(define the-class-env '())
(define add-to-class-env!
(lambda (class-name class)
(set! the-class-env
(cons (list class-name class) the-class-env))))
;; A class holds everything needed to create objects and find methods.
;; It stores its superclass name, its accumulated field names, and its methods.
(define-datatype class class?
(a-class
(super-name (maybe symbol?))
(field-names (list-of symbol?))שומר את כל השדות - גם אלו שהורשו.
(method-env method-environment?)סביבה שממפה שמות מתודות לגופי המתודות (Closures).))
איך מממשים ירושה ב-EOPL? זהו מנגנון אלגנטי הנקרא "Flat Inheritance".
במקום לחפש במעלה עץ המחלקות בזמן ריצה, אנחנו משטחים את היררכיית הירושה פעם אחת בלבד בשלב האתחול הסטטי (initialize-class-decl!)!
כאשר מחלקה מוגדרת, שדותיה נדבקים (append) לסוף רשימת השדות של מחלקת האב, ומתודות האב מתמזגות עם מתודות הבן (הבן דורס את האב).
בזכות השטחת הירושה, פונקציית find-method היא טריוויאלית וסופר-מהירה (O(1) מבחינת עץ המחלקות). היא פשוט שולפת מהסביבה של המחלקה הספציפית ללא צורך "לטפס" למעלה לאורך שרשרת ההורשה בזמן ריצה, כי כל המתודות המורשות כבר הועתקו פנימה מראש.
;; When we see a class declaration, we create the class and add it to the environment.
(define initialize-class-decl!
(lambda (c-decl)
(cases class-decl c-decl
(a-class-decl (class-name super-name field-names method-decls)
(let ((f-names
;; Inheritance of fields: we append our new fields to the superclass's fields!
(append-field-names
(class->field-names (lookup-class super-name))
field-names)שדות מורשים נוספים בתחילת הרשימה (מיקומם הפיזי בזיכרון נשמר!)))
(add-to-class-env!
class-name
(a-class
super-name
f-names
;; Inheritance of methods: we merge our methods with the superclass's methods.
(merge-method-envs
(method-decls->method-env method-decls super-name f-names)
(class->method-env (lookup-class super-name)))מיזוג מתודות - דריסה קורית כאן אם יש התנגשות שמות.)))))))
;; finding a method is simple because merge-method-envs already put them all in the class's method-env!
(define find-method
(lambda (c-name name)
(let ((m-env (class->method-env (lookup-class c-name))))
(let ((maybe-pair (assq name m-env)))
(if (pair? maybe-pair)
(cadr maybe-pair)
(report-method-not-found name))))))
זהו השלב בו אנו מוסיפים מערכת טיפוסים סטטית קשיחה (בדומה ל-Java) לתוך OOP בשפת TYPED-OO. כעת כל מתודה דורשת חתימת טיפוסים מחמירה למשתנים ולתוצאה.
בנוסף להורשה רגילה (extends), הוכנס רעיון ה-ממשקים (Interfaces). מחלקה יכולה ליישם (implements) מרובה ממשקים.
התוספות cast ו-instanceof קיימות כביטויים מיוחדים מכיוון שהן מייצגות פעולות של "Dynamic Typing בתוך מערכת סטטית". הבודק הסטטי לא יכול לדעת ב-100% האם האובייקט שייך למחלקה ספציפית (בגלל Polymorphism), ולכן פעולות אלו נבדקות בזמן ריצה.
;; TYPED-OO adds Interfaces and Types to OOP.
;; A program now starts with interfaces, then classes, then the expression.
Program ::= (ARBITRARY-NUMBER-OF InterfaceDecl)
(ARBITRARY-NUMBER-OF ClassDecl)
Expression
;; Interfaces declare what methods a class must implement, and their types.
InterfaceDecl ::= interface Identifier (ARBITRARY-NUMBER-OF MethodDecl)
an-interface-decl (iface-name method-decls)
;; Classes can implement multiple interfaces!
ClassDecl ::= class Identifier extends Identifier
implements (ARBITRARY-NUMBER-OF Identifier)
...
;; Methods now have type signatures
MethodDecl ::= method Type Identifier (COMMA-SEPARATED-LIST-OF Identifier : Type) Expression
;; Types include class types and interface types (which are just identifiers)
Type ::= Identifier (class-type or interface-type)
;; Casting and type checking (Dynamic Typing within a Static system)
Expression ::= cast Expression Identifier
cast-exp (exp class-name)
Expression ::= instanceof Expression Identifier
instanceof-exp (exp class-name)
מערכת הטיפוסים הסטטית עובדת במקביל למערכת הריצה. לכן הוגדרה סביבת מחלקות סטטית (the-static-class-env). היא לא שומרת קוד ריצה של מתודות, אלא רק את חתימות הטיפוסים שלהן (method types)!
בנוסף, כשאנו בודקים new-object, אנו מסיקים שהטיפוס המוחזר הוא פשוט שם המחלקה עצמה, אך לפני כן אנו מוודאים בקפידה שסוגי הארגומנטים שהועברו תואמים לחתימת המתודה initialize של המחלקה.
כל בדיקה של קריאה למתודה (type-of-call) דורשת את חתימת הטיפוסים מהסביבה הסטטית. אם אנחנו מנסים לקרוא למתודה שלא הוגדרה בחתימה של טיפוס האובייקט (ה-Static Type שלו), התוכנית תיפסל סטטית ב-Type Checker עוד לפני שהיא מתחילה לרוץ!
כמו שבנינו class-env לזמן ריצה, אנחנו בונים static-class-env לזמן הקומפילציה! כדי לוודא שקריאה למתודה היא חוקית, מחפשים את חתימת המתודה במחלקה, ומוודאים שכל ארגומנט שמועבר הוא תת-טיפוס (Subtype) של הפרמטר הנדרש. (כלומר שמחלקת הארגומנט יורשת מהמחלקת הפרמטר).
;; The checker builds a 'static-class-env' to know about all classes and interfaces before checking the expression.
(define type-of-program
(lambda (pgm)
(cases program pgm
(a-program (iface-decls class-decls exp)
(initialize-static-class-env! iface-decls class-decls)סורק את כל המחלקות כדי לייצר מפות טיפוסים לשימוש הבודק.
(for-each check-class-decl! class-decls) ; Check each class
(type-of exp (empty-tenv)))))) ; Check the main expression
;; Checking a method call involves finding the static type of the object,
;; and then looking up the method signature in the static class environment.
(define type-of-call
(lambda (obj-type method-name args tenv)
(let ((m-type (find-method-type (type->class-name obj-type) method-name)))
(cases proc-type m-type
(proc-type (arg-types result-type)
(if (= (length arg-types) (length args))
(begin
(for-each
(lambda (arg-type expected-type)
;; Check if each argument is a SUBTYPE of the expected parameter type!
(check-is-subtype! arg-type expected-type exp)חוק תת-הטיפוסים: אפשר להעביר טיפוס ספציפי (Subclass) למקום שדורש טיפוס כללי (Superclass).)
(types-of args tenv)
arg-types)
result-type) ; Return the method's result type
(report-wrong-number-of-arguments ...)))))))
הסורק (Scanner) הוא השלב הראשון בקומפיילר/מפרש. הוא מקבל מחרוזת של תווים ושובר אותה לאסימונים (Tokens) בעלי משמעות (מזהים, מספרים, מילות מפתח). ב-SLLGEN, אנו מגדירים חוקים פשוטים. היתרון העצום: הסורק זורק אוטומטית רווחים והערות (באמצעות skip).
מכיוון שהסורק זורק את הרווחים וההערות (Comments) עוד לפני שניגשים למנתח התחבירי, המנתח התחבירי (ה-Parser) לא צריך בכלל לדעת שהם היו קיימים בקוד המקור. זה מפשט משמעותית את כתיבת חוקי הדקדוק (Grammar).
;; The scanner breaks a string of characters into meaningful "tokens" (words).
;; We define a scanner specification in SLLGEN:
(define scanner-spec-a
'(
;; Ignore whitespace completely!
(white-sp (whitespace) skip)skip אומר ל-SLLGEN לזרוק את התוכן הזה ולא להעביר אותו לשלב הבא.
;; A comment starts with '%' and goes until the end of the line. Ignore it!
(comment ("%" (arbno (not #\newline))) skip)
;; An identifier starts with a letter, followed by letters or digits.
(identifier (letter (arbno (or letter digit))) symbol)הגדרת ביטוי רגולרי בסיסי המומר ל-symbol סכים (Scheme).
;; A number is a sequence of digits.
(number (digit (arbno digit)) number)
))
;; By ignoring whitespace and comments, the parser never even sees them!
המנתח התחבירי (Parser) לוקח את ה-Tokens שמייצר הסורק, ומרכיב מהם עץ תחביר מופשט (AST) לפי חוקי הדקדוק של השפה.
ב-SLLGEN, זה נעשה בצורה דקלרטיבית ואלגנטית: כותבים את כלל הדקדוק ובסופו נותנים שם ל-Variant (למשל assign-stat).
אין צורך לכתוב קוד בנייה ידני ל-AST! SLLGEN יבנה לבד את ה-define-datatype המתאים מאחורי הקלעים וייצור פונקציית Parse מלאה (scan&parse) שמחזירה AST מוכן לשימוש (הכולל statement או expression וכו').
;; The grammar defines the structure of the language, and tells SLLGEN how to build the Abstract Syntax Tree (AST) using define-datatype!
(define grammar-a1
'(
;; A Statement can be an assignment: Identifier = Expression
(statement (identifier "=" expression) assign-stat)assign-stat הוא השם של התבנית (Variant) בתוך ה-datatype statement.
;; Or a print statement: print Expression
(statement ("print" expression) print-stat)
;; An Expression can be an identifier
(expression (identifier) var-exp)
;; Or a number
(expression (number) num-exp)
))
;; We use SLLGEN to generate a parsing function for us!
;; It takes a string, runs the scanner, runs the parser, and outputs an AST node (e.g. an 'assign-stat').
(define scan&parse1
(sllgen:make-string-parser scanner-spec-a grammar-a1))פונקציית קסם שלוכדת את המחרוזת ומחזירה AST מוכן לעבודה!
כדי להתמודד עם קוד המכיל מספר לא ידוע של אלמנטים (למשל סדרה של פקודות או משתנים), מספקת SLLGEN כלים ייעודיים: arbno (Arbitrary Number) מציין אפס או יותר מופעים ברצף. separated-list מציין סדרה של איברים המופרדים על ידי תו ספציפי (למשל פסיק או נקודה-פסיק).
כאשר משתמשים ב-separated-list, המנתח התחבירי זורק את התווים המפרידים באופן אוטומטי. שני הכללים הללו מייצרים בסופו של דבר רשימה שטוחה רגילה (Scheme List) בתוך איבר ה-AST, מה שמפשט מאד את כתיבת פונקציות ה-eval-expression ו-value-of.
;; What if we want a block of multiple statements? We use 'arbno' (Arbitrary Number)!
(define grammar-a2
'(
;; A compound statement is '{' followed by zero or more statements, and '}'
(statement ("{" (arbno statement) "}") compound-stat)מייצר רשימה של statements ב-AST.
...
))
;; The AST for compound-stat will automatically receive a (list-of statement?) as its argument!
;; What if we want a list separated by commas? We use 'separated-list'!
(define grammar-a4
'(
;; A block is '{' followed by statements separated by ';', and '}'
(statement ("{" (separated-list statement ";") "}") block-stat)מייצר גם הוא רשימה של statements ב-AST, אך דורש ; בין הפקודות בקוד.
...
))
;; The AST for block-stat will ALSO receive a (list-of statement?) as its argument.
;; The parser automatically throws away the ';' separators!