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

command design pattern: בקשה כאובייקט שמאפשרת תור פעולות, Undo ו-Redo

תבנית command design pattern מטפלת ברגע שבו פעולה כבר אינה רק קריאה ישירה למתודה, אלא משהו שצריך לשמור, להעביר, להריץ מאוחר יותר, לבטל או לבצע שוב. במקום שאובייקט אחד יגיד לאחר “עשה עכשיו”, הוא יוצר אובייקט שמייצג את הבקשה עצמה: מה לבצע, על מי לבצע, ואיך לבטל אם צריך.

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

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

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

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

במילים פשוטות יותר: במקום שהלקוח יקרא ישירות ל־receiver.save() או ל־editor.delete(), הוא יוצר אובייקט מסוג Command. האובייקט הזה יודע להפעיל את הפעולה, ולעיתים גם לבטל אותה. מי שמריץ את הפקודה לא חייב לדעת איזו פעולה מסתתרת בפנים.

ההפרדה הזו חשובה כי היא מנתקת בין שלושה דברים שבמערכת נאיבית מתערבבים:

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

command design pattern בהקשר של תקשורת בין אובייקטים

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

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

Command פותרת זאת כך: הכפתור יודע רק לקרוא ל־execute(). אם יש צורך בביטול, הוא יודע גם לקרוא ל־undo(). כל הפרטים נשארים בתוך אובייקט הפקודה.

המשתתפים בתבנית Command

משתתף אחריות דוגמה
Command מגדיר ממשק אחיד להרצת פעולה, ולעיתים גם לביטול execute(), undo()
ConcreteCommand מממש פקודה מסוימת ושומר את המידע הדרוש לביצוע או ביטול InsertTextCommand
Receiver האובייקט שמבצע בפועל את העבודה TextEditor
Invoker מקבל פקודות ומפעיל אותן בלי להכיר את הפרטים הפנימיים CommandManager
Client יוצר את הפקודות ומחבר בינן לבין ה־Receiver main או שכבת UI

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

מתי להשתמש ב־Command?

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

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

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

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

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

  1. מהו ה־Receiver? מי באמת מבצע את העבודה? עורך טקסט, שירות הזמנות, רכיב קבצים?
  2. מהי פעולה אחת? האם “הדבקה” היא פעולה אחת? האם שמירה והדפסה יחד הן פעולה אחת או שתיים?
  3. האם צריך Undo? אם כן, מה בדיוק צריך לזכור כדי להפוך את הפעולה?
  4. האם צריך Redo? אם כן, מתי מנקים את מחסנית ה־Redo?
  5. האם הפעולות חייבות להיות אידמפוטנטיות? בתורים ומיקרו־שירותים זו שאלה חשובה במיוחד.
  6. האם פעולה יכולה להיכשל? ואם כן, האם היא נכנסת להיסטוריה או לא?

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

מימוש Java מלא: עורך טקסט עם Undo ו־Redo

הדוגמה הבאה מציגה מימוש קטן ורץ של Command. יש עורך טקסט פשוט, שתי פקודות, ומנהל פקודות ששומר שתי מחסניות: אחת ל־Undo ואחת ל־Redo. הדוגמה אינה מדריך Java; היא רק ממחישה את מבנה התבנית.

Java
import java.util.ArrayDeque;
import java.util.Deque;

public class CommandPatternDemo {
    public static void main(String[] args) {
        TextEditor editor = new TextEditor();
        CommandManager manager = new CommandManager();

        manager.executeCommand(new InsertTextCommand(editor, "Hello"));
        manager.executeCommand(new InsertTextCommand(editor, " Command"));
        manager.executeCommand(new DeleteLastCharsCommand(editor, 8));

        System.out.println(editor.getText());

        manager.undo();
        System.out.println(editor.getText());

        manager.undo();
        System.out.println(editor.getText());

        manager.redo();
        System.out.println(editor.getText());
    }
}

interface Command {
    void execute();
    void undo();
}

class TextEditor {
    private final StringBuilder text = new StringBuilder();

    public void insert(String value) {
        text.append(value);
    }

    public String deleteLastChars(int count) {
        int actualCount = Math.min(count, text.length());
        int start = text.length() - actualCount;
        String removed = text.substring(start);
        text.delete(start, text.length());
        return removed;
    }

    public String getText() {
        return text.toString();
    }
}

class InsertTextCommand implements Command {
    private final TextEditor editor;
    private final String value;

    public InsertTextCommand(TextEditor editor, String value) {
        this.editor = editor;
        this.value = value;
    }

    @Override
    public void execute() {
        editor.insert(value);
    }

    @Override
    public void undo() {
        editor.deleteLastChars(value.length());
    }
}

class DeleteLastCharsCommand implements Command {
    private final TextEditor editor;
    private final int count;
    private String removedText = "";

    public DeleteLastCharsCommand(TextEditor editor, int count) {
        this.editor = editor;
        this.count = count;
    }

    @Override
    public void execute() {
        removedText = editor.deleteLastChars(count);
    }

    @Override
    public void undo() {
        editor.insert(removedText);
    }
}

class CommandManager {
    private final Deque<Command> undoStack = new ArrayDeque<>();
    private final Deque<Command> redoStack = new ArrayDeque<>();

    public void executeCommand(Command command) {
        command.execute();
        undoStack.push(command);
        redoStack.clear();
    }

    public void undo() {
        if (undoStack.isEmpty()) {
            return;
        }

        Command command = undoStack.pop();
        command.undo();
        redoStack.push(command);
    }

    public void redo() {
        if (redoStack.isEmpty()) {
            return;
        }

        Command command = redoStack.pop();
        command.execute();
        undoStack.push(command);
    }
}

בדוגמה הזו Command הוא הממשק המשותף. InsertTextCommand ו־DeleteLastCharsCommand הן פקודות קונקרטיות. TextEditor הוא ה־Receiver, כי הוא מחזיק את מצב הטקסט ומבצע את הפעולות בפועל. CommandManager הוא ה־Invoker: הוא מריץ פקודות, שומר היסטוריה, ומנהל את מחסניות ה־Undo וה־Redo. המתודה main משחקת כאן את תפקיד ה־Client שמרכיב את האובייקטים.

איך Undo ו־Redo באמת עובדים?

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

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

הנקודה שרבים מפספסים: Redo תקין תלוי בכך ש־execute() יהיה בטוח להרצה חוזרת בהקשר הזה. בדוגמה שלנו, DeleteLastCharsCommand מעדכנת את removedText בכל ביצוע. זה מתאים למודל שבו Redo מתבצע מיד אחרי Undo, אבל במערכות מורכבות יותר ייתכן שצריך לשמור מצב עשיר יותר.

תור פעולות: Command מעבר למסך העריכה

Command לא מוגבלת ל־Undo/Redo. אחת הסיבות המרכזיות לקיומה היא האפשרות לשים בקשות בתור. במקום לבצע פעולה מיד, אפשר לשמור אובייקטי Command ולהריץ אותם לפי סדר, לפי עדיפות או בזמן מתאים.

הדבר שימושי למשל במקרים הבאים:

  • עיבוד משימות רקע אחרי פעולה של משתמש.
  • ביצוע פעולות כאשר משאב מסוים מתפנה.
  • הרצת סדרת פעולות כמאקרו.
  • תזמון פעולות שדורשות סביבה יציבה יותר מזו של בקשת HTTP קצרה.

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

Command מול Strategy: הדמיון שמבלבל

Command ו־Strategy נראות לפעמים דומות כי שתיהן עוטפות התנהגות באובייקט. ההבדל הוא בכוונה.

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

אם האובייקט מתאר “מה לבצע עכשיו או מאוחר יותר”, כנראה מדובר ב־Command. אם הוא מתאר “באיזו שיטה לבצע חישוב”, כנראה מדובר ב־Strategy. ההבחנה הזו חשובה במיוחד בתכנון מונחה עצמים, כי שתיהן עלולות להיראות כמו “ממשק עם מתודה אחת”.

טעויות נפוצות ביישום Command

פקודה שמכירה יותר מדי

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

Undo בלי שמירת מצב

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

שימוש בתבנית לכל פעולה קטנה

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

התעלמות מכישלונות

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

שלבי עבודה מומלצים

  1. בחרו תרחיש אחד — למשל Undo בעורך, ולא את כל מערכת הפעולות בבת אחת.
  2. הגדירו ממשק Command קטן — בדרך כלל execute(), ואם צריך גם undo().
  3. זהו את ה־Receiver — האובייקט שמבצע בפועל את הפעולה.
  4. כתבו פקודה אחת מלאה — כולל המידע הדרוש לביטול.
  5. הוסיפו Invoker — מנהל שמפעיל פקודות ושומר היסטוריה או תור.
  6. בדקו תרחישי קצה — Undo כשאין פעולות, Redo אחרי פעולה חדשה, כישלון באמצע ביצוע.

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

מתי לא להשתמש ב־Command?

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

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

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

האם Command חייב לכלול undo()?

לא. ההגדרה של GoF מזכירה תמיכה בפעולות שניתנות לביטול כאחת האפשרויות, לא כחובה. יש מימושים שבהם Command כולל רק execute(). אם Undo הוא דרישה אמיתית, הוסיפו אותו בממשק או הגדירו ממשק נפרד לפקודות הפיכות.

האם אפשר להשתמש בלמבדה במקום מחלקת Command?

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

איך Command קשורה ל־UML?

ב־UML class diagram טיפוסי תראו ממשק Command, מחלקות קונקרטיות שמממשות אותו, קשר אל Receiver, ו־Invoker שמחזיק Command. התרשים מועיל במיוחד כדי להראות מי תלוי במי: ה־Invoker תלוי בממשק, לא בפקודות הספציפיות.

סיכום: בקשה עם זהות משלה

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

המחיר הוא תוספת מחלקות ומעט יותר תכנון מוקדם. לכן כדאי להשתמש בה כאשר הפעולות באמת צריכות ניהול: Undo/Redo, תורים, מאקרואים, לוג פעולות או הפרדה ברורה בין UI לבין לוגיקה. במקומות כאלה, Command נותנת שם וצורה לבעיה שכמעט תמיד מסתבכת אם משאירים אותה כאוסף קריאות ישירות.

נכתב על ידי

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

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

להמשך קריאה

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

תקשורת בין אובייקטים

Observer Design Pattern: תבנית Observer למנוי לשינויים, אירועים וההבדל בינה לבין Pub/Sub

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

10 דק׳ קריאה

תקשורת בין אובייקטים

תבנית Chain of Responsibility: שרשרת מטפלים שבה כל חוליה מחליטה אם לטפל או להעביר — מימוש מלא

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

8 דק׳ קריאה

תקשורת בין אובייקטים

תבנית Mediator: אובייקט מתווך שמחליף רשת של תלויות הדדיות — מימוש מלא על טופס עם רכיבים תלויים

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

8 דק׳ קריאה