דלג לתוכן הראשי
שכבת הפשטה ארכיטקטורת תוכנה בעברית
תבניות יצירה

singleton design pattern: תבנית Singleton, מימוש נכון, בטיחות בין חוטים, ולמה רבים רואים בה אנטי־תבנית

תבנית singleton design pattern נראית פשוטה: מחלקה אחת, מופע אחד, נקודת גישה אחת. דווקא בגלל הפשטות הזאת היא אחת מתבניות היצירה שהכי קל לממש לא נכון. מימוש נאיבי עלול להישבר בסביבה מרובת חוטים, להקשות על בדיקות, להחביא תלות גלובלית, וליצור צימוד שלא רואים מיד בקוד.

במונחי GoF, כפי שמופיע ב־Design Patterns, Gamma et al., 1994, ההגדרה היא: להבטיח שלמחלקה יש מופע יחיד בלבד, ולספק נקודת גישה גלובלית אליו. זו הגדרה מדויקת, אבל היא לא אומרת שכל אובייקט “חשוב” צריך להיות Singleton. להפך: הרבה שימושים נפוצים הם סימן לעיצוב שצריך עצירה וחשיבה מחדש.

אם זו הפעם הראשונה שבה אתם ממפים תבניות יצירה מול תבניות אחרות, כדאי לקרוא גם את המבוא לתבניות עיצוב. כאן נתמקד ב־Singleton עצמו: איך מזהים צורך אמיתי, איך מממשים בצורה בטוחה, איפה נתקעים, ומתי עדיף לוותר.

מה Singleton פותר באמת?

Singleton נועד למצבים שבהם יש הצדקה עיצובית לכך שקיים מופע יחיד של מחלקה מסוימת. לא “כי זה נוח”, לא “כי לא רצינו להעביר אובייקט בין מחלקות”, אלא כי כפילות של האובייקט תפגע במשמעות או בתקינות המערכת.

דוגמאות סבירות עשויות להיות:

  • רישום מרכזי של הגדרות קריאה בלבד, שנטענות פעם אחת.
  • אובייקט שמתאם גישה למשאב יחיד בתהליך, למשל שער לכתיבה לקובץ לוג מקומי.
  • רכיב תשתיתי שבו יש משמעות חזקה לזהות יחידה בזמן ריצה.

לעומת זאת, מחלקות שירות עסקיות, גישה למסד נתונים, לקוחות API, ואובייקטים שמחזיקים מצב משתנה לפי בקשה — לרוב לא צריכים להיות Singleton במימוש ידני. במערכות מודרניות, מכולות תלות או מנגנוני הרכבה יכולים לנהל מחזור חיים בלי להפוך את המחלקה עצמה לגלובלית.

המשתתפים בתבנית והקולבורציה בין אובייקטים

בתבנית Singleton יש משתתף מרכזי אחד: המחלקה עצמה. היא אחראית גם על מניעת יצירה חיצונית, גם על אחסון המופע היחיד, וגם על חשיפת נקודת הגישה.

משתתף אחריות הערה עיצובית
Singleton שומר מופע יחיד ומספק מתודת גישה סטטית הבנאי בדרך כלל פרטי, כדי למנוע יצירה מבחוץ
לקוח מבקש את המופע דרך נקודת הגישה אין לו שליטה על מחזור החיים של האובייקט

שיתוף הפעולה פשוט: הקוד הלקוח קורא למתודה כמו getInstance(), והמחלקה מחזירה את אותו מופע בכל קריאה. הפשטות הזאת היא גם מקור הסיכון: אם כל קוד במערכת יכול לגשת לאותו אובייקט, קשה לדעת מי משתמש בו, מתי, ובאיזה מצב.

singleton design pattern: מימוש Java בטוח ופשוט

המימוש הבא משתמש באידיומה מוכרת בשם Initialization-on-demand holder. היא מנצלת את מנגנון טעינת המחלקות של Java כדי לבצע אתחול עצל ובטוח בין חוטים, בלי synchronized בכל קריאה ובלי נעילה כפולה מסובכת.

Java
import java.util.concurrent.CountDownLatch;

final class AppConfig {
    private final String source;

    private AppConfig() {
        this.source = "defaults";
    }

    private static class Holder {
        private static final AppConfig INSTANCE = new AppConfig();
    }

    public static AppConfig getInstance() {
        return Holder.INSTANCE;
    }

    public String get(String key) {
        if ("timezone".equals(key)) {
            return "Asia/Jerusalem";
        }
        return "";
    }

    public String source() {
        return source;
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        int workers = 4;
        CountDownLatch ready = new CountDownLatch(workers);
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(workers);
        int[] identities = new int[workers];

        for (int i = 0; i < workers; i++) {
            final int index = i;
            Thread thread = new Thread(() -> {
                ready.countDown();
                try {
                    start.await();
                    AppConfig config = AppConfig.getInstance();
                    identities[index] = System.identityHashCode(config);
                    System.out.println(Thread.currentThread().getName()
                            + " -> " + config.get("timezone"));
                } catch (InterruptedException ex) {
                    Thread.currentThread().interrupt();
                } finally {
                    done.countDown();
                }
            }, "worker-" + i);
            thread.start();
        }

        ready.await();
        start.countDown();
        done.await();

        boolean sameInstance = true;
        for (int i = 1; i < identities.length; i++) {
            sameInstance = sameInstance && identities[i] == identities[0];
        }

        System.out.println("All threads received the same instance: " + sameInstance);
    }
}

בדוגמה, AppConfig היא ה־Singleton. הבנאי AppConfig() פרטי ולכן אי אפשר ליצור מופע מבחוץ. המחלקה הפנימית Holder נטענת רק כשהקוד קורא ל־getInstance(). השדה INSTANCE נוצר פעם אחת, בצורה בטוחה בין חוטים לפי כללי טעינת המחלקות של Java.

המחלקה Main אינה חלק מהתבנית; היא רק מדגימה גישה מקבילה מארבעה חוטים. כל חוט קורא ל־AppConfig.getInstance(), ובסוף נבדק שכולם קיבלו אותו מופע לפי System.identityHashCode().

שלבי עבודה לפני שמממשים Singleton

לפני שכותבים קוד, כדאי לעצור לכמה דקות. ברוב המקרים הדיון העיצובי חשוב יותר מהמימוש.

  1. נסחו את הסיבה למופע יחיד. אם התשובה היא “כדי שיהיה קל לגשת אליו”, זו נורת אזהרה.
  2. בדקו אם האובייקט מחזיק מצב משתנה. Singleton עם מצב משתנה דורש זהירות רבה יותר, במיוחד במקביליות.
  3. החליטו מי אחראי למחזור החיים. האם המחלקה עצמה, מנגנון הרכבה, או קוד אתחול ייעודי?
  4. כתבו בדיקה פשוטה למקביליות. לא כדי להוכיח מתמטית, אלא כדי לתפוס מימושים נאיביים.
  5. בדקו השפעה על בדיקות יחידה. אם אי אפשר להחליף את התלות, ייתכן שהעיצוב קשיח מדי.

כלל מעשי: אם אפשר להעביר את האובייקט כתלות רגילה דרך בנאי, ולעיתים להחליף אותו בבדיקה, זו בדרך כלל אפשרות בריאה יותר מ־Singleton גלובלי.

מימושים נפוצים ומה הבעיה בהם

מימוש עצל לא בטוח

המימוש הנאיבי נראה בערך כך: שדה סטטי ריק, בדיקה אם הוא null, ואם כן יצירת מופע. בסביבה חד־חוטית זה עובד. בסביבה מרובת חוטים, שני חוטים עלולים להיכנס לבדיקה כמעט יחד וליצור שני מופעים. לכן מימוש כזה אינו מתאים לקוד שרץ בשרתים, שירותים מקביליים או אפליקציות שבהן אין שליטה מלאה על סדר הקריאות.

synchronized על כל גישה

אפשר לסמן את getInstance() כ־synchronized. זה פשוט ובטוח, אבל גורם לנעילה בכל קריאה, גם אחרי שהמופע כבר נוצר. בהרבה מערכות זה לא יהיה צוואר בקבוק משמעותי, אבל אם אין צורך לשלם את המחיר הזה, אידיומת Holder נקייה יותר.

Double-checked locking

מימוש עם בדיקה כפולה ונעילה פנימית יכול להיות נכון ב־Java רק אם השדה מוגדר כ־volatile. בלי זה, יש סיכון לבעיות נראות וסדר אתחול. מכיוון שקל לטעות במימוש הזה, עדיף בדרך כלל להשתמש ב־Holder או ב־enum כאשר זה מתאים.

enum Singleton

ב־Java אפשר לממש Singleton בעזרת enum עם ערך יחיד. זה פתרון חזק במיוחד מול סריאליזציה וחלק מניסיונות השבירה באמצעות reflection. החיסרון הוא שהוא פחות גמיש במבנה, ולעיתים פחות נוח כאשר רוצים אתחול עצל מפורש או מבנה מחלקה רגיל.

בטיחות בין חוטים: איפה באמת נופלים?

בטיחות בין חוטים אינה מסתכמת בכך שנוצר רק מופע אחד. גם אם יצירת המופע בטוחה, השדות והפעולות שלו עלולים להיות לא בטוחים.

שאלות שצריך לשאול:

  • האם ה־Singleton בלתי ניתן לשינוי אחרי הבנייה?
  • האם מתודות משנות מצב פנימי?
  • האם יש אוספים פנימיים שאפשר לשנות מבחוץ?
  • האם קריאות מקבילות יכולות להשפיע זו על זו?

בדוגמה למעלה, AppConfig כמעט חסר מצב משתנה: השדה source הוא final, והמתודה get() אינה משנה דבר. זה הופך את המימוש לפשוט יותר. אם Singleton מחזיק cache משתנה, חיבורים, מונים או תורים פנימיים, נדרש תכנון נפרד: נעילות, מבני נתונים מקביליים, או הפרדה בין מנהל גלובלי לבין מצב פר־בקשה.

למה Singleton נחשב לעיתים אנטי־תבנית?

Singleton אינו אנטי־תבנית מעצם קיומו. הוא מופיע בספרות הסטנדרטית ויש לו שימושים לגיטימיים. הבעיה מתחילה כשהוא הופך לברירת מחדל לכל תלות נוחה.

הטענות הנפוצות נגדו הן:

  • תלות גלובלית מוסתרת: מחלקה שקוראת ישירות ל־AppConfig.getInstance() אינה מצהירה בבנאי שהיא תלויה ב־AppConfig.
  • בדיקות קשות: קשה להחליף Singleton במימוש מזויף בלי לשנות קוד או להוסיף מנגנוני איפוס מסוכנים.
  • מצב משותף: שינוי במקום אחד יכול להשפיע על בדיקה או בקשה אחרת.
  • צימוד למחזור חיים: המחלקה שולטת בעצמה ביצירה, ולכן קשה להעביר אחריות החוצה.
  • התפשטות איטית: אחרי שמופיע Singleton אחד, קל להוסיף עוד ועוד “מנהלים” גלובליים.

לכן, כשאומרים ש־Singleton הוא אנטי־תבנית, לרוב מתכוונים לשימוש הלא־מבוקר בו כתחליף להזרקת תלויות, למודל אובייקטים ברור, או לגבולות ארכיטקטוניים.

מתי לא להשתמש ב־Singleton?

יש מצבים שבהם ההחלטה להימנע מ־Singleton צריכה להיות כמעט אוטומטית:

  • כאשר האובייקט מייצג משתמש, בקשה, session או פעולה עסקית.
  • כאשר צריך כמה תצורות שונות באותו תהליך.
  • כאשר הבדיקות צריכות להחליף את המימוש לעיתים קרובות.
  • כאשר המופע תלוי במשאבים חיצוניים שצריך לפתוח ולסגור במועדים שונים.
  • כאשר השימוש היחיד הוא “שלא נצטרך להעביר פרמטרים”.

במערכות microservices, למשל, קל להתבלבל: השירות עצמו עשוי להיות מופעל בכמה עותקים, ולכן Singleton בתוך תהליך אינו Singleton של המערכת כולה. אם יש שלושה מופעי שירות, יהיו שלושה Singletons. מי שזקוק ליחידת אמת גלובלית צריך לחשוב על תיאום חיצוני, אחסון משותף או פרוטוקול ברור — לא על שדה סטטי.

קשר ל־UML ולעיצוב מונחה עצמים

ב־UML class diagram אפשר לסמן Singleton באמצעות בנאי פרטי, מתודה סטטית כמו getInstance(), ושדה סטטי שמחזיק את המופע. הסימון עוזר להראות שהמחלקה שולטת ביצירה שלה, אבל הוא לא חושף את כל הדיון החשוב: האם התלות גלויה? האם יש מצב משתנה? האם יש חלופה טובה יותר?

מבחינת עיצוב מונחה עצמים, Singleton קצת חריג: הוא מערבב אחריות עסקית או תשתיתית עם אחריות יצירה. לפעמים זה מתקבל על הדעת. בפעמים אחרות, עדיף להשתמש ב־Factory, בהרכבת אובייקטים מפורשת, או בהעברת תלות דרך בנאי. אם אתם משווים בין משפחות של תבניות, כדאי להכיר גם תבניות מבנה, משום שחלק מהן מטפלות בצימוד ובחזיתות גישה בלי לכפות מופע יחיד.

טעויות נפוצות ומה עושים במקום

הוספת מתודת reset() רק בשביל בדיקות

כאשר מוסיפים ל־Singleton מתודת איפוס ציבורית או חצי־ציבורית כדי שבדיקות יעברו, זה סימן שהעיצוב מתנגד לבדיקה. במקום זאת, שקלו להעביר ממשק כתלות למחלקות שמשתמשות בו, ולהשאיר את ה־Singleton רק בשכבת האתחול.

הכנסת יותר מדי אחריות למחלקה אחת

מחלקות בשם Manager, Registry או ServiceLocator נוטות לגדול. אם Singleton מתחיל לדעת על קונפיגורציה, לוגים, הרשאות ולקוחות רשת, הוא כבר לא תבנית יצירה אלא מרכז כבידה מסוכן. פצלו אחריות והגדירו גבולות.

הנחה ש־Singleton פותר ביצועים

מופע יחיד לא בהכרח מהיר יותר. לפעמים הוא יוצר נעילה, תחרות על משאב, או cache שמתנפח. ביצועים מודדים לפי עומס אמיתי, לא לפי תחושה שמופע אחד “חסכוני”.

שאלות שקוראים באמת שואלים

האם Singleton מפר את עקרונות SOLID?

לא תמיד, אבל הוא עלול לפגוע בעיקרון האחריות היחידה ובעקרון היפוך התלויות. אם מחלקה גם עושה עבודה עסקית וגם מנהלת את יצירת עצמה, יש ערבוב אחריות. אם קוד תלוי ישירות ב־Singleton קונקרטי, קשה להחליף אותו במימוש אחר.

האם Singleton מתאים ל־API client?

לפעמים, אבל בזהירות. לקוח API יכול להחזיק connection pool, הגדרות timeout ומדיניות retry. ברוב המקרים עדיף לנהל אותו כחלק מהרכבת היישום ולהזריק אותו למחלקות שצריכות אותו, ולא לקרוא אליו גלובלית מכל מקום.

האם Singleton בטוח מול serialization ו־reflection?

לא אוטומטית. מימוש רגיל עלול להישבר בסריאליזציה אם לא מטפלים בכך, ו־reflection יכול לעקוף בנאי פרטי בתנאים מסוימים. enum Singleton חזק יותר בהיבטים האלה, אבל גם הוא לא תחליף לדיון עיצובי נכון.

סיכום: להשתמש ב־Singleton רק כשיש סיבה חזקה

Singleton הוא תבנית יצירה תקנית ושימושית כאשר באמת צריך מופע יחיד ונקודת גישה גלובלית. מימוש נכון ב־Java יכול להיות קצר, בטוח וקריא, במיוחד באמצעות Holder או enum. אבל המחיר העיצובי עלול להיות גבוה: תלות מוסתרת, בדיקות קשות, מצב משותף וצימוד למחזור חיים.

הגישה המעשית היא פשוטה: קודם מוכיחים שיש צורך אמיתי במופע יחיד, אחר כך בוחרים מימוש בטוח, ולבסוף בודקים שהקוד הלקוח לא הופך תלות גלובלית להרגל. אם אין סיבה חזקה, העברת תלות רגילה דרך בנאי תהיה לרוב פתרון נקי, גמיש ובריא יותר.

נכתב על ידי

רועי פרידמן : כותב

כותב על תבניות העיצוב של GoF ועל תקשורת בין אובייקטים. אינו מרצה באוניברסיטה, אינו יועץ ארכיטקטורה ואינו מגייס — מסביר כל תבנית דרך בעיה מוחשית ודוגמת קוד, ומציין מתי היא לא שווה את המחיר.

להמשך קריאה

לכל המאמרים בנושא

תבניות יצירה

תבנית Builder: בניית אובייקט מורכב צעד אחר צעד, מימוש מלא, Fluent Builder ו-Director

Builder מפרידה בין תהליך הבנייה של אובייקט מורכב לבין הייצוג שלו, כך שאותו תהליך יכול לייצר ייצוגים שונים. ההגדרה של ספר ה-GoF עם Director, הווריאציה השוטפת המוכרת מ-Java, מימוש מלא של שתיהן ומתי לא צריך אף אחת.

9 דק׳ קריאה

תבניות יצירה

תבנית Abstract Factory: משפחות של אובייקטים קשורים, מימוש מלא וההבדל מ-Factory Method

Abstract Factory מספקת ממשק ליצירת משפחות של אובייקטים קשורים בלי לציין את המחלקות הקונקרטיות שלהם. ההגדרה לפי ספר ה-GoF, חמשת המשתתפים, מימוש מלא ב-Java ובפייתון, ומה ההבדל האמיתי בינה לבין Factory Method.

8 דק׳ קריאה

תבניות יצירה

תבנית Factory Method: הגדרה, מבנה, מימוש מלא ב-Java ובפייתון, ומתי היא עדיפה על new ישיר

Factory Method מגדירה ממשק ליצירת אובייקט ומשאירה למחלקות־הבת להחליט איזו מחלקה ליצור. ההגדרה המדויקת, ארבעת המשתתפים, מימוש מלא ב-Java ובפייתון, ההבדל מ"פקטורי פשוט" ומתי new ישיר עדיף.

8 דק׳ קריאה