أنماط التصميم في جافا

نمط المفرد (Singleton)

15 دقيقة الدرس 2 من 13

نمط المفرد (Singleton)

يضمن نمط المفرد أن يكون للصنف كائن واحد فقط طوال دورة حياة التطبيق، مع توفير نقطة وصول عالمية إلى ذلك الكائن. يُعدّ من أكثر أنماط الإنشاء استخدامًا — وفي الوقت ذاته من أكثرها إساءةً في التوظيف — ضمن مجموعة أنماط GoF.

متى يكون استخدام المفرد مناسبًا؟

يجب أن يكون الصنف من نوع مفرد حين تتحقّق الشرطان معًا:

  • يجب أن يوجد كائن واحد بالضبط — مثل سجل الإعدادات، أو مجمّع الاتصالات، أو الذاكرة المؤقتة الآمنة، أو واجهة تعامل مع أجهزة مادية.
  • يجب الوصول إلى ذلك الكائن من أماكن متعددة دون الحاجة إلى تمريره عبر كل مُنشئ أو دالة.
المفرد مقابل الصنف الثابت (static class): لا يستطيع الصنف الثابت تنفيذ واجهة، ولا يمكن تمريره كتبعية، ولا يمكن استبداله في الاختبارات. أمّا المفرد فهو كائن حقيقي — يمكنه تنفيذ الواجهات وحقنه كتبعية ومحاكاته في الاختبارات. فضّل المفرد على الصنف الثابت كلما كانت تعددية الأشكال أو قابلية الاختبار مهمتين.

التهيئة الفورية (Eager Initialization)

أبسط صورة: يُنشأ الكائن فور تحميل الصنف بواسطة JVM. ولأن تحميل الأصناف مضمون خيطيًا بحسب مواصفة JVM، لا حاجة إلى أي تزامن.

public final class AppConfig { // يُنشأ مرة واحدة عند تحميل الصنف private static final AppConfig INSTANCE = new AppConfig(); private final String dbUrl; private AppConfig() { // تحميل من البيئة أو ملف الخصائص this.dbUrl = System.getenv().getOrDefault("DB_URL", "jdbc:h2:mem:test"); } public static AppConfig getInstance() { return INSTANCE; } public String getDbUrl() { return dbUrl; } }

المقايضة: يُنشأ الكائن حتى لو لم يُستخدم قط. بالنسبة للكائنات خفيفة الوزن هذا مقبول. أمّا للموارد المكلفة (مثل مجمّع اتصالات قاعدة البيانات) التي قد لا تكون مطلوبة دائمًا، فالتهيئة الكسولة أفضل.

التهيئة الكسولة — اصطلاح القفل ذي الفحص المزدوج

تؤجّل التهيئة الكسولة إنشاء الكائن حتى أول استدعاء لـ getInstance(). في بيئة متزامنة، فحص if (instance == null) البسيط ليس آمنًا — يمكن لخيطين أن يريا null ويُنشئ كل منهما كائنًا مستقلًا. الحل الكلاسيكي هو القفل ذو الفحص المزدوج مقرونًا بالكلمة المفتاحية volatile:

public final class ConnectionPool { // volatile تضمن أن تكون عملية الكتابة على INSTANCE مرئية لجميع الخيوط // قبل أن يُنشر المرجع private static volatile ConnectionPool INSTANCE; private final int maxConnections; private ConnectionPool() { this.maxConnections = Integer.parseInt( System.getenv().getOrDefault("POOL_SIZE", "10") ); } public static ConnectionPool getInstance() { if (INSTANCE == null) { // الفحص الأول — بلا قفل synchronized (ConnectionPool.class) { if (INSTANCE == null) { // الفحص الثاني — تحت القفل INSTANCE = new ConnectionPool(); } } } return INSTANCE; } public int getMaxConnections() { return maxConnections; } }
لماذا volatile إلزامية هنا؟ بدونها يُجيز JVM إعادة ترتيب الكتابة على INSTANCE بحيث قد يرى خيط آخر كائنًا مُنشأ جزئيًا. تفرض volatile علاقة "يحدث قبل": يكتمل البناء الكامل قبل نشر المرجع. إغفالها يُحدث سباق بيانات خفيًا يصعب إعادة إنتاجه على المعالجات متعددة الأنوية.

اصطلاح الحامل — التهيئة عند الطلب

بديل كسول أكثر أناقة يتجنّب التزامن الصريح كليًا، مستغلًا ضمان JVM بأن الصنف لا يُهيَّأ إلا مرة واحدة وعند أول وصول إليه فقط:

public final class MetricsRegistry { private MetricsRegistry() {} // يُهيّئ JVM هذا الصنف الداخلي فقط حين يُقرأ Holder.INSTANCE للمرة الأولى. // تهيئة الصنف مضمونة خيطيًا بطبيعتها — لا volatile ولا قفل. private static final class Holder { static final MetricsRegistry INSTANCE = new MetricsRegistry(); } public static MetricsRegistry getInstance() { return Holder.INSTANCE; } }
فضّل اصطلاح الحامل على القفل ذي الفحص المزدوج في الكود الجديد. فهو أقصر وأسهل في التحقّق منه، وبنفس القدر كسولًا وآمنًا خيطيًا. أما القفل المزدوج فلا يزال شائعًا في قواعد الكود القديمة لذا يجب أن تتمكّن من قراءته وإصلاحه.

المفرد بصيغة Enum — المعيار الذهبي

يوصي Joshua Bloch في كتابه Effective Java (البند 3) بتنفيذ المفرد على شكل مُعدَّد (enum) ذي عنصر واحد. هذا هو الأسلوب الأكثر إيجازًا وأمانًا خيطيًا وتسلسلًا في Java:

public enum AuditLogger { INSTANCE; // الحالة مقبولة — المُعدَّدات كائنات private final List<String> log = new java.util.ArrayList<>(); public void record(String event) { synchronized (log) { log.add(java.time.Instant.now() + " " + event); } } public List<String> getLog() { synchronized (log) { return List.copyOf(log); } } } // الاستخدام — لا حاجة لـ getInstance() AuditLogger.INSTANCE.record("Payment processed");

يُبطل أسلوب enum ثلاث هجمات كلاسيكية تكسر المفرد:

  1. الانعكاس (Reflection): يُطلق Constructor.setAccessible(true) استثناء IllegalArgumentException لمُنشئات enum — يفرض ذلك JVM نفسه.
  2. التسلسل (Serialization): عادةً يُنتج إلغاء التسلسل كائنًا جديدًا مما يُخلّ بالضمان. أما تسلسل enum فيُديره JVM ويعيد دائمًا الكائن الوحيد الأصلي — دون الحاجة لتجاوز readResolve().
  3. النسخ (Cloning): لا يُنفّذ Enum واجهة Cloneable، لذا يُطلق clone() استثناء CloneNotSupportedException.
لماذا لا نستخدم enum دائمًا؟ لا يستطيع enum توسيع صنف أصلي (فهو يمتد ضمنيًا من java.lang.Enum). إن كان مفردك بحاجة للوراثة من صنف مجرد، أو لتهيئة كسولة بمنطق معقد، فاستخدم اصطلاح الحامل عوضًا عن ذلك. لكن في الحالة الشائعة — مفرد عديم الحالة أو بحالة بسيطة — صيغة enum هي الأمثل.

مقارنة سلامة الخيوط

  • التهيئة الفورية: آمنة خيطيًا دون جهد إضافي. يُنشأ الكائن دائمًا.
  • القفل ذو الفحص المزدوج: آمن مع volatile؛ كسول. شائع في الكود القديم.
  • اصطلاح الحامل: آمن عبر دلالات تهيئة الصنف في JVM؛ كسول. موصى به للمفردات القائمة على الصنف.
  • مفرد enum: آمن خيطيًا؛ آمن تسلسليًا؛ محصّن ضد الانعكاس. موصى به في Effective Java.

المفردات وحقن التبعيات

النمط الاحترافي الشائع هو الجمع بين نطاق المفرد وحقن التبعيات. تُدير أطر العمل مثل Spring كائنات المفرد (beans) نيابةً عنك — تُعلن صنفًا بـ @Service أو @Component وتضمن الحاوية وجود كائن واحد. تحصل على كامل مزايا قابلية الاختبار دون الحاجة لكتابة قفل مزدوج يدويًا.

حين تكتب مفردات خاملة (خارج حاوية DI)، برمج بحسب الواجهة لا الصنف المفرد الملموس. هذا يحفظ قابلية الاختبار:

public interface ConfigProvider { String get(String key); } public enum AppSettings implements ConfigProvider { INSTANCE; private final java.util.Properties props = new java.util.Properties(); AppSettings() { try (var in = getClass().getResourceAsStream("/app.properties")) { if (in != null) props.load(in); } catch (java.io.IOException e) { throw new ExceptionInInitializerError(e); } } @Override public String get(String key) { return props.getProperty(key); } }

الخلاصة

استخدم التهيئة الفورية حين تكون تكلفة إنشاء الكائن منخفضة. استخدم اصطلاح الحامل حين تحتاج لتهيئة كسولة لمفرد قائم على صنف. استخدم مفرد enum كخيار افتراضي لأي مفرد جديد لا يحتاج للوراثة — فهو موجز وآمن خيطيًا وتسلسليًا ومحصّن ضد الانعكاس. تعلّم القفل ذا الفحص المزدوج للتعامل مع الكود القديم بأمان.