תבנית Composite: מבני עץ שבהם עלה וצומת מטופלים אותו דבר — מימוש מלא על מערכת קבצים
Composite מרכיבה אובייקטים למבני עץ שמייצגים היררכיות של חלק ושלם, ומאפשרת ללקוח לטפל באובייקט בודד ובהרכב של אובייקטים באותה צורה. ההגדרה לפי ספר ה-GoF, המשתתפים, מימוש מלא על מערכת קבצים ב-Java ובפייתון, והדילמה בין שקיפות לבטיחות.
כמעט כל מערכת מכילה איפשהו עץ: תיקיות שמכילות קבצים ותיקיות, תפריטים שמכילים פריטים ותתי־תפריטים, מחלקות בארגון שמכילות עובדים ומחלקות־משנה, ביטוי חשבוני שמכיל מספרים וביטויים. הקוד שעובד עם עצים כאלה נוטה להתמלא בבדיקות: אם זה קובץ — עשה כך, אם זו תיקייה — עבור על התוכן ועשה כך לכל אחד. תבנית Composite — composite design pattern — מעלימה את הבדיקות האלה. היא נותנת לעלה ולצומת אותו ממשק, כך שהלקוח שואל "מה הגודל שלך?" בלי לדעת אם הוא מדבר עם קובץ אחד או עם תיקייה של אלף קבצים.
הכוונה
ספר ה-Gang of Four מגדיר את הכוונה של Composite כך: להרכיב אובייקטים למבני עץ כדי לייצג היררכיות של חלק־שלם. Composite מאפשרת ללקוחות לטפל באובייקטים בודדים ובהרכבים של אובייקטים באופן אחיד. זו תבנית מבנה ברמת האובייקט.
המפתח הוא המילה "אחיד". המטרה אינה רק לייצג עץ — כל רשימה של רשימות עושה את זה — אלא לאפשר לקוד שמשתמש בעץ להתעלם מההבדל בין עלה לצומת. הדוגמה בספר היא עורך גרפי: קו, מלבן וטקסט הם צורות בסיסיות, וקבוצה של צורות היא גם צורה. אפשר לצייר אותה, להזיז אותה ולקבץ אותה בתוך קבוצה גדולה יותר, בדיוק כמו קו בודד.
הבעיה: בדיקות סוג בכל מקום
נניח כלי שמנתח תיקיית פרויקט: מחשב את הגודל הכולל, מדפיס עץ, מוצא את הקבצים הגדולים. בלי התבנית, קובץ ותיקייה הם שתי מחלקות שאין ביניהן קשר. כל פעולה על העץ מתחילה בבדיקה: אם זה File, קח את הגודל שלו; אם זה Folder, עבור על הילדים, ולכל ילד — בדוק שוב מה הוא. הבדיקה הזו חוזרת בכל פעולה. כשמוסיפים סוג צומת חדש — קיצור דרך, או ארכיון שמתנהג כמו תיקייה — צריך לעבור על כל הפעולות ולהוסיף לו ענף.
Composite הופכת את הכיוון. במקום שהלקוח ישאל כל צומת מה הוא, כל צומת יודע לענות בעצמו. קובץ מחזיר את הגודל שלו. תיקייה מחזירה את סכום הגדלים של הילדים שלה — בלי לדעת, ובלי לשאול, מה כל ילד. הרקורסיה מתפזרת על פני העץ, ואף אחד לא צריך לבדוק סוגים.
המשתתפים והמבנה
בתרשים המחלקות יש מחלקה מופשטת או ממשק Component, ושתי מחלקות שיורשות ממנו: Leaf ו-Composite. ל-Composite יש קשר הרכבה בחזרה אל Component, עם ריבוי של אפס ומעלה — "מכיל הרבה Components". הקשר הזה, שבו מחלקה גם היא Component וגם מכילה Components, הוא מה שמאפשר לעץ להיות עמוק כמה שרוצים. הלקוח מחזיק הפניה ל-Component בלבד.
| משתתף | תפקיד | בדוגמה שלנו |
|---|---|---|
| Component | מצהיר על הממשק המשותף לכל האובייקטים בהרכב, ולפעמים גם על פעולות לניהול ילדים | FsNode |
| Leaf | אובייקט בסיסי שאין לו ילדים, ומגדיר את ההתנהגות של עצמו | FileNode |
| Composite | מחזיק ילדים, מממש את פעולות הממשק בעזרתם ומנהל את רשימת הילדים | Directory |
| Client | עובד עם כל אובייקט בעץ דרך ממשק ה-Component | Main |
מימוש מלא ב-Java
import java.util.ArrayList;
import java.util.List;
// Component: the common interface for files and directories.
abstract class FsNode {
private final String name;
protected FsNode(String name) { this.name = name; }
String getName() { return name; }
abstract long size();
abstract void print(String indent);
}
// Leaf: a file has no children.
class FileNode extends FsNode {
private final long bytes;
FileNode(String name, long bytes) {
super(name);
this.bytes = bytes;
}
@Override long size() { return bytes; }
@Override void print(String indent) {
System.out.println(indent + getName() + " (" + bytes + " B)");
}
}
// Composite: a directory holds children and delegates to them.
class Directory extends FsNode {
private final List<FsNode> children = new ArrayList<>();
Directory(String name) { super(name); }
Directory add(FsNode child) {
children.add(child);
return this;
}
void remove(FsNode child) { children.remove(child); }
@Override long size() {
long total = 0;
for (FsNode child : children) {
total += child.size();
}
return total;
}
@Override void print(String indent) {
System.out.println(indent + getName() + "/ (" + size() + " B)");
for (FsNode child : children) {
child.print(indent + " ");
}
}
}
public class Main {
public static void main(String[] args) {
FileNode readme = new FileNode("README.md", 1200);
Directory util = new Directory("util").add(new FileNode("Strings.java", 800));
Directory src = new Directory("src").add(new FileNode("Main.java", 3400)).add(util);
Directory project = new Directory("project")
.add(readme)
.add(src)
.add(new FileNode("logo.png", 15000));
project.print("");
// The client treats a single file and a whole tree the same way.
for (FsNode node : List.of(readme, src, project)) {
System.out.println(node.getName() + " = " + node.size());
}
}
}הפלט:
project/ (20400 B)
README.md (1200 B)
src/ (4200 B)
Main.java (3400 B)
util/ (800 B)
Strings.java (800 B)
logo.png (15000 B)
README.md = 1200
src = 4200
project = 20400הלולאה בסוף היא התבנית כולה: שלושה אובייקטים — קובץ, תיקייה קטנה ופרויקט שלם — מטופלים בשורה אחת, באותה קריאה, בלי בדיקת סוג. אין בקוד אף instanceof, וסוג צומת חדש — נניח קיצור דרך שגודלו אפס — הוא מחלקה אחת שיורשת מ-FsNode.
אותו מבנה בפייתון
class FileNode:
def __init__(self, name, size):
self.name, self._size = name, size
def size(self):
return self._size
def print(self, indent=""):
print(f"{indent}{self.name} ({self._size} B)")
class Directory:
def __init__(self, name, *children):
self.name, self.children = name, list(children)
def size(self):
return sum(child.size() for child in self.children)
def print(self, indent=""):
print(f"{indent}{self.name}/ ({self.size()} B)")
for child in self.children:
child.print(indent + " ")
project = Directory(
"project",
FileNode("README.md", 1200),
Directory("src", FileNode("Main.java", 3400), Directory("util", FileNode("Strings.java", 800))),
FileNode("logo.png", 15000),
)
project.print()בפייתון אין צורך במחלקת בסיס משותפת: כל עוד לשתי המחלקות יש size ו-print, הלקוח יכול לטפל בהן באחידות. זה duck typing, והתבנית עדיין שם — הממשק המשותף פשוט לא כתוב במפורש. בפרויקט גדול כדאי בכל זאת להגדיר אותו, למשל עם typing.Protocol, כדי שכלי בדיקת טיפוסים יתפסו צומת שחסרה לו מתודה.
איפה עוד פוגשים את התבנית
מערכת קבצים היא הדוגמה הנוחה להסבר, אבל אותו מבנה חוזר בהרבה תחומים. בממשקי משתמש, חלון מכיל לוחות, לוח מכיל כפתורים ולוחות נוספים, ופעולת ציור או חישוב גודל עוברת על העץ באותה צורה. במבנה ארגוני, עלות של מחלקה היא סכום העלויות של העובדים שלה ושל מחלקות־המשנה שלה. בהזמנה בחנות, חבילה יכולה להכיל מוצרים בודדים וחבילות קטנות יותר, והמחיר הכולל מחושב באותה רקורסיה. ובעץ ביטויים, ביטוי (2 + 3) * 4 הוא צומת כפל שאחד מילדיו הוא צומת חיבור — מבנה שנחזור אליו במאמר על Visitor.
מה שמשותף לכל הדוגמאות: הלקוח שואל שאלה אחת — מה הגודל, מה העלות, מה הערך — ואינו אמור לדעת אם התשובה מגיעה מאובייקט בודד או מאלף אובייקטים מתחתיו. אם השאלה שהלקוח שואל שונה לעלים ולצמתים, זו כנראה לא בעיה של Composite.
הדילמה: שקיפות מול בטיחות
השאלה התכנונית החשובה ביותר בתבנית היא היכן להצהיר על פעולות ניהול הילדים — add ו-remove. הספר מציג שתי אפשרויות, ואומר במפורש שיש ביניהן פשרה:
- שקיפות: מצהירים עליהן ב-Component. כל הצמתים נראים זהים לחלוטין, והלקוח לעולם לא צריך לדעת עם מה הוא עובד. המחיר: לעלה יש מתודת
addשאין לה משמעות, וקריאה אליה תיכשל רק בזמן ריצה — בדרך כלל בחריגה. - בטיחות: מצהירים עליהן רק ב-Composite, כמו בדוגמה שלנו. הקומפיילר מונע הוספת ילד לקובץ. המחיר: מי שרוצה להוסיף ילד צריך לדעת שהוא מחזיק Composite, ולפעמים להמיר טיפוס.
הספר נוטה לשקיפות, משום שהיא מממשת את הכוונה של התבנית עד הסוף. בפועל, בשפות עם טיפוסים סטטיים כמו Java, הרבה מימושים מעדיפים בטיחות: את העץ בונים במקום אחד, שבו ממילא יודעים מה יוצרים, ולשאר הקוד — שרק קורא מהעץ — הממשק המשותף מספיק. אין כאן תשובה אחת נכונה, אבל כדאי לבחור במודע.
השלכות
- היררכיות של אובייקטים פשוטים ומורכבים. בכל מקום שבו הלקוח מצפה לאובייקט פשוט, אפשר לתת לו גם מורכב.
- לקוח פשוט. אין צורך לכתוב קוד מיוחד לכל סוג צומת, ואין שרשראות של בדיקות סוג.
- קל להוסיף סוגי צמתים. עלה או Composite חדש עובדים אוטומטית עם כל הקוד הקיים.
- המחיר: עיצוב כללי מדי. אותה קלות שמאפשרת להוסיף כל צומת גם מקשה להגביל. אם תיקייה מסוימת אמורה להכיל רק תמונות, מערכת הטיפוסים לא תאכוף את זה, וצריך בדיקות בזמן ריצה.
הספר מזכיר גם כמה החלטות מימוש שכדאי לשקול: הפניה מפורשת מכל ילד להורה שלו, שמקלה על מעבר כלפי מעלה ועל מחיקה; שמירת תוצאות במטמון — למשל גודל התיקייה — עם הצורך לבטל את המטמון כשילד משתנה; ושאלת הסדר בין הילדים, כשיש לו משמעות.
מתי לא להשתמש
- המבנה שטוח. אם יש רק רמה אחת — רשימה של פריטים — רשימה רגילה מספיקה.
- עלים וצמתים מתנהגים שונה מהותית. אם רוב הפעולות שמעניינות את הלקוח קיימות רק לעלים, או רק לצמתים, הממשק המשותף יתמלא במתודות ריקות. האחידות היא רק במראית עין.
- הפעולות על העץ משתנות כל הזמן. Composite מקלה על הוספת סוגי צמתים, ומקשה על הוספת פעולות — כל פעולה חדשה נוגעת בכל המחלקות. אם הפעולות הן מה שמשתנה, שלבו את העץ עם Visitor.
תבניות קשורות
Visitor מרכזת פעולות על עץ Composite במחלקה אחת, במקום לפזר אותן על פני העלים והצמתים. Decorator משמשת לעיתים קרובות יחד עם Composite, ושתיהן חולקות לרוב מחלקת אב — אבל קשטן עוטף ילד אחד כדי להוסיף אחריות, ו-Composite מחזיק הרבה ילדים כדי לצבור. בעץ שבו לכל ילד יש הפניה להורה, השרשרת כלפי מעלה היא בסיס טבעי ל-Chain of Responsibility: בקשה שצומת לא יודע לטפל בה עוברת להורה שלו. Iterator משמשת למעבר על העץ, ו-Builder מתאימה לבנייה שלו צעד אחר צעד. בספרייה הסטנדרטית של Java, java.awt.Container הוא דוגמה מוכרת: הוא יורש מ-Component ומכיל רכיבים אחרים.
טעויות נפוצות
- מעגלים בעץ. אם תיקייה מתווספת לתוך אחת מתתי־התיקיות של עצמה,
sizeתרוץ עד שהמחסנית תתמלא. מימוש אמיתי צריך לבדוק את זה ב-add. - ילד שמופיע בשני הורים. בעץ, לכל צומת יש הורה אחד. אם אותו אובייקט מתווסף לשתי תיקיות, הוא ייספר פעמיים, ומחיקה מאחת תשאיר אותו בשנייה.
- בדיקות סוג שחוזרות בדלת האחורית. אם הלקוח שואל
instanceof Directoryלפני כל פעולה, התבנית לא מומשה — רק המחלקות קיבלו אב משותף. - מטמון שלא מתבטל. אם התיקייה שומרת את הגודל שלה, הוספת קובץ לתת־תיקייה עמוקה חייבת לבטל את המטמון בכל ההורים — וזה בדיוק המקום שבו הפניה להורה נעשית הכרחית.
Composite היא תבנית שמופיעה כמעט בכל מקום שיש בו היררכיה, לעיתים קרובות בלי שם. כשאתם רואים בקוד בדיקה חוזרת של "האם זה עלה או צומת", זה הסימן: תנו לשניהם ממשק אחד, ותנו לכל אחד לענות בעצמו. הרקע הכללי על תבניות המבנה נמצא במבוא לתבניות עיצוב.


