תבנית Builder: בניית אובייקט מורכב צעד אחר צעד, מימוש מלא, Fluent Builder ו-Director
Builder מפרידה בין תהליך הבנייה של אובייקט מורכב לבין הייצוג שלו, כך שאותו תהליך יכול לייצר ייצוגים שונים. ההגדרה של ספר ה-GoF עם Director, הווריאציה השוטפת המוכרת מ-Java, מימוש מלא של שתיהן ומתי לא צריך אף אחת.
תבנית Builder היא כנראה התבנית שהכי הרבה מפתחים משתמשים בה בלי לדעת שיש לה שתי גרסאות שונות מאוד. הגרסה שרובנו מכירים — Email.builder().subject("...").urgent().build() — היא פתרון לבנאי עם יותר מדי פרמטרים. הגרסה שמופיעה בספר ה-Gang of Four עוסקת בבעיה אחרת לגמרי: איך אותו תהליך בנייה מייצר ייצוגים שונים לחלוטין. שתיהן לגיטימיות ושתיהן נקראות builder design pattern, אבל הן פותרות בעיות שונות, ומי שמבלבל ביניהן בוחר לעיתים קרובות את הכלי הלא נכון. המאמר מציג את שתיהן, עם מימוש מלא לכל אחת.
הכוונה המקורית
לפי ספר ה-GoF, הכוונה של Builder היא להפריד את בנייתו של אובייקט מורכב מהייצוג שלו, כך שאותו תהליך בנייה יוכל ליצור ייצוגים שונים. זו תבנית יצירה ברמת האובייקט.
הדוגמה בספר היא קורא מסמכים בפורמט RTF שצריך להמיר מסמך לכמה פורמטים: טקסט פשוט, TeX, או רכיב טקסט שאפשר לערוך. הקורא — התהליך — זהה בכל המקרים: הוא עובר על המסמך ומזהה תווים, החלפות גופן ומעברי פסקה. מה שמשתנה הוא מה עושים עם כל אחד מהם. הקורא מקבל אובייקט "ממיר" ומודיע לו על כל רכיב שמצא, והממיר בונה את התוצאה בפורמט שלו. כדי להוסיף פורמט חדש כותבים ממיר חדש, והקורא לא משתנה.
הבעיה: תהליך אחד, תוצרים שונים
נניח מערכת תכנון מסלולים. מסלול הוא רשימה של עצירות והמרחקים ביניהן. מאותו מסלול צריך להפיק שני דברים שונים: הוראות נסיעה מילוליות לנהג, וסיכום מספרי — מוצא, יעד, סך הקילומטרים ומספר המקטעים — לחישוב עלות. הדרך הישירה היא שתי פונקציות, שכל אחת עוברת על המסלול בעצמה. זה עובד, עד שהמעבר עצמו נעשה מסובך: דילוג על עצירות כפולות, טיפול במקטעי מעבורת, סדר מיוחד למסלול מעגלי. כל כלל כזה צריך להיכתב פעמיים, ושתי הגרסאות מתחילות לסטות זו מזו.
Builder מפרידה את שני הדברים: אובייקט אחד — ה-Director — יודע איך עוברים על מסלול ובאיזה סדר. אובייקט אחר — ה-Builder — יודע מה לבנות מכל צעד. כל כלל חדש של מעבר נכתב פעם אחת, ב-Director, וכל ייצוג חדש הוא Builder חדש.
המשתתפים והמבנה
בתרשים המחלקות יש ממשק Builder עם מתודה לכל צעד בבנייה. מתחתיו מחלקות ConcreteBuilder, שכל אחת מהן מרכיבה מוצר משלה ומחזיקה מתודה לקבלת התוצאה. ה-Director מחזיק הפניה לממשק Builder בלבד וקורא לצעדים לפי הסדר. הלקוח יוצר את ה-ConcreteBuilder, מעביר אותו ל-Director, ובסוף מבקש את התוצאה ישירות מה-ConcreteBuilder.
| משתתף | תפקיד | בדוגמה שלנו |
|---|---|---|
| Builder | ממשק מופשט ליצירת חלקים של המוצר | RouteBuilder |
| ConcreteBuilder | בונה ומרכיב את החלקים, שומר את הייצוג ומספק דרך לקבל את התוצאה | DirectionsBuilder, SummaryBuilder |
| Director | בונה את המוצר דרך ממשק ה-Builder, לפי סדר קבוע | RouteDirector |
| Product | האובייקט המורכב שנבנה | מחרוזת הוראות, TripSummary |
פרט שקל לפספס: המתודה שמחזירה את התוצאה נמצאת על ה-ConcreteBuilder, ולא על הממשק. הספר מסביר שהמוצרים שונים זה מזה כל כך, שלרוב אין טעם לתת להם מחלקת בסיס משותפת. בדוגמה שלנו מוצר אחד הוא מחרוזת והשני אובייקט עם מספרים — אין ממשק סביר שמאחד אותם.
מימוש מלא ב-Java: הגרסה של ה-GoF
import java.util.List;
// Builder: one method per construction step.
interface RouteBuilder {
void start(String place);
void leg(String to, int km);
void finish();
}
// ConcreteBuilder 1: builds human-readable driving directions.
class DirectionsBuilder implements RouteBuilder {
private final StringBuilder text = new StringBuilder();
private int step = 0;
@Override public void start(String place) {
text.append("Start at ").append(place).append('\n');
}
@Override public void leg(String to, int km) {
step++;
text.append(step).append(". Drive ").append(km).append(" km to ").append(to).append('\n');
}
@Override public void finish() {
text.append("Arrived.");
}
String getResult() { return text.toString(); }
}
// A second, unrelated product type.
class TripSummary {
private final String origin;
private final String destination;
private final int totalKm;
private final int legs;
TripSummary(String origin, String destination, int totalKm, int legs) {
this.origin = origin;
this.destination = destination;
this.totalKm = totalKm;
this.legs = legs;
}
@Override public String toString() {
return origin + " -> " + destination + ": " + totalKm + " km in " + legs + " legs";
}
}
// ConcreteBuilder 2: builds the summary from the same steps.
class SummaryBuilder implements RouteBuilder {
private String origin;
private String destination;
private int totalKm = 0;
private int legs = 0;
@Override public void start(String place) { origin = place; }
@Override public void leg(String to, int km) {
destination = to;
totalKm += km;
legs++;
}
@Override public void finish() {
// Nothing to add at the end for this representation.
}
TripSummary getResult() { return new TripSummary(origin, destination, totalKm, legs); }
}
// Director: owns the construction order, knows nothing about representations.
class RouteDirector {
private final List<String> stops;
private final List<Integer> distances;
RouteDirector(List<String> stops, List<Integer> distances) {
if (distances.size() != stops.size() - 1) {
throw new IllegalArgumentException("need one distance per leg");
}
this.stops = stops;
this.distances = distances;
}
void construct(RouteBuilder builder) {
builder.start(stops.get(0));
for (int i = 1; i < stops.size(); i++) {
builder.leg(stops.get(i), distances.get(i - 1));
}
builder.finish();
}
}
public class Main {
public static void main(String[] args) {
RouteDirector director = new RouteDirector(
List.of("Haifa", "Hadera", "Tel Aviv", "Jerusalem"),
List.of(50, 50, 65));
DirectionsBuilder directions = new DirectionsBuilder();
director.construct(directions);
System.out.println(directions.getResult());
SummaryBuilder summary = new SummaryBuilder();
director.construct(summary);
System.out.println(summary.getResult());
}
}הפלט:
Start at Haifa
1. Drive 50 km to Hadera
2. Drive 50 km to Tel Aviv
3. Drive 65 km to Jerusalem
Arrived.
Haifa -> Jerusalem: 165 km in 3 legsאותו construct, אותו סדר צעדים, שני מוצרים שאין ביניהם שום דבר משותף. ייצוג שלישי — נניח קובץ GPX לניווט — הוא מחלקה חדשה שמממשת את RouteBuilder, ושום שורה ב-Director אינה משתנה.
הגרסה השוטפת: Fluent Builder
הגרסה שרוב המפתחים מכירים היום פותרת בעיה אחרת: אובייקט עם שדה חובה אחד או שניים והרבה שדות אופציונליים. החלופות הישנות היו שרשרת של בנאים, כל אחד עם פרמטר נוסף, או בנאי ריק ואחריו סדרת setters — שמשאירה את האובייקט במצב חלקי ולא יכול להיות בלתי ניתן לשינוי. הווריאציה הזו התפרסמה בעיקר דרך הספר Effective Java, והיא אינה התבנית כפי שהיא מוגדרת בספר ה-GoF: אין בה Director ואין בה כמה ייצוגים. יש בה מוצר אחד, מחלקה פנימית שאוספת ערכים, ומתודת build שמאמתת ויוצרת אובייקט שלם בבת אחת.
import java.util.ArrayList;
import java.util.List;
// The product: immutable and always complete once built.
final class Email {
private final String to;
private final String subject;
private final List<String> cc;
private final boolean urgent;
private Email(Builder b) {
this.to = b.to;
this.subject = b.subject;
this.cc = List.copyOf(b.cc);
this.urgent = b.urgent;
}
static Builder builder(String to) { return new Builder(to); }
@Override public String toString() {
return (urgent ? "[URGENT] " : "") + subject + " -> " + to + (cc.isEmpty() ? "" : " cc " + cc);
}
// The builder collects values, then validates once in build().
static final class Builder {
private final String to; // required
private String subject = "(no subject)"; // optional, with a default
private final List<String> cc = new ArrayList<>();
private boolean urgent = false;
private Builder(String to) { this.to = to; }
Builder subject(String value) { this.subject = value; return this; }
Builder cc(String address) { this.cc.add(address); return this; }
Builder urgent() { this.urgent = true; return this; }
Email build() {
if (!to.contains("@")) {
throw new IllegalStateException("invalid recipient: " + to);
}
return new Email(this);
}
}
}
public class Main {
public static void main(String[] args) {
Email plain = Email.builder("[email protected]").build();
Email full = Email.builder("[email protected]")
.subject("Disk almost full")
.cc("[email protected]")
.urgent()
.build();
System.out.println(plain);
System.out.println(full);
}
}הפלט הוא (no subject) -> [email protected] ו-[URGENT] Disk almost full -> [email protected] cc [[email protected]]. האובייקט נוצר פעם אחת, שלם ומאומת, ואין רגע שבו הוא קיים במצב חלקי.
אותו רעיון בפייתון
class DirectionsBuilder:
def __init__(self):
self.lines, self.step = [], 0
def start(self, place):
self.lines.append(f"Start at {place}")
def leg(self, to, km):
self.step += 1
self.lines.append(f"{self.step}. Drive {km} km to {to}")
def finish(self):
self.lines.append("Arrived.")
def result(self):
return "\n".join(self.lines)
class SummaryBuilder:
def __init__(self):
self.origin = self.destination = None
self.total_km = self.legs = 0
def start(self, place):
self.origin = place
def leg(self, to, km):
self.destination, self.total_km, self.legs = to, self.total_km + km, self.legs + 1
def finish(self):
pass
def result(self):
return f"{self.origin} -> {self.destination}: {self.total_km} km in {self.legs} legs"
def construct(stops, distances, builder):
builder.start(stops[0])
for stop, km in zip(stops[1:], distances):
builder.leg(stop, km)
builder.finish()
for builder in (DirectionsBuilder(), SummaryBuilder()):
construct(["Haifa", "Hadera", "Tel Aviv", "Jerusalem"], [50, 50, 65], builder)
print(builder.result())את הגרסה השוטפת כמעט אין צורך לכתוב בפייתון: ארגומנטים בעלי שם וערכי ברירת מחדל, יחד עם dataclass קפוא, פותרים את אותה בעיה בשורה אחת. זו דוגמה טובה לתבנית שהשפה בולעת. הגרסה של ה-GoF, לעומת זאת, נשארת רלוונטית בכל שפה, כי היא עוסקת בהפרדה בין תהליך לייצוג ולא בתחביר.
השלכות
- אפשר לשנות את הייצוג הפנימי של המוצר. ה-Director רואה רק ממשק מופשט, ולכן ייצוג חדש הוא Builder חדש.
- הפרדה בין קוד הבנייה לקוד הייצוג. הכללים של "איך עוברים על מסלול" חיים במקום אחד, וכל Builder מכיל רק את הכללים של הפורמט שלו.
- שליטה עדינה בתהליך. המוצר נבנה צעד אחר צעד בהנחיית ה-Director, ונמסר רק בסוף. זה שונה מתבניות יצירה אחרות שמחזירות מוצר בקריאה אחת.
- המחיר: עוד ממשק, עוד מחלקה לכל ייצוג, ו-Director שצריך להחליט על ממשק צעדים כללי מספיק לכל הייצוגים העתידיים. אם הוא צר מדי, ייצוג חדש ידרוש לשנות אותו.
מתי לא להשתמש
- לאובייקט שלושה שדות. בנאי רגיל קריא יותר מכל Builder. הגרסה השוטפת מתחילה להשתלם בערך כשיש יותר מארבעה פרמטרים, וחלקם אופציונליים.
- יש ייצוג אחד בלבד. בלי ייצוגים נוספים, Director ו-Builder הם פונקציה אחת שפוצלה לשתי מחלקות בלי סיבה.
- הבחירה היא בין מחלקות, לא בין שלבים. אם הקושי הוא איזו מחלקה ליצור, Factory Method או Abstract Factory מתאימות יותר.
תבניות קשורות
ההבדל בין Builder לבין Abstract Factory, לפי הספר, הוא שבנייה צעד אחר צעד ומסירת המוצר בסוף מאפיינות את Builder, בעוד ש-Abstract Factory מתמקדת במשפחות של מוצרים ומחזירה כל מוצר מיד. Builder משמשת לעיתים קרובות לבניית מבנה Composite — עץ של צמתים ועלים — כי בניית עץ צעד אחר צעד היא בדיוק מה שהיא טובה בו. וה-Director, שמגדיר סדר צעדים קבוע וקורא למימושים שמשתנים, קרוב ברוחו ל-Template Method, אלא שהוא עושה זאת בהרכבה ולא בירושה.
טעויות נפוצות
- לחשוב שהגרסה השוטפת היא ההגדרה. היא וריאציה שימושית מאוד, אבל מי שמכיר רק אותה מפספס את הבעיה שהתבנית המקורית פותרת.
- Builder שמחזיר אובייקט חלקי. אם
buildלא מאמת את השדות החובה, יצרתם setters עם תחביר נחמד יותר. כל הערך הוא אובייקט שלם ותקין מרגע היווצרו. - שימוש חוזר ב-Builder אחרי
build. בדוגמה שלנו רשימת ה-cc מועתקת לתוך המוצר, ולכן שינוי ב-Builder אחר כך לא ישפיע עליו. בלי העתקה כזו, שני מוצרים שנבנו מאותו Builder ישתפו רשימה — באג שקשה מאוד למצוא. - ממשק Builder עם מתודת
getResultמשותפת. כשהמוצרים שונים, החזרה של טיפוס כללי מאלצת את הלקוח להמיר. השאירו את קבלת התוצאה על המחלקה הקונקרטית, כמו בספר.
אם יש לכם תהליך אחד שצריך לייצר כמה תוצרים, Builder עם Director היא הדרך לכתוב את התהליך פעם אחת. אם יש לכם בנאי ארוך מדי, הגרסה השוטפת תעשה את העבודה. ואם אין לכם אף אחת מהבעיות האלה — בנאי פשוט מספיק. רקע על משפחת תבניות היצירה כולה נמצא במבוא לתבניות עיצוב.


