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

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

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

יש לכם מבנה אובייקטים יציב — עץ ביטויים, מסמך עם סוגי פסקאות, עץ תחביר של שפה — ואתם מוסיפים עליו פעולות כל הזמן: חישוב, הדפסה, בדיקת תקינות, אופטימיזציה, ייצוא. אם כל פעולה היא מתודה בכל מחלקה, כל פעולה חדשה פותחת את כל המחלקות, והקוד של כל פעולה מפוזר על פני עשרה קבצים. תבנית Visitor — visitor design pattern — הופכת את זה: כל פעולה היא מחלקה אחת, שמכילה את הקוד שלה לכל סוגי האלמנטים. המחיר הוא מנגנון שנקרא double dispatch, שנראה מוזר בפעם הראשונה ופשוט מאוד מהפעם השנייה. המאמר מסביר את שניהם, עם מימוש מלא.

הכוונה

ספר ה-Gang of Four מגדיר את הכוונה של Visitor כך: לייצג פעולה שמתבצעת על האלמנטים של מבנה אובייקטים. Visitor מאפשרת להגדיר פעולה חדשה בלי לשנות את המחלקות של האלמנטים שעליהם היא פועלת. זו תבנית התנהגות ברמת האובייקט.

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

הבעיה: פעולות שמתרבות על מבנה יציב

ניקח עץ ביטויים פשוט עם שלושה סוגי צמתים: מספר, חיבור וכפל. הביטוי (2 + 3) * 4 הוא צומת כפל שהילד השמאלי שלו הוא צומת חיבור. הגישה הראשונה היא להוסיף לכל צומת מתודה evaluate, ואחר כך print, ואחר כך countNodes. אחרי עשר פעולות, כל מחלקת צומת מכילה עשר מתודות לא קשורות, וכדי להבין איך עובדת ההדפסה צריך לקרוא שלושה קבצים.

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

double dispatch: הלב של התבנית

ב-Java, כמו ברוב השפות מונחות העצמים, קריאה למתודה נבחרת לפי סוג האובייקט בזמן ריצה — אבל רק לפי אובייקט אחד: זה שמקבל את הקריאה. זו single dispatch. העמסה (overloading), לעומת זאת, נבחרת בזמן קומפילציה לפי הטיפוס הסטטי של הארגומנט. לכן אם יש לכם Expr e ושלוש מתודות visit(Num), visit(Add) ו-visit(Mul), הקריאה visitor.visit(e) לא תתקמפל — הקומפיילר מכיר רק Expr, ואין מתודה כזו.

Visitor פותרת את זה בשתי קפיצות. הלקוח קורא ל-e.accept(visitor): זו הקפיצה הראשונה, והיא נבחרת לפי הסוג האמיתי של e. בתוך Add.accept, הקוד קורא ל-visitor.visit(this) — ושם הטיפוס הסטטי של this הוא Add, ולכן הקומפיילר בוחר את visit(Add). הקפיצה השנייה נבחרת לפי הסוג האמיתי של המבקר. התוצאה: המתודה שרצה נקבעת לפי שני סוגים — של האלמנט ושל המבקר. זו double dispatch, וזה כל הסוד.

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

בתרשים המחלקות יש שתי היררכיות. באחת, ממשק Element עם מתודה accept(Visitor), ומחלקות אלמנט קונקרטיות שכל אחת מממשת אותה בשורה אחת. בשנייה, ממשק Visitor עם מתודת visit אחת לכל מחלקת אלמנט קונקרטית, ומחלקות מבקר קונקרטיות — אחת לכל פעולה. כל אלמנט תלוי בממשק המבקר, וכל מבקר תלוי בכל מחלקות האלמנטים. ה-ObjectStructure הוא האוסף או העץ שעליו עוברים — לעיתים קרובות Composite.

משתתף תפקיד בדוגמה שלנו
Visitor מצהיר על פעולת visit לכל מחלקת אלמנט קונקרטית ExprVisitor<R>
ConcreteVisitor מממש פעולה אחת לכל סוגי האלמנטים, ויכול לצבור מצב תוך כדי מעבר Evaluator, Printer, NodeCounter
Element מגדיר פעולת accept שמקבלת מבקר Expr
ConcreteElement מממש accept בקריאה למתודת ה-visit המתאימה לו Num, Add, Mul
ObjectStructure מחזיק את האלמנטים ומאפשר לעבור עליהם העץ עצמו

מימוש מלא ב-Java

Java
// Element: every node accepts a visitor.
interface Expr {
    <R> R accept(ExprVisitor<R> visitor);
}

// Visitor: one visit method per concrete element class.
interface ExprVisitor<R> {
    R visit(Num num);
    R visit(Add add);
    R visit(Mul mul);
}

// Concrete elements: accept() calls back the matching overload.
// Inside each class the static type of `this` selects the overload.
final class Num implements Expr {
    final int value;
    Num(int value) { this.value = value; }
    @Override public <R> R accept(ExprVisitor<R> visitor) { return visitor.visit(this); }
}

final class Add implements Expr {
    final Expr left;
    final Expr right;
    Add(Expr left, Expr right) { this.left = left; this.right = right; }
    @Override public <R> R accept(ExprVisitor<R> visitor) { return visitor.visit(this); }
}

final class Mul implements Expr {
    final Expr left;
    final Expr right;
    Mul(Expr left, Expr right) { this.left = left; this.right = right; }
    @Override public <R> R accept(ExprVisitor<R> visitor) { return visitor.visit(this); }
}

// Concrete visitor 1: evaluates the expression.
class Evaluator implements ExprVisitor<Integer> {
    @Override public Integer visit(Num num) { return num.value; }
    @Override public Integer visit(Add add) { return add.left.accept(this) + add.right.accept(this); }
    @Override public Integer visit(Mul mul) { return mul.left.accept(this) * mul.right.accept(this); }
}

// Concrete visitor 2: prints the expression, with parentheses around sums.
class Printer implements ExprVisitor<String> {
    @Override public String visit(Num num) { return String.valueOf(num.value); }
    @Override public String visit(Add add) {
        return "(" + add.left.accept(this) + " + " + add.right.accept(this) + ")";
    }
    @Override public String visit(Mul mul) {
        return mul.left.accept(this) + " * " + mul.right.accept(this);
    }
}

// Concrete visitor 3: added later, without touching Num, Add or Mul.
class NodeCounter implements ExprVisitor<Integer> {
    @Override public Integer visit(Num num) { return 1; }
    @Override public Integer visit(Add add) { return 1 + add.left.accept(this) + add.right.accept(this); }
    @Override public Integer visit(Mul mul) { return 1 + mul.left.accept(this) + mul.right.accept(this); }
}

public class Main {
    public static void main(String[] args) {
        Expr expr = new Mul(new Add(new Num(2), new Num(3)), new Num(4));
        System.out.println(expr.accept(new Printer()) + " = " + expr.accept(new Evaluator()));
        System.out.println("nodes: " + expr.accept(new NodeCounter()));
    }
}

הפלט:

פלט
(2 + 3) * 4 = 20
nodes: 5

המבקר השלישי, NodeCounter, הוא כל מה שצריך כדי להוסיף פעולה חדשה: מחלקה אחת, שלוש מתודות, אף שינוי במחלקות הצמתים. והחלק החשוב לא פחות: אם תוסיפו צומת Pow ותוסיפו לממשק את visit(Pow), הקומפיילר יסרב לקמפל כל מבקר שלא מימש אותה. אף פעולה לא "תשכח" את הצומת החדש בשקט.

הממשק הגנרי ExprVisitor<R> הוא תוספת מודרנית לגרסה שבספר, שבה מתודות ה-Visit לא מחזירות ערך והמבקר צובר תוצאה בשדות שלו. שתי הגרסאות לגיטימיות; הגנרית חוסכת מצב משותף ומאפשרת לכל מבקר להחזיר טיפוס משלו.

אותו מבנה בפייתון

Python
class Num:
    def __init__(self, value):
        self.value = value

    def accept(self, visitor):
        return visitor.visit_num(self)


class Add:
    def __init__(self, left, right):
        self.left, self.right = left, right

    def accept(self, visitor):
        return visitor.visit_add(self)


class Mul:
    def __init__(self, left, right):
        self.left, self.right = left, right

    def accept(self, visitor):
        return visitor.visit_mul(self)


class Evaluator:
    def visit_num(self, n):
        return n.value

    def visit_add(self, a):
        return a.left.accept(self) + a.right.accept(self)

    def visit_mul(self, m):
        return m.left.accept(self) * m.right.accept(self)


expr = Mul(Add(Num(2), Num(3)), Num(4))
print(expr.accept(Evaluator()))

בפייתון אין העמסה, ולכן כל מתודת visit מקבלת שם נפרד — בדיוק כמו בספר, שבו השמות הם VisitElementA ו-VisitElementB. ה-double dispatch זהה: accept בוחר לפי סוג הצומת, ומתודת ה-visit בוחרת לפי סוג המבקר.

החלופה המודרנית: pattern matching

חלק מהשפות מציעות היום דרך ישירה יותר לאותה מטרה. ב-Java 21, ממשק sealed שמגדיר מראש את כל המימושים שלו, יחד עם switch על טיפוסים, נותן את אותה בדיקת שלמות של הקומפיילר בלי accept ובלי ממשק מבקר:

Java
sealed interface Expr permits Num, Add, Mul {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}

public class Main {
    // The compiler checks that every permitted subtype is handled.
    static int eval(Expr e) {
        return switch (e) {
            case Num n -> n.value();
            case Add a -> eval(a.left()) + eval(a.right());
            case Mul m -> eval(m.left()) * eval(m.right());
        };
    }

    public static void main(String[] args) {
        System.out.println(eval(new Mul(new Add(new Num(2), new Num(3)), new Num(4))));
    }
}

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

השלכות

  • קל להוסיף פעולות. פעולה חדשה היא מבקר חדש, בלי לגעת באלמנטים.
  • פעולות קשורות מרוכזות. כל הקוד של ההדפסה נמצא ב-Printer, וכל המצב שהיא צריכה נשאר בתוכו.
  • קשה להוסיף סוגי אלמנטים. וזה המחיר המרכזי. כל צומת חדש מחייב מתודה חדשה בממשק המבקר ובכל המבקרים. לכן הספר מדגיש שהשאלה המכרעת היא מה צפוי להשתנות: האלגוריתמים או מחלקות האלמנטים.
  • שבירה של כימוס. כדי שהמבקר יעשה את עבודתו, האלמנטים צריכים לחשוף את המצב הפנימי שלהם — בדוגמה שלנו, השדות left ו-right. זה מנוגד לרוח של כימוס, ושווה לשים לב אליו.
  • מעבר על היררכיות שונות. בניגוד ל-Iterator, מבקר יכול לבקר אלמנטים שאין להם מחלקת אב משותפת, כל עוד יש להם accept.

מתי לא להשתמש

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

תבניות קשורות

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

טעויות נפוצות

  • לממש accept במחלקת הבסיס. אם accept נכתבת פעם אחת במחלקת אב, הטיפוס הסטטי של this שם הוא מחלקת האב, וה-double dispatch נשבר. כל מחלקה קונקרטית חייבת לממש אותה בעצמה, גם אם הקוד נראה זהה.
  • מבקר עם instanceof. אם המבקר בודק סוגים בעצמו, אין צורך בתבנית — וגם אין יותר בדיקה של הקומפיילר.
  • לבלבל עם Iterator. Iterator עונה על "איך עוברים", Visitor על "מה עושים בכל צומת". מי שמחליט על סדר המעבר — המבנה, המבקר או איטרטור נפרד — היא החלטה נפרדת, והספר דן בכל שלוש האפשרויות.
  • לבחור Visitor כשהאלמנטים הם שמשתנים. זו הטעות היקרה ביותר, כי קשה מאוד לצאת ממנה אחרי שכבר יש עשרה מבקרים.

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

נכתב על ידי

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

כותב על תבניות העיצוב של 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 דק׳ קריאה

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

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

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

9 דק׳ קריאה