תבנית Singleton - מתי להשתמש ומתי להימנע¶
בפוסט על תבניות היצירה נגענו ב-Singleton בקצרה ואמרנו שהיא השנויה ביותר במחלוקת. זה מספיק חשוב שמגיע לו פוסט משלו - כי Singleton היא כנראה התבנית שהכי הרבה מפתחים מתחילים משתמשים בה בהתלהבות, ואז מגלים שנה או שנתיים אחר כך שהיא בעצם יצרה להם כאב ראש שקשה מאוד לתקן.
מה זה בעצם¶
Singleton מבטיחה שלמחלקה מסוימת יהיה בדיוק מופע אחד בכל התוכנית, עם נקודת גישה גלובלית אליו. במקום ליצור אובייקט חדש בכל מקום שצריך אותו, כל הקוד ניגש לאותו מופע יחיד ומשותף.
class Config:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.load_from_file()
return cls._instance
הרעיון הבסיסי נשמע הגיוני - למה ליצור עשרים אובייקטי קונפיגורציה זהים כשאפשר לטעון פעם אחת ולשתף? אבל כאן בדיוק מתחילה הבעיה.
הבעיה הראשונה - מצב גלובלי מוסתר¶
Singleton הוא בעצם דרך אלגנטית ליצור משתנה גלובלי. כל מקום בקוד שנוגע במופע היחיד יכול לשנות אותו, וההשפעה של השינוי הזה מתפזרת לכל מקום אחר בתוכנית שמשתמש באותו מופע - בלי שזה מופיע בחתימה של אף פונקציה. אם פונקציה A בקצה אחד של הקוד משנה שדה במופע ה-Singleton, ופונקציה B בקצה השני מגיבה לשינוי הזה, אין שום דרך לדעת את זה מקריאה של קוד B בלבד - צריך לדעת שהוא בכלל תלוי במצב גלובלי משותף.
הבעיה השנייה - טסטים הופכים לסיוט¶
זו הבעיה שהכי מכאיבה בפועל. אם המחלקה שלכם ניגשת ישירות ל-Singleton בתוך המימוש שלה, קשה לבודד אותה בטסט - אי אפשר בקלות "להחליף" את המופע הגלובלי במופע מזויף (mock) לצורך בדיקה, כי הקוד קורא ישירות למחלקה ולא מקבל אותה כפרמטר. וגרוע מזה - טסטים שרצים ברצף ומשתמשים באותו Singleton עלולים "לדלוף" מצב אחד לשני, כי אף אחד לא איפס אותו ביניהם. טסט שעובד לבד ונכשל כשרצים אותו יחד עם טסטים אחרים הוא לרוב סימפטום קלאסי של Singleton שדולף מצב.
הבעיה השלישית - thread-safety שקל לפספס¶
ביישום נאיבי כמו הדוגמה למעלה, אם שני threads מנסים ליצור את המופע בו זמנית ברגע הראשון, יכולים להיווצר שני מופעים שונים בטעות - בדיוק ההפך ממה שה-Singleton אמור להבטיח. הפתרון דורש נעילה (locking) נכונה, שמוסיפה מורכבות נוספת רק כדי לתקן בעיה שה-Singleton עצמו יצר.
אז מתי בכל זאת כן¶
חשוב לא להשליך את התבנית לגמרי - יש מקרים לגיטימיים. חיבור לוגר (logger) שכל חלקי המערכת כותבים אליו, cache פנימי בתהליך יחיד, או רישום קונפיגורציה שנטענת פעם אחת ולא משתנה לאורך חיי התהליך - כל אלה מקרים סבירים, במיוחד כשמדובר באובייקט read-only שלא באמת "מוטציה" מקום אחר בקוד.
הכלל המבחין הוא - אם המצב הגלובלי הוא immutable בפועל (למשל קונפיגורציה שנטענת פעם אחת ולא משתנה), הסיכונים המרכזיים - דליפת מצב בין טסטים, תלות נסתרת בין פונקציות - כמעט נעלמים. הבעיה מתחילה כשה-Singleton הופך ל"מקום נוח לשמור מצב משותף שמשתנה", ואז הוא בעצם הופך לפצצת זמן.
החלופה הנקייה יותר¶
ברוב המקרים, מה שנראה כמו צורך ב-Singleton פתיר טוב יותר עם הזרקת תלויות (dependency injection). במקום שהקוד יגש גלובלית ל-Config(), מעבירים את מופע הקונפיגורציה כפרמטר לכל מי שצריך אותו:
עכשיו, בטסט, פשוט מעבירים אובייקט קונפיגורציה מזויף במקום האמיתי. אין תלות נסתרת, אין דליפה בין טסטים, וברור מקריאת החתימה של הפונקציה בדיוק ממה היא תלויה. יוצרים את המופע היחיד פעם אחת בנקודת הכניסה של האפליקציה, ומזריקים אותו הלאה לכל מי שצריך - לא כל מקום בקוד "מושך" אותו בעצמו.
לסיכום¶
Singleton פותרת בעיה אמיתית - מקרים שבהם באמת צריך מופע יחיד משותף - אבל היא עושה את זה במחיר של מצב גלובלי, טסטים בעייתיים, ולפעמים בעיות thread-safety. בהרבה מקרים, הזרקת תלויות פותרת את אותה בעיה בלי המחיר. השאלה הכי טובה לשאול לפני שכותבים עוד Singleton היא לא "האם אני צריך מופע יחיד", אלא "האם אני חייב שהגישה אליו תהיה גלובלית, או שאפשר פשוט להעביר אותו כפרמטר".
בקורס ארכיטקטורת תוכנה שלנו אנחנו עוברים על הזרקת תלויות לעומק ומראים איך היא מחליפה כמעט כל שימוש נאיבי ב-Singleton.
נתקלתם בטסט שנכשל רק כשרצים אותו יחד עם טסטים אחרים? הצטרפו לקהילה בדיסקורד, יש סיכוי טוב שיש שם Singleton דולף.