Single Responsibility ב-OOP ו-Composition לעומת Inheritance

מטרות השיעור
  • לזהות Class שיש לה יותר מאחריות אחת, ולפרק אותה
  • להסביר מהי Composition ולבנות אובייקט שמורכב מאובייקטים אחרים
  • להבחין בין has-a ל-is-a ולבחור בין Composition ל-Inheritance
  • להבין למה “Composition over Inheritance” הוא כלל אצבע מקובל

מה נלמד?

  • Single Responsibility Principle (SRP) ברמת ה-Class.
  • Composition (הרכבה): אובייקט שמורכב מאובייקטים אחרים.
  • Composition מול Inheritance: איך בוחרים.

למה צריך את זה?

בנושא Python למדנו SRP לפונקציות: כל פונקציה עושה דבר אחד. אותו עיקרון בדיוק חל על Classes, ושם הוא אפילו חשוב יותר, כי Class גדולה היא “ערימה” של נתונים והתנהגות. כשכל שינוי (בפורמט הקבלה, בשיטת התשלום, בחישוב המע"מ) נוגע באותה Class, היא נהיית שבירה ומפחידה לשינוי.

ובשיעור על ירושה ראינו שמכונית לא צריכה לרשת ממנוע. היום נראה מה כן עושים.

הסבר מהבסיס

SRP ברמת Class

לכל Class צריכה להיות סיבה אחת להשתנות (Responsibility = “סיבה לשינוי”).

איך מזהים Class עם יותר מדי אחריות?

  1. קשה לתאר אותה במשפט אחד בלי “וגם”.
  2. יש בה קבוצות של Attributes שכל קבוצה משמשת רק חלק מהמתודות.
  3. היא משתנה מסיבות שונות: שינוי בעיצוב הקבלה, ושינוי בכללי המס, ושינוי בשליחת מייל.
  4. השם שלה כללי מדי: Manager, Handler, Processor, Utils, System.

Composition — “יש לו” (has-a)

Composition = בניית אובייקט מורכב מתוך אובייקטים אחרים, שכל אחד אחראי לחלק שלו. האובייקט המורכב מחזיק אותם כ-Attributes, ומאציל (Delegate) להם עבודה.

class Car:
    def __init__(self):
        self.engine = Engine()       # the car HAS an engine
        self.gps = GPS()             # the car HAS a GPS

    def start(self):
        return self.engine.start()   # delegate the work to the part that knows how
אנלוגיה: מחשב

מחשב מורכב ממעבד, זיכרון, דיסק וכרטיס מסך (נושא מחשב, מערכת הפעלה ורשת!). המחשב לא “יורש” מהמעבד. יש לו מעבד. כל רכיב אחראי לתפקיד אחד, ואפשר להחליף רכיב (דיסק גדול יותר) בלי לשנות את השאר. זו Composition.

Composition מול Inheritance

InheritanceComposition
יחסis-a (“הוא סוג של”)has-a (“יש לו”)
דוגמהManager(Employee)Car מחזיקה Engine
קשרהדוק. Child תלויה בכל הפרטים של Parentרופף. מדברים רק דרך הממשק של החלק
גמישותנקבע כשכותבים את ה-Classאפשר להחליף חלק בזמן ריצה
כשמשהו משתנה ב-Parent/בחלקעלול לשבור את כל הילדיםמשפיע רק על מי שמשתמש בממשק

כלל אצבע מקובל בתעשייה: “Favor Composition over Inheritance” — העדיפו Composition. השתמשו בירושה רק כשיש יחס is-a אמיתי, וכשרוצים Polymorphism.

המבחן הפשוט:

  • “X הוא סוג של Y”? ‏←‏ ירושה.
  • “ל-X יש Y”? / “X משתמש ב-Y”? → Composition.

תחביר

class Part:
    def do_work(self):
        return "part did its job"


class Whole:
    def __init__(self, part):
        self.part = part                 # composition: hold a part object

    def work(self):
        return self.part.do_work()       # delegation: let the part do it


whole = Whole(Part())                    # the part is passed in (can be swapped)

כשה-Part מועבר כפרמטר (ולא נוצר בפנים), אפשר להעביר חלקים שונים, וזה המקום שבו Composition ו-Polymorphism נפגשים.

דוגמת קוד ראשונה

God Class לפני פירוק:

class ShopSystem:
    def __init__(self):
        self.items = []
        self.vat = 0.17
        self.customer_email = ""

    def add_item(self, name, price, quantity):
        self.items.append((name, price, quantity))

    def subtotal(self):
        return sum(price * quantity for name, price, quantity in self.items)

    def total_with_vat(self):
        return round(self.subtotal() * (1 + self.vat), 2)

    def receipt_text(self):
        lines = []
        for name, price, quantity in self.items:
            lines.append(name + " x" + str(quantity) + " = " + str(price * quantity))
        lines.append("TOTAL: " + str(self.total_with_vat()))
        return "\n".join(lines)

    def send_receipt(self):
        print("Sending email to", self.customer_email)
        print(self.receipt_text())

כמה סיבות לשינוי יש כאן? (1) איך מנהלים פריטים בעגלה. (2) איך מחשבים מס. (3) איך נראית קבלה. (4) איך שולחים אותה. ארבע אחריויות ב-Class אחת.

אחרי פירוק עם Composition. week-3/shop_composition.py:

class Cart:
    def __init__(self):
        self._items = []

    def add(self, name, price, quantity=1):
        if price < 0 or quantity <= 0:
            raise ValueError("invalid price or quantity")
        self._items.append((name, price, quantity))

    def items(self):
        return list(self._items)

    def subtotal(self):
        return sum(price * quantity for name, price, quantity in self._items)


class TaxCalculator:
    def __init__(self, rate):
        self.rate = rate

    def total(self, amount):
        return round(amount * (1 + self.rate), 2)


class ReceiptFormatter:
    def format(self, cart, total):
        lines = []
        for name, price, quantity in cart.items():
            lines.append(name + " x" + str(quantity) + " = " + str(price * quantity))
        lines.append("TOTAL: " + str(total))
        return "\n".join(lines)


class Checkout:
    def __init__(self, cart, tax, formatter):
        self.cart = cart
        self.tax = tax
        self.formatter = formatter

    def receipt(self):
        total = self.tax.total(self.cart.subtotal())
        return self.formatter.format(self.cart, total)


cart = Cart()
cart.add("Bread", 8, 2)
cart.add("Cheese", 32)

checkout = Checkout(cart, TaxCalculator(0.17), ReceiptFormatter())
print(checkout.receipt())

פלט:

Bread x2 = 16
Cheese x1 = 32
TOTAL: 56.16

פירוק הקוד שורה אחר שורה

Classאחריות אחתסיבה אחת לשינוי
Cartלנהל את הפריטיםחוקי העגלה (כמויות, מגבלות)
TaxCalculatorלחשב מסשינוי בחוק המס
ReceiptFormatterלעצב קבלה כטקסטשינוי בעיצוב הקבלה
Checkoutלחבר בין החלקיםשינוי בתהליך הקופה
  • Checkout מורכבת מעגלה, מחשבון מס ומעצב (Composition), ומאצילה להם את העבודה: self.tax.total(...), self.formatter.format(...). היא עצמה כמעט לא מחשבת כלום.
  • כל Class קטנה, ברורה, ואפשר לבדוק אותה לבד: TaxCalculator(0.17).total(100) → 117.0.

דוגמה נוספת

הכוח של Composition: החלפת חלקים (Composition + Polymorphism). מוסיפים מעצב קבלה אחר, ומחשבון מס לעסק פטור. Checkout לא משתנה בכלל.

class HtmlReceiptFormatter:
    def format(self, cart, total):
        rows = ""
        for name, price, quantity in cart.items():
            rows = rows + "<li>" + name + " x" + str(quantity) + "</li>"
        return "<ul>" + rows + "</ul><b>Total: " + str(total) + "</b>"


class TaxExempt:
    def total(self, amount):
        return amount


html_checkout = Checkout(cart, TaxCalculator(0.17), HtmlReceiptFormatter())
print(html_checkout.receipt())

exempt_checkout = Checkout(cart, TaxExempt(), ReceiptFormatter())
print(exempt_checkout.receipt())

פלט:

<ul><li>Bread x2</li><li>Cheese x1</li></ul><b>Total: 56.16</b>
Bread x2 = 16
Cheese x1 = 32
TOTAL: 48

השוו לגרסת ה-God Class: כדי לתמוך בקבלה ב-HTML ובעסק פטור ממס, היינו צריכים להוסיף if-ים בתוך receipt_text ו-total_with_vat. כאן כל אפשרות היא חלק שמחליפים.

Composition במקום ירושה — הדוגמה הקלאסית

class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower
        self.running = False

    def start(self):
        self.running = True
        return "Engine started (" + str(self.horsepower) + " hp)"


class Car:
    def __init__(self, model, engine):
        self.model = model
        self.engine = engine

    def start(self):
        return self.model + ": " + self.engine.start()

    def upgrade_engine(self, new_engine):
        self.engine = new_engine


car = Car("Corolla", Engine(130))
print(car.start())
car.upgrade_engine(Engine(180))
print(car.start())
Corolla: Engine started (130 hp)
Corolla: Engine started (180 hp)

עם ירושה (class Car(Engine)), אי אפשר “להחליף מנוע”. המכונית היא המנוע. עם Composition, מחליפים חלק בזמן ריצה.

טעות נפוצה

הטעות 1: ירושה בשביל “לקבל מתודות שימושיות”.

class Order(list):
    pass


order = Order()
order.append("pizza")
order.sort()
order.clear()        # an order that can be "cleared", "reversed", "popped"...?

Order קיבלה את כל 40+ המתודות של list, כולל כאלה שאין להן משמעות להזמנה, ואי אפשר לשלוט בכללים. הנכון: Composition, self._items = [], וחשיפה רק של מה שהזמנה צריכה (add, items).

הטעות 2: Composition “חשופה” — לעקוף את האובייקט המורכב.

checkout.cart._items.append(("Free TV", -500, 1))

שרשור עמוק שנוגע בפנים של חלק עוקף את הכללים שלו. מדברים עם חלק רק דרך המתודות הציבוריות שלו (checkout.cart.add(...)).

הטעות 3: פירוק יתר. 15 Classes של 3 שורות לבעיה שנפתרת ב-2 Classes ברורות. SRP אומר “סיבה אחת לשינוי”, ולא “מתודה אחת ל-Class”. אם שתי פעולות תמיד משתנות יחד, הן כנראה אחריות אחת.

הטעות 4: “Composition” שבה החלק יוצר את עצמו בפנים, תמיד אותו דבר. self.tax = TaxCalculator(0.17) בתוך __init__ עובד, אבל אז אי אפשר להחליף. העברת החלק כפרמטר (Checkout(cart, tax, formatter)) נותנת את הגמישות.

תרגול

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

מה מתרגלים בדף הזה: אחריות אחת לכל Class (SRP), הרכבה (Composition) — Class שמחזיקה אובייקטים אחרים, החלפת חלק בלי לשנות את השאר, ומתי לא להשתמש בירושה.

בסוף הדף תדעו:

  • לזהות Class שעושה יותר מדי ולפרק אותה
  • לבנות Class שמורכבת מאובייקטים אחרים (has-a)
  • להחליף רכיב באובייקט בלי לשנות את הקוד שמשתמש בו
  • להבחין בין “הוא סוג של” (ירושה) ל"יש לו" (הרכבה)

תרגיל 1: Class שעושה הכול

מה עושים: תכנון בלבד — מזהים Class שעושה יותר מדי, ומציעים חלוקה.

הקוד:

class ShopSystem:
    def add_item(self, name, price, quantity): ...
    def subtotal(self): ...
    def add_vat(self, amount): ...
    def format_receipt(self): ...
    def send_email(self, address): ...

המשימה:

  1. כמה אחריויות שונות יש כאן? כתבו כל אחת במילים.
  2. הציעו חלוקה לכמה Classes: שם לכל Class, ואילו מתודות עוברות אליה.

מה מגישים: רשימת האחריויות, וטבלה של Class ומתודות.

הצג פתרון
  1. ארבע אחריויות: ניהול העגלה (פריטים וסכום), חישוב מס, עיצוב קבלה, שליחת אימייל. כל אחת היא “סיבה להשתנות” אחרת — זו God Class.
  2. חלוקה:
ClassאחריותMethods
Cartהפריטיםadd_item, subtotal
VatCalculatorהמסadd_vat
ReceiptFormatterהעיצובformat_receipt
EmailSenderהשליחהsend_email

תרגיל 2: Composition — העגלה והקופה

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

מה לכתוב:

  1. Cart — עם add שמקבלת שם, מחיר וכמות (ברירת מחדל 1), ו-subtotal שמחזירה את סכום העגלה.
  2. IsraeliVat — עם apply שמקבלת סכום ומחזירה אותו כולל 17% מע"מ.
  3. Checkout — מקבלת עגלה ומחשבון מס ב-__init__ ומחזיקה אותם (has-a).
  4. ב-Checkout: total — הסכום של העגלה, אחרי מחשבון המס.

קוד לבדיקה:

cart = Cart()
cart.add("Pen", 5, 4)
cart.add("Bag", 60)
checkout = Checkout(cart, IsraeliVat())
print(checkout.total())

הפלט הרצוי:

93.6
הצג פתרון
class Cart:
    def __init__(self):
        self._items = []

    def add(self, name, price, quantity=1):
        self._items.append((name, price, quantity))

    def subtotal(self):
        total = 0
        for name, price, quantity in self._items:
            total = total + price * quantity
        return total


class IsraeliVat:
    RATE = 0.17

    def apply(self, amount):
        return round(amount * (1 + IsraeliVat.RATE), 2)


class Checkout:
    def __init__(self, cart, tax):
        self.cart = cart
        self.tax = tax

    def total(self):
        return self.tax.apply(self.cart.subtotal())


cart = Cart()
cart.add("Pen", 5, 4)
cart.add("Bag", 60)
checkout = Checkout(cart, IsraeliVat())
print(checkout.total())
93.6

Checkout לא מחשבת בעצמה — היא מאצילה (Delegation) לעגלה ולמחשבון המס, שכל אחד מהם מומחה בדבר אחד.

תרגיל 3: מחליפים חלק

מה בונים: ממשיכים את הקופה — מחליפים את מחשבון המס בלי לשנות את Checkout.

מה לכתוב:

  1. Class חדשה בשם TaxExempt, עם apply שמחזירה את הסכום בלי שינוי.
  2. יוצרים קופה אחת עם IsraeliVat וקופה שנייה עם TaxExempt, על אותה עגלה, ומדפיסים את הסכום של שתיהן.
  3. בדקו: לא שיניתם אף שורה ב-Checkout.

הפלט הרצוי:

93.6
80
הצג פתרון
class Cart:
    def __init__(self):
        self._items = []

    def add(self, name, price, quantity=1):
        self._items.append((name, price, quantity))

    def subtotal(self):
        total = 0
        for name, price, quantity in self._items:
            total = total + price * quantity
        return total


class IsraeliVat:
    RATE = 0.17

    def apply(self, amount):
        return round(amount * (1 + IsraeliVat.RATE), 2)


class TaxExempt:
    def apply(self, amount):
        return amount


class Checkout:
    def __init__(self, cart, tax):
        self.cart = cart
        self.tax = tax

    def total(self):
        return self.tax.apply(self.cart.subtotal())


cart = Cart()
cart.add("Pen", 5, 4)
cart.add("Bag", 60)
print(Checkout(cart, IsraeliVat()).total())
print(Checkout(cart, TaxExempt()).total())
93.6
80

זה הכוח של Composition + Polymorphism: כל מחשבון מס מממש apply, והקופה עובדת עם כל אחד מהם.

תרגיל 4: להוריש כדי לחסוך קוד?

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

הקוד:

class Engine:
    def start(self):
        return "engine started"

class Car(Engine):
    def __init__(self, model):
        self.model = model

המשימה:

  1. למה הירושה כאן לא נכונה, למרות שהקוד עובד? השתמשו במבחן “הוא סוג של”.
  2. שכתבו: Engine מקבלת כוח סוס, ו-Car מקבלת דגם ומנוע ומחזיקה אותו (has-a). ל-Car יש start שמשתמשת במנוע.

קוד לבדיקה:

car = Car("Mazda", Engine(150))
print(car.start())

הפלט הרצוי:

Mazda: engine 150hp started
הצג פתרון
  1. מכונית היא לא סוג של מנוע — למכונית יש מנוע. ירשו רק כדי “לקבל” את start, ועכשיו Car כוללת כל מה שיש למנוע, ואי אפשר להחליף מנוע.
  2. has-a:
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower

    def start(self):
        return "engine " + str(self.horsepower) + "hp started"


class Car:
    def __init__(self, model, engine):
        self.model = model
        self.engine = engine

    def start(self):
        return self.model + ": " + self.engine.start()


car = Car("Mazda", Engine(150))
print(car.start())
Mazda: engine 150hp started

תרגיל 5: מחשב מחלקים

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

מה לכתוב:

  1. CPU — עם מספר ליבות ומהירות ב-GHz.
  2. RAM — עם גודל ב-GB.
  3. Disk — עם גודל ב-GB.
  4. Computer — מקבלת את שלושת החלקים ומחזיקה אותם. יש לה __str__ שמתאר את המחשב כמו בפלט, ו-can_run שמקבלת מינימום ליבות ומינימום זיכרון ומחזירה True או False.

קוד לבדיקה:

pc = Computer(CPU(8, 3.2), RAM(8), Disk(512))
print(pc)
print("Can run the game?", pc.can_run(6, 16))

הפלט הרצוי:

8-core 3.2GHz CPU, 8GB RAM, 512GB SSD
Can run the game? False
הצג פתרון
class CPU:
    def __init__(self, cores, ghz):
        self.cores = cores
        self.ghz = ghz

    def __str__(self):
        return str(self.cores) + "-core " + str(self.ghz) + "GHz CPU"


class RAM:
    def __init__(self, gb):
        self.gb = gb

    def __str__(self):
        return str(self.gb) + "GB RAM"


class Disk:
    def __init__(self, gb):
        self.gb = gb

    def __str__(self):
        return str(self.gb) + "GB SSD"


class Computer:
    def __init__(self, cpu, ram, disk):
        self.cpu = cpu
        self.ram = ram
        self.disk = disk

    def can_run(self, min_cores, min_ram):
        return self.cpu.cores >= min_cores and self.ram.gb >= min_ram

    def __str__(self):
        return str(self.cpu) + ", " + str(self.ram) + ", " + str(self.disk)


pc = Computer(CPU(8, 3.2), RAM(8), Disk(512))
print(pc)
print("Can run the game?", pc.can_run(6, 16))
8-core 3.2GHz CPU, 8GB RAM, 512GB SSD
Can run the game? False

יש מספיק ליבות (8 ≥ 6), אבל רק 8GB RAM מתוך 16 הנדרשים. כל חלק יודע להציג את עצמו, ו-Computer רק מחבר.

סיכום

  • SRP ב-Class: סיבה אחת להשתנות. God Class (...Manager, ...System) ‏←‏ מפרקים.
  • Composition = אובייקט שמורכב מאובייקטים אחרים (has-a), ומאציל להם עבודה.
  • Inheritance רק ל-is-a אמיתי. ל-has-a או “uses-a” → Composition.
  • Favor Composition over Inheritance: קשר רופף, וחלקים שאפשר להחליף.
  • העבירו חלקים כפרמטרים ל-__init__, כדי שאפשר יהיה להחליף אותם (Composition + Polymorphism).
  • מדברים עם חלק דרך הממשק שלו, לא דרך הפנים שלו.

דוקומנטציה רשמית