לדלג לתוכן

תבנית Strategy מוסברת לעומק

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

הבעיה - if-else שגדל לנצח

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

def calculate_shipping(order, method):
    if method == "weight":
        return order.weight * 2.5
    elif method == "distance":
        return order.distance * 1.2
    elif method == "flat_vip":
        return 15
    else:
        raise ValueError("Unknown method")

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

הפתרון - כל אסטרטגיה כמחלקה נפרדת

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

from abc import ABC, abstractmethod

class ShippingStrategy(ABC):
    @abstractmethod
    def calculate(self, order):
        pass


class WeightBasedShipping(ShippingStrategy):
    def calculate(self, order):
        return order.weight * 2.5


class DistanceBasedShipping(ShippingStrategy):
    def calculate(self, order):
        return order.distance * 1.2


class FlatVipShipping(ShippingStrategy):
    def calculate(self, order):
        return 15


class Order:
    def __init__(self, weight, distance, shipping_strategy):
        self.weight = weight
        self.distance = distance
        self.shipping_strategy = shipping_strategy

    def get_shipping_cost(self):
        return self.shipping_strategy.calculate(self)

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

החלפה בזמן ריצה

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

order = Order(weight=5, distance=10, shipping_strategy=WeightBasedShipping())
print(order.get_shipping_cost())  # 12.5

order.shipping_strategy = FlatVipShipping()
print(order.get_shipping_cost())  # 15

Strategy מול פונקציות מועברות כפרמטר

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

מתי Strategy מוגזם

לא כל if-else צריך להפוך למחלקה. אם יש רק שני מקרים שלא צפויים להשתנות בקרוב, ה-if-else הפשוט קריא יותר מחמש מחלקות ומחלקת בסיס מופשטת. Strategy משתלמת כשיש - או צפוי להיות - יותר משני-שלושה מקרים, כשלוגיקת המקרים מורכבת מספיק כדי להצדיק מחלקה נפרדת, וכשיש סבירות אמיתית שיתווספו עוד מקרים בעתיד. הפעלת התבנית "כי אולי בעתיד" בלי צורך אמיתי כרגע היא תקורה מיותרת שרק מקשה לקרוא את הקוד.

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

יש לכם if-else שגדל מעבר לשליטה וחשבתם להפוך אותו ל-Strategy? הצטרפו לקהילה בדיסקורד ותשתפו את המקרה שלכם.

לסיכום

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