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

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

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

כימוס טוב מסתיר החלטות: איך מחשבים הנחה, מתי הזמנה תקפה, מי רשאי לשנות מצב, איזה אובייקט אמור לדבר עם איזה אובייקט, ואיפה מותר להכיר פרטים פנימיים. כאן נכנסים שני כללי תכנון שימושיים במיוחד: Tell, Don't Ask וחוק דמטר. הם לא חוקים מתמטיים, ולא כדאי להפוך אותם לדת. אבל הם נותנים שפה מעשית לזיהוי קוד שמתחיל לדלוף החוצה.

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

כימוס אמיתי: לא “אין גטרים”, אלא “איפה נמצאת ההחלטה”

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

נניח שיש לנו עגלת קניות, לקוח, תוכנית נאמנות ומוצרים. אפשר לכתוב שירות חיצוני ששואל את כל האובייקטים:

  • מה רמת הנאמנות של הלקוח?
  • מה מחיר כל מוצר?
  • כמה יחידות יש מכל פריט?
  • האם יש הנחה?
  • איך מחשבים אותה?

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

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

Tell, Don't Ask: לבקש פעולה במקום לחלץ מצב

Tell, Don't Ask הוא כלל אצבע פשוט: אל תשאל אובייקט על מצבו כדי להחליט במקומו; אמור לו מה אתה רוצה שיקרה, ותן לו להפעיל את הכללים הפנימיים שלו.

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

  • אם customer.getType() הוא VIP, חשב הנחה.
  • אם order.getStatus() הוא DRAFT, אפשר לאשר.
  • אם account.getBalance() גדול מסכום המשיכה, בצע חיוב.

בכל אחת מהדוגמאות האלה יש רמז לכך שהחלטה ברחה החוצה. אולי Customer צריך לדעת לחשב הנחה. אולי Order צריך לדעת האם הוא יכול לעבור לאישור. אולי Account צריך לבצע משיכה ולשמור על האינווריאנטים שלו.

איך מזהים הפרה של Tell, Don't Ask

יש כמה סימנים שחוזרים הרבה בבסיסי קוד:

  • שרשראות של גטרים: order.getCustomer().getPlan().getRules().
  • תנאים לפי טיפוס או סטטוס שמופיעים בהרבה מקומות.
  • שירותים “חכמים מדי” ואובייקטים שהם רק שקי נתונים.
  • בדיקות שבירות שמכירות יותר מדי פרטים פנימיים.
  • שינויים קטנים במדיניות עסקית שדורשים עריכה בכמה אזורים לא קשורים.

המטרה אינה למחוק כל get. המטרה היא לצמצם החלטות חיצוניות שמבוססות על פרטים פנימיים.

חוק דמטר: לדבר עם שכנים, לא עם כל העיר

חוק דמטר, שמוכר גם כ־Principle of Least Knowledge, אומר בקירוב: מתודה צריכה לדבר רק עם אובייקטים קרובים אליה. למשל עם עצמה, עם פרמטרים שקיבלה, עם אובייקטים שהיא יצרה, או עם שדות ישירים שלה. היא לא אמורה לנווט עמוק דרך גרף אובייקטים שלם.

הבעיה אינה אסתטית בלבד. כשקוד כותב משהו כמו cart.getCustomer().getLoyaltyPlan().getDiscountRules().calculate(), הוא תלוי במבנה הפנימי של כמה מחלקות. כל שינוי במבנה הזה, אפילו בלי שינוי עסקי אמיתי, עלול לשבור את הלקוח.

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

דוגמת Java מלאה: עגלה שמסתירה את החלטות התמחור

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

Java
import java.util.ArrayList;
import java.util.List;

public class EncapsulationDemo {
    public static void main(String[] args) {
        Product keyboard = new Product("Keyboard", Money.ofCents(25_000));
        Product mouse = new Product("Mouse", Money.ofCents(12_000));

        Cart cart = new Cart();
        cart.add(keyboard, 1);
        cart.add(mouse, 2);

        Customer customer = new Customer("Dana", new LoyaltyPlan(10));

        Receipt receipt = cart.checkoutFor(customer);
        System.out.println(receipt.summary());
    }
}

final class Cart {
    private final List<LineItem> lines = new ArrayList<>();

    public void add(Product product, int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
        lines.add(new LineItem(product, quantity));
    }

    public Receipt checkoutFor(Customer customer) {
        Money subtotal = Money.zero();

        for (LineItem line : lines) {
            subtotal = subtotal.plus(line.total());
        }

        Money discount = customer.discountFor(subtotal);
        Money total = subtotal.minus(discount);

        return new Receipt(subtotal, discount, total);
    }
}

final class LineItem {
    private final Product product;
    private final int quantity;

    LineItem(Product product, int quantity) {
        this.product = product;
        this.quantity = quantity;
    }

    Money total() {
        return product.priceFor(quantity);
    }
}

final class Product {
    private final String name;
    private final Money unitPrice;

    Product(String name, Money unitPrice) {
        this.name = name;
        this.unitPrice = unitPrice;
    }

    Money priceFor(int quantity) {
        return unitPrice.times(quantity);
    }

    String name() {
        return name;
    }
}

final class Customer {
    private final String name;
    private final LoyaltyPlan loyaltyPlan;

    Customer(String name, LoyaltyPlan loyaltyPlan) {
        this.name = name;
        this.loyaltyPlan = loyaltyPlan;
    }

    Money discountFor(Money subtotal) {
        return loyaltyPlan.discountFor(subtotal);
    }

    String name() {
        return name;
    }
}

final class LoyaltyPlan {
    private final int discountPercent;

    LoyaltyPlan(int discountPercent) {
        if (discountPercent < 0 || discountPercent > 100) {
            throw new IllegalArgumentException("Discount percent must be between 0 and 100");
        }
        this.discountPercent = discountPercent;
    }

    Money discountFor(Money amount) {
        return amount.percentage(discountPercent);
    }
}

final class Receipt {
    private final Money subtotal;
    private final Money discount;
    private final Money total;

    Receipt(Money subtotal, Money discount, Money total) {
        this.subtotal = subtotal;
        this.discount = discount;
        this.total = total;
    }

    String summary() {
        return "Subtotal: " + subtotal
                + ", Discount: " + discount
                + ", Total: " + total;
    }
}

final class Money {
    private final long cents;

    private Money(long cents) {
        if (cents < 0) {
            throw new IllegalArgumentException("Money cannot be negative");
        }
        this.cents = cents;
    }

    static Money zero() {
        return new Money(0);
    }

    static Money ofCents(long cents) {
        return new Money(cents);
    }

    Money plus(Money other) {
        return new Money(this.cents + other.cents);
    }

    Money minus(Money other) {
        long result = this.cents - other.cents;
        if (result < 0) {
            throw new IllegalArgumentException("Result cannot be negative");
        }
        return new Money(result);
    }

    Money times(int multiplier) {
        if (multiplier < 0) {
            throw new IllegalArgumentException("Multiplier cannot be negative");
        }
        return new Money(this.cents * multiplier);
    }

    Money percentage(int percent) {
        return new Money(this.cents * percent / 100);
    }

    @Override
    public String toString() {
        return String.format("%.2f", cents / 100.0);
    }
}

בדוגמה הזו, Cart לא חושפת רשימת פריטים כדי שמישהו אחר יסכם אותה. LineItem יודע לחשב את הסכום שלו. Product יודע לתמחר כמות. Customer לא חושף החוצה את LoyaltyPlan, אלא מספק פעולה בעלת משמעות: discountFor. המחלקה Money שומרת שלא ייווצר כסף שלילי. זה כימוס של החלטות ושל אינווריאנטים, לא רק של שדות.

תפקידים ואחריות בדוגמה

רכיב אחריות מה הוא לא חושף
Cart ניהול פריטים וביצוע checkoutFor את מבנה הרשימה הפנימית
LineItem חישוב מחיר לפריט וכמות את פרטי המוצר ללקוח חיצוני
Product תמחור מוצר לפי כמות את ייצוג המחיר כשדה לשינוי חיצוני
Customer חישוב הנחה רלוונטית ללקוח את מבנה תוכנית הנאמנות
LoyaltyPlan מדיניות ההנחה את פרטי המדיניות למי שלא צריך לדעת
Money פעולות כספיות חוקיות ייצוג פנימי שמאפשר ערכים לא תקינים

שימו לב לנקודה עדינה: Cart כן מבקש מ־Customer לחשב הנחה. זה לא סותר את Tell, Don't Ask, כי הוא לא שואל “מה סוג הלקוח שלך?” ואז מקבל החלטה בעצמו. הוא מבקש פעולה עסקית ברורה, והלקוח מסתיר את הדרך שבה היא מתבצעת.

איך מיישמים את זה בקוד קיים, שלב אחר שלב

1. מחפשים החלטות חיצוניות

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

2. נותנים שם לפעולה העסקית

במקום לחשוב “איך אני מסתיר את השדה?”, שאלו: “מה הפעולה שהקוד החיצוני באמת רוצה לבצע?” למשל:

  • order.canBeCancelled() עשוי להפוך ל־order.cancel().
  • customer.getPlan().getDiscount() עשוי להפוך ל־customer.discountFor(amount).
  • invoice.getLines() וסיכום חיצוני עשויים להפוך ל־invoice.total().

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

3. מעבירים את ההחלטה קרוב לנתונים

אם החלטה תלויה בעיקר בשדות של Order, היא כנראה שייכת ל־Order. אם היא תלויה במדיניות משתנה, ייתכן שהיא שייכת לאובייקט מדיניות נפרד. כאן תבניות כמו Strategy יכולות לעזור, ובמקרים שבהם בניית האובייקטים עצמה מסתבכת, כדאי להכיר גם תבניות יצירה כמו Factory Method או Abstract Factory.

4. משאירים ממשק קטן וברור

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

5. מכסים בהתנהגות, לא בפרטים פנימיים

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

איפה נתקעים בדרך

“אבל אני צריך את הנתונים למסך”

מסכים, דוחות ו־API צריכים נתונים. זה בסדר. ההבחנה היא בין מודל דומיין לבין מודל הצגה או העברה. אפשר לחשוף DTO שמיועד לקריאה, בלי להפוך את אובייקטי הדומיין לשקי נתונים. אם המסך צריך להציג מחיר סופי, תנו לו מחיר סופי; אל תכריחו אותו להרכיב את כל החישוב בעצמו.

“נוצרות לי מתודות קטנות בכל מקום”

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

“השירותים שלי כבר מכילים את כל הלוגיקה”

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

הקשר ל־SOLID ולתבניות עיצוב

Tell, Don't Ask וחוק דמטר מתחברים במיוחד ל־Single Responsibility ול־Open/Closed. כשכל החלטה נמצאת במקום אחד, יש פחות סיבות לשנות מחלקות לא קשורות. כשמדיניות מוחבאת מאחורי ממשק יציב, אפשר לשנות או להחליף אותה בלי לגרור את כל המערכת.

גם בתבניות GoF רבות אפשר לראות את אותו רעיון. Facade מצמצמת היכרות עם תת־מערכת. Mediator משנה את אופן שיתוף הפעולה בין אובייקטים. Strategy מעבירה החלטה משתנה לאובייקט מדיניות. Decorator מוסיף התנהגות בלי לחשוף את המבנה הפנימי של האובייקט המקורי. לכן כימוס טוב הוא לא תחליף לתבניות; הוא הקרקע שעליה הן עומדות.

מחירים ופשרות

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

האיזון הוא לשאול:

  • האם המתודה החדשה מבטאת פעולה עסקית אמיתית?
  • האם היא מסתירה החלטה שצפויה להשתנות?
  • האם היא מצמצמת תלות במבנה פנימי?
  • האם הקוד שקורא לה נעשה ברור יותר?

אם התשובות שליליות, ייתכן שאין צורך בשינוי.

מתי לא להשתמש בגישה הזו בכוח

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

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

שאלות שקוראים באמת שואלים

האם גטרים הם תמיד דבר רע?

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

האם Tell, Don't Ask מתאים גם למיקרו־שירותים?

כן, אבל בזהירות. בתוך שירות בודד, העיקרון עוזר לבנות מודל ברור. בין שירותים, לא “אומרים” לאובייקט מרוחק לבצע כל פעולה קטנה, כי יש עלויות רשת, זמינות וחוזים. ברמת microservices architecture, הכימוס מתבטא גם בכך שכל שירות מסתיר את מודל הנתונים שלו וחושף API שמתאר יכולות, לא טבלאות פנימיות.

האם חוק דמטר אומר שאסור לשרשר מתודות?

לא כל שרשור הוא בעיה. שרשור על אובייקטים בלתי־משתנים או API פלואנטי יכול להיות תקין. הבעיה היא שרשור שחושף מבנה פנימי של דומיין: לקוח, תוכנית, חוקים, פריטים פנימיים וכן הלאה. שם נוצרת תלות שלא באמת צריכה להיות קיימת.

סיכום: כימוס הוא כלי לשינוי בטוח

כימוס טוב לא נמדד במספר השדות הפרטיים, אלא בכמות ההחלטות שנשארות במקום הנכון. Tell, Don't Ask מזכיר לנו לבקש מאובייקטים לבצע פעולות במקום לחלץ מהם מצב ולקבל החלטות מבחוץ. חוק דמטר מזכיר לנו לצמצם היכרות מיותרת עם מבנה פנימי.

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

נכתב על ידי

ליאת שגיא : כותב

כותבת על SOLID, תכנון מונחה עצמים, UML, מיקרו־שירותים ותכנון API. לא ארכיטקטית מוסמכת ולא נציגה של חברה או קורס — מסבירה החלטות תכנון עם יתרונות, חסרונות ודוגמה לפני ואחרי.

להמשך קריאה

לכל המאמרים בנושא

תכנון מונחה עצמים

הרכבה במקום ירושה: איך לתכנן אובייקטים גמישים בלי לבנות עץ מחלקות שביר

הכלל “העדיפו הרכבה על פני ירושה” נשמע לפעמים כמו סיסמה שמפתחים חוזרים עליה בלי לבדוק את ההקשר. אבל מאחוריו עומדת בעיה ממשית בתכנון מונחה עצמים: ירושה קושרת מחלקות זו לזו…

9 דק׳ קריאה