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

תבנית Abstract Factory: משפחות של אובייקטים קשורים, מימוש מלא וההבדל מ-Factory Method

Abstract Factory מספקת ממשק ליצירת משפחות של אובייקטים קשורים בלי לציין את המחלקות הקונקרטיות שלהם. ההגדרה לפי ספר ה-GoF, חמשת המשתתפים, מימוש מלא ב-Java ובפייתון, ומה ההבדל האמיתי בינה לבין Factory Method.

יש מצבים שבהם לא מספיק לבחור מחלקה אחת נכונה — צריך לבחור קבוצה שלמה של מחלקות שמתאימות זו לזו. כותרת בפורמט HTML לא יכולה לשבת ליד פסקה בפורמט Markdown באותו מסמך. רכיב ממשק בערכת עיצוב אחת לא יכול להופיע בחלון שבנוי מרכיבים של ערכה אחרת. ספק תשתית אמיתי לא אמור לפגוש תור הודעות מדומה שנועד לבדיקות. תבנית Abstract Factory — ובחיפושים, abstract factory design pattern — נועדה בדיוק לזה: להבטיח שכל האובייקטים שנוצרים יחד שייכים לאותה משפחה, בלי שהקוד שמשתמש בהם יכיר אף אחת מהמחלקות הקונקרטיות.

הכוונה

לפי ספר ה-Gang of Four, הכוונה של Abstract Factory היא לספק ממשק ליצירת משפחות של אובייקטים קשורים או תלויים זה בזה, בלי לציין את המחלקות הקונקרטיות שלהם. השם החלופי שלה בספר הוא Kit — ערכה. זו תבנית יצירה ברמת האובייקט: המפעל הוא אובייקט שמועבר לקוד, ואפשר להחליף אותו בזמן ריצה.

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

הבעיה: עקביות בין מוצרים

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

Java
// Before: every creation site repeats the format check.
String heading = format.equals("html") ? "<h1>" + title + "</h1>" : "# " + title;
String para = format.equals("html") ? "<p>" + line + "</p>" : line;

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

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

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

משתתף תפקיד בדוגמה שלנו
AbstractFactory מצהיר על פעולת יצירה לכל מוצר מופשט DocumentFactory
ConcreteFactory מממש את פעולות היצירה עבור משפחה אחת HtmlFactory, MarkdownFactory
AbstractProduct ממשק לסוג אחד של מוצר Heading, Paragraph
ConcreteProduct מימוש של מוצר בתוך משפחה מסוימת HtmlHeading, MarkdownParagraph וכו'
Client משתמש רק בממשקים המופשטים ReportWriter

מימוש מלא ב-Java

קובץ אחד שלם, לשמירה בשם Main.java:

Java
import java.util.ArrayList;
import java.util.List;

// Abstract products: one interface per kind of product.
interface Heading {
    String render();
}

interface Paragraph {
    String render();
}

// Abstract factory: one creation method per product kind.
interface DocumentFactory {
    Heading createHeading(String text);
    Paragraph createParagraph(String text);
}

// Family 1: HTML products and their factory.
class HtmlHeading implements Heading {
    private final String text;
    HtmlHeading(String text) { this.text = text; }
    @Override public String render() { return "<h1>" + text + "</h1>"; }
}

class HtmlParagraph implements Paragraph {
    private final String text;
    HtmlParagraph(String text) { this.text = text; }
    @Override public String render() { return "<p>" + text + "</p>"; }
}

class HtmlFactory implements DocumentFactory {
    @Override public Heading createHeading(String text) { return new HtmlHeading(text); }
    @Override public Paragraph createParagraph(String text) { return new HtmlParagraph(text); }
}

// Family 2: Markdown products and their factory.
class MarkdownHeading implements Heading {
    private final String text;
    MarkdownHeading(String text) { this.text = text; }
    @Override public String render() { return "# " + text; }
}

class MarkdownParagraph implements Paragraph {
    private final String text;
    MarkdownParagraph(String text) { this.text = text; }
    @Override public String render() { return text; }
}

class MarkdownFactory implements DocumentFactory {
    @Override public Heading createHeading(String text) { return new MarkdownHeading(text); }
    @Override public Paragraph createParagraph(String text) { return new MarkdownParagraph(text); }
}

// Client: knows only the abstract factory and the abstract products.
class ReportWriter {
    private final DocumentFactory factory;

    ReportWriter(DocumentFactory factory) { this.factory = factory; }

    String write(String title, List<String> lines) {
        List<String> parts = new ArrayList<>();
        parts.add(factory.createHeading(title).render());
        for (String line : lines) {
            parts.add(factory.createParagraph(line).render());
        }
        return String.join("\n", parts);
    }
}

public class Main {
    public static void main(String[] args) {
        List<String> lines = List.of("Orders: 42", "Returns: 3");
        for (DocumentFactory factory : List.of(new HtmlFactory(), new MarkdownFactory())) {
            System.out.println(new ReportWriter(factory).write("Weekly report", lines));
            System.out.println("---");
        }
    }
}

הפלט:

HTML
<h1>Weekly report</h1>
<p>Orders: 42</p>
<p>Returns: 3</p>
---
# Weekly report
Orders: 42
Returns: 3
---

ReportWriter לא יודע שקיימים HTML או Markdown. הוא מקבל מפעל אחד, וכל מה שהוא יוצר דרכו שייך לאותה משפחה. אין דרך, מתוך הקוד שלו, לייצר מסמך מעורב. כדי להוסיף פורמט שלישי — נניח טקסט פשוט לדוא"ל — כותבים מפעל חדש ושני מוצרים, ו-ReportWriter לא משתנה.

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

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

Python
from abc import ABC, abstractmethod


class DocumentFactory(ABC):
    @abstractmethod
    def heading(self, text): ...

    @abstractmethod
    def paragraph(self, text): ...


class HtmlFactory(DocumentFactory):
    def heading(self, text):
        return f"<h1>{text}</h1>"

    def paragraph(self, text):
        return f"<p>{text}</p>"


class MarkdownFactory(DocumentFactory):
    def heading(self, text):
        return f"# {text}"

    def paragraph(self, text):
        return text


def write_report(factory, title, lines):
    parts = [factory.heading(title)] + [factory.paragraph(line) for line in lines]
    return "\n".join(parts)


for factory in (HtmlFactory(), MarkdownFactory()):
    print(write_report(factory, "Weekly report", ["Orders: 42", "Returns: 3"]))
    print("---")

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

Abstract Factory מול Factory Method

זו השאלה שחוזרת הכי הרבה, והתשובה הקצרה היא שההבדל הוא בין ירושה להרכבה, ובין מוצר אחד למשפחה.

Factory Method Abstract Factory
מה נוצר מוצר אחד משפחה של מוצרים שחייבים להתאים
מנגנון ירושה: מחלקת־בת דורסת מתודה הרכבה: אובייקט מפעל מועבר ללקוח
מי מחליט מחלקת־הבת של ה-Creator מי שבוחר איזה מפעל להעביר
החלפה בזמן ריצה לא בקלות כן, מחליפים אובייקט

הן גם לא סותרות זו את זו. הספר עצמו מציין שמפעל קונקרטי ממומש לעיתים קרובות בעזרת Factory Methods: כל מתודת create היא בדיוק מתודת יצירה שמחלקת־בת דורסת.

איפה עוד פוגשים את התבנית

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

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

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

השלכות

הספר מונה ארבע השלכות, ושלוש מהן חיוביות:

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

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

מתי לא להשתמש

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

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

מפעלים קונקרטיים ממומשים לעיתים קרובות בעזרת Factory Method, ולפעמים בעזרת Prototype — המפעל מחזיק אב־טיפוס לכל מוצר ומשכפל אותו. בדרך כלל יש ביישום רק מופע אחד של כל מפעל קונקרטי, ולכן הספר מציין שמפעל הוא מועמד טבעי ל-Singleton, אם כי היום מקובל יותר פשוט ליצור אותו פעם אחת ולהעביר אותו הלאה. כשהתבנית משמשת להסתרת תת־מערכת שלמה, היא נפגשת עם Facade. והעיקרון שמאחוריה הוא היפוך תלויות: הלקוח תלוי בהפשטות, והקונקרטי מגיע מבחוץ.

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

  • מפעל שהוא בעצם Service Locator. מפעל עם עשרים מתודות יצירה לא קשורות זו לזו אינו Abstract Factory — אין שם משפחה שחייבת להתאים, רק מקום מרכזי לקבל ממנו הכול.
  • לקוח שבודק את סוג המפעל. אם הקוד שואל instanceof HtmlFactory, ההפשטה נשברה. כל הבדל בין משפחות צריך לחיות בתוך המוצרים.
  • מוצר שמכיר מחלקה קונקרטית ממשפחה אחרת. אם HtmlParagraph מקבל בבנאי MarkdownHeading, העקביות שהתבנית אמורה להבטיח כבר לא קיימת.
  • לבחור מפעל בכמה מקומות. כל הרעיון הוא שהבחירה נעשית פעם אחת. אם התנאי שבוחר מפעל מופיע בכמה קבצים, החזרתם את הבעיה המקורית.

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

נכתב על ידי

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

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

להמשך קריאה

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

תבניות יצירה

singleton design pattern: תבנית Singleton, מימוש נכון, בטיחות בין חוטים, ולמה רבים רואים בה אנטי־תבנית

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

8 דק׳ קריאה

תבניות יצירה

תבנית Builder: בניית אובייקט מורכב צעד אחר צעד, מימוש מלא, Fluent Builder ו-Director

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

9 דק׳ קריאה

תבניות יצירה

תבנית Factory Method: הגדרה, מבנה, מימוש מלא ב-Java ובפייתון, ומתי היא עדיפה על new ישיר

Factory Method מגדירה ממשק ליצירת אובייקט ומשאירה למחלקות־הבת להחליט איזו מחלקה ליצור. ההגדרה המדויקת, ארבעת המשתתפים, מימוש מלא ב-Java ובפייתון, ההבדל מ"פקטורי פשוט" ומתי new ישיר עדיף.

8 דק׳ קריאה