תבנית Decorator (קשטן): עטיפת אובייקט בהתנהגות נוספת בלי ירושה — מימוש מלא ודוגמת זרמי קלט/פלט
תבנית Decorator, או קשטן, מוסיפה אחריות לאובייקט באופן דינמי ומהווה חלופה גמישה לירושה. ההגדרה לפי ספר ה-GoF, ארבעת המשתתפים, מימוש מלא ב-Java ובפייתון, איך זרמי הקלט/פלט של Java בנויים עליה, ולמה הסדר בין העטיפות קובע.
נניח שיש לכם מחלקה שקוראת טקסט, ואתם צריכים לפעמים לנקות ממנו רווחים, לפעמים להמיר אותו לאותיות גדולות, ולפעמים להסתיר בו מילים רגישות — ובכל צירוף אפשרי. עם ירושה, כל צירוף הוא מחלקה: שלוש התנהגויות הן שבעה צירופים, ארבע הן חמישה־עשר. תבנית Decorator, שבעברית נקראת גם קשטן, פותרת את זה בדרך אחרת: כל התנהגות היא עטיפה דקה סביב האובייקט, והצירוף נבנה בזמן ריצה על ידי עטיפה של עטיפה. המאמר מציג את ההגדרה המדויקת, מימוש מלא ב-Java ובפייתון, ואת הדוגמה המוכרת ביותר לתבנית בספרייה הסטנדרטית: זרמי הקלט והפלט של Java.
הכוונה
ספר ה-Gang of Four מגדיר את הכוונה של Decorator כך: להצמיד לאובייקט אחריות נוספת באופן דינמי. קשטנים מספקים חלופה גמישה ליצירת מחלקות־בת לשם הרחבת פונקציונליות. השם החלופי בספר הוא Wrapper — עוטף. זו תבנית מבנה ברמת האובייקט.
שתי מילים בהגדרה עושות את כל העבודה. "לאובייקט" — לא למחלקה. ההתנהגות נוספת למופע מסוים, ומופעים אחרים של אותה מחלקה לא מושפעים. "דינמי" — בזמן ריצה, ולא בזמן קומפילציה. אפשר להחליט לפי הגדרה של משתמש אילו עטיפות להפעיל, ואפשר להוסיף אותן או לוותר עליהן בלי לשנות שורה בקוד שמשתמש באובייקט.
הבעיה: התפוצצות של מחלקות
הספר מדגים את הבעיה ברכיב תצוגת טקסט שאפשר להוסיף לו מסגרת, פס גלילה, או את שניהם. הפתרון בירושה הוא מחלקה לכל צירוף, ובעיה זו גדלה באופן מעריכי: לכל התנהגות חדשה מכפילים את מספר המחלקות. יש גם בעיה עמוקה יותר. הירושה סטטית: אובייקט שנוצר כ"טקסט עם מסגרת" יישאר כזה, ואי אפשר להוסיף לו פס גלילה כשהתוכן גדל.
הפתרון של Decorator הוא לעטוף. עוטף המסגרת הוא אובייקט שמממש את אותו ממשק כמו רכיב הטקסט, מחזיק הפניה אליו, מעביר אליו כל בקשה, ומוסיף לפני או אחרי את מה שהוא אחראי עליו. מכיוון שהעוטף עצמו מממש את הממשק, אפשר לעטוף גם אותו. הלקוח רואה אובייקט אחד עם הממשק המוכר, ואינו יודע כמה שכבות יש מתחת.
המשתתפים והמבנה
בתרשים המחלקות יש ממשק Component. שתי מחלקות מממשות אותו: ConcreteComponent, האובייקט האמיתי, ו-Decorator, מחלקה מופשטת שגם מחזיקה הפניה ל-Component — קשר הרכבה שמסומן בתרשים במעוין שיוצא מ-Decorator וחוזר אל Component. מתחת ל-Decorator יש מחלקות ConcreteDecorator, שכל אחת מוסיפה התנהגות אחת. הצורה הזו, שבה מחלקה גם יורשת מממשק וגם מחזיקה מופע שלו, היא החתימה החזותית של התבנית. היא דומה מאוד לתרשים של Composite, וההבדל ביניהן נדון בהמשך.
| משתתף | תפקיד | בדוגמה שלנו |
|---|---|---|
| Component | הממשק של אובייקטים שאפשר להוסיף להם אחריות | TextSource |
| ConcreteComponent | האובייקט הבסיסי שאליו מוסיפים אחריות | StringSource |
| Decorator | מחזיק הפניה ל-Component, מממש את אותו ממשק ומעביר אליו בקשות | TextDecorator |
| ConcreteDecorator | מוסיף אחריות אחת לפני או אחרי ההעברה | Trimmed, UpperCased, Redacted |
מימוש מלא ב-Java
// Component: the interface clients use.
interface TextSource {
String read();
}
// ConcreteComponent: the real object being decorated.
class StringSource implements TextSource {
private final String text;
StringSource(String text) { this.text = text; }
@Override public String read() { return text; }
}
// Decorator: same interface, wraps a component, forwards by default.
abstract class TextDecorator implements TextSource {
protected final TextSource inner;
protected TextDecorator(TextSource inner) { this.inner = inner; }
@Override public String read() { return inner.read(); }
}
// Concrete decorators: each adds exactly one responsibility.
class Trimmed extends TextDecorator {
Trimmed(TextSource inner) { super(inner); }
@Override public String read() { return super.read().trim(); }
}
class UpperCased extends TextDecorator {
UpperCased(TextSource inner) { super(inner); }
@Override public String read() { return super.read().toUpperCase(); }
}
class Redacted extends TextDecorator {
private final String word;
Redacted(TextSource inner, String word) {
super(inner);
this.word = word;
}
@Override public String read() {
return super.read().replace(word, "*".repeat(word.length()));
}
}
public class Main {
public static void main(String[] args) {
TextSource raw = new StringSource(" the password is swordfish ");
// Same three decorators, two different orders.
TextSource redactLast = new Redacted(new UpperCased(new Trimmed(raw)), "swordfish");
TextSource redactFirst = new UpperCased(new Redacted(new Trimmed(raw), "swordfish"));
System.out.println("[" + raw.read() + "]");
System.out.println(redactLast.read());
System.out.println(redactFirst.read());
}
}הפלט:
[ the password is swordfish ]
THE PASSWORD IS SWORDFISH
THE PASSWORD IS *********השורה השנייה היא לא באג בתבנית — היא השיעור החשוב ביותר עליה. ב-redactLast, ההסתרה רצה אחרי ההמרה לאותיות גדולות, ומחפשת את המילה באותיות קטנות בטקסט שכבר אין בו אותיות קטנות. אותם שלושה קשטנים, בסדר אחר, נותנים תוצאה אחרת. כשעוטפים, הסדר הוא חלק מהתכנון, וצריך לתעד אותו ולבדוק אותו.
שימו לב גם ל-raw: הוא לא השתנה. כל קשטן יוצר אובייקט חדש סביבו, והאובייקט המקורי ממשיך להתנהג בדיוק כמו קודם עבור כל מי שמחזיק אותו ישירות.
הדוגמה הקלאסית: זרמי קלט/פלט ב-Java
חבילת java.io היא אולי השימוש המוכר ביותר בתבנית. InputStream הוא ה-Component, FileInputStream ו-ByteArrayInputStream הם רכיבים קונקרטיים, ו-FilterInputStream הוא ה-Decorator המופשט — מחלקה שמחזיקה InputStream אחר ומעבירה אליו את כל הקריאות. BufferedInputStream, DataInputStream ו-GZIPInputStream הם קשטנים קונקרטיים. אותו מבנה חוזר בצד הפלט עם FilterOutputStream, ובצד התווים עם Reader ו-Writer.
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
public class Main {
public static void main(String[] args) throws IOException {
String original = "decorator ".repeat(50);
// Write: characters -> bytes -> gzip -> memory.
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (Writer out = new OutputStreamWriter(new GZIPOutputStream(bytes), StandardCharsets.UTF_8)) {
out.write(original);
}
// Read: memory -> gunzip -> characters -> buffered lines.
try (BufferedReader in = new BufferedReader(new InputStreamReader(
new GZIPInputStream(new ByteArrayInputStream(bytes.toByteArray())),
StandardCharsets.UTF_8))) {
String restored = in.readLine();
System.out.println("compressed is smaller: " + (bytes.size() < original.length()));
System.out.println("round trip ok: " + original.equals(restored));
}
}
}הפלט הוא compressed is smaller: true ו-round trip ok: true. כל שכבה בשרשרת מוסיפה אחריות אחת — דחיסה, חציצה — והקוד שקורא שורות מ-BufferedReader אינו יודע שמתחתיו יש דחיסה. דקות אחת חשובה: InputStreamReader ו-OutputStreamWriter אינם קשטנים. הם ממירים זרם בתים לזרם תווים — ממשק אחד לממשק אחר — ולכן הם Adapter. קשטן תמיד שומר על אותו ממשק כמו מה שהוא עוטף.
אותו מבנה בפייתון
class StringSource:
def __init__(self, text):
self.text = text
def read(self):
return self.text
class TextDecorator:
def __init__(self, inner):
self.inner = inner
def read(self):
return self.inner.read()
class Trimmed(TextDecorator):
def read(self):
return super().read().strip()
class UpperCased(TextDecorator):
def read(self):
return super().read().upper()
class Redacted(TextDecorator):
def __init__(self, inner, word):
super().__init__(inner)
self.word = word
def read(self):
return super().read().replace(self.word, "*" * len(self.word))
raw = StringSource(" the password is swordfish ")
print(UpperCased(Redacted(Trimmed(raw), "swordfish")).read())הפלט הוא THE PASSWORD IS *********. כדאי להבהיר בלבול נפוץ: התחביר @decorator של פייתון אינו התבנית הזו. הוא מנגנון של השפה שעוטף פונקציה או מחלקה בזמן ההגדרה שלה. הרעיון דומה — עטיפה שמוסיפה התנהגות ושומרת על הממשק — אבל הוא פועל על הגדרות ולא על אובייקטים בזמן ריצה, ותבנית ה-GoF אינה דורשת אותו.
מחוץ לזרמים: עטיפות סביב שירות
בקוד עסקי, המקום שבו התבנית מופיעה הכי הרבה הוא סביב לקוחות של שירותים חיצוניים. נניח ממשק PriceClient עם מתודה אחת שמחזירה מחיר של מוצר משירות מרוחק. המימוש האמיתי פונה לרשת. סביבו אפשר להלביש שלושה קשטנים: אחד שרושם ביומן כל קריאה ואת משך הזמן שלה, אחד שמנסה שוב אחרי כשל רגעי, ואחד שמחזיק מטמון לדקה. כל אחד מהם הוא מחלקה קטנה שמממשת PriceClient, מחזיקה PriceClient אחר, ועושה דבר אחד בלבד.
גם כאן הסדר קובע, והפעם יש לו משמעות תפעולית. אם המטמון נמצא בחוץ, פגיעה במטמון לא נרשמת ביומן ולא מפעילה ניסיון חוזר — בדרך כלל זה מה שרוצים. אם הרישום ביומן נמצא בחוץ, רואים גם את הפגיעות במטמון, אבל לא את הניסיונות החוזרים הבודדים. אם ניסיון חוזר נמצא מחוץ לרישום, כל ניסיון נרשם בנפרד. אין סדר נכון אחד; יש סדר שמתאים לשאלה שאתם רוצים שהיומן יענה עליה. היתרון של התבנית הוא שהשאלה הזו נענית במקום אחד, בקוד שמרכיב את השרשרת, ולא מפוזרת בתוך מחלקה אחת ענקית.
מי שמכיר "middleware" בשרתי אינטרנט מכיר רעיון קרוב מאוד: כל שכבה עוטפת את הבאה אחריה, מקבלת את הבקשה, ומחליטה מה לעשות לפני ואחרי שהיא מעבירה אותה הלאה. כששכבה כזו מחליטה בעצמה אם לטפל בבקשה או להעביר אותה הלאה, המבנה כבר קרוב יותר ל-Chain of Responsibility. ההבדל הוא בכוונה: קשטן מוסיף אחריות לאובייקט אחד ושומר על החוזה שלו, ושרשרת אחריות מנתבת בקשה בין כמה מטפלים אפשריים.
השלכות
- יותר גמישות מירושה סטטית. אחריות מתווספת ונגרעת בזמן ריצה, ואפשר להפעיל את אותה התנהגות פעמיים — מסגרת כפולה, למשל — דבר שירושה לא מאפשרת בקלות.
- מחלקות פשוטות בראש ההיררכיה. במקום מחלקה אחת עמוסה בכל התכונות האפשריות, מתחילים מרכיב פשוט ומוסיפים רק את מה שצריך. משלמים רק על מה שמשתמשים בו.
- הקשטן והרכיב אינם אותו אובייקט. מבחינת זהות, אובייקט עטוף הוא אובייקט אחר. קוד שמשווה הפניות, או שבודק
instanceof StringSource, יתנהג אחרת אחרי עטיפה. - הרבה אובייקטים קטנים. מערכת שבנויה מקשטנים קלה להתאמה, אבל קשה יותר ללמוד ולדבג: הקריאה עוברת דרך שכבות, ומחסנית הקריאות ארוכה.
מתי לא להשתמש
- הממשק רחב. קשטן חייב לממש את כל הממשק, גם את המתודות שהוא לא משנה. בממשק עם עשרים מתודות, כל קשטן הוא עשרים מתודות העברה, ומחלקת Decorator מופשטת עוזרת רק בחלק מהשפות.
- ההתנהגות צריכה לגשת לפרטים הפנימיים. קשטן רואה רק את הממשק הציבורי. אם התוספת צריכה את המצב הפנימי של האובייקט, היא שייכת לאובייקט עצמו, או ל-Strategy שהוא מחזיק — הספר מתאר את זה כ"שינוי העור" מול "שינוי הקרביים".
- הצירוף קבוע. אם תמיד מפעילים בדיוק אותן שלוש התנהגויות באותו סדר, מתודה אחת שעושה את שלושתן ברורה יותר.
תבניות קשורות
Adapter משנה את הממשק של אובייקט; Decorator שומר על הממשק ומשנה את האחריות. Composite נראה דומה בתרשים, אבל המטרה שונה: Composite מאחד הרבה ילדים תחת ממשק אחד, וקשטן הוא Composite מנוון עם ילד אחד שמטרתו להוסיף אחריות ולא לצבור. ו-Strategy היא החלופה כשרוצים לשנות את ההתנהגות מבפנים במקום לעטוף מבחוץ. הבסיס העקרוני לכולן הוא הרכבה במקום ירושה.
טעויות נפוצות
- להתעלם מהסדר. כפי שראינו, אותם קשטנים בסדר אחר נותנים תוצאה אחרת. מי שמרכיב את השרשרת צריך לדעת מה כל שכבה מצפה לקבל.
- לשכוח להעביר מתודה. בממשק עם כמה מתודות, קשטן שלא מעביר אחת מהן "בולע" אותה. מחלקת Decorator מופשטת שמעבירה הכול כברירת מחדל מונעת את זה.
- לסגור את הזרם הפנימי פעמיים או לא בכלל. ב-
java.io, סגירה של הקשטן החיצוני סוגרת את כל השרשרת. מי שסוגר גם את הזרם הפנימי בנפרד, או מחזיק הפניה אליו ומשתמש בו אחרי הסגירה, יקבל שגיאות. - קשטן שמשנה את המשמעות של הממשק. קשטן שהופך
readלפעולה שכותבת לקובץ מפר את עקרון ההחלפה של ליסקוב. הלקוח אמור לקבל את אותו חוזה, רק עם אחריות נוספת.
Decorator היא אחת התבניות השימושיות ביותר בספר, דווקא משום שהיא פשוטה: ממשק אחד, הפניה אחת, והעברה. כשאתם מוצאים את עצמכם כותבים מחלקת־בת חדשה רק כדי להוסיף "עוד דבר אחד" לפני או אחרי מתודה קיימת — עצרו ובדקו אם עטיפה לא תעשה את זה טוב יותר. רקע על משפחת תבניות המבנה נמצא במבוא לתבניות עיצוב.


