למה מאריך זה רע

extendsמילת המפתח היא רעה; אולי לא ברמה של צ'רלס מנסון, אבל מספיק גרוע מכדי שאפשר יהיה להימנע ממנה. הספר "חבורת ארבע דפוסי העיצוב " דן בהרחבה בהחלפת ירושת יישום ( extends) בירושת ממשק ( implements).

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

ממשקים מול שיעורים

פעם השתתפתי בפגישה של קבוצת משתמשים בג'אווה בה ג'יימס גוסלינג (ממציא ג'אווה) היה הדובר המוצג. במהלך מושב השאלות והתשובות הבלתי נשכחות, מישהו שאל אותו: "אם היית יכול לעשות ג'אווה שוב, מה היית משנה?" "הייתי עוזב שיעורים," הוא ענה. לאחר שהצחוק שקט, הוא הסביר כי הבעיה האמיתית אינה שיעורים כשלעצמם, אלא ירושת יישום ( extendsהקשר). ירושת ממשק ( implementsהקשר) עדיפה. כדאי להימנע מירושת יישום במידת האפשר.

מאבד גמישות

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

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

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

תכנות לממשקים הוא הליבה של מבנה גמיש. כדי לראות מדוע, בואו נסתכל מה קורה כשלא משתמשים בהם. שקול את הקוד הבא:

f () {LinkedList list = חדש LinkedList (); // ... ז (רשימה); } g (רשימת LinkedList) {list.add (...); g2 (רשימה)}

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

שכתוב הקוד כך:

f () {רשימת אוספים = LinkedList חדש (); // ... ז (רשימה); } g (רשימת אוספים) {list.add (...); g2 (רשימה)}

מאפשר לשנות את הרשימה המקושרת לטבלת hash פשוט על ידי החלפת ה- new LinkedList()a new HashSet(). זהו זה. אין צורך בשינויים אחרים.

כדוגמה נוספת, השווה קוד זה:

f () {אוסף c = HashSet חדש (); // ... ז (ג); } g (אוסף ג) {עבור (Iterator i = c.iterator (); i.hasNext ();) do_something_with (i.next ()); }

לזה:

f2 () {אוסף c = HashSet חדש (); // ... g2 (c.iterator ()); } g2 (Iterator i) {while (i.hasNext ();) do_something_with (i.next ()); }

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

צימוד

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

כמעצב, עליך לשאוף למזער מערכות יחסים בין צימודים. אינך יכול לבטל את ההצמדה לחלוטין מכיוון שקריאת שיטה מאובייקט ממעמד אחד לאובייקט של אחרת היא סוג של צימוד רופף. אתה לא יכול לקבל תוכנית ללא צימוד כלשהו. עם זאת, ניתן למזער צימוד באופן ניכר על ידי ביצוע הוראות OO (מונחות עצמים) בצורה עבדות (החשוב ביותר הוא כי יישום של אובייקט צריך להיות מוסתר לחלוטין מפני האובייקטים המשתמשים בו). לדוגמה, משתני המופע של אובייקט (שדות חבר שאינם קבועים), צריכים להיות תמיד private. פרק זמן. ללא יוצאי דופן. אֵיִ פַּעַם. אני מתכוון לזה. (ניתן להשתמש מדי פעם protectedבשיטות בצורה יעילה, אךprotected משתני מופע הם תועבה.) לעולם אל תשתמש בפונקציות קבל / הגדר מאותה סיבה - הם פשוט דרכים מסובכות מדי להפוך שדה לציבורי (אם כי פונקציות גישה שמחזירות אובייקטים מלאים במקום ערך בסיסי הן סביר במצבים שבהם המעמד של האובייקט שהוחזר הוא הפשטה מרכזית בעיצוב).

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

בעיית המעמד הבסיסית השברירית

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

בואו נבדוק את בעיות הצמדה השבירות של המעמד הבסיסי ושל המעמד הבסיסי. המחלקה הבאה מרחיבה את ArrayListהמחלקה של Java כך שהיא תתנהג כמו מחסנית:

class Stack מרחיב את ArrayList {private int stack_pointer = 0; דחיפת חלל ציבורית (מאמר אובייקט) {הוסף (stack_pointer ++, מאמר); } פופ אובייקט ציבורי () {return remove (--stack_pointer); } push_many (ריק מאמרים [] מאובטלים) ציבורי {עבור (int i = 0; i <articles.length; ++ i) push (מאמרים [i]); }}

אפילו בכיתה פשוטה כמו זו יש בעיות. שקול מה קורה כשמשתמש ממנף את הירושה ומשתמש בשיטה ArrayListשל clear()כדי להקפיץ הכל מהערימה:

מחסנית a_stack = מחסנית חדשה (); a_stack.push ("1"); a_stack.push ("2"); a_stack.clear ();

הקוד מהדר בהצלחה, אך מכיוון שמחלקת הבסיס אינה יודעת דבר על מצביע הערימה, Stackהאובייקט נמצא כעת במצב לא מוגדר. הקריאה הבאה להכניס push()את הפריט החדש לאינדקס 2 ( stack_pointerהערך הנוכחי של), כך שלערימה יש למעשה שלושה אלמנטים - השניים התחתונים הם זבל. ( Stackבכיתה של ג'אווה יש בדיוק את הבעיה הזו; אל תשתמש בה.)

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

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

פתרון טוב יותר לסוגיית המעמד הבסיסי הוא תמצית מבנה הנתונים במקום שימוש בירושה. הנה גרסה חדשה ומשופרת של Stack:

מחלקה מחלקה {int int stack_pointer = 0; פרטי ArrayList the_data = ArrayList חדש (); דחיפת חלל ציבורית (מאמר אובייקט) {the_data.add (stack_pointer ++, מאמר); } פופ אובייקט ציבורי () {להחזיר את_data.remove (- stack_pointer); } public_pid_many ריק (מאמרים [אובייקטים]] {עבור (int i = 0; i <o.length; ++ i) push (מאמרים [i]); }}

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

class Monitorable_stack מרחיב את Stack {private int high_water_mark = 0; פרטי int הנוכחי_גודל; דחיפת חלל ציבורית (מאמר אובייקט) {if (++ current_size> high_water_mark) high_water_mark = current_size; super.push (מאמר); } פופ אובייקט ציבורי () {- current_size; להחזיר super.pop (); } public int maximum_size_so_far () {return high_water_mark; }}

הכיתה החדשה הזו עובדת היטב, לפחות לזמן מה. למרבה הצער, הקוד מנצל את העובדה push_many()שעושה את עבודתו על ידי התקשרות push(). בהתחלה, פרט זה לא נראה כמו בחירה גרועה. זה מפשט את הקוד, ואתה מקבל את גרסת המחלקה הנגזרת של push(), גם כאשר Monitorable_stackניתן לגשת אליו דרך Stackהפניה, כך high_water_markשהעדכונים נכונים.

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

מחלקה מחלקה {int intack_pointer = -1; אובייקט פרטי [] stack = אובייקט חדש [1000]; דחיפת חלל ציבורית (מאמר אובייקט) {assert stack_pointer = 0; מחסנית החזרה [stack_pointer--]; } ריק ריק / push_many מאמרים (אובייקט []) {assert (stack_pointer + articles.length) <stack.length; System.arraycopy (מאמרים, 0, מחסנית, stack_pointer + 1, articles.length); stack_pointer + = articles.length; }}

שימו לב שכבר push_many()לא מתקשר push()מספר רב של פעמים - זה מבצע העברת חסימות. הגרסה החדשה של Stackעובדת בסדר; למעשה, זה טוב יותר מהגרסה הקודמת. למרבה הצער, Monitorable_stackהמחלקה הנגזרת אינה פועלת יותר מכיוון שהיא לא תעקוב כראוי אחר השימוש בערימה אם push_many()קוראים לה (הגרסה של המחלקה הנגזרת של push()כבר אינה נקראת push_many()בשיטה שעברה בירושה , ולכן push_many()אינה מעדכנת עוד את high_water_mark). Stackהוא מעמד בסיס שברירי. כפי שמתברר, כמעט בלתי אפשרי לחסל בעיות מסוג זה פשוט על ידי זהירות.

שים לב שאין לך בעיה זו אם אתה משתמש בירושת ממשק, מכיוון שאין פונקציונליות תורשתית שתתאים לך. אם Stackהוא ממשק, המיושם על ידי a Simple_stackו- a Monitorable_stack, הקוד הוא הרבה יותר חזק.