הרכבה במקום ירושה: איך לתכנן אובייקטים גמישים בלי לבנות עץ מחלקות שביר
הכלל “העדיפו הרכבה על פני ירושה” נשמע לפעמים כמו סיסמה שמפתחים חוזרים עליה בלי לבדוק את ההקשר. אבל מאחוריו עומדת בעיה ממשית בתכנון מונחה עצמים: ירושה קושרת מחלקות זו לזו בקשר חזק, לעיתים מוקדם מדי, ולעיתים סביב דמיון שטחי במקום סביב אחריות אמיתית.
הרכבה, לעומת זאת, מעבירה את הדגש מ־“מה האובייקט הוא” אל “עם מי האובייקט משתף פעולה”. במקום לבנות מחלקה שיורשת התנהגות ומנסה להתאים אותה לכל תרחיש, בונים אובייקט שמחזיק אובייקטים אחרים ומאציל להם חלק מהעבודה. זהו רעיון מרכזי בתבניות GoF רבות, במיוחד Strategy, Decorator, State ו־Composite.
במאמר הזה נפרק את הכלל בצורה מעשית: למה הוא קיים, מתי הוא עוזר, מתי ירושה עדיין נכונה, ואיך נראה שינוי קוד לפני ואחרי. אם צריך ריענון רחב יותר של המושגים, כדאי להתחיל גם ב־מבוא לתבניות עיצוב.
למה ירושה נראית נוחה בהתחלה
ירושה מציעה התחלה מפתה: יש מחלקה קיימת, צריך וריאציה שלה, אז יוצרים תת־מחלקה. אם יש Order, ניצור DiscountedOrder. אם יש משלוח, ניצור ShippedOrder. אם צריך גם הנחה וגם משלוח, אולי DiscountedShippedOrder. בשלב הזה עוד לא מרגישים את הבעיה.
הקושי מופיע כאשר צירי שינוי שונים מתחילים להתערבב:
- סוג ההנחה: קבועה, באחוזים, לפי קופון, לפי לקוח.
- אופן ההצגה: טקסט פשוט, JSON, HTML, קבלה פנימית.
- אופן המשלוח: ללא משלוח, שליח, איסוף עצמי, משלוח בינלאומי.
- כללי אימות: מלאי, תשלום, כתובת, מגבלות עסקיות.
אם כל ציר כזה הופך לענף בעץ ירושה, מספר המחלקות גדל מהר. גרוע מזה: מחלקה אחת מתחילה לייצג שילוב מקרי של החלטות. השם שלה מספר סיפור טכני, לא מודל נקי.
מה באמת אומר “הרכבה במקום ירושה”
הרכבה אינה אומרת “אסור להשתמש בירושה”. היא אומרת: לפני שיוצרים קשר is-a קשיח, בדקו אם עדיף קשר has-a או uses-a. כלומר, במקום לומר “הזמנה מוזלת היא סוג של הזמנה”, אפשר לומר “שירות הזמנות משתמש במדיניות הנחה”.
במונחי UML, ירושה מתוארת כקו הכללה בין מחלקות. הרכבה או צבירה מתארות קשר שבו אובייקט אחד מחזיק או משתמש באובייקט אחר. בפועל, השאלה החשובה אינה הצורה בדיאגרמה אלא כיוון התלות: האם שינוי בהתנהגות אחת מחייב שינוי בכל ההיררכיה, או שאפשר להחליף משתף פעולה קטן וממוקד?
הכלל הזה קשור ישירות ל־SOLID:
- SRP: כל מחלקה אחראית לדבר אחד ברור.
- OCP: מוסיפים התנהגות חדשה באמצעות מחלקה חדשה, לא באמצעות עריכת תנאים קיימים בכל מקום.
- LSP: תת־מחלקה לא שוברת את הציפיות ממחלקת האב.
- DIP: קוד ברמה גבוהה תלוי בממשקים, לא במימושים קונקרטיים.
דוגמה לפני ואחרי: הנחה ותצוגת קבלה
נניח שיש מערכת קטנה שמחשבת מחיר סופי להזמנה ומדפיסה קבלה. בגישה מבוססת ירושה, קל להתחיל ממחלקת בסיס וליצור וריאציות. אבל ברגע שיש גם סוגי הנחה וגם פורמטים שונים של קבלה, הירושה תתחיל לייצר שילובים.
בדוגמה הבאה יש חלק “לפני” שמראה ירושה פשוטה, וחלק “אחרי” שמראה הרכבה באמצעות שני שיתופי פעולה: DiscountPolicy ו־ReceiptFormatter. הדוגמה מלאה וניתנת להרצה כקובץ Main.java.
public class Main {
public static void main(String[] args) {
// Before: behavior is inherited from a specialized order.
BeforeDiscountedOrder before = new BeforeDiscountedOrder(100.0, 15.0);
System.out.println(before.receipt());
// After: behavior is composed from small collaborators.
DiscountPolicy discount = new PercentageDiscountPolicy(15.0);
ReceiptFormatter formatter = new PlainTextReceiptFormatter();
OrderService service = new OrderService(discount, formatter);
System.out.println(service.receiptFor(100.0));
}
static class BeforeOrder {
protected final double total;
BeforeOrder(double total) {
this.total = total;
}
double finalTotal() {
return total;
}
String receipt() {
return "Total: " + finalTotal();
}
}
static class BeforeDiscountedOrder extends BeforeOrder {
private final double percent;
BeforeDiscountedOrder(double total, double percent) {
super(total);
this.percent = percent;
}
@Override
double finalTotal() {
return total - (total * percent / 100.0);
}
@Override
String receipt() {
return "Discounted total: " + finalTotal();
}
}
interface DiscountPolicy {
double apply(double total);
}
static class NoDiscountPolicy implements DiscountPolicy {
public double apply(double total) {
return total;
}
}
static class PercentageDiscountPolicy implements DiscountPolicy {
private final double percent;
PercentageDiscountPolicy(double percent) {
this.percent = percent;
}
public double apply(double total) {
return total - (total * percent / 100.0);
}
}
static class FixedDiscountPolicy implements DiscountPolicy {
private final double amount;
FixedDiscountPolicy(double amount) {
this.amount = amount;
}
public double apply(double total) {
double result = total - amount;
return result < 0 ? 0 : result;
}
}
interface ReceiptFormatter {
String format(double originalTotal, double finalTotal);
}
static class PlainTextReceiptFormatter implements ReceiptFormatter {
public String format(double originalTotal, double finalTotal) {
return "Original: " + originalTotal + ", Final: " + finalTotal;
}
}
static class OrderService {
private final DiscountPolicy discountPolicy;
private final ReceiptFormatter receiptFormatter;
OrderService(DiscountPolicy discountPolicy, ReceiptFormatter receiptFormatter) {
this.discountPolicy = discountPolicy;
this.receiptFormatter = receiptFormatter;
}
String receiptFor(double total) {
double finalTotal = discountPolicy.apply(total);
return receiptFormatter.format(total, finalTotal);
}
}
}בגרסה הראשונה, BeforeDiscountedOrder יורשת מ־BeforeOrder ומשנה גם את החישוב וגם את הטקסט. זה עובד, אבל שתי סיבות שינוי חיות באותה מחלקה. אם מחר תהיה הנחה קבועה עם פורמט קבלה אחר, נצטרך להחליט אם להוסיף עוד תת־מחלקה או תנאים בתוך המחלקות הקיימות.
בגרסה השנייה, OrderService אינו יודע איך מחשבים הנחה ואינו יודע איך מעצבים קבלה. הוא משתף פעולה עם שני ממשקים. PercentageDiscountPolicy אחראית לחישוב אחוזים בלבד, ו־PlainTextReceiptFormatter אחראית לטקסט בלבד. זהו מעבר מירושה של התנהגות אל הרכבה של התנהגויות.
מי משחק איזה תפקיד
| רכיב | תפקיד בתכנון | מה משתנה סביבו |
|---|---|---|
OrderService |
מתאם את התהליך | בדרך כלל מעט מאוד; הוא מחבר בין משתפי פעולה |
DiscountPolicy |
ממשק למדיניות הנחה | סוגי הנחות חדשים |
ReceiptFormatter |
ממשק לעיצוב פלט | פורמטים חדשים של קבלה |
PercentageDiscountPolicy |
מימוש קונקרטי של חישוב | כללי אחוזים בלבד |
מבנה כזה קרוב מאוד לרעיון של Strategy: אלגוריתם משתנה מיוצג כאובייקט נפרד, והלקוח מחזיק הפניה לממשק. זה לא מחייב “להכניס תבנית” בשם מפוצץ; לפעמים זו פשוט דרך נקייה להוציא אחריות ממחלקה שהתנפחה.
איך מבצעים מעבר מירושה להרכבה, שלב אחר שלב
1. מזהים צירי שינוי
לפני שנוגעים בקוד, מחפשים מה משתנה מסיבה עצמאית. אם מחלקה משתנה גם בגלל חישוב מחיר, גם בגלל פורמט פלט וגם בגלל הרשאות, יש כאן כמה צירי שינוי. כל ציר כזה מועמד להפוך לממשק או לאובייקט משתף פעולה.
2. מחפשים שמות של פעולות, לא שמות של סוגים
עצי ירושה נוטים להוליד שמות כמו PremiumInternationalDiscountedOrder. בהרכבה השמות נוטים להיות קטנים יותר: DiscountPolicy, ShippingCalculator, InvoiceFormatter. אם השם של המחלקה כולל שלושה תארים, זו נורת אזהרה.
3. מוציאים ממשק קטן
אין צורך להתחיל ממבנה גדול. ממשק טוב יכול להכיל מתודה אחת אם היא מייצגת החלטה יציבה. למשל apply עבור הנחה או format עבור קבלה. ממשק קטן קל לבדוק, קל להחליף, וקשה פחות להבין.
4. מזריקים את משתף הפעולה
במקום שהמחלקה תיצור בעצמה את המדיניות, היא מקבלת אותה מבחוץ. זה יכול להיות דרך בנאי, מתודה או תצורה של שכבה גבוהה יותר. לא צריך לקפוץ מיד למסגרת Dependency Injection; הרעיון העיצובי חשוב יותר מהכלי.
5. מוחקים ירושה רק כשההתנהגות כבר מכוסה
מעבר בטוח נעשה בהדרגה. קודם מוציאים אחריות, מוסיפים בדיקות אם יש, מריצים תרחישים קיימים, ורק אז מוחקים מחלקות ביניים מיותרות. היעד אינו “אפס ירושה”, אלא מודל שבו כל קשר ירושה שנותר מוצדק.
מתי ירושה דווקא נכונה
יש מצבים שבהם ירושה היא הכלי המדויק. הכלל אינו איסור, אלא העדפה זהירה.
- קשר
is-aאמיתי ויציב: אם תת־מחלקה באמת יכולה להחליף את מחלקת האב בלי להפתיע את המשתמשים בה, ירושה עשויה להתאים. - מימוש בסיס משותף: כאשר יש קוד משותף יציב מאוד, ומספר קטן של הרחבות צפויות, מחלקת בסיס מופשטת יכולה להיות פתרון פשוט.
- Template Method: תבנית GoF שבה מחלקת אב מגדירה שלד אלגוריתם ותת־מחלקות מממשות צעדים מסוימים. כאן הירושה היא חלק מהכוונה התכנונית, לא תקלה.
- מודלים סגורים יחסית: אם תחום הבעיה קטן, ברור וכמעט לא משתנה, עץ ירושה קצר עשוי להיות קריא יותר מהרבה אובייקטים קטנים.
הבדיקה החשובה היא עקרון ההחלפה של ליסקוב. אם קוד שמצפה ל־Order נשבר כשהוא מקבל SpecialOrder, הירושה כנראה לא מתארת קשר נכון. אם תת־מחלקה זורקת חריגות עבור פעולות שהאב מבטיח, מחזירה ערכים במבנה אחר, או דורשת סדר קריאות חדש — זה סימן רע.
הקשר לתבניות GoF ולתבניות יצירה
הרבה תבניות עיצוב בנויות סביב שיתוף פעולה בין אובייקטים. Strategy מחליפה אלגוריתם, Decorator עוטפת אובייקט ומוסיפה אחריות, Observer מפרידה בין מקור אירוע למאזינים, ו־Facade מסתירה תת־מערכת מאחורי ממשק פשוט. בכולן, ההבנה המרכזית היא שאובייקטים לא רק “יורשים”; הם מדברים זה עם זה.
גם תבניות יצירה משתלבות כאן. אם קשה ליצור את הגרף של האובייקטים, אפשר להשתמש במפעלים או בבנאים כדי לרכז את החלטת היצירה. למשל, במקום ש־OrderService יחליט איזו DiscountPolicy לבנות, שכבה אחרת יכולה לבחור אותה לפי תצורה או סוג לקוח. הרחבה בנושא נמצאת ב־תבניות יצירה.
טעויות נפוצות כשעוברים להרכבה
יותר מדי ממשקים מוקדם מדי
הרכבה אינה תירוץ ליצור ממשק לכל מחלקה. אם יש מימוש יחיד, אין צפי לשינוי, והמחלקה קטנה וברורה, ייתכן שממשק רק יוסיף רעש. ממשק טוב מייצג נקודת שינוי אמיתית, לא הרגל אוטומטי.
אובייקטים קטנים בלי בעל בית
לפעמים מפרקים מחלקה גדולה לעשרה אובייקטים, אבל אף אחד לא מבין מי מנהל את התהליך. לכן חשוב להשאיר אובייקט מתאם, כמו OrderService, שמספר את סיפור הזרימה. הרכבה טובה לא מפזרת אחריות באקראי; היא מחלקת אותה.
העברת תנאים ממקום אחד למקום אחר
אם הוצאתם if ממחלקה אחת ושמתם אותו במפעל ענק עם עשרות תנאים, לא באמת פתרתם את הבעיה. לפעמים זה עדיין שיפור כי התנאים מרוכזים בנקודת יצירה, אבל צריך להיזהר ממפעל שהופך למרכז הכובד של כל המערכת.
בלבול בין הרכבה לבין החזקה טכנית
מחלקה שמחזיקה חמש תלויות אינה בהכרח מתוכננת היטב. השאלה היא האם התלויות מייצגות שיתופי פעולה בעלי משמעות, או שהן פשוט רשימת שירותים שהמחלקה קוראת להם כי היא עושה יותר מדי.
מתי לא להשתמש בהרכבה
יש מקרים שבהם הרכבה תסבך במקום לעזור:
- כאשר יש התנהגות אחת פשוטה ולא צפויות וריאציות.
- כאשר הפיצול יוצר הרבה מחלקות עם שמות מלאכותיים.
- כאשר ביצועי זמן ריצה קריטיים מאוד וכל שכבת עקיפה נמדדת בפועל, לא בתחושת בטן.
- כאשר הצוות לא מצליח לעקוב אחרי גרף האובייקטים, והמודל נהיה מופשט מדי.
עיצוב טוב אינו מרדף אחרי הפשטה מרבית. לפעמים מחלקה אחת עם שני תנאים ברורים עדיפה על מערכת קטנה של ממשקים, מפעלים ומחלקות אסטרטגיה. המדד הוא שינוי עתידי סביר, לא יופי צורני.
איך זה נראה בדיאגרמת UML
בדיאגרמת מחלקות UML, הגרסה המבוססת על ירושה תראה חץ הכללה מתת־המחלקה אל מחלקת האב. זה פשוט לציור, אבל לא תמיד מספר את כל האמת. הגרסה המבוססת על הרכבה תראה את OrderService מחזיק קשרים אל DiscountPolicy ו־ReceiptFormatter, כאשר מחלקות קונקרטיות מממשות את הממשקים האלה.
אם מציירים את זה לצוות, כדאי לא להעמיס. דיאגרמה טובה למקרה כזה כוללת:
- מחלקת מתאם אחת.
- שני ממשקים קטנים.
- שניים או שלושה מימושים לדוגמה.
- חצים שמראים תלות בממשק, לא במימוש.
המטרה של UML כאן אינה תיעוד כבד, אלא שיחה מדויקת: מה משתנה, מי תלוי במי, ואיפה מוסיפים התנהגות חדשה בלי לערוך קוד קיים.
שאלות שקוראים באמת שואלים
האם הרכבה תמיד טובה יותר לבדיקות?
בדרך כלל היא מקלה, כי אפשר לבדוק כל משתף פעולה בנפרד ולהעביר מימושים פשוטים בזמן בדיקה. אבל אם המערכת מפורקת יתר על המידה, הבדיקות עלולות להפוך לאוסף של פרטים טכניים. בדקו התנהגות משמעותית, לא רק קריאות בין אובייקטים.
האם כל extends הוא ריח קוד?
לא. extends הוא סימן שצריך לשאול שאלה, לא הוכחה לבעיה. אם תת־המחלקה עומדת בחוזה של האב, וההיררכיה קצרה וברורה, ירושה יכולה להיות פתרון נקי.
איך יודעים שהגענו להפשטה נכונה?
כאשר שינוי חדש נכנס במקום צפוי. אם כדי להוסיף סוג הנחה חדש צריך ליצור מחלקה אחת שמממשת DiscountPolicy, בלי לערוך את OrderService, ההפשטה כנראה מועילה. אם צריך לעדכן חמישה מקומות, משהו עדיין מעורבב.
סיכום מעשי
הרכבה במקום ירושה היא דרך לחשוב על אובייקטים כמשתפי פעולה, לא רק כחברים בעץ משפחה. היא עוזרת להפריד צירי שינוי, לצמצם מחלקות ענק, ולהוסיף התנהגות בלי לשבור חוזים קיימים. אבל היא אינה דת: ירושה קצרה, יציבה ומוצדקת עדיין שייכת לארגז הכלים של תכנון מונחה עצמים.
בפעם הבאה שאתם עומדים ליצור תת־מחלקה, עצרו לרגע ושאלו: האם זה באמת “סוג של”, או שזה אובייקט שצריך “להשתמש ב־”? אם התשובה השנייה נשמעת טבעית יותר, ייתכן שהגיע הזמן להחליף ירושה בהרכבה קטנה, מפורשת וקריאה.


