انتقال للمقال
وقت القراءة: ≈ 20 دقيقة (بمعدل فنجان واحد من القهوة 😊)

الـ Singleton Pattern لإنشاء نسخة واحدة فقط

السلام عليكم ورحمة الله وبركاته

يمكنك متابعة السلسلة بالترتيب أو الانتقال مباشرة إلى أي مقال:


المقدمة

اليوم سنتعرف على أحد أبسط وأشهر الـ Design Patterns على الإطلاق
وغالبًا ما يكون أول نمط يتعلمه أي مطور جديد في عالم الـ Design Patterns وهو الـ Singleton Pattern

الـ Singleton Pattern ينتمي إلى عائلة الـ Creational Design Patterns
والـ Creational Design Patterns كما تعرف تهتم بكيفية إنشاء الـ object بطريقة مرنة وبسيطة ومنظمة
والـ Singleton Pattern يتحكم في هذه العملية بطريقة مميزة وهو ضمان أنك لن تنشئ سوى نسخة واحدة فقط من الكلاس طوال عمر التطبيق

ما هو الـ Singleton Pattern ؟

فكرة الـ Singleton Pattern تقوم على فكرة واحدة بسيطة وهي ضمان إنشاء نسخة واحدة فقط من الكلاس
وتوفير دالة أو طريقة للوصول لهذه النسخة من أي مكان في الكود
ومنع أي شخص من إنشاء نسخة جديدة من الكلاس باستخدام new أو أي طريقة أخرى

بحيث أنه دائمًا وأبدًا يجب أن يكون هناك object واحد فقط من هذا الكلاس في المشروع أو البرنامج

لفعل هذا علينا منع أي مستخدم من خارج الكلاس من إنشاء object جديد منه
بالتالي سنجعل الـ constructor الخاص بكلاس يكون private
وبالتالي لا يمكن لأي شخص أن ينشئ object من الكلاس

قد تستغرب من أن الـ constructor يمكن أن يكون private، لكن هذا فقط لنمنع إنشاء أي object جديد من الكلاس
لكننا نستطيع داخل الكلاس نفسه أن ننشئ نسخة جديدة باستخدام new لأن الـ constructor لا يزال متاحًا داخل الكلاس
لذا الخطوة التالية هي إنشاء object من نوع static داخل الكلاس يحتفظ وهو سيكون الـ object الوحيد الذي نستخدمه طوال الوقت
والخطوة الأخيرة هي إنشاء دالة static تسمى getInstance أو أي اسم تريده، هذه الدالة ستكون مسؤولة عن إرجاع هذا الـ object الذي يعد النسخة الوحيدة من الكلاس

هذه هى الخطوات الأساسية لتطبيق الـ Singleton Pattern
حتى أن بعض الأشخاص يشبهون الـ Singleton أنه مثل Global Object
لأنه يوفر دائمًا object واحد فقط من الكلاس يمكن الوصول إليه من أي مكان في الكود

وفكرة أنه Global فهذا يجعلك تحذر قليلًا منه لأسباب سنتطرق لها لاحقًا
لكن كالعادة، قبل أي شيء دعونا نتعرف على المشكلة التي يقوم بحلها

ما المشكلة التي يحلها الـ Singleton Pattern ؟

تخيل أنك تبني تطبيقًا وتحتاج إلى كلاس يمثل قاعدة البيانات التي تتعامل معها
لنتخيل أن هذا الكلاس يتصل بقاعدة البيانات في الـ constructor الخاص به
ويحتوي على دوال لتنفيذ أي query تريده

class DatabaseConnection {
  constructor(
    private host: string,
    private port: number,
    private database: string,
  ) {
    console.log(
      `Opening new connection to database: ${host}:${port}/${database}`,
    );
    // تخيل هنا كود يتصل بقاعدة البيانات
  }

  public query(sql: string): any {
    console.log(`Executing query: ${sql}`);
    // تنفيذ الأمر وإرجاع النتيجة
  }
}

المشكلة هنا أن هذا الكلاس سيتصل بقاعدة البيانات في كل مرة يتم إنشاء object منه
فتخيل ماذا سيحدث في تطبيق حقيقي كبير
بحيث نفترض أن لديك أكثر من services في تطبيقك مثل UserService و ProductService و OrderService
وكل واحد منهم يحتاج إلى الاتصال بقاعدة البيانات لتنفيذ الـ query التي يحتاجها

class UserService {
  private db: DatabaseConnection;

  constructor() {
    this.db = new DatabaseConnection("localhost", 5432, "myapp");
  }

  public getUser(id: number) {
    return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
  }
}

class ProductService {
  private db: DatabaseConnection;

  constructor() {
    this.db = new DatabaseConnection("localhost", 5432, "myapp");
  }

  public getProduct(id: number) {
    return this.db.query(`SELECT * FROM products WHERE id = ${id}`);
  }
}

class OrderService {
  private db: DatabaseConnection;

  constructor() {
    this.db = new DatabaseConnection("localhost", 5432, "myapp");
  }

  public getOrder(id: number) {
    return this.db.query(`SELECT * FROM orders WHERE id = ${id}`);
  }
}

هل ترى المشكلة ؟
كل service يقوم بإنشاء object جديد بالتالي كل كلاس سيقوم بإنشاء اتصال جديد ومنفصل بقاعدة البيانات
وهذا يعني أن كل مرة يتم فيها استخدام الكلاسات UserService أو ProductService أو OrderService سيتم فتح اتصال جديد بقاعدة البيانات
وكل اتصال يعد مكررًا وغير ضروري لأنهم جميعًا يتصلون بنفس قاعدة البيانات وبنفس الإعدادات

const userService = new UserService();
const productService = new ProductService();
const orderService = new OrderService();

userService.getUser(1);
productService.getProduct(1);
orderService.getOrder(1);

// OUPUT:
// Opening new connection to database: localhost:5432/myapp
// Opening new connection to database: localhost:5432/myapp
// Opening new connection to database: localhost:5432/myapp

// Executing query: SELECT * FROM users WHERE id = 1
// Executing query: SELECT * FROM products WHERE id = 1
// Executing query: SELECT * FROM orders WHERE id = 1

وعدا هذا لو أردت تغيير البيانات التي نرسلها إلى الـ constructor
مثل تغيير الـ host أو port أو database، ستضطر لتعديلها في كل مكان قمت فيه بعمل واستدعاء new DatabaseConnection(...)
وهذا عدا مشكلة التكرار بحيث أنك تنشئ object في كل service بالتالي قد تواجه مشاكل في الأداء أو تكرار

لأن الأمر لا يقتصر على كلاس DatabaseConnection بل نحن نتحدث عن أي كلاس أو service تحتاج نسخة واحدة منها
وغالبًا ما تكون Shared Resource/Service مثل Logger أو Configuration Manager أو Cache Manager وغيرها

الحل ؟ نريد أن يكون لدينا اتصال واحد فقط بقاعدة البيانات يستخدمه الجميع
ولا نضطر لفتح اتصال جديد في كل مرة نحتاج فيها للتعامل مع قاعدة البيانات
هنا يأتي دور الـ Singleton Pattern الذي يضمن أن يكون هناك نسخة واحدة فقط من DatabaseConnection طوال عمر التطبيق
ويمكن للجميع الوصول إليها من أي مكان في الكود دون الحاجة لإنشاء اتصال جديد

تطبيق الـ Singleton Pattern على كلاس DatabaseConnection

لنطبق الـ Singleton Pattern على كلاس الـ DatabaseConnection الذي أنشأناه سابقًا

class DatabaseConnection {
  private static instance: DatabaseConnection | null = null;

  private constructor(
    private host: string,
    private port: number,
    private database: string,
  ) {
    console.log(
      `Opening new connection to database: ${host}:${port}/${database}`,
    );
  }

  public static getInstance(): DatabaseConnection {
    if (!DatabaseConnection.instance) {
      DatabaseConnection.instance = new DatabaseConnection(
        "localhost",
        5432,
        "myapp",
      );
    }

    return DatabaseConnection.instance;
  }

  public query(sql: string): any {
    console.log(`Executing query: ${sql}`);
  }
}

لاحظ أن الـ constructor أصبح private مما يمنع أي شخص من إنشاء نسخة جديدة من DatabaseConnection باستخدام new
وأنشأنا متغير static يسمى instance وهو من نوع DatabaseConnection أو null في البداية لأنه لا توجد نسخة بعد
والـ instance سيكون مسؤولًا عن تخزين النسخة الوحيدة من الكلاس
ثم أنشأنا دالة static تسمى getInstance تقوم بالتحقق أولًا إذا كانت النسخة موجودة بالفعل في المتغير instance
إذا كانت النسخة موجودة، ترجعها مباشرة
وإذا لم تكن موجودة، تنشئ نسخة جديدة باستخدام الـ constructor الخاص وتخزنها في المتغير instance ثم ترجعها

الآن لنرى كيف نستخدمه

const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();
const db3 = DatabaseConnection.getInstance();

console.log(db1 === db2); // true
console.log(db2 === db3); // true

هنا قمت باستدعاء الدالة getInstance ثلاث مرات، لكي نتأكد أن كل مرة نحصل على نفس النسخة من DatabaseConnection
وبالفعل ستلاحظ كلًا من db1 و db2 و db3 يشير إلى نفس النسخة، وبالتالي النتيجة ستكون true في كل المقارنات

ولاحظ أننا لم نستخدم new DatabaseConnection(...) في أي مكان في الكود لأننا قمنا بجعل الـ constructor يكون private
وعوضًا عن هذا استخدمنا فقط DatabaseConnection.getInstance() للحصول على النسخة الوحيدة من الكلاس
وبسبب أننا جعلنا الـ instance والدالة getInstance من نوع static فنستطيع الوصول إليهما بشكل مباشر من الكلاس نفسه دون الحاجة لإنشاء نسخة منه

لأن الـ static يعني أن المتغير أو الدالة تنتمي للكلاس نفسه وليس لأي نسخة منه، وبالتالي يمكننا الوصول إليها باستخدام DatabaseConnection.getInstance() مباشرة

والآن يمكننا تحديث الـ services التي كانت تعاني من المشكلة

class UserService {
  private db: DatabaseConnection;

  constructor() {
    this.db = DatabaseConnection.getInstance();
  }

  public getUser(id: number) {
    return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
  }
}

class ProductService {
  private db: DatabaseConnection;

  constructor() {
    this.db = DatabaseConnection.getInstance();
  }

  public getProduct(id: number) {
    return this.db.query(`SELECT * FROM products WHERE id = ${id}`);
  }
}

class OrderService {
  private db: DatabaseConnection;

  constructor() {
    this.db = DatabaseConnection.getInstance();
  }

  public getOrder(id: number) {
    return this.db.query(`SELECT * FROM orders WHERE id = ${id}`);
  }
}

الآن كل service يستخدم نفس الاتصال بقاعدة البيانات بدلًا من فتح اتصالات جديدة في كل مرة

الآن لنلاحظ النتيجة عند تشغيل الكود

const userService = new UserService();
const productService = new ProductService();
const orderService = new OrderService();

userService.getUser(1);
productService.getProduct(1);
orderService.getOrder(1);

// OUTPUT:
// Opening new connection to database: localhost:5432/myapp
// Executing query: SELECT * FROM users WHERE id = 1
// Executing query: SELECT * FROM products WHERE id = 1
// Executing query: SELECT * FROM orders WHERE id = 1

لاحظ أن رسالة Opening new connection to database ظهرت مرة واحدة فقط، مما يعني أن الاتصال تم إنشاؤه مرة واحدة فقط وتمت مشاركته بين جميع الـ services
وكل استدعاء لـ getUser أو getProduct أو getOrder يستخدم نفس الاتصال ونفس الـ object دون الحاجة لفتح اتصال جديد أو إنشاء object جديد

وكما قلنا كأن الـ Singleton Pattern هو Global Object خاص بكلاس معين، يضمن أن يكون هناك نسخة واحدة فقط منه طوال عمر التطبيق

متى لا يفضل استخدام الـ Singleton

بعض الأشخاص يشبهون الـ Singleton أنه مثل Global Object
لأنه يوفر دائمًا object واحد فقط من الكلاس يمكن الوصول إليه من أي مكان في الكود

وفكرة أنه Global فهذا يجعلك تحذر قليلًا لأن أي جزء من الكود يستطيع الوصول إليه في أي وقت عبر getInstance
بالتالي هذا سيجعل الـ object يمكن التعديل عليه من أي مكان

تخيل أن لديك متغيرًا عامًا يخزن معلومات معينة
أي جزء من الكود يستطيع قراءته وأي جزء من الكود يستطيع أيضًا تعديله وهذا هو بالضبط مصدر المشكلة

نفس الأمر ينطبق تمامًا على الـ Singleton الذي يحتفظ ببيانات قابلة للتعديل
لنأخذ مثالًا واقعيًا، تخيل أن لديك Singleton يمثل إعدادات التطبيق ويسمح بتعديلها

class AppConfig {
  private static instance: AppConfig | null = null;

  private readonly settings: ReadonlyMap<string, string>;

  private constructor() {
    const map = new Map<string, string>();

    map.set("theme", process.env.THEME || "light");
    map.set("language", process.env.LANGUAGE || "ar");
    map.set("pageSize", process.env.PAGE_SIZE || "10");

    this.settings = map;
  }

  public static getInstance(): AppConfig {
    if (!AppConfig.instance) {
      AppConfig.instance = new AppConfig();
    }
    return AppConfig.instance;
  }

  public get(key: string): string | undefined {
    return this.settings.get(key);
  }

  public set(key: string, value: string): void {
    this.settings.set(key, value);
  }
}

هذا الكلاس يهتم ببيانات وإعدادات التطبيق ولدينا دوال مثل get و set لقراءة القيم وتعديلها من أي مكان في التطبيق

الآن لنتخيل أن هناك ثلاثة أجزاء مختلفة في التطبيق تستخدم نفس الـ instance

// UserDashboard
const config = AppConfig.getInstance();
this.config.set("pageSize", "50");
console.log(`Dashboard pageSize: ${this.config.get("pageSize")}`);

// ProductList
const config = AppConfig.getInstance();
console.log(`ProductList pageSize: ${this.config.get("pageSize")}`);

// OrderHistory
const config = AppConfig.getInstance();
this.config.set("pageSize", "5");
console.log(`OrderHistory pageSize: ${this.config.get("pageSize")}`);

لاحظ أنه في الـ UserDashboard تم تعديل قيمة الـ pageSize إلى 50
ثم مع الـ ProductList برغم من أنها لم تعدل في شيء إلا أنها ستحصل على نتائج مختلفة معتمدة على تعديلات سابقة للـ pageSize
لأن الكلاسات الأخرى ستعدل نفس الـ object الذي تشاركه معها

هذا النوع من المشاكل يعد من أصعب الأخطاء في البرمجة لأن الكود يبدو صحيحًا عند النظر إليه بشكل منفصل، كل كلاس يؤدي عمله بشكل طبيعي
لكن المشكلة تظهر فقط عند تشغيل الكلاسات معًا وبترتيب معين
إذا تغير الترتيب تتغير النتائج، مما يجعل التنبؤ بسلوك التطبيق أمرًا صعبًا

هذا ما يسميه المطورون Shared Mutable State أي حالة مشتركة قابلة للتعديل
وهو أحد أكثر الأسباب شيوعًا للأخطاء الصعب تتبعها في البرامج الكبيرة

لهذا أي شيء يندرج تحت مسمى Shared Mutable State يعد أمرًا مكروهًا أو حساسًا في عالم البرمجة
لهذا أي شيء أو متغير Global على مستوى التطبيق سيكون معرض لهذه المشكلات
وهذه المشكلات قد يسميها البعض Side Effects بمعنى أن الشيء الـ Global القابل للتعديل سيعرض التطبيق بأكمله لتأثيرات جانبية ومشاكل غير متوقعة
لهذا قد يندرج الـ Singleton تحت هذا المسمى وهذه المشاكل

استخدام الـ Singleton بشكل صحيح

القاعدة الذهبية عند استخدام الـ Singleton Pattern هي أنك تحاول أن تجعل الـ Singleton أن يكون إما للقراءة فقط
أو أن تكون كل الدوال الخاصة به pure functions أي تكون دوال لا تعدل في أي شيء Global بتالي لا تسبب بمشاكل أو Side Effects غير متوقع

بمعنى آخر، حاول قدر الإمكان تحويل الكلاس من Shared Mutable State إلى Shared Immutable State

وهذا يعني أنك إذا أنشأت Singleton يحتفظ ببيانات، فتأكد أن هذه البيانات لا تتغير بعد الإنشاء
أو أن العمليات التي يقوم بها لا تغير حالته الداخلية

class AppConfig {
  private static instance: AppConfig | null = null;

  private readonly settings: ReadonlyMap<string, string>;

  private constructor() {
    const map = new Map<string, string>();

    map.set("theme", process.env.THEME || "light");
    map.set("language", process.env.LANGUAGE || "ar");
    map.set("pageSize", process.env.PAGE_SIZE || "10");

    this.settings = map;
  }

  public static getInstance(): AppConfig {
    if (!AppConfig.instance) {
      AppConfig.instance = new AppConfig();
    }
    return AppConfig.instance;
  }

  public get(key: string): string | undefined {
    return this.settings.get(key);
  }
}

لاحظ أنه تمت إزالة دالة set من الكلاس بالتالي لم يعد هناك أي طريقة لتعديل الإعدادات بعد الإنشاء الـ object

الآن لو حاول أي كلاس تعديل الإعدادات، لن يجد أي طريقة للقيام بذلك
والبيانات ستبقى ثابتة كما هي منذ إنشاء الـ Singleton لأول مرة
وكل استدعاء لـ get سيرجع دائمًا نفس القيمة بغض النظر عن من استدعاها أو متى

const config1 = AppConfig.getInstance();
const config2 = AppConfig.getInstance();
const config3 = AppConfig.getInstance();

console.log(config1.get("pageSize")); // 10
console.log(config2.get("pageSize")); // 10
console.log(config3.get("pageSize")); // 10

الآن قد تتساءل ماذا لو أردت Singleton لا يحتفظ بأي بيانات داخلية على الإطلاق؟
بل يقوم فقط بتقديم خدمات ووظائف بحتة

كل دالة فيه تأخذ معطيات معينة وترجع لك نتيجة محددة دون أن تغير أي شيء في تطبيقك
وهذا هو تعريف الـ Pure Function
بحيث أن تنفيذ الدالة لا يؤثر ولا يغير على أي شيء في أي حالة في تطبيقك

لنرى بعض الكلاسات كأمثلة على هذا

class MathHelper {
  private static instance: MathHelper | null = null;

  private constructor() {}

  public static getInstance(): MathHelper {
    if (!MathHelper.instance) {
      MathHelper.instance = new MathHelper();
    }
    return MathHelper.instance;
  }

  public calculateTax(price: number, rate: number): number {
    return price * (rate / 100);
  }

  public formatCurrency(amount: number, currency: string): string {
    return `${amount.toFixed(2)} ${currency}`;
  }

  public applyDiscount(price: number, discountPercent: number): number {
    return price - price * (discountPercent / 100);
  }
}

لاحظ أن هذا الكلاس لا يحتفظ بأي بيانات داخلية على الإطلاق
كل دالة فيه تعتمد كليًا على المعطيات التي تمرر لها فقط
وترجع لك نتيجة محددة
بغض النظر عن كم مرة استدعيتها ومتى استدعيتها، النتيجة ستكون دائمًا نفسها

const math = MathHelper.getInstance();

console.log(math.calculateTax(100, 15)); // 15 دائمًا
console.log(math.calculateTax(100, 15)); // 15 دائمًا
console.log(math.formatCurrency(99.9, "USD")); // Always 99.90 USD

ومثال آخر هو مثال الـ Logger وهو أحد أكثر أمثلة الـ Singleton شيوعًا

class Logger {
  private static instance: Logger | null = null;

  private constructor() {}

  public static getInstance(): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger();
    }
    return Logger.instance;
  }

  public info(message: string): void {
    console.log(`[INFO] ${new Date().toISOString()}: ${message}`);
  }

  public error(message: string): void {
    console.error(`[ERROR] ${new Date().toISOString()}: ${message}`);
  }

  public warn(message: string): void {
    console.warn(`[WARN] ${new Date().toISOString()}: ${message}`);
  }
}

هذا الكلاس يعتبر Singleton آمن تمامًا لأن دواله لا تعدل أي بيانات داخلية
كل ما تفعله هو إرسال رسالة خارج التطبيق في ملف في console
وبالتالي لا يوجد أي Shared Mutable State قد يسبب مشاكل أو Side Effects غير متوقعة

الفرق بين الـ Lazy Initialization و Eager Initialization في الـ Singleton

بعد أن فهمنا كيف يعمل الـ Singleton وكيف نستخدمه بشكل صحيح، هناك بعض الأمور الإضافية التي يجب أن نذكرها ونتكلم عنها منها الفرق بين الـ Lazy Initialization و Eager Initialization

الـ Lazy Initialization

في كل الأمثلة التي استخدمناها طوال المقالة كنا نؤجل إنشاء النسخة حتى أول استدعاء لـ getInstance
وهذا الأسلوب يسمى الـ Lazy Initialization بمعنى أن الـ Object لا يتم إنشاءه إلا عند الحاجة إليها لأول مرة
يعني آخر لو لم يستدعي أحد الدالة getInstance فلن يتم إنشاء الـ object أبدًا وهذا هو معنى Lazy أي أننا نؤجل الإنشاء حتى اللحظة التي يطلب فيها النسخة

class DatabaseConnection {
  private static instance: DatabaseConnection | null = null;

  private constructor(
    private host: string,
    private port: number,
    private database: string,
  ) {
    console.log("Opening new connection to database...");
  }

  public static getInstance(): DatabaseConnection {
    // Lazy Initialization
    if (!DatabaseConnection.instance) {
      // يت الإنشاء فقط عند أول استدعاء
      DatabaseConnection.instance = new DatabaseConnection(
        "localhost",
        5432,
        "myapp",
      );
    }

    return DatabaseConnection.instance;
  }
}

لاحظ أن المتغير instance يبدأ بقيمة null لأنه لا توجد نسخة في البداية
والنسخة تنشأ فقط عند لحظة أول استدعاء لدالة الـ getInstance

// The Object will be created for the first time, when we call getInstance for the first time
const db = DatabaseConnection.getInstance();

وفائدة هذا الأسلوب بسيطة بحيث أنه في حالة أن إنشاء الـ Object مكلفة فنحن نحاول أن نؤجل إنشاءه لحين الاحتياج له
أو لا يتم إنشاء الـ Object فحين أننا لم نحتج له أو لم نستخدمه بالتالي لن يتم هدر أي موارد في شيء قد لا يكون له داعي

الـ Eager Initialization

أما الطريقة الثانية فهي الـ Eager Initialization وهي العكس تمامًا بحيث أنه في هذه الطريقة يتم إنشاء الـ Object فورًا عند بدأ تشغيل التطبيق أو عند تجهيز أو تهيئة الكلاس في التطبيق وليس عند أول استدعاء لدالة الـ getInstance

class DatabaseConnection {
  // Eager Initialization
  // يتم الإنشاء فور تجهيز أو تهيئة الكلاس في التطبيق
  private static instance: DatabaseConnection = new DatabaseConnection(
    "localhost",
    5432,
    "myapp",
  );

  private constructor(
    private host: string,
    private port: number,
    private database: string,
  ) {
    console.log(
      `Opening new connection to database: ${host}:${port}/${database}`,
    );
  }

  public static getInstance(): DatabaseConnection {
    // لا يوجد أي تحقق، نرجع المتغير مباشرة
    return DatabaseConnection.instance;
  }

  public query(sql: string): any {
    console.log(`Executing query: ${sql}`);
  }
}

لاحظ أن المتغير instance لم يعد يقبل null لأنه سيكون دائمًا سيكون Object
ولاحظ أيضًا أن دالة getInstance أصبحت أبسط بكثير، لأنها تقوم فقط بإرجاع الـ Object مباشرة دون أي تحقق
وهذه هى فكرة الـ Eager Initialization ببساطة وهى إنشاء النسخة حتى لو لم يحتجها التطبيق أبدًا
وهى عكس فكرة الـ Lazy Initialization


متى تختار كل طريقة ؟
إذا كان إنشاء الـ Object مكلفًا ولا تعلم إن كانت ستُستخدم فعلًا فـ Lazy Initialization هو الأنسب لأنك لن تهدر موارد على شيء قد لا يحتاجه التطبيق أصلًا
أما إذا كنت متأكدًا أن الـ Object ستستخدم دائمًا وتريد التأكد من جاهزيتها منذ البداية، أو إذا كانت تكلفة الإنشاء خفيفة وتفضل بساطة الكود، فـ Eager Initialization يؤدي الغرض

لكن في معظم الحالات الـ Lazy Initialization هو الخيار الأفضل لأنه أكثر مرونة ويحافظ على الموارد
لكن في حالة استخدامك للـ Lazy Initialization فقد تواجهك مشكلة الـ Race Condition

مشكلة الـ Race Condition في الـ Singleton

هنا تخيل معي ماذا سيحدث عندما يحاول أكثر من Request إنشاء الـ Singleton أو استخدامه في نفس اللحظة
وغالبًا المشكلة تحدث في اللغات أو بيئات الـ Multi-threading بحيث أن هناك عدة threads تحاول إنشاء الـ Singleton
فيحدث تكرار ويتم إنشاء أكثر من Object من الـ Singleton وهذا مخالف لفكرة الـ Singleton نفسها

لذا علينا إيجاد حل لحل هذه المشكلة
والحلول تختلف باختلاف اللغة أو بيئة التشغيل التي تستخدمها

فعلي سبيل المثال في اللغات مثل الـ JavaScript و PHP التي تصنف على أنها لغات Single Thread فهذه المشكلة لن تحدث فيها
أما اللغات أو البيئات الـ Multi-threading هى التي يجب نهتم بيها وهى التي نركز عليها الآن
مثل لغة الـ JAVA أو C# فهنا ستحدث المشكلة التي تحدثنا عليها

الـ Singleton في لغات الـ Multi-threading كـ Java و C#

في هذه اللغات الـ threads تتشارك نفس الذاكرة داخل نفس الـ process وهذا سيوصلنا لمشكلة الـ Race Condition
فتخيل معي أن Thread A و Thread B يعملان في نفس اللحظة وكلاهما يستدعي getInstance لأول مرة

فتخيل معي أن Thread A و Thread B يعملان في نفس اللحظة بالضبط
وكلاهما يستدعي getInstance لأول مرة

فى حالة أن الكود كما هو دون تغير ماذا سيحدث ؟

    Thread A                              Thread B
      |                                     |
      |  getInstance()                      |
      |  instance is null? (Yes)            |
      |                                     |  getInstance()
      |                                     |  instance is null? (Yes)
      |                                     |
      |  new DatabaseConnection()           |
      |  (Create Instance A)                |
      |  instance = Instance A              |
      |                                     |  new DatabaseConnection()
      |                                     |  (Create Instance B)
      |                                     |  instance = Instance B
      |                                     |  (replace Instance A)
      v                                     v
Instance A created                     Instance B created

لاحظ أن كلا الـ threads وجدا أن المتغير instance بقيمة null
بالتالي كل واحد منهما أنشأ نسخة جديدة من الكلاس
والنتيجة ؟ تم إنشاء نسختين من الكلاس وليس نسخة واحدة
وهذا يكسر القاعدة الأساسية للـ Singleton Pattern بشكل كامل كما قلنا
حتى لو قامت النسخة الثانية بعمل override للنسخة الأولى

الحل الأشهر هو نمط الـ Double-Checked Locking حيث نتحقق مرتين مع وضع الـ lock بينهما
لنرى كيف سيكون الحل في لغة الـ Java كمثال

// Java
public class DatabaseConnection {
    private static volatile DatabaseConnection instance = null;

    private DatabaseConnection() {}

    public static DatabaseConnection getInstance() {
        if (instance == null) {
            synchronized (DatabaseConnection.class) {
                if (instance == null) {
                    instance = new DatabaseConnection();
                }
            }
        }
        return instance;
    }
}

ونفس الحل في لغة C#

// C#
public class DatabaseConnection {
    private static DatabaseConnection? _instance = null;
    private static readonly object _lock = new object();

    private DatabaseConnection() {}

    public static DatabaseConnection GetInstance() {
        if (_instance == null) {
            lock (_lock) {
                if (_instance == null) {
                    _instance = new DatabaseConnection();
                }
            }
        }
        return _instance;
    }
}

لحد تحدثنا عن الـ lock في عدة مقالات من قبل وطبقنا عليه في Laravel
لاحظ أن كلا اللغتين الـ Java والـ C# يدعمان فكرة الـ lock بطرق مختلفة، لكن الكود مشابه هنا وهناك
وأظن أنك بمجرد قراءة الكود تستطيع أن تفهم الفكرة والحل

لاحظ أننا قمنا بعمل الشرط if (instance == null) الأول قبل الـ lock وهذا يضمن أننا لن ندخل في الـ lock إلا عند الحاجة
ثم عندما ندخل إلى الـ lock نجد أن هناك شرط if (instance == null) آخر يحمينا في حالة أننا مازلنا في الـ lock
وهناك thread آخر سبقنا وأنشأ النسخة

لنرجع للرسمة التوضيحي الجميل

    Thread A                              Thread B
      |                                     |
      |  getInstance()                      |  getInstance()
      |  instance is null? (Yes)            |  instance is null? (Yes)
      |                                     |
      |  acquire lock                       |
      |                                     |  waits for lock...
      |                                     |  .
      |  instance is null? (Yes)            |  .
      |  new DatabaseConnection()           |  .
      |  instance = Instance A              |  .
      |  release lock                       |  .
      |                                     |  .
      |                                     |  acquire lock
      |                                     |  instance is null? (No)
      |                                     |  return Instance A
      v                                     v
            Both threads use Instance A

أولًا Thread A و Thread B وجدا أن الشرط الأول if (instance == null) لذا قاما بالاستمرار لأن المتغير instance مازال بـ null
فالنفترض أن Thread A دخل إلى الـ lock أولًا بينما اضطر Thread B إلى الانتظار حتى ينتهي Thread A
عندما دخل الـ Thread A إلى داخل كود الـ lock وجد الشرط الثاني if (instance == null) وطالما أن المتغير instance مازال بـ null استمر ثم قام Thread A بإنشاء الـ Singleton وخرج من الـ lock
الآن حان دور Thread B ليدخل داخل كود الـ lock لأن الـ lock اتفك
الآن Thread B وجد الشرط الثاني if (instance == null) لكنه هذه المرة وجد أن المتغير instance له قيمة وليس بـ null
لذلك لم يقم بإنشاء نسخة جديدة، وإنما أعاد استخدام نفس الـ instance الذي أنشئه الـ Thread A

ولهذا السبب يسمى هذا الأسلوب Double-Checked Locking، لأننا نتحقق من المتغير مرتين if (instance == null)
مرة قبل الدخول إلى الـ lock لتجنب تكلفة القفل في كل استدعاء
ومرة أخرى بعد الدخول إليه للتأكد من أن Thread آخر لم يسبقنا وأنشأ الـ object أثناء فترة الانتظار


وعلى أي حال المشكلة قد تحدث أيضًا مع لغات الـ Single Thread في حال استخدمنا مكتبات أو تقنيات تضيف مفهوم الـ Parallel Execution داخل نفس التطبيق

فعلى سبيل المثال في Node.js يمكن استخدام Worker Threads، حيث يعمل كل Worker في Thread مستقل
وبالتالي إذا كانت هناك ذاكرة مشتركة بين هذه الـ Thread فقد تظهر نفس مشكلة الـ Race Condition إذا لم تتم التعامل معه بشكل صحيح

لذلك فالمشكلة ليست مرتبطة باللغة نفسها، وإنما بفكرة وجود أكثر من Request يريد الوصول إلى نفس البيانات في نفس الوقت
بمجرد تحقق هذا الشرط، يصبح احتمال حدوث الـ Race Condition قائمًا بغض النظر عن اللغة المستخدمة

الفرق بين الـ Singleton والكلاس الـ Static بالكامل

هناك سؤال قد تطرحه عند تعلم الـ Singleton وهو ما الفرق بينه وبين كلاس كل دواله static؟ هذا السؤال طرحته على نفسي أيضًا عندما كنت أتعلم، وعرفت الإجابة فيما بعد

انظر إلى الأمثلة الأربعة التي طرحناها في المقالة كان لدينا كلاسات مثل DatabaseConnection و AppConfig و MathHelper و Logger
جميع هذه الكلاسات يمكن أن تكون Singleton، لكن هل جميع هذه الكلاسات يمكن أن تكون Static Class بشكل كامل ؟

عندما تفكر في السؤال ستجد أن الكلاسات MathHelper و Logger يمكن أن نحولها إلى Static Class بسهولة تامة وبدون تعقيد
لأن دوالها لا تحتاج إلى إنشاء object أو تهيئة مسبقة، ويمكن استدعاؤها مباشرة

أما الكلاسات DatabaseConnection و AppConfig فلا يمكن تحويلها إلى Static Class بنفس السهولة
لأننا نحتاج إلى تهيئته عبر الـ constructor عند إنشائه لأول مرة
وإنشاء object لكي نحتفظ بـ state خاصة به
تتشارك فيها الدوال التي في داخل الكلاس

أما الـ Static Class لا يعتمد على إنشاء object من الكلاس، لذلك لا يستفيد من خصائص الـ object مثل التهيئة عبر الـ constructor أو الاحتفاظ بـ state داخلية تربط بين دواله المختلفة
لأن كل دوال الـ Static Class تكون static فلا يوجد فائدة من امتلاك constructor أو object منه

لذا لا نستطيع تحويل DatabaseConnection و AppConfig إلى Static Class بسهولة والأفضل أن يظلا كلاسات تطبق الـ Singleton

لأن الـ Singleton مناسب للكلاسات التي تندرج تحت فكرة Shared Immutable State
لأنه في النهاية هو object حقيقي تستخدمه عندما تحتاج لـ object وتهيئة الكلاس عبر بيانات تمرر إلى الـ constructor
والدوال تكون مرتبطة ببعضها في State معين

أما إذا كانت جميع دوال الكلاس عبارة عن pure functions أو لا تعتمد على أي state، فهنا يكون استخدام الـ Static Class مناسبًا، لأنه يناسب الحالات التي تريد فيها تجميع مجموعة من الدوال المساعدة Helper Class التي لا تحتاج إلى تهيئة أو إلى state داخلية

كما رأينا مع الـ Logger والـ MathHelper
فهي كلاسات لا تحتاج لـ constructor ولا إلى تهيئة معينه عبره ولا تحتاج إلى state داخلية
ويمكننا القول أنها مجرد Helper Class لا أكثر ولا أقل


في النهاية كلا الطريقتين تخدمان أغراضًا مختلفة

فهناك أمور يستطيع الـ Singleton تحقيقها لأنك تتعامل مع Object حقيقي
بينما لا يستطيع الـ Static Class تحقيقها بنفس المرونة

وفي المقابل، إذا كانت جميع الدوال مستقلة ولا تعتمد على أي state
فغالبًا يكون استخدام Static Class أبسط وأوضح من إنشاء Singleton لا تحتاج إليه

بالطبع هناك فروق ومميزات أخرى لكن هذا الاستنتاج والفهم الذي أحب أن أفرق بينهما به
وباختصار، أحب أن أفرق بينهما بهذه القاعدة البسيطة:

  • الـ Singleton هو Shared Object يمكنه امتلاك state وتهيئته عبر الـ Constructor ودواله تتشارك هذه الـ state أي أنه كلاس يقال عليه Shared Immutable State
  • أما الـ Static Class فهو ليس Object ويستخدم غالبًا لتجميع دوال مستقلة لا تعتمد على أي state مثل Helper Class

ملحوظة: استخدمت Logger هنا كمثال تعليمي لتوضيح الفكرة، لكن في التطبيقات الحقيقية قد يمتلك Logger إعدادات أو state داخلي، ولذلك كثيرًا ما ينفذ كـ Singleton وليس بالضرورة أن يكون Static Class في كل الحالات


ملحوظة: عندما ذكرت أن الـ Singleton يناسب فكرة Shared Immutable State، فأنا أقصد أنه يفضل أن يكون هذا الـ state ثابتًا لا يتغير أي أن يكون Immutable قدر الإمكان لتجنب الـ side effects
لكن هذا ليس شرط أساسي في نمط الـ Singleton


ملحوظة: وصف الـ Static Class بأنه Helper Class هو تبسيط للفكرة، لأن أغلب استخداماته تكون لتجميع الدوال المساعدة، لكنه قد يستخدم أيضًا لأغراض أخرى بالطبع

عيوب الـ Singleton Pattern

الـ Singleton Pattern من أكثر الأنماط إثارة للجدل في عالم الـ Design Patterns
حتى أن بعض الأشخاص قد يعتبرونه Anti-Pattern في بعض السياقات
ولقد ذكرنا بعض هذه السياقات مثل أن حتى أن بعض الأشخاص يشبهونه أنه مثل Global Object أو Global State
وأي Global State يصعب تتبعه لأن أي جزء من التطبيق يستطيع الوصول إليه وتعديله

ولهذا قد تجده يندرج تحت مسمى Shared Mutable State يعد أمرًا سيء وخصوصًا لو كان Global على مستوى التطبيق وسهل الوصول له
وهذا يجعله معرض للمشكلات والـ Side Effects لأنه شيء قابل للتعديل ويعرض التطبيق بأكمله لتأثيرات جانبية ومشاكل غير متوقعة

وهذا بالطبع ليس حكمًا نهائيًا عليه بل هو مفيد ونحن ذكرنا مميزاته
وعرض بعض الحلول في أننا نحاول أن نحوله من Shared Mutable State إلى Shared Immutable State

الختام

في هذه المقالة تعرفنا على فكرة الـ Singleton Pattern وهي ضمان وجود نسخة واحدة فقط من الكلاس طوال عمر التطبيق
ورأينا المشكلة التي يحلها وهي منع تكرار إنشاء نسخ متعددة من Shared State

وتعلمنا الفرق بين الـ Lazy Initialization و Eager Initialization ومتى نختار كلًا منهما
وكيف تتعامل اللغات المختلفة مع مشكلة الـ Race Condition في بيئات الـ Multi-threading
ثم فهمنا الفرق بين الـ Singleton والكلاس الـ Static بالكامل
وأخيرًا تعرفنا على متى نستخدمه ومتى نتجنبه وما العيوب التي يجب أخذها بعين الاعتبار

القاعدة الذهبية التي يجب أن تبقى معك هي أن الـ Singleton مفيد فعلًا لكن فقط عندما تكون متأكدًا من شيئين
الأول أنك فعلًا تحتاج نسخة واحدة فقط من هذا الكلاس طوال فترة التطبيق
والثاني أن هذه النسخة ستكون إما للقراءة فقط أو ستقدم خدمات لا تغير حالتها الداخلية أي Shared Immutable State عدا هذا فلا تستخدمه

على أي حال طوال رحلتك التعليمية والمهنية ستتفاجأ من تطبيقات الـ Singleton في أمور لا تتخيلها
ستجد معظم الـ Framework تستخدمه بشكل داخلي


رسالة خاصة

أرسل ملاحظاتك أو رأيك بشكل خاص — لن يظهر للآخرين

التعليقات

شاركنا رأيك في هذه المقالة أو اسأل عن أي شيء يخصها