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

strategy design pattern: תבנית Strategy להחלפת אלגוריתם בזמן ריצה, ומתי פונקציה פשוטה כבר לא מספיקה

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

לפי ההגדרה הקלאסית בספר Design Patterns, Gamma et al., 1994, Strategy מגדירה משפחה של אלגוריתמים, עוטפת כל אחד מהם באובייקט נפרד, והופכת אותם לניתנים להחלפה. כך האלגוריתם יכול להשתנות בלי לשנות את הלקוח שמשתמש בו. זה נשמע דומה מאוד ל״פשוט להעביר פונקציה״, ובשפות מודרניות לפעמים זה באמת מספיק. אבל ההבדל נעוץ בשאלה אם מדובר בפעולה קטנה ומקומית, או בשיתוף פעולה יציב בין אובייקטים שיש לו שם, אחריות, בדיקות, הרחבות ותחזוקה לאורך זמן.

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

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

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

  • אם סוג המשלוח הוא רגיל — חשב כך.
  • אם סוג המשלוח הוא מהיר — חשב אחרת.
  • אם סוג המשלוח הוא איסוף עצמי — החזר אפס.
  • אם המשקל גבוה — הוסף תוספת.

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

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

מהי תבנית Strategy לפי GoF?

תבנית Strategy כוללת שלושה משתתפים עיקריים:

משתתף אחריות בדוגמה שלנו
Strategy ממשק משותף לכל האלגוריתמים ShippingStrategy
ConcreteStrategy מימוש ספציפי של אלגוריתם StandardShipping, ExpressShipping, PickupShipping
Context האובייקט שמשתמש באסטרטגיה דרך הממשק Checkout

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

דוגמת Java מלאה: חישוב משלוח עם Strategy

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

Java
public class Main {
    public static void main(String[] args) {
        Order order = new Order(120.0, 2.5);

        Checkout checkout = new Checkout(new StandardShipping());
        System.out.println("Standard total: " + checkout.total(order));

        checkout.setShippingStrategy(new ExpressShipping());
        System.out.println("Express total: " + checkout.total(order));

        checkout.setShippingStrategy(new PickupShipping());
        System.out.println("Pickup total: " + checkout.total(order));
    }

    interface ShippingStrategy {
        double shippingCost(Order order);
    }

    static class StandardShipping implements ShippingStrategy {
        public double shippingCost(Order order) {
            double base = 12.0;
            double weightFee = order.weightKg() * 1.5;
            return base + weightFee;
        }
    }

    static class ExpressShipping implements ShippingStrategy {
        public double shippingCost(Order order) {
            double base = 25.0;
            double weightFee = order.weightKg() * 3.0;
            return base + weightFee;
        }
    }

    static class PickupShipping implements ShippingStrategy {
        public double shippingCost(Order order) {
            return 0.0;
        }
    }

    static class Checkout {
        private ShippingStrategy shippingStrategy;

        Checkout(ShippingStrategy shippingStrategy) {
            this.shippingStrategy = shippingStrategy;
        }

        void setShippingStrategy(ShippingStrategy shippingStrategy) {
            this.shippingStrategy = shippingStrategy;
        }

        double total(Order order) {
            return order.itemsTotal() + shippingStrategy.shippingCost(order);
        }
    }

    static class Order {
        private final double itemsTotal;
        private final double weightKg;

        Order(double itemsTotal, double weightKg) {
            this.itemsTotal = itemsTotal;
            this.weightKg = weightKg;
        }

        double itemsTotal() {
            return itemsTotal;
        }

        double weightKg() {
            return weightKg;
        }
    }
}

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

strategy design pattern מול העברת פונקציה: איפה באמת ההבדל?

בשפות שתומכות בפונקציות כערכים, או אפילו ב-Java עם lambda, אפשר לשאול בצדק: למה לא להעביר פונקציה שמקבלת הזמנה ומחזירה עלות משלוח?

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

אבל Strategy נהיית שימושית כאשר האלגוריתם הוא יותר מ״שורה שמחזירה ערך״:

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

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

איך מיישמים Strategy צעד אחר צעד?

1. מזהים את נקודת השינוי

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

  • שרשרת תנאים לפי type, mode או kind.
  • מתודה ארוכה שמכילה כמה אלגוריתמים שונים.
  • בדיקות רבות שמכסות וריאציות של אותו חישוב.
  • בקשות חוזרות להוסיף ״עוד סוג״ בלי לשנות את השאר.

2. מגדירים ממשק קטן

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

3. מעבירים כל אלגוריתם למחלקה נפרדת

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

4. משאירים את ההקשר תלוי בממשק

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

5. בודקים כל אסטרטגיה בנפרד

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

מה להכין מראש לפני שמחלצים Strategy?

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

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

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

איפה נפוצים להיתקע, ומה עושים?

יותר מדי מחלקות קטנות

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

ממשק רחב מדי

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

ההקשר עדיין מכיר את כל המימושים

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

אסטרטגיות עם תלות כבדה מדי

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

הקשר ל-SOLID ולעיצוב מונחה עצמים

Strategy מתחברת יפה לכמה עקרונות מוכרים בעיצוב מונחה עצמים:

  • Open/Closed Principle — אפשר להוסיף אסטרטגיה חדשה בלי לשנות את ה-Context.
  • Single Responsibility Principle — ההקשר אחראי לתהליך הכללי, והאסטרטגיה אחראית לאלגוריתם מסוים.
  • Dependency Inversion Principle — הקוד המרכזי תלוי בממשק ולא במימוש קונקרטי.

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

איך זה נראה ב-UML?

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

במילים, היחסים הם:

  • Checkout תלויה ב-ShippingStrategy.
  • StandardShipping, ExpressShipping ו-PickupShipping מממשות את ShippingStrategy.
  • הלקוח או קוד ההרכבה מספק ל-Checkout את האסטרטגיה הרצויה.

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

מתי לא להשתמש ב-Strategy?

לא כל תנאי הוא ריח קוד. לא כל אלגוריתם צריך מחלקה. כדאי להימנע מ-Strategy במקרים הבאים:

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

במילים אחרות: Strategy מתאימה כאשר ההתנהגות היא ציר שינוי משמעותי. אם אין ציר שינוי, אין סיבה לבנות סביבו מנגנון.

Strategy במערכות שירותים ו-API

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

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

סיכום: פונקציה היא כלי, Strategy היא גבול תכנוני

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

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

להעמקה בהקשר הרחב של תבניות התנהגות ותבניות בכלל, אפשר להתחיל ממבוא לתבניות עיצוב, ואז להשוות בין Strategy לבין תבניות אחרות שמטפלות בשיתוף פעולה בין אובייקטים, כמו Observer, State או Command.

נכתב על ידי

רועי פרידמן : כותב

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

להמשך קריאה

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

תבניות התנהגות

תבנית Template Method: שלד אלגוריתם יציב עם צעדים גמישים — template method design pattern

תבנית Template Method, או בשם החיפוש הנפוץ template method design pattern, היא אחת מתבניות ההתנהגות החשובות בתכנות מונחה־עצמים. הרעיון פשוט אך חזק: מחלקת־אב מגדירה את שלד האלגוריתם, כלומר את סדר…

11 דק׳ קריאה

תבניות התנהגות

State Design Pattern: תבנית State שמחליפה switch ענק באובייקטים עם התנהגות משתנה

state design pattern היא תבנית התנהגות קלאסית שמטפלת במצב מוכר מאוד בתכנון מונחה־עצמים: אובייקט אחד מתנהג אחרת לפי הסטטוס הפנימי שלו, והקוד מתחיל להתמלא ב־switch, ב־if ובבדיקות חוזרות. במקום לרכז…

9 דק׳ קריאה

תבניות התנהגות

תבנית Visitor: הוספת פעולות למבנה אובייקטים בלי לשנות אותו — double dispatch ומימוש מלא על עץ ביטויים

Visitor מייצגת פעולה שמתבצעת על האלמנטים של מבנה אובייקטים, ומאפשרת להגדיר פעולה חדשה בלי לשנות את מחלקות האלמנטים. ההגדרה לפי ספר ה-GoF, איך double dispatch עובד באמת, מימוש מלא על עץ ביטויים ב-Java ובפייתון, והחלופה המודרנית של pattern matching.

8 דק׳ קריאה