המחלקה Object: ארכיטקטורת השורש, מנגנון ההדפסה, זהות מול שוויון וחוזה ה-Hash
איך מחלקה אחת ללא שום משתנה מופע מעניקה כ-400 התנהגויות לכל העצמים במערכת, איך פועל הצינור של printString ו-printOn:, מהו עצם המשערך את עצמו (Self-Evaluating), ואיך נשמר החוזה הקריטי בין = ל-hash.
מודל מנטלי ועיקרון מנחה: חוקת האזרחות של עולם העצמים ב-Smalltalk
תחשוב על המחלקה Object כמו על "תעודת האזרחות הבסיסית" שמקבל כל עצם שנולד ב-Squeak: עוד לפני שכתבת מתודה אחת במחלקה חדשה, היא כבר יודעת להציג את עצמה כמחרוזת, להשוות את עצמה לאחרים ולהשתלב במילונים!
איסור מוחלט להוסיף משתני מופע ל-Object! למופעים של Object אין משתני מופע כלל, משום שהוספת שדה תשנה מיידית את מבנה הזיכרון של כל העצמים במערכת ותהרוס מחלקות קריטיות כמו SmallInteger שה-VM מסתמכת על ייצוגן הפיזי המדויק.
הפרד תמיד בין התנהגות משותפת (Common Behavior) שאין צורך לדרוס (כמו printString, ~~, ~=) לבין התנהגות ברירת מחדל (Default Behavior) שנועדה לדריסה בתת-מחלקות (כמו printOn: ו-= יחד עם hash).
1. מעמדה הארכיטקטוני של המחלקה Object ומקומה של ProtoObject
מדוע כמעט כל מחלקה יורשת מ-Object, למה נוספה מעליה ProtoObject, ולמה אסור להוסיף לה שדות?
אנחנו נכנסים עכשיו אל לב ליבה של ספריית המחלקות בסביבת Squeak. אם בפרקים הקודמים למדנו איך לבנות מחלקות ואיך עובד מנגנון הירושה, עכשיו אני רוצה שנפתח את מכסה המנוע של השפה עצמה ונכיר מקרוב את עמודי התווך שעליהם בנוי כל עולם ה-Smalltalk.
בראש הפירמידה הזו ניצבת המחלקה Object. חשוב לנו מאוד להכיר את המתודות של Object, משום שרוב רובן המוחלט של המחלקות בסביבת Squeak יורשות ממנה — בין אם ישירות ובין אם בעקיפין. לכן, Object היא זו שמגדירה את התשתית ואת ההתנהגות הבסיסית והמשותפת של כלל העצמים במערכת.
אגב, אם תסתכל על הקצה העליון ביותר של עץ הירושה בסביבת העבודה Squeak, תגלה שמעל Object נוספה מחלקת-על רזה ומינימלית בשם ProtoObject (שממנה יורשת Object). תפקידה של ProtoObject הוא לרכז אך ורק את ההגדרות הגרעיניות ביותר של עצמים — כמו בדיקת זהות פרימיטיבית בזיכרון (==), בדיקת isNil ומתודת האתחול initialize — עבור עצמים מיוחדים ונדירים (כגון עטיפות Proxy) שאינם מעוניינים לרשת את כל המטען העשיר של Object.
🚫 החלטה הנדסית קריטית: איסור הוספת משתני מופע ל-Object!
למופעים של Object אין משתני מופע כלל, וחל איסור מוחלט להוסיף לה משתני מופע!
מדוע? מכיוון שכל מחלקה במערכת יורשת מ-Object, הוספת משתנה מופע ל-Object תשנה מיידית את המבנה הפיזי בזיכרון של כל העצמים והמחלקות במערכת! שינוי מבנה כזה הוא הרה אסון עבור מחלקות קריטיות (כגון SmallInteger), אשר המכונה הווירטואלית (VM) מסתמכת על המבנה והייצוג הפיזי המדויק שלהן לצורך ביצועים.
🧭 כ-400 מתודות ב-Object: שני סוגי התנהגויות
- 1. התנהגות משותפת (Common Behavior): מתודות גנריות המספקות שירות שלם ואין שום צורך או סיבה לדרוס אותן בתת-מחלקות (למשל
printString,~~,~=). - 2. התנהגות ברירת מחדל (Default Behavior): מתודות בסיסיות שנועדו לכך שתת-מחלקות ידרסו אותן (Overriding) ויספקו מימוש מותאם וייחודי עבורן (למשל
printOn:,=).
2. מתודות הדפסה (printString ו-printOn:) ועצמים המשערכים את עצמם
איך תבנית Template Method מפרידה בין יצירת הזרם לבין כתיבת התוכן, ולמה קיים רווח ב-Point עם y שלילי?
איך עצם ב-Smalltalk יודע להפוך את עצמו למחרוזת טקסט קריאה כשאנחנו מדפיסים אותו?
הדפסת עצם מתבצעת באמצעות שליחת ההודעה printString אל העצם. למעשה, כאשר אתה מסמן ביטוי בחלון העבודה (Workspace) ומבקש להדפיס אותו (באמצעות ctrl+p / print it), המערכת מעריכה תחילה את הביטוי כולו, ואז שולחת אוטומטית את ההודעה printString אל העצם המוחזר — ולכן אין צורך להקיף ביטויים בסוגריים בחלון העבודה רק כדי להדפיס אותם!
אבל כאן מסתתר עיצוב הנדסי מבריק: המתודה printString היא התנהגות משותפת שאנחנו לא דורסים. מאחורי הקלעים היא שולחת לעצם את ההודעה self printStringLimitedTo: 50000, אשר יוצרת זרם תווים / זרם פלט (WriteStream) ושולחת אל העצם את ההודעה printOn: aStream עם הזרם כארגומנט!
המתודה printOn: היא כבר התנהגות ברירת מחדל שאמורה לכתוב אל הזרם aStream סדרת תווים המייצגת את העצם. בוא נראה איך Object מממשת אותה כברירת מחדל:
"Append to the argument, aStream, a sequence of characters that identifies the receiver."
| title |
title := self class name.
aStream
nextPutAll: (title first isVowel ifTrue: ['an '] ifFalse: ['a ']);
nextPutAll: title
בוא נעקוב יחד אחרי הקוד של Object >> printOn::
המתודה שולפת את שם המחלקה של העצם ושומרת אותו במשתנה המקומי title (באמצעות self class name). לאחר מכן היא בודקת האם התו הראשון בשם המחלקה הוא אות תנועה באנגלית או עיצור (title first isVowel), וכותבת לזרם את התווית הדקדוקית המתאימה — 'an ' או 'a ' — ואחריה את שם המחלקה עצמו (למשל 'a Point' או 'an Object').
שים לב שפעולת הכתיבה לזרם מתבצעת באמצעות ההודעה nextPutAll:, אשר מקבלת אוסף כלשהו (כגון מחרוזת או מערך), עוברת על איברי האוסף ומשרשרת אותם בזה אחר זה אל תוך הזרם!
הגדרה מחדש (Overriding) של printOn: במחלקה Point:
תודות למנגנון הירושה, כל עצם מבין את printOn:, אך במחלקות המייצגות ערכים אנו דורסים אותה כדי להעניק לעצם ייצוג שימושי ומשמעותי יותר מאשר סתם 'a Point'. הנה המימוש במחלקה Point (בעלת משתני המופע x ו-y המייצגים מיקום במישור):
"The receiver prints on aStream in terms of infix notation."
x printOn: aStream.
aStream nextPut: $@.
(y isNumber and: [y negative]) ifTrue: [aStream space].
y printOn: aStream
🔍 למה נוספה שורת הרווח עבור y שלילי ב-Point?
המתודה שולחת printOn: aStream אל x, משרשרת את התו הבודד $@ באמצעות nextPut: $@, ואז מדפיסה את y (כך שעבור הנקודה 3@4 מודפסת המחרוזת '3@4').
אבל למה בודקים (y isNumber and: [y negative]) ifTrue: [aStream space]?
אם y הוא מספר שלילי (למשל -4), ללא הרווח היה מודפס הביטוי 3@-4, שב-Smalltalk מתפרש כאופרטור בינארי בלתי חוקי בשם @- ואינו ניתן לפענוח תחבירי תקין של נקודה! הוספת הרווח יוצרת את הפלט 3@ -4, המאפשר פענוח וקריאה מחדש של הטקסט כקוד Smalltalk תקני!
💡 עצמים המשערכים את עצמם (Self-Evaluating Objects)
עצם נקרא Self-Evaluating (או Evaluating Self) כאשר ערכו זהה לעצמו — כלומר תוצאת ה-printString שלו (כשמדפיסים אותו עם ctrl+p) מהווה קוד Smalltalk תקני שהרצתו (ב-do it או print it) מחזירה עצם בעל ערך זהה!
• דוגמאות: מספרים, תווים, סימבולים, נקודות (Point), תחומים (Interval), ומערכים סטטיים (#(...)).
• מערכים דינמיים ({...}): הם self-evaluating רק כאשר כל איבריהם הם self-evaluating! למשל, ביצוע {3@4. 4.5} מחזיר {3@4 . 4.5} (הוא כן self-evaluating), אך ביצוע {3@4. 4+5} מחזיר {3@4 . 9} (הוא אינו זהה לקוד המקור 4+5 שיצר אותו!).
3. זהות (==, ~~) מול שוויון (=, ~=) וחוזה ה-hash
ההבדל בין כתובת זיכרון לתוכן מבני, תפקיד ההודעה species ב-Point, וחוק הברזל של דריסת hash
אחת האבחנות החשובות ביותר בתכנות מונחה עצמים היא ההבדל בין השאלה "האם שני מצביעים מתייחסים בדיוק לאותו עצם פיזי בזיכרון?" (זהות – Identity) לבין השאלה "האם שני עצמים מייצגים את אותו ערך ותוכן?" (שוויון – Equality):
📍 בדיקת זהות פיזית (== ו-~~)
==(זהות): ממומשת במחלקתProtoObjectכפרימיטיב המשווה ישירות כתובות זיכרון ומצביעים (האם שני הביטויים מתייחסים לאותו עצם עצמו).~~(אי-זהות): ממומשת כהתנהגות משותפת:anObject == self ifTrue: [^false] ifFalse: [^true]
⚖️ בדיקת שוויון ערכי (= ו-~=)
=ב-Object(ברירת מחדל): מוגדרת כבדיקת זהות:^self == anObject. מחלקות המייצגות ערכים דורסות אותה לשוויון מבני.~=(אי-שוויון): ממומשת ב-Objectכ-^(self = anObject) not— לכן דריסה של=מעדכנת אוטומטית גם את תוצאת~=ואין סיבה לדרוס את~=!
בוא נראה כיצד המחלקה Point דורסת את מתודת השוויון = כך שהנקודה (3@4) תהיה שווה לנקודה (3@4) גם כאשר מדובר בשני עצמים נפרדים בזיכרון:
self species = aPoint species
ifTrue: [^x = aPoint x and: [y = aPoint y]]
ifFalse: [^false]
המתודה מוודאת תחילה ששני העצמים שייכים לאותו ה-species (אותה משפחת טיפוסים לוגית), ורק אם כן — היא בודקת שוויון ערכים בין קואורדינטות ה-x ובין קואורדינטות ה-y.
🔑 חוק בל יעבור ב-OOP: הקשר החיוני בין = לבין מתודת hash!
ההודעה hash המוגדרת ב-Object מחזירה מספר שלם המאפיין ומייצג את העצם לצורך אחסון וחיפוש מהיר כמפתחות במילונים (Dictionary) ובקבוצות (Set).
כלל הברזל הארכיטקטוני קובע: אם דרסת את מתודת השוויון =, חובה עליך לדרוס בהתאמה גם את מתודת ה-hash, כך ששני עצמים שנקבע כי הם שווים (a = b) יחזירו תמיד את אותו ערך hash מדויק (a hash = b hash)! אם תפר את החוזה הזה, מילונים וקבוצות יחפשו את העצם בתא הלא נכון ולא יצליחו למצוא אותו!
מ-ProtoObject ו-Object דרך זרם printOn: ועד לחוזה השוויון (=) וה-hash
מיני-סימולציה 11.1 א': סייר מנגנון ההדפסה (printOn:) ועצמים המשערכים את עצמם
מעבדת זהות (==), שוויון (=) וחוזה ה-hash במילונים
במעבדה זו ניצור שני מופעים נפרדים של נקודה בזיכרון: p1 := 3 @ 4 (בכתובת זיכרון 0x10A0) ו-p2 := 3 @ 4 (בכתובת זיכרון 0x20B4). בחר אילו מתודות לדרוס ובדוק מה מחזירות ==, = וחיפוש במילון (Dictionary):
⚙️ בחר מצב מימוש במחלקת העצם שלנו:
🔬 תוצאות ההשוואה וחיפוש המפתח במילון:
הגשר הנרטיבי: הבנו איך עצם מדפיס ומשווה את עצמו – אבל איך הוא בודק לאיזו מחלקה הוא שייך, איך משכפלים אותו בזיכרון ואיך מטפלים בשגיאות?
הכרנו את החצי הראשון של Object: מנגנון ההדפסה (printString ו-printOn:) והחוזה שבין זהות (==), שוויון (=) ו-hash.
בלשונית הבאה נשלים את התמונה של המחלקה Object: נחקור את מתודות הרפלקציה ושייכות למחלקה (class, isMemberOf:, isKindOf:, respondsTo:), נבין מדוע משתני מופע כמצביעים דורשים זהירות בשכפול עצמים (shallowCopy, copyTwoLevel, deepCopy ו-copy + postCopy), ונכיר את כלי ניפוי השגיאות והאתחול (halt, assert:, subclassResponsibility, shouldNotImplement ו-initialize)!
המחלקה Object (המשך): שייכות למחלקה, העתקה ושכפול עצמים, ניפוי שגיאות ואתחול
תשאול טיפוס העצם בזמן ריצה (ולמה עדיף להימנע ממנו לטובת קישור דינמי), 4 גישות לשכפול עצמים בזיכרון מצביעים, פרוטוקול error handling ותכנון לפי חוזה, ואתחול תקני עם super initialize.
מודל מנטלי ועיקרון מנחה: אל תשאל עצם "מי אתה?" – שלח לו הודעה! וכשאתה משכפל – היזהר ממצביעים משותפים!
שכפול רדוד (shallowCopy) הוא כמו לצלם דף קשר עם מספרי טלפון של דירות: קיבלת דף נייר חדש, אבל שני הדפים מצביעים בדיוק לאותן דירות! אם מישהו יצבע קיר בדירה דרך הדף הראשון, גם הדף השני יראה קיר צבוע.
הימנע מבדיקות טיפוס מפורשות (isKindOf:, respondsTo:, isArray) שמפרות את הכימוס והפולימורפיזם — סמוך על הקישור הדינמי! ובעת שכפול, דרוס את postCopy (המופעלת ע"י copy) כדי לשכפל נקודתית שדות פנימיים.
מתודת new ב-Squeak שולחת אוטומטית initialize למופע החדש; כשאתה דורס את initialize בתת-מחלקה, פתח תמיד בקריאה ל-super initialize!
1. רפלקציה ושייכות למחלקה (Class Membership) בזמן ריצה
המתודות class, isMemberOf:, isKindOf: ו-respondsTo: — ומדוע שימוש יתר בהן נחשב ל-Anti-Pattern?
בזכות העובדה שב-Smalltalk גם המחלקות עצמן הן אובייקטים חיים, המחלקה Object מעניקה לנו סט של מתודות שמאפשרות לתשאל כל עצם בזמן ריצה לגבי המחלקה שלו והיכולות שלו:
מחזירה את עצם המחלקה הישירה של המקבל.
• לדוגמה: הביטוי 4 class מחזיר את עצם המחלקה SmallInteger, והביטוי (3@4) class מחזיר את עצם המחלקה Point.
מקבלת מחלקה כפרמטר ומחזירה true אך ורק אם המקבל הוא מופע מדויק וישיר של aClass!
• לדוגמה: 4 isMemberOf: SmallInteger מחזיר true, ואילו 4 isMemberOf: Number מחזיר false (למרות ש-SmallInteger היא תת-מחלקה של Number!).
מקבלת מחלקה כפרמטר ומחזירה true אם המקבל הוא מופע של aClass או של כל תת-מחלקה שלה במעלה עץ הירושה.
• לדוגמה: 4 isKindOf: SmallInteger, 4 isKindOf: Number ו-4 isKindOf: Object מחזירים שלושתם true, אך 4 isKindOf: Point מחזיר false.
מקבלת סלקטור (שם של הודעה) כסימבול (עם קידומת סולמית #) ומחזירה true אם העצם כולל מתודה מתאימה ומבין את ההודעה.
• לדוגמה: 4 respondsTo: #+ ו-'hello' respondsTo: #size מחזירים שניהם true.
isKindOf:, isMemberOf:) או אם הוא מבין הודעה כלשהי (respondsTo:) בקוד הרגיל שלנו! בדיקות כאלו פוגעות בגמישות ובפולימורפיזם; במקום זאת, עדיף לשלוח את ההודעה ישירות ולסמוך על מנגנון הקישור הדינמי שיאתר את המתודה המתאימה במעלה היררכיית הירושה.
2. העתקה ושכפול עצמים (shallowCopy, copyTwoLevel, deepCopy, copy + postCopy)
מדוע משתני מופע כמצביעים יוצרים שיתוף זיכרון מסוכן בשכפול רדוד, ואיך דריסת postCopy פותרת זאת באלגנטיות?
כשאנחנו משכפלים עצם, אנחנו רוצים בדרך כלל לקבל עצם חדש ועצמאי שהמבנה וההתנהגות שלו זהים לאלה של העצם המקורי, מבלי שהעותק החדש והעצם המקורי ישתפו ביניהם מידע פנימי שעלול להשתנות (למשל, אם נשכפל נקודה Point, לא נרצה ששינוי ב-x של העותק ישנה גם את הנקודה המקורית).
אלא שבשפת Smalltalk כל משתני המופע ממומשים כמצביעים לזיכרון (Pointers)! לכן המחלקה Object מציעה מספר רמות שכפול:
1. שכפול רדוד – shallowCopy (מבוסס על basicSize ו-basicAt:)
שיתוף מצביעים פנימיים!
המתודה shallowCopy (הממומשת על בסיס המתודות הפרימיטיביות basicSize ו-basicAt:) משכפלת את עצם השורש בלבד; כל משתני המופע הפנימיים שלו מועתקים כמצביעים זהותיים ומשותפים בין העותק למקור!
בוא נראה דוגמה מוחשית עם מערך דו-ממדי:
a2 := a1 shallowCopy.
(a1 at: 1) at: 1 put: 'z'.
אם נבדוק כעת את a2, נגלה שגם בו האיבר הראשון הפך ל-'z' (כלומר {{'z'. 'b'}. {'c'. 'd'}}), משום שתתי-המערכים ברמה הראשונה משותפים זהותית בין a1 ל-a2!
משכפלת את השורש ומשכפלת רדודה כל אחד מהאיברים ברמה הראשונה (רמה אחת נוספת פנימה). בדוגמה הדו-ממדית שלנו שינוי ב-a1 לא ישפיע על a2; אך אם נגדיר מערך עם שלוש רמות קינון (a1 := {{{'a'}. {'b'}}. {{'c'}. {'d'}}}), השכפול של הרמה השלישית יישאר משותף!
מבצעת שכפול עמוק ורקורסיבי מלא של כל גרף/עץ העצמים בכל רמות העומק ככל הנדרש, כך שנוצר עותק עצמאי לחלוטין.
המתודה copy ב-Object ממומשת כך:
copy ^ self shallowCopy postCopy
ברירת המחדל של postCopy מחזירה את self. תת-מחלקות נדרשות לדרוס אך ורק את postCopy ולשכפל בה ספציפית שדות שאסור שיהיו משותפים!
3. ניפוי שגיאות ומצבים חריגים (error handling), בדיקות ואתחול (initialize)
המתודות halt, assert:, deprecated:, doesNotUnderstand:, error:, subclassResponsibility, shouldNotImplement ו-initialize
בפרוטוקול error handling במחלקה Object מוגדר אוסף עשיר של מתודות תשתית לניפוי שגיאות, לתכנון לפי חוזה ולטיפול במצבים חריגים:
משמשת לניפוי שגיאות: שליחת ההודעה (למשל self halt מתוך מתודה) משהה את ביצוע התוכנית ומקפיצה מיידית את חלון ה-Debugger על מחסנית הקריאות הפעילה לבחינת ערכי המשתנים בזמן אמת.
תומכת במודל Design by Contract (תכנון לפי חוזה) להגדרת תנאי כניסה ויציאה למתודות. מקבלת בלוק או ערך בוליאני, ואם התנאי אינו מתקיים (false) – מפסיקה את הביצוע, מעוררת חריגת AssertionFailure ופותחת את ה-Debugger.
מתריעה למתכנת שהמתודה מיושנת ומציעה חלופה קיימת. למשל, המתודה הישנה doIfNotNil: ב-Object הוחלפה ב-ifNotNilDo: ומשוכתבת עם הודעת deprecated: לפני קריאה למתודה החדשה.
מתודת הגיבוי של הקישור הדינמי! כאשר חיפוש מתודה נכשל במעלה עץ הירושה, ההודעה doesNotUnderstand: נשלחת לעצם כהזדמנות אחרונה לטיפול בשגיאה (או לבניית Proxy דינמי) לפני קריסה ב-Debugger.
מעוררת באופן יזום מצב חריג (Exception) עם הודעת שגיאה מותאמת.
subclassResponsibility: מגדירה מתודות מופשטות (Abstract Methods) ומעוררת חריגה המציינת שעל תת-המחלקה לממש את המתודה.
shouldNotImplement: משמשת כשמתודה שנורשה ממחלקת-על אינה מתאימה סמנטית לתת-המחלקה ("ביטול" מתודה מורשת) ומעוררת שגיאה שאין להשתמש בה בעצם זה.
🔍 בדיקות (Testing): isNil ו-notNil כיוצאות דופן
אף ש-Object כוללת מתודות בדיקה כגון isArray, isBoolean, isBlock, השימוש בהן אינו מומלץ משום שהוא מפר את עקרון הכימוס (Encapsulation) והפולימורפיזם (עדיף לשלוח הודעה לעצם ולתת לו לבחור את התגובה המתאימה).
עם זאת, המתודה isNil (של ProtoObject) והמתודה notNil (של Object) הן יוצאות דופן ושימושיות מאוד לבדיקה האם משתנה או עצם מאותחלים!
🚀 אתחול עצמים: initialize ו-super initialize
המתודה initialize (המוגדרת ב-ProtoObject) אחראית לאתחול עצמים. בסביבת Squeak, מימוש ברירת המחדל של new שולח אוטומטית למופע החדש את ההודעה initialize מיד לאחר יצירתו!
תת-מחלקות הדורסות את initialize כדי לאתחל את משתני המופע שלהן חייבות לפתוח תמיד בקריאה ל-super initialize כדי שמחלקות-העל יאתחלו כהלכה את משתני המופע שהוגדרו בהן.
השוואת מצביעי הזיכרון: shallowCopy מול copyTwoLevel, deepCopy ו-copy + postCopy
מיני-סימולציה 11.1 ב': מעבדת שייכות למחלקה (isMemberOf: מול isKindOf:) וטיפול בשגיאות
מעבדת השכפול בזיכרון ה-RAM: shallowCopy, copyTwoLevel ו-deepCopy
בחר את מבנה המערך המקורי a1 (דו-ממדי או תלת-ממדי), בחר את מתודת השכפול ליצירת a2, ואז לחץ על "שנה את האיבר הפנימי ב-a1 ל-'z'" וראה בזמן אמת האם הזיכרון של a2 נפגע!
a1:
a2 := a1 ...:
🔬 תמונת הזיכרון בזמן אמת (a1 מול a2):
הגשר הנרטיבי: סיימנו את מחלקת השורש Object – איך בנויה היררכיית המספרים שיורשת ממנה?
אחרי שהכרנו לעומק את המחלקה Object ואת כל השירותים שהיא מעניקה לכל עצם במערכת, אנחנו עוברים אל משפחת המחלקות הראשונה שיורשת ממנה: מערכת המספרים (Numbers) והמחלקה המופשטת Magnitude.
איך ייתכן שב-Smalltalk אפילו המספר 4 הוא עצם מלא? איך הגדרת שתי מתודות בלבד ב-Magnitude (< ו-=) מפעילה אוטומטית את כל אופרטורי ההשוואה? ואיך Smalltalk מחשבת שברים מדויקים (Fraction) ומספרים שלמים ענקיים ללא גלישה (Overflow)? בוא נעבור ללשונית 11.2!
מערכת המספרים (Numbers): מהמחלקה Magnitude ועד לשברים מדויקים ושלמים ללא גבולות
כיצד מספרים ב-Smalltalk פועלים כעצמים מלאים, תבנית ה-Template Method ב-Magnitude, חילוק שלמים (//) ושארית (\\) ב-Number, המרה אוטומטית בין SmallInteger ל-LargeInteger, ודיוק מתמטי ב-Fraction.
מודל מנטלי ועיקרון מנחה: מספרים אינם "ביטים גולמיים" – הם עצמים חכמים שלעולם לא מאבדים דיוק בשקט!
המחלקה המופשטת Magnitude היא כמו "מנוע השוואה אוניברסלי": ברגע שתת-מחלקה מספקת לה רק שתי אבני בניין (< ו-=), היא מרכיבה מהן אוטומטית את כל שאר פעולות הסדר (>, <=, >=, min:, max:, between:and:)!
בניגוד לשפות כמו C או Java שבהן חריגה מגודל שלם גורמת לגלישה (Overflow) וחילוק שלמים מאבד את השבר, ב-Smalltalk גלישה מ-SmallInteger מומרת אוטומטית ל-LargePositiveInteger / LargeNegativeInteger, וחילוק / יוצר שבר מדויק Fraction!
האופרטור // מבצע חילוק שלמים עם עיגול המנה כלפי מטה, ועליו ממומש האופרטור \\ (שארית החלוקה / מודולו) בנוסחה האלגנטית: ^ self - (self // aNumber * aNumber).
1. היררכיית המספרים ב-Smalltalk והמחלקה המופשטת Magnitude
למה מספרים הם עצמים מלאים תחת הקטגוריה Kernel-Numbers, ואיך עובדת תבנית ה-Template Method ב-Magnitude?
בשפות תכנות כמו Java או C, התרגלנו להפרדה חדה: יש "אובייקטים", ויש "טיפוסים פרימיטיביים" (כמו int או float) שאינם אובייקטים.
ב-Smalltalk התמונה טהורה ואחידה לחלוטין: המספרים הם עצמים מלאים לכל דבר ועניין, והם מאורגנים תחת הקטגוריה Kernel-Numbers. בוא נתבונן במבנה עץ הירושה שלהם:
בוא נתחיל מהקומה הראשונה מתחת ל-Object: המחלקה המופשטת Magnitude.
מחלקה זו מאגדת את כל הישויות במערכת הניתנות להשוואה ולסדר (לא רק מספרים, אלא גם תווים Character, תאריכים וזמנים!).
איך היא עובדת? ב-Magnitude מוגדרות שתי מתודות מופשטות בלבד (באמצעות subclassResponsibility): < ו-=.
וכל שאר מתודות ההשוואה — >, <=, >=, min:, max:, ו-between:and: — ממומשות ב-Magnitude באופן מלא על גבי < ו-= (זוהי דוגמה מופתית לתבנית העיצוב Template Method)!
2. המחלקה Number, חילוק שלמים (//, \\), SmallInteger ו-Fraction
המתודות המופשטות ב-Number, המימוש האלגנטי של שארית החלוקה \\, המרה שקופה בשלמים ושברים מצומצמים
מתחת ל-Magnitude נמצאת המחלקה המופשטת Number, המוסיפה את כל מנגנוני החישוב האריתמטיים:
➕ אופרטורים ופונקציות ב-Number
- אופרטורים אריתמטיים בסיסיים:
+,-,*,/מוגדרים ב-Numberכמתודות מופשטות הנשענות עלsubclassResponsibility. - הודעת
@: יוצרת מופע של נקודה (Point) — למשל10 @ 20. - פונקציות מתמטיות מובנות:
sqrt,squared,raisedTo:,sin,log(וב-Floatקבועים כגוןpiו-e; וב-Integerפעולות כגוןfactorial,gcd:,isPrime,atRandom). - בדיקות והמרות:
even,odd,positive,negative, וכןasFloat,asInteger.
➗ מנגנון החילוק והשארית בשלמים (// ו-\\)
בנוסף לחילוק הרגיל /, המחלקה Number מגדירה שני אופרטורים מיוחדים לחלוקת שלמים:
• // (חילוק שלמים): מבצע חילוק שלמים ומעגל את המנה תמיד כלפי מטה (לכיוון אינסוף שלילי).
• \\ (שארית החלוקה / מודולו): מחזיר את שארית החלוקה. תראה כמה אלגנטי המימוש שלו ב-Number שנשען על //:
^ self - (self // aNumber * aNumber)
🚀 SmallInteger והמרת טיפוסים אוטומטית ושקופה
המחלקה SmallInteger מייצגת מספרים שלמים בטווח קבוע המנוהל ישירות במכונה הווירטואלית (VM) ביעילות גבוהה (הוא נשמר באופן ישיר בזיכרון במקום להיות מצביע לעצם).
הקסם של Smalltalk: המתכנת לעולם לא צריך לבחור בין SmallInteger ל-LargeInteger! כאשר מתרחשת חריגה מגבולות הטווח (Overflow), המערכת מזהה זאת וממירה את המספר אוטומטית ושקופה ל-LargePositiveInteger או ל-LargeNegativeInteger ללא שום קטיעה וללא אובדן מידע!
🍰 המחלקה Fraction: שברים פשוטים ללא אובדן דיוק!
כאשר אנחנו מחלקים שני מספרים שלמים באמצעות / ותוצאת החילוק אינה שלמה, Smalltalk אינה מבצעת עיגול לאובדן מידע (אינה חותכת לשלם ואינה הופכת לנקודה צפה מקורבת), אלא יוצרת מופע של המחלקה Fraction!
עצם Fraction שומר על מונה (numerator) ומכנה (denominator) מדויקים, והוא תמיד נשמר בצורה המצומצמת ביותר — לדוגמה, הביטוי 6 / 8 יחזיר אוטומטית את השבר המצומצם (3/4)!
תבנית Template Method ב-Magnitude, חילוק // ו-\\, והמרה אוטומטית ב-Integer וב-Fraction
מיני-סימולציה 11.2: סייר היררכיית המספרים ומתודות Magnitude / Number
מעבדת החישוב ב-Smalltalk: / (שבר מדויק), // ו-\\, והמרה ל-LargePositiveInteger
בחר שני מספרים שלמים או הרץ תרחישים מוכנים כדי לראות כיצד / יוצר שבר מצומצם (Fraction), כיצד // מעגל כלפי מטה וכיצד \\ מחושב לפי הנוסחה self - (self // aNumber * aNumber):
🔬 תוצאות ההערכה ב-Squeak:
הגשר הנרטיבי: ראינו שהמחלקה Magnitude היא בסיס למספרים – אבל מי עוד יורש מ-Magnitude? תווי הטקסט (Character)!
גילינו שכל דבר שניתן להשוואה ולסידור יורש מ-Magnitude. בדיוק בגלל זה גם המחלקה Character (תווים בודדים כמו $a או Character space) יורשת ישירות מ-Magnitude!
ומה קורה כשאנחנו מאגדים רצף סדור של תווים יחד? אנחנו מקבלים מחרוזות (ByteString) וסימבולים ייחודיים (Symbol). בוא נעבור ללשונית הבאה (11.3 ו-11.4) ונבין את ההבדל הקריטי ביניהם בזיכרון!
תווים (Character), מחרוזות (ByteString) וסימבולים (Symbol): מוטביליות מול ייחודיות בזיכרון
יצירת תווים ליטרליים ולא-דפיסים, טבלת CharacterTable, היררכיית האוספים המובילה אל String, וההבדל הארכיטקטוני העמוק בין מחרוזת רגילה (ByteString) לבין סימבול ייחודי ובלתי ניתן לשינוי (Symbol).
מודל מנטלי ועיקרון מנחה: מחרוזת היא דף טיוטה לעריכה; סימבול הוא תעודת זהות חד-פעמית בזיכרון!
שתי מחרוזות רגילות 'hello' הן כמו שני פתקים נפרדים שעל שניהם כתבת בעיפרון את המילה hello (אפשר למחוק ולשנות אות באחד מהם בלי להשפיע על השני). לעומת זאת, הסימבול #hello הוא מונח רשום בטבלה מרכזית אחת בזיכרון שכולם מצביעים אליו ואסור לערוך אותו!
עבור שתי מחרוזות ByteString בעלות תוכן זהה: s1 = s2 יחזיר true אך s1 == s2 יחזיר false, והן ניתנות לשינוי (at:put:). עבור שני סימבולים Symbol: #hello == #hello יחזיר תמיד true, והם בלתי ניתנים לשינוי (Immutable)!
בזכות היותו של כל Symbol עצם ייחודי במערכת (Interned / Singleton פנימי), השוואת סימבולים מהירה להפליא — מספיק להשוות מצביעי זיכרון (==) במקום להשוות אות-אחר-אות!
1. סעיף 11.3: תווים (Character) – יורשים ישירים של Magnitude
תחביר $, תווים לא דפיסים, Character value:, בדיקות תווים ומשתנה המחלקה CharacterTable
בוא נכיר תחילה את אבן הבניין הבודדת של כל טקסט: המחלקה Character.
המחלקה Character מוגדרת תחת הקטגוריה Collections-Strings, אך מבחינת עץ הירושה היא יורשת ישירות מהמחלקה המופשטת Magnitude! המשמעות היא שניתן להשוות בין תווים ישירות באמצעות <, >, between:and: וכל שאר מתודות הסדר של Magnitude (למשל $a < $z מחזיר true).
- תווים ליטרליים: נרשמים באמצעות הסימן
$— למשל$a,$Z,$7. - תווים מיוחדים שאינם דפיסים: נוצרים באמצעות מתודות מחלקה ב-
Character:Character space, Character cr, Character tab, Character backspace, Character escape, Character enter
Character value: anInteger: מתודת מחלקה המקבלת מספר שלם המייצג ערך קוד ASCII או Unicode ומחזירה את מופע התו המתאים (למשלCharacter value: 65מחזיר$A).- משתנה המחלקה
CharacterTable: מאחסן טבלת מופעים מוכנים מראש של תווי ה-ASCII.
- מתודות בדיקה:
isDigit,isLetter,isAlphaNumeric,isLowercase,isUppercase,isVowel. - המרה למחרוזת: המתודה
asStringממירה את התו הבודד למחרוזת באורך 1 (למשל$a asStringמחזיר'a').
2. סעיף 11.4: מחרוזות (ByteString) וסימבולים (Symbol)
היררכיית האוספים של String, ההבדל המהותי בין ByteString ל-Symbol, וכל מתודות הטיפול במחרוזות
בשונה מתו בודד, מחרוזת (String) היא אוסף סדור של תווים הנגישים לפי אינדקס. לכן המחלקה String אינה יורשת מ-Magnitude כענף נפרד של מספרים, אלא משתלבת בתוך עץ האוספים (Collection). בוא נראה את מקומה המדויק בהיררכיה:
המחלקה String עצמה מוגדרת כמחלקה מופשטת. בפועל, כשאנחנו עובדים עם טקסט ב-Smalltalk, אנחנו עובדים עם שתי תת-המחלקות הקונקרטיות שלה — וההבדל ביניהן הוא אחד הנושאים החשובים ביותר בשפה:
📝 מחרוזת רגילה – ByteString
'hello'
- תחביר: נרשמת בין גרשיים בודדים:
'hello'. - אינה ייחודית בזיכרון: ייתכנו שתי מחרוזות שונות בזיכרון בעלות תוכן זהה לחלוטין. לכן עבור שתי מחרוזות כאלו הביטוי
s1 = s2יחזירtrue(שוויון תוכן), אך השוואת הזהותs1 == s2תחזירfalse(שני אובייקטים נפרדים בזיכרון). - ניתנת לשינוי (Mutable): ניתן לערוך ולעדכן את התווים שבתוכה במקום באמצעות
s1 at: 1 put: $H.
💎 סימבול ייחודי – Symbol
#hello
- תחביר: נפתח בסימן סולמית:
#hello. - עצם ייחודי במערכת (Interned Object / Singleton פנימי): המערכת מבטיחה שלעולם לא יתקיימו שני סימבולים עם אותו תוכן כאובייקטים נפרדים. שתי מחרוזות סימבול בעלות אותם תווים מצביעות תמיד על אותו העצם בדיוק בזיכרון (
s1 == s2מחזיר תמידtrue!). - בלתי ניתן לשינוי (Immutable): לא ניתן לשנות את תוכנו של סימבול לאחר יצירתו.
- יתרון ביצועים אדיר: השוואת סימבולים מהירה להפליא, כי מספיק להשוות את מצביעי הזיכרון שלהם (זהות פיזית
==) במקום להשוות אות-אחר-אות!
🧰 מתודות מרכזיות במחלקה String (ומורשות מ-Collection):
size, at:, at:put:, indexOf:, findString:, replaceFrom:to:with:, replaceAll:with:
, (שרשור מחרוזות), copyFrom:to:, copyUpTo:, copyReplaceAll:with:
asLowercase, asUppercase, asNumber, asDate, asArray, reverse
includes:, isAllDigits, do:, collect:, detect:
השוואת ייצוג הזיכרון: ByteString (אובייקטים נפרדים וניתנים לעריכה) מול Symbol (עצם יחיד ו-Immutable)
מיני-סימולציה 11.3: חוקר המחלקה Character, תווים לא-דפיסים וטבלת ה-ASCII
מעבדת הזיכרון: ByteString מול Symbol ופעולות על מחרוזות
נסה להריץ פעולות השוואה (= מול ==), ניסיון שינוי תו במקום (at: 1 put: $H) ומתודות של המחלקה String על מחרוזת רגילה ('hello') לעומת סימבול (#hello):
🔬 ניתוח הזיכרון והתוצאה:
הגשר הנרטיבי: כל הבדיקות שעשינו (isVowel, =, ==, includes:) מחזירות true או false – איך ממומשת המערכת הבוליאנית ב-Smalltalk ללא שום משפט if תחבירי?
שים לב שבכל הסעיפים הקודמים השתמשנו בביטויים כמו ifTrue: ['an '] ifFalse: ['a '] או (y isNumber and: [y negative]) ifTrue: [...].
בשפות רגילות, if הוא מילת מפתח של המהדר. אבל ב-Smalltalk אין מבני בקרה פרימיטיביים בשפה! הכל עובד באמצעות שליחת הודעות ופולימורפיזם טהור בין המחלקות Boolean, True ו-False. בוא נעבור ללשונית 11.5 ונחזה באחד המימושים האלגנטיים ביותר במדעי המחשב!
ערכים בוליאניים (Boolean): מופעים יחידים (Singletons), פולימורפיזם טהור והערכה מקוצרת
איך שפת Smalltalk מממשת משפטי תנאי ופעולות לוגיות ללא שום מילת מפתח if או switch, באמצעות המחלקה המופשטת Boolean, תת-המחלקות True ו-False, והבחנה בין אופרטורים עצלים (and:, or:) למלאים (&, |).
מודל מנטלי ועיקרון מנחה: תנאי ב-Smalltalk אינו "פקודת פיצול" – הוא שליחת הודעה לעצם שיודע אם הוא אמת או שקר!
במקום שוטר תנועה חיצוני (משפט if) שבודק שלט ומחליט לאן לפנות, ב-Smalltalk יש שני פקידים חכמים בלבד: הפקיד true (שתמיד מפעיל את המעטפה הראשונה ומתעלם מהשנייה) והפקיד false (שתמיד מפעיל את המעטפה השנייה ומתעלם מהראשונה)!
המחלקה המופשטת Boolean יורשת מ-Object, ולה שתי תת-מחלקות קונקרטיות בלבד: True ו-False. לכל אחת מהן קיים מופע יחיד בדיוק במערכת (Singleton) — המשתנים-פסאודו true ו-false!
הבחן תמיד בין אופרטורים לוגיים עצלים / הערכה מקוצרת (and:, or:) המקבלים בלוק קוד ומעריכים אותו רק בעת הצורך, לבין אופרטורים מלאים / Eager (&, |) שמעריכים תמיד את שני הצדדים מראש!
1. ארכיטקטורת Boolean, מופעים יחידים (Singletons) ותנאים ללא IF
איך פולימורפיזם טהור בין המחלקות True ו-False מייתר לחלוטין את הצורך במבני בקרה תחביריים?
הגענו אל אחת הפנינים היפות ביותר של שפת Smalltalk. בשפות כמו C, Java או Python, מילות המפתח if, else ו-switch הן חלק קשיח מתחביר השפה ומהמהדר.
ב-Smalltalk אין מבני בקרה פרימיטיביים בשפה כלל! הכל — כולל תנאים והסתעפויות — מתנהל דרך העברת הודעות ופולימורפיזם טהור.
איך הקסם הזה בנוי?
תחת המחלקה Object מוגדרת המחלקה המופשטת Boolean. למחלקה זו יש שתי תת-מחלקות קונקרטיות בלבד:
True: מייצגת אמת לוגית. קיים לה מופע יחיד בדיוק במערכת (Singleton), המיוצג על ידי המשתנה-פסאודוtrue.False: מייצגת שקר לוגי. קיים לה מופע יחיד בדיוק במערכת (Singleton), המיוצג על ידי המשתנה-פסאודוfalse.
עכשיו תראה איך פועלות מתודות התנאי (ifTrue:ifFalse:, ifTrue:, ifFalse:, ifFalse:ifTrue:) שמקבלות בלוקים של קוד כארגומנטים:
כאשר אנחנו כותבים (x > 0) ifTrue: [block1] ifFalse: [block2], הביטוי x > 0 מוערך ומחזיר או את העצם true (מופע של True) או את העצם false (מופע של False). ואז נשלחת אליו ההודעה ifTrue:ifFalse:!
"מריצה את הבלוק הראשון ומתעלמת מהשני!"
^ trueBlock value
True >> not
^ false
"מריצה את הבלוק השני ומתעלמת מהראשון!"
^ falseBlock value
False >> not
^ true
תעצור לרגע להתפעל מזה: במחלקה True, המתודה ifTrue:ifFalse: פשוט שולחת את ההודעה value לבלוק הראשון ומתעלמת לחלוטין מהבלוק השני — בלי שום בדיקת תנאי פנימית! ובמחלקה False, אותה מתודה בדיוק פשוט שולחת value לבלוק השני ומתעלמת מהראשון! באותו אופן בדיוק, המתודה not (שלילה) ממומשת ב-True על ידי החזרת הקבוע false, וב-False על ידי החזרת הקבוע true. זהו פולימורפיזם טהור בהתגלמותו!
2. אופרטורים לוגיים: הערכה מקוצרת (and:, or:) מול הערכה מלאה (&, |)
למה false & (1/0 = 0) מתרסק בשגיאת חלוקה באפס, בעוד ש-false and: [1/0 = 0] עובר בשלום?
בדיוק מאותו עיקרון של שליחת הודעות ובלוקים נובע גם ההבדל הקריטי בין שתי משפחות של אופרטורים לוגיים ב-Smalltalk:
⚡ 1. אופרטורים עצלים – הערכה מקוצרת (Short-Circuit: and: ו-or:)
ההודעות and: ו-or: מקבלות בלוק קוד ([...]) כארגומנט, ומעריכות אותו אך ורק במקרה הצורך!
• ב-and:: נשלח value לבלוק אך ורק אם המקבל הוא true. אם המקבל הוא false, המתודה ב-False יודעת מיד שהתוצאה היא שקר ומחזירה false מבלי להריץ את הבלוק!
• ב-or:: אם המקבל הוא true מוחזר מיד true ללא הרצת הבלוק; רק אם המקבל הוא false נשלח value לבלוק.
היתרון: משפר ביצועים ומונע שגיאות ריצה (למשל הגנה מפני חלוקה באפס או פנייה ל-nil בתנאי השני).
⚙️ 2. אופרטורים מלאים – הערכה להוטה (Eager: & ו-|)
האופרטורים הבינאריים & ו-| אינם מקבלים בלוק, אלא מקבלים ערך בוליאני מחושב מראש כארגומנט רגיל!
לפי חוקי השפה הרגילים של Smalltalk, כל ארגומנט של הודעה חייב להיות מחושב במלואו לפני שההודעה עצמה נשלחת אל המקבל.
התוצאה: בביטוי עם & או |, שני הצדדים יוערכו תמיד במלואם — גם אם הצד השמאלי כבר קבע את התוצאה!
פולימורפיזם בין המופעים היחידים true ו-false, והשוואת and: (עצל) מול & (מלא)
מיני-סימולציה 11.5: מנתב הפולימורפיזם הבוליאני (איך עובד תנאי ללא if)
מעבדת ה-Short-Circuit החיה: and: / or: (עם בלוק) מול & / | (הערכה מלאה)
הרץ כל אחד מארבעת הביטויים הבאים וראה צעד-אחר-צעד את סדר ההערכה של המכונה הווירטואלית ב-Smalltalk — מתי הבלוק [1 / 0 = 0] מדולג בבטחה, ומתי הביטוי (1 / 0 = 0) מתרסק בחלוקה באפס!
🔬 מעקב ביצוע צעד-אחר-צעד (Execution Trace):
הגשר הנרטיבי: השלמנו את כל חמשת הסעיפים של פרק 11 – בוא נחבר את כל התמונה ונתרגל!
סיימנו לעבור לעומק על כל חמשת הסעיפים של פרק 11: מהמחלקה Object (ו-ProtoObject), דרך מערכת המספרים (Magnitude, Number, Integer, Fraction, Float), תווים (Character), מחרוזות וסימבולים (ByteString מול Symbol), ועד למימוש הפולימורפי הטהור של Boolean (True ו-False).
בלשונית המסכמת הבאה נבחן את עצמנו באמצעות 15 כרטיסיות תלת-ממד אינטראקטיביות ונתכונן לתחנה הבאה שלנו: פרק 12 – אוספים ב-Squeak!
מילון מושגים אינטראקטיבי וסיכום עקרונות פרק 11 (מחלקות בסיסיות ב-Squeak)
סיכום אינטגרטיבי של עמודי התווך בספריית המחלקות של Smalltalk: מחלקת השורש Object, היררכיית המספרים, תווים, מחרוזות וסימבולים, והמערכת הבוליאנית הפולימורפית.
מודל מנטלי ועיקרון מנחה: איך תבניות עיצוב ופולימורפיזם טהור מחברים את כל המחלקות הבסיסיות
פגשנו אותה לאורך כל הפרק: ב-Object (printString קוראת ל-printOn:; copy קוראת ל-shallowCopy postCopy; ~= נשענת על =) וב-Magnitude (כל אופרטורי ההשוואה נשענים על < ו-=).
ל-Object אסור להוסיף משתני מופע (כדי לא לשבור את SmallInteger ב-VM); דריסת = מחייבת דריסת hash; ו-Symbol מבטיח זהות יחידה בזיכרון (==) ואי-השתנות לעומת ByteString.
אנחנו נמנעים מבדיקות isKindOf:/respondsTo: וסומכים על קישור דינמי — ואפילו תנאים בוליאניים (ifTrue:ifFalse:, and:, or:) ממומשים פולימורפית בין המופעים היחידים true ו-false!
הסינרגיה בין ProtoObject ו-Object אל Magnitude, Number, Character, String ו-Boolean
מיני-סימולציה 11.6: בוחן אבחנה מהיר – 4 מלכודות קלאסיות בפרק 11
כרטיסיות זיכרון תלת-ממדיות (3D Flashcards) לכל 15 מושגי פרק 11
לחץ על כל כרטיסייה כדי להפוך אותה ולבחון את עצמך על כל אחד מ-15 מושגי היסוד של פרק 11:
נחשפו: 0 / 15סיימנו במלואו את פרק 11! מה מחכה לנו בפרק 12?
בפרק 11 הכרנו לעומק את עמודי התווך של ספריית המחלקות ב-Squeak: מחלקת השורש Object (ו-ProtoObject), היררכיית המספרים (Magnitude, Number, Integer, Fraction, Float), תווי הטקסט (Character), מחרוזות וסימבולים (ByteString ו-Symbol), והמימוש הפולימורפי של המערכת הבוליאנית (Boolean, True ו-False).
OrderedCollection, SortedCollection, Array, Interval, Set, Bag, Dictionary ו-IdentityDictionary!