תבניות עיצוב - מפעל
תבניות עיצוב
״ הי-יו ג׳ורד ת׳יודע מה חזק ?
קוד שמתכנת לממשק ולא למימוש זה חזק ! ״
אנחנו רוצים שהקוד שלנו יהיה גנרי ומאפשר התפתחות עתידית, כדי שאם נרצה להוסיף אובייקטים שונים, לא נצטרך לשנות את הקוד שלנו בכמה מקומות.
אנחנו גם רוצים ששינוי ברכיב אחד לא ישפיע על רכיב אחר, כי אנחנו לא אוהבים כפל קוד ולהתחיל לחפש ב- 10 מקומות איפה צריך לשנות/להוסיף את מה שאנחנו רוצים, וכתיבה בצורה הזו היא אבן יסוד בתכנות מונחה עצמים.
אנחנו לא רוצים גם להמציא את הגלגל, אין שום צורך, אבותינו וחכמינו זצ״ל כבר חשבו על בעיות נפוצות וגם חשבו על דרכים לפתור אותם.
אם אני רוצה ליצור מכונית, יש כבר אנשים שחשבו על ה״תבנית״, מנוע, 4 גלגלים, הגה ועוד...
לא צריך להמציא כל פעם את הגלגל מחדש... תרתי משמע :)
נכון שזה לא פותר לנו את העיצוב של כל המבנה, אבל זה אומר שאני לא צריך להמציא כל דבר קטן.
לפתרונות כאלה קוראים ״תבניות עיצוב״.
בתמונה: התנ״ך של תבניות העיצוב: Design Patterns(1994)
ניתן עוד דוגמה להמחשה, אנחנו פותחים סטארט-אפ וכותבים אפליקציה לניהול לוגיסטי.
האפליקציה שלנו בהתחלה יכולה להתמודד רק עם הובלה באמצעות משאיות, כך שחלק הארי של הקוד שלנו נמצא במחלקת ״()Truck״, לאחר זמן מה, האפליקציה שלנו הופכת פופולרית, ואנחנו רוצים להוסיף פיצ׳ר של הובלות ימיות, מה נעשה אז ? נוסיף מחלקת ״()Ship״ ?
הרי רוב הקוד שלנו מקושר למחלקת משאית, הוספת ספינות לאפליקציה תדרוש ביצוע שינויים בבסיס הקוד כולו 🤯.
יתרה מכך, אם מאוחר יותר נחליט להוסיף סוג נוסף של תחבורה לאפליקציה, למשל באמצעות אופניים, כנראה שנצטרך לבצע את כל השינויים הללו שוב.
ועל בעיות מן הסוג הזה באה התבנית ״מפעל״ לענות.
אבל לפני שנצלול להבנת התבנית,צריך ל״יישר קו״ על מושגים בסיסיים יותר, ורק אז נוכל להמשיך...
ממשק:
ממשק הוא בעצם ״הסכם-חוזה שימוש״, שכל מי שמשתמש בו (יורש ממנו) חייב לממש את הקוד על פי הפרמטרים שהממשק הגדיר.
בפועל, ממשק זה לרוב מחלקה עם רשימה של פונקציות ותכונות אבסטרקטיות (דוגמה בהמשך).
שימו לב, אי אפשר לייצר אובייקטים בצורה ישירה באמצעות ממשק, כי ממשק הוא לא מגדיר אובייקט קונקרטי אלא רק מגדיר תכונות אבסטרקטיות ללקוח שרוצה להשתמש בו.
כשמחלקה אחרת מממשת את הממשק, זה אומר שהיא מתחייבת שיש לה את השיטות והתכונות הרשומות בו.
ממשק מדבר על ״מה עושים״, ולא על ״איך עושים״.
ממשק בפייתון:
ממשקים באופן כללי אינם נחוצים ב- Python.
הסיבה לכך היא של- Python יש אופציה ל״ירושה מרובה״ (לרשת מכמה מחלקות במקביל), מה שאומר שהמקומות שבהם אתם חייבים ממשקים בג'אווה, אתם לא חייבים אותם ב-Python.
עם זאת, גם בפייתון אפשר (וכדאי) לייצר ממשק.
שלא כמו ב Java, זה לא מובנה ב Python, ולכן אנחנו צריכים לייבא את הספריה Abstract Base Classes או בקיצור ABC.
נשמע תיאורתי מדי ? ויקיפדי... לא לדאוג, הכל יובן לאט לאט, דוגמאות בהמשך...
פולימורפיזם:
פולימורפיזם, בעברית ״רב-צורתיות״ (פולי - רב, מורפ - צורה).
המקור של המושג הוא בביולוגיה, זה השם של תופעה מאוד נפוצה שרואים בכל מקום.
למשל, ייתכן שהלכתם לטייל וראיתם מגוון חתולים, יש להם צבעים שונים או צורות שונות (פעם בתור אריה, פעם בתור נמר, פעם בתור חתול חמוד, אבל כולם ממשפחת החתולים).
מדעי המחשב השאילו את המושג פולימורפיזם כי אותו דבר קורה עם עצמים.
בשביל להסביר את זה לרוב נותנים דוגמה נפוצה של מחלקת ״צורה גיאומטרית״:
לאותו עצם יש למעשה מספר צורות/וריאציות שונות.
ועל כן, על פי הגדרה זו, הוא מרובה צורות - פולימורפי.
הממשק Shape מגדיר מספר תכונות ופעולות שצורות גאומטריות יכולות לבצע.
נניח שצורה יכולה לחשב את ההיקף של עצמה ואת השטח של עצמה.
אז נכתוב את הממשק Shape עם השיטות ()calc_perimeter ו- ()calc_area.
- המחלקה ״ריבוע״ מממשת את הממשק Shape ומגדירה את ההיקף והשטח לפי הנוסחאות של ריבוע.
- המחלקה ״עיגול״, לעומת זאת, שגם כן מממשת את Shape, מגדירה את השטח וההיקף כפונקציה של רדיוס.
אז לממשק של Shape יש הרבה ביטויים בעולם, הוא ״פולימורפי״, דהיינו הוא ממשק שיש לו כמה מחלקות שמממשות אותו, ולקוחות שמשתמשים בו.
חלק מהפריטים הם ריבועים והם מממשים את הפונקציה ()calc_area בצורה אחת,
ואחרים הם ממחלקה אחרת ומממשים פונקציה אחרת, אבל כולם Shapes.
הבהרה:
כשאני אומר ״לקוח״ שמשתמש במחלקה, זה יכול להיות אני... ויכול להיות מתכנת חבר שלי שמשתמש בקוד שלי, וזה יכול להיות כל אחד אחר שרוצה להשתמש בקוד שלי.
לקוחות, כל לקוח, מעדיף לתכנת רק לממשק ולא למימוש של אובייקט קונקרטי .
ובפרט הם לא רוצים לקרוא לבנאי של מחלקה קונקרטית. כל עוד הם מכירים רק את הממשק, לא צריך לעדכן אותם כשהם מוסיפים מחלקה מממשת.
תבנית מפעל:
תבנית העיצוב ״מפעל״ היא דרך מאוד נפוצה לפתור את הבעיה של הלקוחות.
כשאנחנו כותבים את הממשק והמחלקות שמממשות אותו, בעסקת חבילה אנחנו מספקים גם מפעל.
הלקוח אומר למפעל מה הוא צריך, והמפעל כבר יקרא לבנאי של המחלקה הנכונה.
מה שהמפעל יחזיר זה רפרנס מסוג הממשק.
בסך הכול הלקוח יצטרך להכיר רק את המפעל ואת הממשק, רשימת המחלקות הקונקרטיות יכולה להשתנות, וללקוח לא אכפת, שמחה !
אגב, למי שלא ירד לשורש השם של התבנית, זה כמו אדם שמגיע למפעל ומבקש שייצרו לו כמה מוצרים, לא אכפת לו וגם אין לו מושג איך המפעל מייצר אותם, והמפעל יכול לייצר לו כמה וריאציות של מוצרים...
על פי עקרון ה״פולימורפיזם״, כאשר נכתוב במחלקות שונות מתודות שמבצעות את אותו התפקיד נצטרך לקרוא להם באותו שם.
לדוגמה, צורות גיאומטריות שונות אחת מהשנייה במספר הצלעות ובדרך חישוב השטח, אבל לכולם יש שטח שניתן לחשב באמצעות פונקציה.
כשנרצה לחשב את השטח, לא משנה עבור איזו צורה - ריבוע, עיגול או מתומן - ננהג לפי עקרון הפולימורפיזם ונקרא לפונקצית חישוב השטח באותו שם בכל המחלקות.
ככה אנחנו יוצרים פשטות ללקוחות ובכך עקרון הפולימורפיזם חסך למשתמשי הקוד את הצורך לברר את שמות המתודות המחשבות את השטח עבור הצורות השונות, אמריקה !
כל מה שהלקוח צריך לדעת הוא שצריך לקרוא לפונקציה ()calc_area כדי לחשב שטח,
מאחורי הקלעים הקוד יודע לאיזו פונקציה ()calc_area לגשת.
בדיוק כמו עם הפונקציה ()print, שאלתם את עצמכם איך היא מתמודדת כל פעם עם טיפוס נתונים אחר ? הרי היא יודעת להדפיס גם אובייקט מסוג str וגם int ועוד ועוד, איך היא יכולה ? זה כי מישהו מימש פולימורפיזם מאחורי הקלעים... הודות לכך, השימוש בקוד הופך אינטואיטיבי ופשוט יותר, בזכות עקרון הפולימורפיזם החיים שלנו תותים 🍓
האפשרות להיות אדיש (indifferent) למחלקות הקונקרטיות היא אבן יסוד בתכנות מונחה עצמים.
שיטת המפעל מפרידה בין קוד בניית המוצר לבין הקוד שמשתמש בפועל במוצר.
לכן קל יותר להרחיב את קוד בניית המוצר באופן עצמאי מה שאר הקוד.
פולימורפיזם והורשה:
עוד אחת מן הדרכים הנפוצות לשימוש בפולימורפיזם היא הורשה.
נגדיר במחלקת האב מתודה שתשמש את כל המחלקות היורשות.
במידה ואחת המחלקות דורשת התנהגות שונה אז נדרוס את הקוד מההורה תוך שמירת שם המתודה (method overriding).
מימוש שונה במחלקות שונות:
זה מאוד נחמד לכתוב קוד שעובד לכל עצם בלי הבחנה. אבל מה אם חייבים להבחין ?
כי בכל עצם אני רוצה לעשות משהו אחר, למשל:
יש חתול (ביתי) שעושה Meow חמוד, ויש חתול-אריה בג׳ונגל שעושה Roar...
האם עכשיו אני כן צריך 2 מתודות ?
בתכנות מונחה עצמים אנחנו חושבים ב״כובעים״, פעם אנחנו בכובע של המתכנת של מחלקת החתול הביתי, ופעם אנחנו בכובע של המתכנת של מחלקת החתול-אריה.
זה לא סביר שהמתכנת שיצר את הממשק הכללי ״חתול״ ידע לצפות את כל ההתנהגויות של כל החתולים במחלקה שלו, ואם מחר נגלה זן חדש של חתולים ?!
וגם אם כן נדע מראש את כל התכונות והפונקציות, ונכתוב הכל במחלקת החתול הכללי, אנחנו בעצם נהרוס את עקרון ההפרדה והאינקפסולציה.
לכן מי שכותב את מחלקת החתול-אריה הוא אחראי על המחלקה שלו בלבד, וכל מתכנת אחראי על המחלקה שלו בלבד, וככה נממש את עקרון האינקפסולציה.
לא הקפדתי על אנקפסולציה ? דברים ששייכים למחלקה שלי מתפזרים לשאר המחלקות.
ואם נקפיד על אינקפסוצלציה, פתאום הלקוחות שלי מתכנתים למימוש ולא לממשק...
וזה מצויין, שהרי כבר אמרו חכמינו זצ״ל: ״קוד שמתכנת לממשק (ולא למימוש) זה חזק ! ״
אז אם בכל זאת מצאתם את עצמכם בסיטואציה שאם צריכים לעשות משהו אחד עבור מחלקה קונקרטית אחת, ומשהו אחר בשביל מחלקה קונקרטית אחרת, זה הזמן לחשוב אחורה ולשאול איפה שברתם אנקפסולציה ?!
איזו מהמחלקות לא דאגה בעצמה לכל מה שספציפי עבורה ?
אם שברנו תכנות לממשק, סביר שגם שברנו אנקפסולציה. ואז מי יאהב אותנו ? אף אחד ! 😭
בטח תאמרו - טוב, טוב... אכלת תראסס, הכל נחמד ויפה, אנחנו כותבים קוד ממשק וגנרי, ויש פרפרים באוויר וגם שלום עולמי...
אבל בסוף אנחנו צריכים לייצר אובייקט קונקרטי-ספציפי !
שהרי יצירה של העצם מחייבת אותנו בסופו של דבר לנקוב בשם של מחלקה קונקרטית שיוצרים, אז מה בעצם עשינו פה ? לקחנו בעיה והעברנו אותה למקום אחר ? מה הועילו חכמים בתקנתם ?!
אתם צודקים... את החלק ה"נגוע" הזה של יצירת אובייקט ספציפי אנחנו רוצים לבודד, ואת זה נעשה עם המושג שנקרא ״תבנית-מפעל״.
מה שקבוע זה הממשק.
מה שמשתנה זה רשימת המחלקות שמממשות את הממשק.
אז אנחנו רוצים להיות תלויים רק בדבר הקבוע ולבטל את התלות במימושים הלא קבועים.
אנחנו לוקחים את החלק של היצירה ושמים אותו במחלקה ייעודית שהמטרה שלה היא לספוג את האש. כל תפקידה ביקום הוא להיות זו שמייצרת את האובייקט.
אם היא מייצרת את האובייקט, היא מאפשרת לאחרים לתכנת רק לממשק, ולתבנית העיצוב הזה קוראים תבנית מפעל.
אז נכון... כאשר יהיו שינויים ברשימת הצורות, המפעל יצטרך להשתנות, אבל לפחות הוא יהיה היחיד שיצטרך להשתנות.
בואו נסדר קצת את הקוד במפעל.
נגדיר מתודה שתייצר צורה מסוימת, למשל ()shape_factory,
בשביל להחזיר אובייקט Shape היא מקבלת איזושהי אינפורמציה שעוזרת להכריע איזו צורה ליצור.
במקרה הזה נניח שהיא מקבלת את השם של הצורה ופרמטרים שיעזרו לה לבנות את הצורה (לדוגמה, אם זה מעגל - זה יהיה רדיוס, אם זה מלבן - זה יהיה אורך הצלעות, וכו׳)
במקרה הדי פשוט שלנו, המפעל לא צריך לעשות הרבה בשביל להבין איזו צורה לייצר.
הוא פשוט יכלול איזה if-else.
נ.ב: הפונקציה כאן לא מטפלת בסיטואציה שהקלט שגוי, אבל זה לא הנושא כרגע).
דפוס העיצוב של שיטת המפעל יעזור לנו להפשט את הצורות הזמינות מהלקוח, כלומר הלקוח לא צריך לדעת את כל הצורות הזמינות, אלא רק ליצור את מה שהוא צריך בזמן הריצה.
על ידי יצירת ()Shape_factory שתשמש ליצירת הצורה הספציפית בהתבסס על הקלט של הלקוח, אנחנו מתקשרים למפעל ומבקשים ממנו ליצור צורה.
ככה זה נראה בקוד:
מפעל הוא עוד תצורה של הסתרת מידע (הכמסה/ encapsulation), רק בסקלה של קבוצה של טיפוסים במקום בסקלה של מחלקה.
חוץ מהסתרת מידע יש כאן גם אבסטרקציה, כי המפעל לא משתף את הלקוח באיך הוא מייצר את העיגול.
למשל, אולי בגרסה הבאה, המפעל לא ייצור מופע של מחלקה Circle, אלא מופע מיוחד של המחלקה אליפסה, כל עוד המפעל מחזיר את מה שהלקוח רצה, זה לא משנה איך הוא עושה את זה. אפילו לא משנה מופע של איזו מחלקה הוא מחזיר, זה כמו דוושת הגז ברכב, לא אכפת לי מה קורה בתוך הרכב כשאני לוחץ עליה, מבחינתי אני לוחץ - והרכב נוסע קדימה, כל השאר לא מעניין אותי.
אם נחזור לדוגמה שנתנו בהתחלת המאמר לגבי אפליקציה ללוגיסטיקה, זה יראה כך:
גם מחלקות משאית וגם מחלקות ספינה צריכות ליישם את ממשק התחבורה, שמצהיר על שיטה שנקראת ()deliver. כל מחלקה מיישמת את השיטה הזו בצורה שונה: משאיות מספקות מטען ביבשה, ספינות מספקות מטען דרך הים, אם מחר יהיה כלי תחבורה אחר למשלוח, זה לא יעניין את הלקוח איך הוא עובד, מבחינתו הוא יודע שיש פונקציה בשם ()deliver, מה קורה מאחורי הקלעים לא מעניין אותו...
לסיכום:
תבנית עיצוב היא פשוט פתרון עיצובי נפוץ לתרחיש נפוץ.
התרחיש הנפוץ שעליו דיברנו הוא שיש לנו ממשק פולימורפי, כלומר ממשק עם כמה מחלקות מממשות, ולקוחות שמשתמשים בו.
הלקוחות מעדיפים לתכנת רק לממשק.
כל עוד הם מכירים רק את הממשק, לא צריך לעדכן אזורים נוספים כשמוסיפים מחלקה מממשת.
תבנית העיצוב מפעל היא דרך מאוד נפוצה לפתור את הבעיה של הלקוחות, כשאנחנו כותבים את הממשק והמחלקות שמממשות אותו, בעסקת חבילה אנחנו מספקים גם מפעל.
הלקוח אומר למפעל מה הוא צריך, והמפעל יספק לנו את המחלקה הנכונה שביקשנו.
מה שהמפעל יחזיר זה רפרנס מסוג הממשק. בסך הכול הלקוח יצטרך להכיר רק את המפעל ואת הממשק, רשימת המחלקות הקונקרטיות יכולה להשתנות, וללקוח לא אכפת.
״ ובא שלום על ישראל, אור לגויים,
איזה הוא קוד גיבור ? המתכנת לממשקו ! ״
קומפוזיציה:
זוכרים שהיללנו את עיקרון הירושה, הגיע הזמן לסייג אותו.
אתם יושבים ? יש הטוענים ש: הורשה היא הפרה בוטה של תכנות מונחה עצמים 🤯
וכי למה ?
הבעיה העיקרית עם הורשה היא שהיא פוגעת בהכמסה (encapsulation).
הכמסה הוא העיקרון החשוב ביותר, והורשה הוא חשוב פחות.
יתרה מכך, להורשה יש אלטרנטיבה לא רעה, היא דורשת הקלדה של קוד נוסף - אך היא איננה מערערת על ההכמסה. כלומר ניתן להיפטר לחלוטין מהורשה ולכתוב קוד מונחה עצמים מצוין.
הטענה העיקרית היא ששדות או מתודות המסומנות כ- protected נגישות לכל מי שיורש ממני ואין לי מושג מה יעשו איתם. התחזוקה של מחלקת האב הופכת למורכבת.
אז מה עלינו לעשות ? מה נעשה בלי הורשה ? יתומים היינו ואין אב !
אז אחרי כל ההקדמה המפחידה הזו, הפתרון הוא להשתמש בקשר הרכבה בין אובייקטים, מה שנקרא Composition.
נכיר את עקרון הקומפוזיציה המהולל שאומר ״אם רוצים לתת לאובייקט את התכונות של אובייקטים אחרים אז עדיף להשתמש בהכלה במקום הורשה.
כיצד לבטא יחס של שייכות ?
חתול יכול להיות שייך לאדם, פרק יכול להיות שייך לסרט, דף אינטרנט הוא לרוב חלק מאתר. כל אלה דוגמאות לשייכות בעולם האמיתי. וכמו בעולם האמיתי גם אובייקטים יכולים שיהיה ביניהם יחס של שייכות.
נאמר שיש לנו שתי מחלקות: Car שמייצגת מכוניות ו-Person המייצגת אנשים, ואנחנו מעוניינים לשייך מכונית מסוימת לאדם מסויים. נכתוב את המחלקות:
אם אנחנו רוצים לבטא את השייכות של מכונית מסוימת לאדם מסוים אז אנחנו יכולים להגיד שהתכונה של מכוניות היא שיש להם בעלים. בהתאם, נוסיף תכונה person למחלקה Car:
ועכשיו בכל פעם שניצור אובייקט מהמחלקה Car נוכל להעביר לו אובייקט של המחלקה Person.
אנחנו יכולים לבטא את העובדה שמשה הוא הבעלים הוא של מכונית מסוג KIA על ידי שנעביר את האובייקט moshe לאובייקט kia:
ראינו כיצד להשתמש בקומפוזיציה כדי לקבל את התכונה שם האדם מהאובייקט של המכונית שבה הוא נוהג. באופן דומה, ניתן להשתמש בקומפוזיציה כדי להעביר לאובייקט מתודות שאין לו.
נתחיל מקלאס המנוע, Engine:
נאפשר למחלקה Car לקבל את אובייקט המנוע בתור תכונה:
נעביר את הפרמטר של אובייקט המנוע בתור תכונה לאובייקט המכונית:
נורה למכונית להפעיל את המנוע:
והנה המנוע מתחיל להרעיש !
אבל מה אם אנחנו רוצים מנוע שקט ושמנצל יותר אנרגיה ולהחליף למנוע חשמלי ?
אין שום בעיה ! מייצרים מחלקה של מנוע חשמלי:
עכשיו ניצור אובייקט מהמחלקה Car ונדאג להעביר לו את המנוע החדש במקום את הישן:
ממש ניתוח השתלת לב, הכל נשאר אותו דבר ולא נגענו בכלום חוץ מאשר הרכיב שאותו רצינו להחליף.
אשרינו שזכינו, ברוך בורא הקומפוזיציה !
קומפוזיציה עם רשימות אובייקטים מאותו סוג
ראינו כבר שניתן להעביר לאובייקט תכונה שהיא אובייקט אחד. אבל מה לגבי רשימה של אובייקטים ? לדוגמה, כיצד נבטא את העובדה שלאותה המכונית קיימים מספר בעלים ?
נגדיר למחלקה ()Car משתנה בשם persons שיכיל את רשימת הבעלים:
את ברירת המחדל של הפרמטר הגדרנו כ-None במקום כרשימה ריקה (בשביל למנוע בעיה שפייתון מוסיף משתנים לרשימה בכל פעם שקוראים לפונקציה במקום לשנות אותה).
ונוסיף למחלקה Car מתודות להוספה, הסרה והדפסה של רשימת הבעלים, עכשיו נוכל להגדיר מספר בעלים לרכב, פשוט נשתמש במתודה של ()add_person .
לסיכום:
קומפוזיציה היא כלי יעיל לביטוי יחסי שייכות בין אובייקטים.
כשאתה מתלבט בין הורשה לקומפוזיציה תעדיף את הקומפוזיציה כי היא מציעה פתרון אלגנטי יותר ובטוח יותר.
קרדיט: למרצה דן ניראל (אונ׳ העברית) וכן ליוסי בן הרוש, שהשתמשתי בכמה מהחומרים שלהם.