-->

יום ראשון, 19 בפברואר 2012

הורשה היא הפרה בוטה של עקרונות ה Object-Oriented!

כן. הורשה היא הפרה בוטה של עקרונות תכנות מונחה-עצמים (OO).

אני מבין שאמירה כזו היא בגדר שחיטת פרה קדושה - הרי הורשה הוא העיקרון הכי מזוהה עם OO.
בואו נבחן את הנושא לעומק.


עקרונות תכנות מונחה-עצמים
תשאלו כמה אנשים שאתם סומכים עליהם מהם עקרונות ה OO, ואם מזלכם דומה לשלי, קרוב לוודאי שהם יתחילו להסתבך באיזו פינה אפלה של מימוש ה messaging בין אובייקטים. דיי מפתיע, אך נראה שרוב אנשי התוכנה שעוסקים ב OO לא יודעים להסביר מהם עקרונות ה OO.

נקודה בהולה לתשומת-לב האוניברסיטאות? (כנראה שלא... מבחינתם העיקר שתדעו משוואות דפירנציאליות חלקיות ואוטומאטים).

אז בואו נעשה קצת סדר: בצורה דיי עקבית, בספרי תכנות בעברית (למי שיצא לעיין), מופיעים שלושת העקרונות בסדר הבא:
  • הורשה (inheritence)
  • ריבוי-צורות (polymorphism)
  • הכמסה (encapsulation) - או כימוס, כפי שהעירו. תודה.
זהו בדיוק סדר החשיבות ההפוך של עקרונות ה OO!


אין לי מושג מה הסיבה לסדר ההפוך. אולי ספר אחד חטא וכל השאר העתיקו. בכל מקרה: הנה סדר החשיבות הנכון:
  • הכמסה (private)
  • ריבוי-צורות (implements / instanceof / interface / upcasting / downcasting)
  • הורשה (extends / protected / super / @Override)
(בסוגריים ציינתי את הכלים בג'אווה המשרתים כל עקרון. #C הוא דומה למדי).

העיקרון החשוב ביותר בתכנות מונחה-עצמים הוא הכמסה. אובייקטים הם יישויות עצמאיות המכילות מצב (state) ופונציונליות (methods) כאשר חלק מהמצב ומהמתודות שלהן הוא פרטי. פרטיות זו היא המפתח לפיתוח מערכות גדולות. שינוי של חלק פרטי אמור לא-להשפיע על החלקים האחרים של המערכת. אם מפתחים של חלקים אחרים של הקוד לא יודעים כיצד מומש החלק הפרטי (הם יכולים להסתכל בקוד, הם זקוקים לקצת משמעת עצמית וקומפיילר שיקשה עליהם) - אזי הם אמורים להיות לא מושפעים משינויים בו.

זוהי קפיצת המדרגה העיקרית מול שפות פרוצדורליות שניהלו את המידע בצורה גלובלית (נאמר C).

ומה עם ריבוי צורות והורשה?
עקרונות אלו הם כלים המאפשרים לכתוב קוד מתוחכם יותר. במשך שנים לא הייתה בניהם הבחנה ברורה. אולי בגלל שפת ++C שאפשרה ריבוי צורות כמקרה פרטי של הורשה, ולא להיפך.

הסבר קצר: שפת ++C הכילה יכולת מקבילה ל abstract בג'אווה / #C שנקראה virtual. מפתחים יכלו להגדיר מחלקות שכל המתודות שלהן virtual (מה שנקרא pure virtual class) וכך לקבל באופן עקיף משהו מקביל ל interface בג'אווה / #C. לא היה לממשק, כפי שאנו מכירים אותו בשפת ג'אווה, שום ייצוג רשמי בשפה - זה היה [1] Pattern.

האבחנה הברורה בין ריבוי-צורות והורשה הייתה לנחלת הכלל רק כאשר הגיעה ג'אווה, שפה שלקחה את עקרונות ה OO צעד אחד קדימה והגדירה אלמנט חזק בשפה לממשק, הרי הוא ה interface.

אט אט, שמו לב עוד ועוד מפתחים שב interface הם משתמשים המון והוא עובד מצוין, אבל בהורשה הם משתמשים פחות - ויש עם הורשה כמה בעיות.

מה הבעיה עם הורשה?
התובנה על המגבלות/בעיות בהורשה לא נתגלו עם ג'אווה. עוד בשנת 1992 הציג סקוט מאיירס, בספרו האלמותי "++Effective C" מספר בעיות עם הורשה והזהיר: "use inheritance judiciously". שנתיים אחר-כך, בספרם המפורסם של GoF, הדבר נאמר יותר במפורש: "Favor object composition over inheritance" - נדבר על כלל זה בהמשך.

הבעיה העיקרית עם הורשה היא שהיא פוגעת בהכמסה. אם הכמסה הוא העיקרון החשוב ביותר, והורשה הוא החשוב פחות - די ברור מכאן מה צריך לעשות. יתרה מכך, להורשה יש אלטרנטיבה לא רעה. היא דורשת הקלדה של קוד נוסף - אך היא איננה מערערת על ההכמסה. כלומר ניתן להיפטר לחלוטין מהורשה ולכתוב קוד OO מצוין.

רוב הסיכויים שמפתחים ותיקים ומנוסים ירצו עדיין להשתמש בהורשה [2] ועבור מפתחים צעירים (הייתי אומר 5 שנים או פחות) - מומלץ לדסבלה (disable it).

הורשה פוגעת בהכמסה: שדות או מתודות המסומנות protected נגישות לכל מי שיורש ממני ואין לי מושג מה יעשו איתם. התחזוקה של מחלקת האב הופכת למורכבת.

הורשה פוגעת בהכמסה: נוצר coupling לא מפורש בין המחלקה היורשת למחלקת האב, כך ששינויים במימוש אצל האב, גם באזורים שמוגדרים private, עלולים להשפיע על המחלקה היורשת. הנה דוגמה:


public class InstrumentedHashSet<E> extends HashSet<E> {
  private int addCount = 0;
  ...
  @Override public boolean add(E e) {
    addCount++;
    return super.add(e);
  }

  @Override public boolean addAll(Collection<? extends E> c) {
    addCount += c.size();
    return super.addAll(c);
  }

  public int getAddCount() {
    return addCount;
  }
}



InstrumentedHashMap היא הורשה שנוצרה על מנת לספור את מספר הפעולות על הרשימה.הריצו לרגע את הקוד בראש: אם אוסיף אוסף של עשרה איברים, מה תהיה התוצאה של getAddCount?

התשובה היא: It Depends. 
אם אני רץ ב JDK 5 או ישן יותר התוצאה תהיה 20, אולם החל מ JDK 6 התוצאה תהיה 10. באופן רשמי ה API של HashSet לא השתנה כלל בין גרסאות ה JDK, אולם כאשר אני משתמש בהורשה אני נחשף ל API לא מפורש ולא מתועד: הקשר בין המתודות. במקרה שלנו זוהי הנקודה האם addAll קורא למתודת add או לא. כאשר אני יורש, אני יורש גם את הקשר הזה בין המתודות. בJDK 6 החליטו לוותר על הקריאה ל add מתוך addAll (עבור שיפור ביצועים) וכך שינו את החוזה הלא מפורש - אך שמשפיע בהחלט על המימוש שלי ל InstrumentedHashSet.
זהו בדיוק ה"שינוי הפנימי" שלא צריך להפריע לאף אחד אך במקרה שלנו, בעקבות ההורשה, הוא שובר פונקציונליות.

הורשה פוגעת בהכמסה: מתודות כמו equals, compareTo, toString ו hashCode, שמומשו באב, מאבדות את נכונותן בעקבות הורשה. יש לכתוב אותן מחדש ולתת גם למחלקת האב (שעשויה להשתנות) לומר את דברה. לא פשוט.

הורשה פוגעת בהכמסה: אם מחלקת האב הכריזה על Serilazible, Clonable, Entity וכו' - המחלקה היורשת יכולה לשבור הגדרה זו בכל רגע בלי יכולת להכריז על ההיפך. יותר גרוע: ירשתי ממחלקה שלא הייתה Serializable ולאחר שנה מחלקת האב הפכה לכזו - הקומפיילר לא יאמר לי דבר. הבעיה תשאר בעינה.

נו... אני מקווה שהבהרתי את הנקודה.

מה הפתרון? הפתרון הוא להשתמש בקשר הרכבה בין אובייקטים, מה שנקרא Composition. הוא כ"כ נפוץ ושימושי כך שהוא קיבל סימון משלו ב UML. אני אמנע מלכתוב על נושא שכבר תועד רבות. למי שמתעצל לחפש, הנה מקור קצת ישן, אך שנראה טוב:
http://www.artima.com/designtechniques/compoinh.html

סיכום
המסקנה ברורה: אם העיקרון החשוב (הכמסה) סובל מעקרון משני (הורשה) - זו סיבה מספיקה להחרים את העיקרון הסורר והפחות חשוב.

לצערי, עם כל זרם השפות החדשות שנוחת עלינו בשנים אלו (לינק) אף אחד לא הכחיד את ההורשה. הייתי שמח לראות וריאנט של ג'אווה או #C שהופך את extends, ברמת השפה, לפעולת composition במקום הורשה. זה פשוט ואפשרי.

עד אז - השמרו לכם מהורשה. ואם אתם שומעים את את עצמכם, או אנשים אחרים לידכם, אומרים "זה ממש וודו. התנהגות תוכנה זה לפעמים משהו מיסטי שלא ניתן להסביר" - בדקו שוב. אולי השתמשתם בכלי בעל flaws, כמו הורשה - ופשוט לא שמתם לב / אתם לא מודעים לפרטים.


-----

[1] אם רוצים לדייק יש לקרוא לזה Idiom, שזה Pattern שנכון לשפת תכנות ספציפית. Patterns הם נכונים בכלל ולא רק לשפה ספציפית.

[2] הנה בשפת סקאלה הם החזירו לחיים את ההורשה המרובה. אבוי!



16 תגובות:

  1. הדבר המוזר בירושה שהיא לא תמיד מתנהגת 100% אותו דבר בכל השפות, וזה יוצר בילבול.
    לא הייתי דואג לגבי הכמסה כמו שהייתי באמת דואג לסיבה האחרונה שהבאת,
    שזה דברים מוזרים שיכולים לקרות אי שם בשרשרת ההורשה
    שקשה יותר לאתר עם כל חוליה נוספת בשרשרת.

    זו שאלה עתיקת יומין, קומפוזיציה נגד הורשה,
    אני אמשיך להשתמש בהורשה כל עוד יש המשך ישיר בין המחלקות.

    בכל אופן, תמיד כיף לקרוא פוסטים שלך לראות זוויות אחרות.

    השבמחק
  2. "צערי, עם כל זרם השפות החדשות שנוחת עלינו בשנים אלו (לינק) אף אחד לא הכחיד את ההורשה. הייתי שמח לראות וריאנט של ג'אווה או #C שהופך את extends, ברמת השפה, לפעולת composition במקום הורשה. זה פשוט ואפשרי."
    זה נכון -אבל לא מעט שפות מוסיפות traits שזה די מקביל

    השבמחק
  3. גם אני לאחרונה נוטש את ההורשה לטובת קומפוזיציה.
    מצד המשתמש במחלקה, יותר פשוט להשתמש במחלקה שהממשק שהיא חושפת החוצה (קרי, המתודות הפומביות שלה) הוא ברור, חד-משמעי, ומשקף אך ורק את המטרה שלשמה היא נוצרה. לחשוף מתודות שהתקבלו בירושה, רק בגלל שהן התקבלו בירושה, עשוי לפגוע באבסטרקציה שהמחלקה אמורה לייצר, ויכול לבלבל את המשתמש.

    השבמחק
  4. תודה לכולם על התגובות.

    Traits אני מכיר מסקאלה, ועד כמה שאני יודע Traits ממחזר את הבעיות של ההורשה (אלא אם אתה שומר על Traits אבסטרקטים שזה סוג של interface בחזרה). Traits גם מותירים mix-in שזה בפועל הורשה-מרובה וכבר יש המלצות ברשת כיצד לשמור על עצמך מפני השפה, הנה דוגמא: http://stackoverflow.com/questions/3422606/mixins-vs-composition-in-scala

    השבמחק
  5. הבנתי את הבעיה הכללית בהורשה, אך לא הבנתי ספציפית את הדוגמה להמחשה.
    בין אם הפעולה AddAll במחלקת בסיס (HashSet) קוראת לפעולה Add או לא, איפה היא בידיוק מגיע למצב שהיא מקדמת את המשתנה מונה שלך? הרי זה לא שהפעולה AddAll במחלקת בסיס קוראת לפעולה Add במחלקה שיורשת.

    אשמח להסבר.
    תודה.

    השבמחק
  6. נניח מחלקה A עם מתודות f ומחלקה B הדורסת את f בעזרת f'.

    כשנוצר אובייקט חדש b מסוג B, בעצם נוצרים בזכרון גם אובייקט מסוג A ("החלק הAי של b") ואובייקט B ("החלק הBי של b"). את המצביעים למתודות של A שB דרס (כלומר f) - דורסים וכעת הם מצביעים למתודות של B, כלומר f'.

    ברגע שמתודה פנימית (אפילו private) בחלק הAי של b
    קוראת ל f - מתודה f' היא זו שתתבצע.

    בהנחה שאני לא עוקב אחר הקוד של A, אני לא יכול לדעת מתי מתודה f' תקרא. מובטח לי שזה בעל פעם שחלק מ A קורא ל f. הרבה פעמים זה מה שאני רוצה, אבל לפעמים זה לא מה שאני רוצה - ואין לי שום יכולת לחזות את זה.

    מה שקורה בדוגמא הוא ש AddAll בהחלט קורא ל Add במחלקה שיורשת (למרות שמי שכתב את האב ואת הבן - אף אחד מהם לא תכנן את זה)

    אני מקווה שההסבר ברור.

    השבמחק
  7. אכן מובן וברור כעת.
    תודה!

    השבמחק
  8. תודה על המאמר. שתי נקודות:

    1. בתור אחד שחושב ומשתמש בהורשה כל כך הרבה זמן, קשה לי לחשוב אחרת. אני אשתדל להשתמש בהרכבה בפעמים הבאות לפני שאקפוץ על פתרון ההורשה.

    2. javascript - לא התייחסת כל כך לשפה זו שהולכת ונעשית מרכזית יותר ויותר. חסידי השפה מתגאים בכך שב javascript הורשה היא רק אחת מצורות רבות של מיחזור קוד ושפות כמו Java ודומיה מוגבלות בגישה זו. עדיין לא חוויתי את היתרון, אבל אשמח להתייחסות מעמיקה בעניין זה.

    השבמחק
  9. פוסט מצוין!
    אני כבר הרבה זמן ממליץ למי שעובד איתי להימנע מירושה כשרק אפשר.
    בהזדמנות זו הייתי מוסיף רפרנס ל LSP.
    ושוב, פוסט מצוין, תודה!

    השבמחק
  10. לגבי JavaScript : אמנם היא שפה דינאמית ופונקיונלית ואולי אפשר לטעון שיש לה יותר יכולות לשימוש חוזר בקוד. אני, במשך השנים, מאמין פחות ופחות ביעילות של מחזור קוד (כלומר עדיין מאמין - אבל פחות) אבל זה נושא לפוסט אחר.
    המטרה של תכנות מונחה עצמים היה להתמודד עם מערכות גדולות ולא בהכרח למחזר קוד. בכל מקרה לכל שפה יש תכונות רבות לפיהן יש לשפוט אותה. אם דיברנו כאן על הסכנה שבהורשה, אזי גאווהסקריפט היא ג׳ונגל של סכנות. שמתי עין לאחרונה על תחליף/שיפור לג׳אווהסקריפט בשם CoffeeScript שאמורה לשפר את המצב, אך לא הזדמן לי עדיין להתעמק בה.  

    השבמחק
  11. פוסט מצוין!
    עם זאת, ישנן דוגמאות נדירות לשימוש נכון בהורשה כאשר יש צורך ממשי בשימוש חוזר של קוד ואינטרס רב יותר בתוכן האובייקט ולא בפונקציונליות שלו - כגון המימוש של מיקרוסופט בכל היררכיית האובייקטים הגרפים (אפשר להסתכל על המימוש ב-WPF כדוגמה).

    השבמחק
  12. אתה צודק. היה חשוב לי לחדד את המסר, גם על חשבון דיוק מלא.

    הכוונה הייתה להזיז בתודעה את ההורשה מאיזור ה"ככה כותבים תוכנה טובה" לאיזור ה"מסוכן - נא להזהר".

    כמובן שניתן להמנע מהסכנות ולכתוב קוד שימושי וטוב, אבל כתנאי לכך צריך ידע על הסכנות או מזל.

    ליאור

    השבמחק
  13. אני חושב שכדאי להבהיר בפוסט שאין בעיה בהורשה עצמה, אלא ב"שימוש במידע פנים" (מה שאסור בבורסה - צריך להיות אסור גם כאן) וקל וחומר שינוי מידע פנימי.
    כלומר, כל עוד נמנעים במחלקה-הבת מ-override (במונחי C#) ומהתייחסות ל-protected-ים - אין בעיה. זה הופך את הירושה לקומפוזישן דה-פקטו, אבל זה מאפשר מה שקומפוזישן לא מאפשר בצורה נוחה: גישה ישירה לחלקים הציבוריים של המחלקה הפנימית/האמא (והאפשרות להתייחס לאובייקט כולו כמו המחלקה הפנימית/האמא).

    השבמחק
    תשובות
    1. היי אנונימי,

      אם לקרוקודיל לא היו שיניים חדות או אחיזת-פרא - הוא לא היה מסוכן.
      כנ"ל אם להורשה לא הייתה גישה ל private members (והיא הייתה בעצם קומפוזיציה) - היא לא הייתה מסוכנת.

      בזה אני מסכים :)

      מחק
  14. > לצערי, עם כל זרם השפות החדשות שנוחת עלינו בשנים אלו (לינק) אף אחד לא הכחיד את ההורשה.

    תסתכל על Go של Google, יש שם רק Interface,
    http://golang.org

    השבמחק