State Design Pattern: תבנית State שמחליפה switch ענק באובייקטים עם התנהגות משתנה
state design pattern היא תבנית התנהגות קלאסית שמטפלת במצב מוכר מאוד בתכנון מונחה־עצמים: אובייקט אחד מתנהג אחרת לפי הסטטוס הפנימי שלו, והקוד מתחיל להתמלא ב־switch, ב־if ובבדיקות חוזרות. במקום לרכז את כל ההחלטות במחלקה אחת גדולה, מעבירים את ההתנהגות למחלקות מצב קטנות, שכל אחת יודעת מה מותר ומה אסור לעשות כשהאובייקט נמצא בה.
הכוונה הרשמית של התבנית, לפי Design Patterns, Gamma et al., 1994, היא: לאפשר לאובייקט לשנות את התנהגותו כאשר המצב הפנימי שלו משתנה; האובייקט ייראה כאילו הוא משנה את המחלקה שלו. בפועל, זה נשמע דרמטי יותר ממה שזה: אותו אובייקט נשאר אותו אובייקט, אבל הוא מאציל את הפעולות שלו לאובייקט מצב אחר.
אם זו החשיפה הראשונה שלך לתבניות עיצוב, כדאי להתחיל גם עם מבוא לתבניות עיצוב. המאמר הזה מניח היכרות בסיסית עם מחלקות, ממשקים ושיתוף פעולה בין אובייקטים, אבל לא דורש מסגרת עבודה או ספרייה חיצונית.
הבעיה: מחלקה אחת שמכירה את כל המצבים
נניח שיש מערכת הזמנות. להזמנה יכולים להיות מצבים כמו טיוטה, שולמה, נשלחה או בוטלה. בהתחלה זה נראה פשוט: מוסיפים שדה status, ואז בכל פעולה בודקים מה הסטטוס הנוכחי.
אלא שעם הזמן מגיעים עוד כללים:
- אי אפשר לשלוח הזמנה שלא שולמה.
- אי אפשר לשלם על הזמנה שכבר בוטלה.
- ביטול אחרי משלוח מתנהג אחרת מביטול לפני משלוח.
- פעולה חוקית במצב אחד היא שגיאה במצב אחר.
- כל מצב חדש דורש שינוי בכמה מתודות שונות.
התוצאה היא מחלקה מרכזית שהופכת למפה של תנאים. כל מתודה כוללת עוד switch, כל שינוי קטן מחייב להבין את כל המצבים, ובדיקות הרגרסיה נהיות חשובות יותר מהקוד עצמו. זה ריח תכנוני ברור: ההתנהגות שייכת למצב, אבל כתובה בתוך ההקשר הכללי.
הרעיון של State: להעביר את ההתנהגות אל המצב
בתבנית State, האובייקט המרכזי נקרא בדרך כלל Context. הוא מחזיק הפניה לאובייקט מצב נוכחי, למשל DraftState או PaidState. כאשר קוראים לפעולה כמו pay, ship או cancel, ההקשר לא מחליט בעצמו מה לעשות; הוא מעביר את הבקשה למצב הנוכחי.
כל מצב מממש את אותו ממשק, למשל OrderState. לכן ההקשר לא צריך לדעת באיזה מצב ספציפי הוא נמצא. הוא רק יודע שלמצב יש אותן פעולות. אם הפעולה גורמת למעבר מצב, אובייקט המצב יכול לעדכן את ההקשר ולשים בו מצב חדש.
זה שינוי קטן בצורה, אבל שינוי גדול באחריות: במקום ש־Order תדע את כל חוקי החיים של ההזמנה, כל מחלקת מצב יודעת את החוקים הרלוונטיים לה.
מתי state design pattern מתאים במיוחד?
state design pattern מתאים כאשר ההתנהגות של אובייקט תלויה באופן מובהק במצבו, והמצבים אינם רק ערכים להצגה אלא חלק מהלוגיקה העסקית. אם הסטטוס משפיע רק על צבע תגית בממשק, זו כנראה לא התבנית הנכונה. אם הסטטוס קובע אילו פעולות חוקיות, אילו מעברים אפשריים ומה קורה בכל פעולה, זו כבר קרקע טובה ל־State.
סימנים שכדאי לשקול את התבנית:
- יש הרבה תנאים לפי ערך של
statusאוstate. - אותם תנאים חוזרים בכמה מתודות.
- הוספת מצב חדש מחייבת שינוי במקומות רבים.
- הקוד מלא במשפטים כמו “אם ההזמנה במצב X, אז…”.
- קשה לראות בתמונה אחת מה מותר לעשות בכל מצב.
היא פחות מתאימה כאשר יש שני מצבים פשוטים בלבד, או כאשר הלוגיקה סביב המצב צפויה להישאר שטוחה וברורה. לא כל enum הוא מועמד להפיכה להיררכיית מחלקות.
המשתתפים בתבנית והתפקידים שלהם
| משתתף | תפקיד | בדוגמה |
|---|---|---|
Context |
האובייקט שההתנהגות שלו משתנה לפי מצב פנימי. | Order |
State |
ממשק משותף לכל המצבים האפשריים. | OrderState |
ConcreteState |
מימוש ספציפי של התנהגות במצב מסוים. | DraftState, PaidState, ShippedState, CancelledState |
| לקוח | משתמש ב־Context בלי להכיר את מחלקות המצב. |
main |
שיתוף הפעולה פשוט: הלקוח קורא לפעולה על Order; ההזמנה מעבירה את הקריאה למצב הנוכחי; המצב מבצע פעולה או דוחה אותה; ואם צריך, הוא משנה את המצב הנוכחי של ההזמנה. זהו עיצוב מונחה־עצמים שבו פולימורפיזם מחליף הסתעפויות.
מה מכינים מראש לפני שמיישמים
לפני שכותבים מחלקות מצב, כדאי לעצור ולמפות את המודל. אם מדלגים על השלב הזה, קל מאוד לייצר הרבה מחלקות קטנות שלא באמת משפרות את המצב.
- רשימת מצבים: כתבו את המצבים האפשריים במילים רגילות: טיוטה, שולמה, נשלחה, בוטלה.
- רשימת פעולות: אילו פעולות יכול הלקוח לבצע? למשל
pay,ship,cancel. - טבלת חוקיות: לכל פעולה, סמנו באילו מצבים היא מותרת ומה התוצאה שלה.
- מעברי מצב: הגדירו מי אחראי להחליף מצב. לרוב, מצב קונקרטי שמבצע פעולה מוצלחת יחליף את מצב ההקשר.
- שגיאות: החליטו האם פעולה לא חוקית זורקת חריגה, מחזירה תוצאה, או נרשמת כאירוע עסקי.
במערכות גדולות יותר אפשר לצייר תרשים מצבים ב־UML, אבל גם טבלה פשוטה מספיקה בהרבה מקרים. אם עובדים עם תרשימים, חשוב לזכור שהתרשים הוא כלי חשיבה, לא תחליף לקוד ברור.
מימוש Java מלא וקומפילבילי
הדוגמה הבאה מציגה הזמנה שמשנה התנהגות לפי מצבה. זהו קוד מינימלי שמטרתו להמחיש את התבנית בלבד, לא לבנות מערכת הזמנות אמיתית.
public class StatePatternDemo {
public static void main(String[] args) {
Order order = new Order();
System.out.println(order.status());
order.pay();
order.ship();
Order cancelledOrder = new Order();
cancelledOrder.cancel();
cancelledOrder.pay();
}
interface OrderState {
void pay(Order order);
void ship(Order order);
void cancel(Order order);
String name();
}
static class Order {
private OrderState state = new DraftState();
void setState(OrderState state) {
this.state = state;
}
void pay() {
state.pay(this);
}
void ship() {
state.ship(this);
}
void cancel() {
state.cancel(this);
}
String status() {
return state.name();
}
}
static class DraftState implements OrderState {
public void pay(Order order) {
System.out.println("Payment accepted.");
order.setState(new PaidState());
}
public void ship(Order order) {
System.out.println("Cannot ship an unpaid order.");
}
public void cancel(Order order) {
System.out.println("Draft order cancelled.");
order.setState(new CancelledState());
}
public String name() {
return "DRAFT";
}
}
static class PaidState implements OrderState {
public void pay(Order order) {
System.out.println("Order is already paid.");
}
public void ship(Order order) {
System.out.println("Order shipped.");
order.setState(new ShippedState());
}
public void cancel(Order order) {
System.out.println("Paid order cancelled and refund requested.");
order.setState(new CancelledState());
}
public String name() {
return "PAID";
}
}
static class ShippedState implements OrderState {
public void pay(Order order) {
System.out.println("Order is already paid and shipped.");
}
public void ship(Order order) {
System.out.println("Order is already shipped.");
}
public void cancel(Order order) {
System.out.println("Cannot cancel a shipped order.");
}
public String name() {
return "SHIPPED";
}
}
static class CancelledState implements OrderState {
public void pay(Order order) {
System.out.println("Cannot pay for a cancelled order.");
}
public void ship(Order order) {
System.out.println("Cannot ship a cancelled order.");
}
public void cancel(Order order) {
System.out.println("Order is already cancelled.");
}
public String name() {
return "CANCELLED";
}
}
}בדוגמה, Order היא ה־Context. היא מחזיקה את OrderState הנוכחי ומאצילה אליו פעולות. OrderState הוא ממשק ה־State. המחלקות DraftState, PaidState, ShippedState ו־CancelledState הן מצבים קונקרטיים. כל אחת מהן מגדירה מה קורה כאשר קוראים ל־pay, ship או cancel בזמן שההזמנה נמצאת באותו מצב.
איך מיישמים שלב אחר שלב בקוד קיים
בדרך כלל לא מתחילים מתבנית נקייה. מתחילים ממחלקה קיימת עם תנאים. הדרך הבטוחה היא לבצע חילוץ הדרגתי ולא “מהפכת מחלקות” ביום אחד.
- זהו את שדה המצב: לרוב זה
status,stateאוtype. - בחרו פעולה אחת בעייתית: אל תנסו להעביר הכול בבת אחת. התחילו ממתודה אחת עם
switchברור. - צרו ממשק מצב: הגדירו בו רק את הפעולות שבאמת משתנות לפי מצב.
- צרו מחלקה לכל מצב קיים: גם אם חלק מהמימושים בהתחלה כמעט זהים.
- העבירו את ההתנהגות: חתכו ענף אחד מה־
switchוהכניסו אותו למחלקת המצב המתאימה. - הוסיפו בדיקות סביב מעברים: ודאו שפעולה שמחליפה מצב עושה זאת רק במקום אחד וברור.
- מחקו תנאים מתים: אחרי שההתנהגות עברה למצבים, אל תשאירו עותקים ישנים במחלקת ההקשר.
במערכת שיש בה יצירת אובייקטים מורכבת, לפעמים משלבים את State עם תבניות יצירה. אם נושא יצירת האובייקטים מעניין אותך, יש הרחבה נפרדת על תבניות יצירה. עם זאת, לא כדאי להכניס Factory רק כי “זה נראה מסודר”. קודם ודאו שהבעיה באמת קיימת.
איפה נתקעים בדרך ומה עושים
יותר מדי מחלקות קטנות
זו הטענה הנפוצה ביותר נגד State. היא גם נכונה חלקית. התבנית מחליפה מורכבות בתוך מתודה במורכבות של מבנה מחלקות. אם יש מעט מצבים ומעט פעולות, ייתכן שהמחיר לא שווה את התועלת.
הפתרון הוא לאחד רק כשיש הצדקה. אם שני מצבים חולקים התנהגות רבה, אפשר להשתמש במחלקת בסיס מופשטת או במימוש ברירת מחדל, אבל בזהירות. ירושה עמוקה מדי תחזיר את הערפל בדלת האחורית.
מי רשאי לשנות את המצב?
אם כל מקום בקוד יכול לקרוא ל־setState, איבדתם חלק מהערך של התבנית. רצוי לצמצם את הנגישות של setState ככל האפשר, או לפחות לקבוע מוסכמה ברורה: רק מחלקות מצב משנות מצב. במערכות עם חבילות מסודרות אפשר להגביל גישה ברמת החבילה.
פעולות שלא רלוונטיות לכל המצבים
לפעמים ממשק המצב מתחיל להתנפח. אם יש עשר פעולות ורק שתיים רלוונטיות לרוב המצבים, כדאי לבדוק האם מדובר באמת באותו מחזור חיים, או בכמה תהליכים שונים שהתערבבו. במקרים כאלה, פיצול ההקשר לשני אובייקטים יכול להיות נכון יותר מתבנית State גדולה מדי.
קשר ל־SOLID ולעיצוב מונחה־עצמים
התבנית מדגימה היטב את עקרון האחריות היחידה: כל מחלקת מצב אחראית להתנהגות במצב אחד. היא גם מתחברת לעקרון הפתוח־סגור: הוספת מצב חדש יכולה להיעשות באמצעות מחלקה חדשה, בלי לערוך switch גדול בכמה מקומות.
עם זאת, אין כאן קסם. אם הממשק של State משתנה כל הזמן, כל המצבים יצטרכו להשתנות. לכן חשוב לבחור ממשק יציב שמייצג פעולות עסקיות אמיתיות, לא פרטים טכניים זמניים.
בהשוואה לתבניות אחרות, State דומה לפעמים ל־Strategy, כי בשתיהן יש אובייקט שמאציל עבודה לממשק. ההבדל העיקרי הוא בכוונה: Strategy בוחרת אלגוריתם שניתן להחלפה, לרוב מבחוץ; State מייצגת מצב פנימי שמשפיע על התנהגות ועל מעברים במהלך חיי האובייקט. אם עדיין לא ברור מתי משתמשים בתבנית בכלל, חזרה קצרה אל מבוא לתבניות עיצוב יכולה לעשות סדר.
Trade-offs: מה מרוויחים ומה משלמים
היתרונות המרכזיים של State הם קריאות, הפרדת אחריות והפחתת תנאים חוזרים. קל יותר לפתוח את PaidState ולהבין מה קורה להזמנה ששולמה, מאשר לחפש את כל הענפים שמתייחסים לסטטוס PAID במחלקה אחת גדולה.
המחירים גם ברורים:
- יותר קבצים ומחלקות: הפרויקט מקבל מבנה רחב יותר.
- מעברים מפוזרים: צריך לדעת איפה כל מצב מחליף למצב הבא.
- סיכון להפשטת יתר: עבור לוגיקה פשוטה, התבנית עלולה להכביד.
- בדיקות רבות יותר: יש לבדוק כל פעולה בכל מצב רלוונטי.
הכלל המעשי: אם המורכבות כבר קיימת, State יכולה לסדר אותה. אם המורכבות עדיין לא קיימת, אל תמהרו להזמין אותה.
מתי לא להשתמש בתבנית State
לא כדאי להשתמש ב־State כאשר הסטטוס הוא נתון בלבד, למשל “פעיל” או “לא פעיל”, בלי שינוי משמעותי בהתנהגות. גם לא כדאי להשתמש בה כשיש חוקים שמוגדרים מבחוץ ומשתנים תדיר על ידי קונפיגורציה; במצב כזה מנוע חוקים, טבלת החלטה או מודל אחר עשויים להתאים יותר.
עוד סימן אזהרה הוא מצב שבו מחלקות המצב מתחילות להכיר יותר מדי פרטים פנימיים של ההקשר. אם כל מצב נוגע בעשרה שדות פנימיים של Context, ייתכן שההקשר גדול מדי או שהגבולות בין האובייקטים אינם נכונים.
שאלות שקוראים באמת שואלים
האם חייבים להשתמש ב־enum?
לא. אפשר לשמור enum לצורך תצוגה או התמדה במסד נתונים, אבל ההתנהגות עצמה יכולה להיות במחלקות מצב. במקרים מסוימים משלבים: ה־enum הוא ייצוג חיצוני, והאובייקט מחזיק OrderState פנימי.
איך שומרים מצב בבסיס נתונים?
בדרך כלל לא שומרים את האובייקט DraftState עצמו, אלא ערך פשוט כמו DRAFT. בעת טעינת האובייקט ממפים את הערך למחלקת מצב מתאימה. זה מיפוי תשתיתי, לא חלק מהתבנית עצמה.
האם זו תבנית שמתאימה למיקרו־שירותים?
כן, אבל בזהירות. בתוך שירות, State יכולה לייצג מחזור חיים של הזמנה, תשלום או בקשה. בגבול ה־API, עדיף לחשוף מצבים יציבים וברורים, ולא את שמות המחלקות הפנימיות. התבנית היא כלי פנימי לעיצוב הקוד, לא חוזה חיצוני מול צרכני השירות.
סיכום: פחות הסתעפויות, יותר אחריות מקומית
State היא תבנית שימושית כאשר אובייקט מתנהג אחרת לפי מצב פנימי, והקוד מתחיל להישען על הסתעפויות רבות. היא מעבירה את הידע ממחלקה מרכזית עמוסה למחלקות מצב קטנות וברורות, ובכך הופכת את ההתנהגות למקומית יותר.
היא לא מחליפה חשיבה תכנונית. לפני שמיישמים אותה, כדאי למפות מצבים, פעולות ומעברים. אחרי שמיישמים, כדאי לבדוק שהמבנה באמת הפחית מורכבות ולא רק פיזר אותה. כשהיא מתאימה, היא אחת הדרכים הנקיות ביותר להיפטר מ־switch ענק בלי לאבד את חוקי הדומיין בדרך.


