שלום לכולם,
קדם דבר
בואו נדבר קצת על ההבדלים בין Struct (להלן: מבנה) ל-Class (להלן: מחלקה). למי שרק עכשיו שומע על מבנה נסביר בתמצות מהו מבנה (מהפן הפרקטי). שאנו כותבים מחלקה חדשה הקוד יראה בערך ככה:
אז שאנחנו עובדים עם מבנה זה יראה ככה:
הבדל ענק נכון? אז אתם, שנתקלים במושג הזה פעם ראשונה או שנתקלתם בו בעבר, כנראה חושבים שהוא מיותר לחלוטין. סתם עוד פדנטיות שמתכנתי הסיפיפי האלו הכניסו לנו לשפה כי הייתה להם חרדת נטישה. אז אני בא ואומר שזה לא המצב. במאמר הזה נראה מה באמת ההבדלים בין מבנה למחלקה, ולא רק מה ההבדלים - אלה נראה איזה כוח עצום טמון במבנים.
חלק א': על Stack ו-Heap, ועל Refrence type ו-Value type (או: “אני אמיתי ואתה לא!“)
נחזור ונכיר כמה מושגי יסוד: Value types ו-Reffrence types. כידוע, כל אובייקט במערכת יורש מ-System.Object, אך יש קבוצה קטנה של בעלי סגולה שיורשים גם מ-System.ValueType ומכאן מתחילים כל ההבדלים. נזכיר כעת כמה ValueTypes שהם סלברטאים: int, bool, char ו-enum. עכשיו נראה כמה Refrence Types שגם הם סלבריטאים: System.Web.UI.DropDownList, System.Windows.Forms.CheckBox ו-ArrayList. מניסיון קודם אנחנו רואים בבירור שיש הבדלים כלשהם.
אחרי שהראינו כי באופן אינטואטיבי קיים הבדל כלשהו, ניכנס עוד יותר לעומק ונדבר על Stack ו-Heap. ה-Stack וה-Heap הם מקומות המוקצים בזכרון המחשב ובו נמצאים האובייקטים שלנו. כל Refrence type שניצור יווצר על ה-Heap, ובתוך בלוק הקוד שלנו יווצר לנו משתנה המצביע על אותו מקום ב-Heap רק שההפנייה הזאת ל-Heap יושבת ב-Stack. במילים פשוטות, שניצור DropDwonList הוא נוצר למעשה ב-Heap ושאנו עובדים עם אותו DropDownList אנו למעשה עובדים עם הפנייה שיושבת ב-Stack שמפנה לאותו מקום ב-Heap. מהסיבה הזאת אפשר ליצור מיליון הפניות ב-Stack לאותו מקום ב-Heap. נראה בדוגמה על מה דיברנו כרגע:
נעקוב בקצרה אחרי מה שעשינו, הצהרנו על Refrence type ובנינו לו שני הפניות. הוספנו להפנייה הראשונה פריט אחד. שהדפסנו כמה פריטים יש בכל DropDonwList נקבל שיש בכל הפניה פריט אחד, למרות שלא הוספנו פריט להפניה השנייה. כלומר, עבדנו למעשה בשני ההפניות האלו על אותו אובייקט בזכרון.
עכשיו נדבר על Stack. הרעיון מאחורי Stack זה לשמור מידע כמה שיותר זמין לתקופת הזמן שאנו עובדים איתו. ה-Stack באופן יחסי קטן באופן ניכר מה-Heap מהסיבה היא שזה הזכרון שאיתו רוב הזמן המעבד עובד איתו וככל שהוא יותר “ממוקד” ככה העבודה מהירה יותר. זה מתקשר ל-ValueTypes בכך שכל ValueType נשמר רק על ה-Stack ואין טיפת קשר ל-Heap. היות ואנו לא עובדים עם הפניות אנו תמיד עובדים עם אובייקטים ממשיים. ניישם את הדוגמה שהוזכרה למעלה על int32:
הצהרנו על משתנה מספרי חדש, השוונו משתנה מספרי נוסף אליו וביצענו שינוי במשתנה המספרי הנוסף. לעומת הדוגמה הקודמת, אנו נקבל שהמספר הראשון לא שונה בעקבות זאת, רק המספר השני השתנה. כלומר, באמת הוכחנו שאנו עובדים עם אובייקטים ממשיים המבוססים על ערך ולא על הפנייה.
חלק ב': ואיך כל זה קשור למבנים ומחלקות? (או: “נו, תכלס', למה שיהיה אכפת לי?”)
מבנה הוא ValueType (בדומה למחרוזת, מספרים, בוליאנים ו-Enums) ומחלקה היא Refrence Type (פחות או יותר כל השאר). נתחיל לדבר על הבדלים מבחינת תכנות:
1. אי-חובת כתיבת Constructors: (או: “אותי אתה לא צריך לאתחל“)
למחלקה תמיד חובה שיהיה Constructor אחד לפחות. כאשר כל ה-Constructorים של מחלקה הם Private - לא ניתן לממש את המחלקה. נראה דוגמה:
למעשה את myClass מהדוגמה אי-אפשר לממש. אין לו שום Constructor שבאמת ניתן דרכו לממש את myClass.
לעומת זאת, מבנה תמיד תמיד תמיד אפשר לאתחל. תכתבו Constructor, אל תכתבו Constructor, תכתבו שה-Constructor הוא Private, למי אכפת?
שמתם לב? בין אם נכתוב או לא נכתוב Constructor יהיה אפשר לממש מבנה. את ההסבר לזה נראה בסוף הסעיף הבא. אבל ביינתים נראה בדוגמה שגם הרי לא חייבים את ה-Constructor של מספר כדי ליצור מופע של מספר.
2. אי-חובת איתחול (או: “ברגע שדיברת אליי, אני כבר פה“)
בזמן שניתן לממש כל מבנה בלי קשר לסטטוס ה-Constructor שלו, לא חייבים בכלל לאתחל אותו. מספיק לתת “הפנייה” למבנה והוא כבר מאותחל. ברגע שנכתוב הפנייה למבנה נוכל לגשת לכל ה-Public members שלו.
יצרנו מבנה שאין לו אפילו Constructor, לא איתחלנו אותו, שמנו ערך חדש במשתנה Public ועוד הדפסנו לו את הערך. אם היינו מנסים כזה דבר במחלקה היינו חוטפים שגיאות על זה שאין לו Constructor ועל זה שלא קראנו ל-Constructor.
ברור גם למה זה קורה, הרי מבנה יושב ב-Stack שברגע שאנו מגדירים אובייקט שיושב שם ברור שלא מדובר באיזה הפנייה ל-Heap, אלא באובייקט לפני עצמו ולכן ה-CLR ישר שם מקום המיועד למבנה ב-Stack. כנ”ל ברור יקרה גם עם int, enum ושאר Value Types.
3. מבנה לא יכול להיות null (או: “אני תמיד איתכם ברוחי“)
היות ותמיד ניתן לאתחל מבנה מרק ליצור אליו הפנייה - לא ייתכן שנדבר על מבנה שהוא null. למעשה, null מתייחס להפנייה ל-Heap שלא מצביעה לשום מקום. היות ואין שום הפנייה ל-Heap לא יכול להיות שמבנה שווה ל-null.
4. אין הורשה למבנה או ממבנה (או: “נדוניה וירושה”)
כל מה שהוא Value Type יורש בהכרח מ-System.ValueType שנותן לו את כל היכולות שהזכרנו ונזכיר. אך, מעבר ללרשת מ-System.ValueType שקורה באופן שקוף לנו, לא ניתן לבצע ירושה על ValueTypes. כלומר מבנים לא יכולים לרשת מבנים\מחלקות אחרות, ולא יכולים להיות אובייקטים מהם יורשים. דוגמה ידועה לזה היא שלא ניתן לרשת int וליצור SuperInt עם הרבה יותר פונקציונליות.
לכאורה מדובר בחסרון, אבל בגלל היעוד של מבנה מדובר למעשה בנכס היות וה-CLR מכריח אותנו לעבוד פשוט. (עוד על היעוד של מבנה בהמשך)
נחדד את ההבדלים בין מבנה למחלקה בנושא ירושה: כל מבנה הוא Sealed (לא ניתן לרשת ממנו והוא לא יכול לרשת), אי-אפשר להצהיר על Abstract struct (הרי אם אין ירושה ממבנה זה מיותר), לא ניתן להצהיר על Protected members (הרי אם לא ניתן לרשת ממנו אז מה הטעם לכתוב משתנים שרק מי שיורש ממנו יכול לגשת אליהם?), ומבנה לא יכול לעשות Override למתודות שלא נורשות מ-System.Object.
5. אסור Destructor (או: “אני הולך לבד הביתה”)
במבנה כל ניסיון לכתוב Destrector (פונקציה שנקראת בזמן הריסת המבנה ע”י ה-garbage collector) תגרום לשגיאה. במחלקה אפשר לרשום Destructor (למרות שאלמלא אתם בונים מחדש תשתיות גישה לקבצים\מסדי-נתונים\... זאת תהיה טעות דרסטית לעשות את זה).
הנה דוגמה למחלקה עם Destructor שיתקמפל, ודוגמה למבנה עם Destructor שלא יתקמפל:
שננסה לקמפל את myStruct_Destrcutor נקבל את השגיאה הבאה: Only class types can contain destructors.
הסיבה לכך נעוצה בתכולת החיים של משתנים ב-Stack ובה-Heap. למעשה ה-Heap מיועדת להחזיק בחיים משתנים מעבר לתחולת החיים של הפונקציה הנוכחית, ה-Thread הנוכחי, ואף מעבר לתכולת החיים של כלל התוכנית. לעומת זאת, משתנים ב-Stack הם משתנים מקומיים שחיים רק כל עוד שבלוק הקוד שלהם פעיל. בנוסף, מבחינה הגיונית - ב-#C קיימים Destructrים אך ורק בשביל תשתיות נרחבות שנבנו מחדש, שזה לא היעוד של מבנים. (עוד על היעוד של מבנים בהמשך)
6. אופן ההשוואה בין מבנים ומחלקות (או: “במחלקות זה האריזה, במבנה זה התוכן“)
כאשר נשווה בין שתי מחלקות מה שיתרחש למעשה זה השוואה של “האם ההפניות בזכרון מפנות לאותו מקום?”
יצרנו שלושה מחלקות: מחלקה ראשונה שהיא myClass ברירת-מחדל, מחלקה שנייה שהיא הפנייה נוספת למחלקה הראשונה, ומחלקה שלישית שהיא גם myClass ברירת-מחדל. המחלקה הראשונה שווה למחלקה השנייה (היות ושניהן מצביעות לאותו מקום בזכרון). לעומת זאת, המחלקה הראשונה לא שווה למחלקה השלישית, למרות שיש להן בדיוק אותו תוכן. למעשה כל מה שאנו בודקים בהשוואה בין מחלקות זה הפניות לזכרון.
לעומת זאת, שאנו משווים בין מבנים חובה עלינו לבנות פונקציה שעושה השוואה בין התוכן שלהם.
יצרנו מבנה שעושה Override ל-Equels ובודק אם ה-X של מופע אחר של אותו מבנה שווה ל-X שלו.
יצרנו שני מבנים מאותו סוג, הכנסנו להם אותו ערך ל-X ובדקנו אם הם שווים. התוצאה תהיה True. אם נשנה את הערך X של אחד מהם - התוצאה תהיה False.
יבואו המתחכמים ויגידו - אבל אתה יכול לממש מחלקה שגם הוא תבדוק השוואה לפי התוכן, ובכלל למה אני צריך לממש את הבדיקה של התוכן במבנה?
דבר ראשון, אפשר לממש מחלקה שתעשה פחות או יותר הכל, אבל זה לא סטנדרט לדרוס את הפונקציות השוואה של מחלקות ולשנות להן לחלוטין את המשמעות.
דבר שני, אנחנו בכלל לא חייבים לממש את Equels של myStruct_Equels. זאת רק הייתה דוגמה.
המימוש הזה של myStruct_Equels ירוץ בדיוק אותו דבר כמו זה שרשמנו למעלה, וה-CLR יעשה לבד את ההשוואה בין כל המשתנים שלו.
ברור לנו גם למה יש את ההבדל המהותי הזה. היות ושני המבנים הם ValueTypes, הם לעולם לא יצביעו לאותו מקום בזכרון (תביטו בחלק א' ותראו דוגמה של איך זה פועל במחרוזות). לכן חובה שההשוואה תהיה לפי תוכן, ולא לפי הפניה.
7. התייחסות פנימית (או: “אני בכלל הוא”)
תשומת לב במיוחד לסעיף הזה (והבא) בבקשה כי עליהם מבוססת הדוגמה בהמשך.
נביט שנייה על myClass:
בדוגמה הזאת ניסינו בתוך פונקציה כלשהי (במקרה היא ה-Constructor) להגיד this שווה משהו אחר. במחלקהזה בלתי אפשרי. הסיבה היא שבמחלקה ה-this הוא רק הפנייה למקום מאוד ספציפי בזכרון. כחלק מרעיון ה-managed code (קוד שמנהל לנו שימוש בזכרון) לא ניתן לעשות את זה. ולכן הקומפיילר זורק לנו שגיאת this is readonly.
לעומת זאת היות ומבנה יושב על ה-Stack כן ניתן בתוך פונקציה כלשהי של המבנה לשים ערך חדש לחלוטין כל עוד הוא מאותו סוג. נביט על דוגמה:
8. אתחול משתנים פנימיים (או: “אני בכלל לא פה”)
כאשר אנו בונים Members למחלקה הם מקבלים ערך של ברירת מחדל כאשר אנו מאתחלים את המחלקה (וכפי שידוע, לא ניתן לגשת למחלקה בלי שתהיה מאותחלת או בלי שה-Memebers יהיו סטטיים). נסביר בפשטות, אם נרשום משתנה פנימי למחלקה, ברגע אתחול המחלקה המשתנה הפנימי יקבל ערך ברירת-מחדל. למשל מספר יקבל 0, מחרוזת תקבל “”, בוליאני יקבל 0 וכך הלאה. נדגים זאת:
שמנו שלושה משתנים פנימיים: אחד מסוג בוליאני, אחד מסוג מספר והשלישי מסוג מחרוזת. בקונסטרקטור הדפסנו אותם. נקבל שהבוליאן הוא False, המספר הוא 0 והמחרוזת ריקה. כך שלמעשה קיבלנו שהיינו יכולים לרשום ככה את המחלקה:
מדובר בערכים ברירת מחדל של ה-CLR שברגע שאנו נאתחל את המחלקה הם יקבלו את ערכים אלו.
נדגים מה קורה במבנה. נשנה את המחלקה למבנה: (באמצעות החלפת ה-“Class“ ב-“Struct“)
ברגע שניסינו לקמפל את המבנה הזה נקבל שגיאה שהמשתנים הפנימיים אינם מאותחלים. נכתוב מחדש את ה-Struct לשם מיקוד:
אפשר לראות בבירור שבשום מקום בקוד (וגם לא בברירת-מחדל) לא מאתחלים את הערכים הפנימיים. למעשה עצם הגדרת המבנה רק אומרת כי המבנה קיים ולא מאתחלת את המשתנים הפנימיים.
9. עוד הבדלים
לא חסרים עוד הבדלים בין מבנים למחלקות: ניתן לשלוח מבנה כ-ref או out בפונקציות פנימיות, לא ניתן לנעול מבנה (כדי שהוא יהיה thread-safe), וכיו”ב. אתם מוזמנים להמשיך לחקור את הנושא.
חלק ב': ייעוד המבנה (או: “עף כמו דבורה, עוקץ כמו פרפר”)
את החלק הזה אני בכוונה שמרתי עד שנגיע למצב בו באמת נראה מה ההבדלים בין מבנה למחלקה. אין פה באמת גם הרבה מה להגיד מבחינת תאוריה.
מבנה יועד להיות האח הקטן, הטיפה נכה והחמוד של מחלקה. הוא צריך היה לתת מענה למצב שבו לקרוא למחלקה זה פשוט Overkill. כמו לקרוא למייק טייסון לקטטת רחוב, פשוט מיותר. מבנה אמור להיות טיפוס נתונים קל ולשם כך הוא בנוי וכל התכונות וההתנהגות שלו מבוססים על זה.
נראה דוגמה ונבין למה מחלקה לא מתאים להיות טיפוס נתונים קל.
חלק ג': דוגמה לשימוש נכון במבנה (או: “הלכתי לאיבוד, מה הקורדינטות של מיקומי הנוכחי?”)
תרחיש אמיתי לחלוטין. אנחנו בונים מערכת GIS (מערכת מפות). ברצוננו לציין ולעבוד עם 10,000-100,000 קורדינטות במסך תצוגה אחד. קורדינטה בכל מסך ישנם שילובים של שלוש סוגי קורדינטותה מהסוגים הבאים:
- קורדינטת ציר אחד (ציר X בלבד)
- קורדינטית שני צירים (צירי X,Y בלבד)
- קורדינטת שלוש צירים (צירי X,Y,Z)
נממש את קורדינטה במחלקה.
יש לנו שתי אפשרויות: 1. לבנות שלושה מחלקות של קורדינטות ונעשה תנאי לבין איזה נממש, 2. לבנות מחלקה אחת שתכיל שלושה צירים ושלושה קונסטרקטורים.
נתחיל מהאפשרות הראשונה. אין טעם אפילו לנסות לממש אותה ההיות ומדובר על אפליקציית זמן אמת שטוענת בכל מסך חדש עד 100,000 נתונים שונים. כלומר, לכל נתון כזה נצטרך להכניס לתנאי ולבדוק לאיזה מחלקה לממש אותו - 100,000 תנאיים על מחשב. בוא נגיד שמדובר ב-Overhead ממש ממש לא רצוי. למרות שמבחינת עיצוב המערכת מדובר במודל טוב, מבחינת ביצועים זה סיוט וספק אם זה יטען במסגרת זמן סבירה. יש עוד סיבות למה לא מדובר ברעיון טוב מבחינת ביצועים (צריך לבצע 100,000 המרות מהמחלקה\ממשק הבסיסי כדי לעבוד עם הנתונים האלו וכך הלאה).
האפשורת השנייה היא לממש מחלקה אחת שמכילה את כל שלושת הנתונים עם שלושה קונסטרקטורים.
(לצורך דיון לא ראיתי לנכון לסבך את הקוד עם Properties).
לפי מה שראינו המחלקה למעלה שקולה למחלקה הבאה:
עוד פעם נקבל שמבחינת ביצועים אנחנו בבעיה, אם למשל נעבוד עם 100,000 קורדינטות מסוג X בלבד נקבל כי ישנם 200,000 מספרים מאותחלים שלא עושים בהם שימוש. גם זה Overhead מיותר לחלוטין שאין אפשרות לעמוד בו.
לפי מה שעשינו עד כה ראינו כי אנו צריכים טיפוס נתונים כלשהו שיכול להכיל את כל השלושת הנתונים בלי לאתחל אותם - תכונה ידועה של מבנה!
קיבלנו מצב שבו לא יהיה שום Overhead שאינו נדרש. אין המרות, אין משתנים שלא בשימוש, אין תנאים. זה לא הרבה יותר טוב?
(במצב אמיתי גם היינו בונים Properties שהיו מגבילות גישה לנתוני Y,Z שאינם מאותחלים)
לסיכום
דיברנו על ההבדלים בין מבנה למחלקה, ראינו מאיפה נובעים ההבדלים האלו, מדוע קיים כזה דבר מבנה, והראינו מצב אחד מרבים בו מבנה הוא טיפוס נתונים קל הרבה יותר טוב ממחלקה. כמו כל דבר בדוט נט מדובר על לבחור את הכלי הנכון בזמן הנכון.
Remember Me