مبدأ Open-Closed في تطوير البرمجيات: شرح عملي بلغة واضحة

دقائق القراءة: 7

ما هو مبدأ Open-Closed ولماذا يهم في تصميم البرمجيات؟

يُعد مبدأ Open-Closed Principle واحداً من أشهر مبادئ التصميم البرمجي ضمن مجموعة SOLID. والفكرة الأساسية فيه أن يكون الكود مفتوحاً للتوسعة، لكنه مغلقاً أمام التعديل المتكرر. بمعنى آخر: عندما نحتاج إلى إضافة سلوك جديد أو دعم تغيير متوقع، ينبغي أن نستطيع فعل ذلك عبر إضافة كود جديد قدر الإمكان، بدلاً من تعديل الكود القديم في أماكن كثيرة.

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

شرح مبدأ Open-Closed في تصميم البرمجيات مع مثال عملي على قابلية التوسعة في الكود

فهم المبدأ بصياغة عملية

هناك التباس شائع حول مصطلح Open-Closed Principle، لكن تطبيقه العملي أبسط مما يبدو. إذا كان النظام مصمماً جيداً، فإن التغيير المدعوم مسبقاً يجب أن يتم بأقل قدر ممكن من التعديلات على الكود الحالي. وفي أفضل الحالات، يكفي إضافة مكوّن جديد أو تمرير مدخلات مختلفة دون المساس بالمنطق الأساسي.

لكن من المهم فهم نقطة جوهرية: لا يوجد كود مفتوح لكل أنواع التغييرات المحتملة. لذلك لا بد من اتخاذ قرار واعٍ بشأن أنواع التغييرات التي نتوقع حدوثها فعلاً، ثم نجعل الكود مرناً تجاهها فقط، من دون الإفراط في التعقيد.

كيف نحدد التغييرات التي يجب أن يدعمها الكود؟

قبل تطبيق المبدأ، نحتاج إلى التفكير في طبيعة التغييرات المحتملة. ويمكن الوصول إلى ذلك عبر عدة وسائل:

  • تحليل الكود الحالي وفهم نقاط الارتباط الصلبة.
  • مراجعة التغييرات السابقة إن وُجدت.
  • الاعتماد على الخبرة العملية في الطلبات المتكررة من فرق العمل أو العملاء.
  • أخذ الميزات المتوقعة مستقبلاً في الحسبان.

هذا التفكير المبكر لا يعني بناء طبقات تجريد مبالغ فيها، بل يعني فقط تصميم الكود حول التغييرات الأكثر احتمالاً.

مثال عملي على مبدأ Open-Closed

لننظر إلى المثال التالي، وهو كود يحسب قيمة GrossToNet بالاعتماد على مجموعة خسائر وعوامل مرتبطة بالطاقة:

public class GrossToNetCalculator
{
    public GrossToNetCalculator(
        IGrossEnergyYield grossYield,
        double grossEnergy,
        double hysteresisLoss,
        double curtailmentLossGrid,
        double turbineLossTurbulence,
        double electricalLoss,
        double turbineLossShear,
        double turbinePerformanceExperience,
        double operationalExperienceLoss)
    {
        double dependentLoss = CombinePercentages(
            grossYield.TurbineAvailability,
            grossYield.BalanceAvailability,
            grossYield.AccessibilityAvailability,
            hysteresisLoss,
            electricalLoss,
            grossYield.EnvironmentalShutdownWeather,
            grossYield.EnvironmentalSiteAccess,
            grossYield.EnvironmentTreeGrowth);

        double independentLoss = CombinePercentages(
            grossYield.GridAvailability,
            turbinePerformanceExperience,
            turbineLossTurbulence,
            grossYield.EnvironmentalPerformanceDegradationIcing,
            grossYield.CurtailmentPowerPurchase,
            grossYield.SubOptimalPerformance,
            turbineLossShear,
            operationalExperienceLoss);

        GrossToNet = 1 - (1 - (dependentLoss + curtailmentLossGrid)) * (1 - independentLoss);
    }

    double CombinePercentages(params double[] percentages)
    {
        double combination = 1;
        foreach (var percentage in percentages)
            combination *= 1 - percentage;
        return 1 - combination;
    }

    public double GrossToNet { get; private set; }
}

رغم أن الكود ليس معقداً جداً، فإنه يقدم مثالاً ممتازاً على كيفية تحليل قابلية التوسعة. عند قراءته، يمكن ملاحظة عدة تغييرات محتملة.

ما التغييرات المتوقعة في هذا الكود؟

  • إضافة عناصر جديدة إلى حساب dependentLoss أو independentLoss.
  • إزالة بعض العناصر من تلك الحسابات.
  • نقل عنصر من مجموعة إلى أخرى، وهو في جوهره نوع من إعادة التصنيف.
  • تغيير معادلة حساب GrossToNet.
  • تغيير آلية CombinePercentages.

هنا يظهر التحدي الحقيقي: ليس كل تغيير محتمل يستحق أن نعيد تصميم الكود من أجله. أحياناً تؤدي محاولة جعل الكود مرناً أكثر من اللازم إلى كسر encapsulation وإضافة طبقات abstraction غير مبررة.

الموازنة بين المرونة والتعقيد

تطبيق Open-Closed Principle ليس قراراً نظرياً بحتاً، بل هو موازنة بين عدة عوامل:

  • مدى احتمال حدوث التغيير مستقبلاً.
  • تكلفة دعمه من ناحية التصميم.
  • أثر ذلك على الوضوح والصيانة والاختبارات.
  • ما إذا كانت الإضافة ستخدم أكثر من حالة استخدام واحدة.

بناءً على هذه المعايير، يمكن تحليل كل نقطة تغيير بشكل منفصل.

أولاً: إضافة أو إزالة عناصر من dependentLoss و independentLoss

لماذا يُعد هذا التغيير مرجحاً؟

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

هذا النوع من التغيير شائع جداً في الأنظمة التي تعتمد على قواعد أعمال متغيرة أو نماذج حسابية تتطور مع الزمن.

الحل المقترح

بدلاً من تمرير كل قيمة على حدة داخل الـ constructor، يمكن تمرير قائمتين: واحدة للخسائر التابعة، وأخرى للخسائر المستقلة.

مثلاً، يمكن استبدال الصيغة الحالية بفكرة مشابهة للتالي:

public GrossToNetCalculator(
    IReadOnlyList<double> dependentLosses,
    IReadOnlyList<double> independentLosses)

بهذه الطريقة، إذا احتجنا إلى إضافة عنصر جديد ضمن dependentLosses مثلاً، فلن نضطر إلى تعديل الكلاس نفسه، بل يكفي تمرير القيمة الجديدة ضمن القائمة القادمة من الطبقة المستدعية.

الفوائد التقنية

  • تقليل الحاجة إلى تعديل الكلاس عند تغير عناصر الحساب.
  • تحسين قابلية التوسعة وإعادة الاستخدام.
  • تقليل تأثير التعديلات على الاختبارات المعقدة داخل الكلاس.
  • حصر التغيير غالباً في مكان إعداد البيانات فقط.

التنازلات والآثار الجانبية

  • يتم فقدان جزء بسيط من encapsulation لأن الكود المستدعي سيصبح مسؤولاً عن تحديد ما الذي يدخل في كل قائمة.
  • قد تزداد مسؤولية الطبقة الخارجية في تجميع المعطيات الصحيحة.
  • يحتاج تصميم الاستدعاء إلى وضوح جيد حتى لا تصبح القوائم غير مفهومة.

القرار الأنسب

هذا تغيير مرجح بدرجة عالية، ولذلك من المنطقي جعل الكود open له. في هذه الحالة، دعم التوسعة هنا يستحق التكلفة، لأنه يحل مشكلة واقعية ومتوقعة.

ثانياً: احتمال تغير معادلة GrossToNet

مدى احتمال التغيير

المعادلة الحالية هي:

GrossToNet = 1 - (1 - (dependentLoss + curtailmentLossGrid)) * (1 - independentLoss);

إذا استثنينا القيم المشتقة من الحسابين السابقين، نجد أن هذه المعادلة تعتمد بشكل مباشر على عنصر إضافي محدد هو curtailmentLossGrid. لذلك يبدو أن احتمال تغير هذه الصيغة أقل بكثير من احتمال تغير قائمة الخسائر نفسها.

حلول ممكنة

  1. تمرير lambda عبر الـ constructor لتتولى حساب GrossToNet اعتماداً على dependentLoss وindependentLoss.
  2. فصل منطق الدمج بالكامل وتحويل الكلاس إلى مكوّن عام مثل PercentageCombiner، ثم ترك التعديل الخاص بـ curtailmentLossGrid للكود المستدعي.
  3. إنشاء مكوّن عام لإعادة استخدام الدمج، ثم بناء GrossToNetCalculator فوقه كطبقة متخصصة.

تحليل المفاضلات

الخياران الأول والثاني يضعفان encapsulation بشكل واضح، لأنهما ينقلان جزءاً كبيراً من منطق الحساب إلى الخارج. أما الخيار الثالث فهو أكثر اتزاناً، لكنه يحتاج إلى وقت تصميم إضافي ويضيف طبقة abstraction جديدة.

القرار التقني

بما أن هذا التغيير غير مرجح كثيراً، فغالباً لا يستحق إعادة التصميم الآن. إلا إذا كان هناك استخدام آخر فعلي لمكوّن عام مثل PercentageCombiner، فعندها قد تصبح إعادة الهيكلة ذات قيمة حقيقية.

ثالثاً: هل يمكن أن تتغير آلية CombinePercentages؟

احتمال التغيير

الدالة الحالية:

double CombinePercentages(params double[] percentages)
{
    double combination = 1;
    foreach (var percentage in percentages)
        combination *= 1 - percentage;
    return 1 - combination;
}

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

حلول نظرية

  1. تمرير lambda إلى الـ constructor لتجميع النسب بدلاً من استخدام الدالة المباشرة.
  2. إنشاء abstraction مستقل مثل PercentageCombiner واستخدامه داخل الكلاس.

لماذا لا يكون ذلك مناسباً غالباً؟

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

القرار الواقعي

الأفضل هنا هو عدم المبالغة في التصميم. وإذا حدث تغيير فعلي في الخوارزمية مستقبلاً، فغالباً سيكون من الأسهل تعديل تنفيذ CombinePercentages مباشرة، بدلاً من بناء بنية معقدة استباقياً.

متى يفشل تطبيق Open-Closed رغم النية الجيدة؟

الخطأ الشائع هو الاعتقاد أن الالتزام بالمبدأ يعني تحويل كل جزء من الكود إلى واجهات وطبقات قابلة للاستبدال. هذا الفهم يقود أحياناً إلى:

  • تعقيد أكبر من الحاجة الفعلية.
  • انخفاض وضوح الكود للمطورين الجدد.
  • زيادة صعوبة تتبع منطق الأعمال.
  • إضعاف العزل الحقيقي بدلاً من تحسينه.

لذلك فإن التصميم الجيد لا يسعى إلى دعم كل الاحتمالات، بل يركز على التغييرات المرجحة وذات القيمة.

كيف تستفيد فرق التطوير من هذا المبدأ عملياً؟

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

  1. حدد أكثر التغييرات تكراراً أو توقعاً.
  2. قس أثر كل تغيير على الكود الحالي.
  3. ابحث عن أبسط تصميم يدعم هذا التغيير.
  4. تجنب أي abstraction لا يخدم حاجة فعلية.
  5. راجع القرار دورياً مع تطور المنتج.

هذا النهج يساعد على تطبيق Open-Closed Principle بصورة عملية ومتوازنة، بدلاً من التعامل معه كقاعدة جامدة.

الخاتمة

الالتزام بمبدأ Open-Closed ليس حالة ثنائية نقول فيها إن الكود صحيح أو خاطئ بشكل مطلق. في الواقع، هو قرار تصميمي يعتمد على الحكم الهندسي، وفهم السياق، وتقدير احتمالات التغيير. أحياناً يكون من الحكمة الاستثمار مبكراً في التوسعة، وأحياناً أخرى يكون التأجيل أفضل حتى تتضح الحاجة الحقيقية.

أفضل استخدام لهذا المبدأ هو أن نجعل الكود مرناً في الأماكن التي يُتوقع أن تتغير فعلاً، وأن نترك بقية الأجزاء بسيطة وواضحة وقابلة للفهم.

الخلاصة التقنية

من منظور هندسي، ينجح Open-Closed Principle عندما يُستخدم لدعم تغييرات متوقعة وعالية الاحتمال، وليس عندما يتحول إلى مبرر لإضافة طبقات تجريد في كل مكان. في المثال السابق، فتح الكود أمام تعديل قوائم الخسائر قرار ذكي لأنه يستجيب لتغيير واقعي ومتكرر، بينما تعميم خوارزمية CombinePercentages مسبقاً يُعد مبالغة غير ضرورية. الخلاصة: صمّم للتغيير المرجح، لا للتخمينات البعيدة.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *