شرح مبادئ SOLID - المبدأ الثالث Liskov Substitution Principle

0

قبل أن نقوم بشرح مبدأ الاستبدال Liskov Substitution Principle سنجري بعض التعديلات على برنامجنا لكي يكون شرح هذا المبدأ واضحا بشكل كبير.
في الأول قد تلاحظ معي أن الوظيفة Convert الموجودة في الكلاس NumberConverter تحتوي على العديد من الأوامر البرمجية التي يمكن إجراء Refactoring عليها، وإلا فإن شكل الوظيفة سيسوء مع ظهور متطلبات جديدة، ولك أن تتخيل إذا طلب منا العميل إقحام أنواع أخرى جديدة للتحويل إليها وكم من case ستصبح في Switch statement.

نموذج Factory Design Pattern
لذلك سنلجأ إلى حيلة ذكية تتمثل في تطبيق أحد نماذج التصميم Design Patterns والذي يسمى Factory ودوره هو إنشاء الأنواع Creating types، بحيث سنوكل له مهمة إنشاء الأنواع بدل أن نضعها داخل الوظيفة Convert.
لذلك تعالوا لنقم بإنشاء كلاس جديد نسميه ConverterFactory ونكتب فيه المحتوى التالي:

ConverterFactory.cs:

    public class ConverterFactory
    {
        public static Converter Create(BaseType baseType, int decimalNumber)
        {
            if (baseType == BaseType.Binary)
            {
                return new BinaryConverter(decimalNumber);
            }

            else if (baseType == BaseType.Octal)
            {
                return new OctalConverter(decimalNumber);
            }

            else if (baseType == BaseType.Hexadecimal)
            {
                return new HexadecimalConverter(decimalNumber);
            }
            else
            {
                return null;
            }
        }
    }
لاحظ أن هذا الكلاس يستقبل النوع المراد التحويل إليه ويقوم بإنشاء Object من النوع المناسب، وهي حيلة ذكية لتطبيق أحد أشهر مبادئ التصميم المعروف ب Inversion Of Control حيث قلبنا عملية إنشاء الأنواع من الكلاس NumberConverter إلى الكلاس ConverterFactory.

مبدأ قلب التحكم Inversion of Control Principle
ومبدأ قلب التحكم Inversion of Control ليس من ضمن مبادئ SOLID، لكنه يصب في نفس أهدافها، حيث نستعمله لجعل كل كلاس يقوم فقط بالمسؤولية المنوطة به، أما عملية إنشاء الأنواع Creating instances فهي مسؤولية إضافية يستحسن أن نقلب التحكم فيها من خلال توكيلها إلى نوع جديد عبر تطبيق نموذج التصميم Factory Design Pattern.
هذا باختصار هو مبدأ قلب التحكم Inversion of Control، الآن لنواصل عملنا، لكن قبل ذلك قد تلاحظ أن الكود الخاص ب ConverterFactory يحتوي على عدة if statement وهي عادة سيئة يستحسن تفاديها لأن كل if تقوم بإنشاء مسار تنفيذ Code Path خاصة بها مما يعني الحاجة إلى إنشاء Unit test خاص بكل حالة، أضف إلى ذلك صعوبة قراءة الكود، وطوله غير المبرر.
إذن سنلجأ إلى حيلة ذكية أخرى لتقليص الكود ولتفادي كتابة if statements أقصى ما نستطيع، والحل هذه المرة مع Reflection.

استعمال Reflection لإنشاء الأنواع
تسمح لنا Reflection بإنشاء الأنواع types بشكل ديناميكي وربطها مع أنواع موجودة عندنا في البرنامج، ولأن المقال يتضح بالمثال، فلا أرى أفضل من أن نشرح Reflection بالتطبيق.
سنغير محتوى الكلاس NumberFactory ليصبح كما يلي:

ConverterFactory.cs:

    public class ConverterFactory
    {
        public static Converter Create(BaseType baseType, int decimalNumber)
        {
            try
            {
            return (Converter)
                   Activator.CreateInstance(
                       Type.GetType($"SolidSample.{baseType.ToString()}Converter"),
                       new object[] { decimalNumber });

            }
            catch (Exception)
            {

                return null;
            }
        }
    }

إذا استثنينا الأمر try catch فإننا اختصرنا كل الكود في الأمر التالي:

Activator.CreateInstance(
Type.GetType($"SolidSample.{baseType.ToString()}Converter"), 
new object[] { decimalNumber }
);

الكود أعلاه يقوم بإنشاء instance من نوع محدد حسب BaseType المرسلة في argument، ويقوم بتمرير البرامترات التي تنتظرها هاته Instance ولأن الأنواع التي ترث من Converter كلها لديها Property باسم decimalNumber ينتظرها المشيد Constructor من أجل أن يقوم بإنشاء نسخة من النوع، فقد أرسلناه أيضا في عملية Reflection.
الأمر Type.GetType يستخرج نوع الكلاس من النص الممرر له، وفي حالتنا هذه سيكون اسم النوع هو SolidSample والذي هو اسم namespace ثم اسم نوع التحويل وهو القادم في baseType parameter ثم نقوم بدمجه مع الكلمة Converter لنحصل على اسم النوع كاملا والذي نريد إنشاء instance منه.
يعني لو مررنا إلى factory قيمة BaseType.Octal فإن الأمر Type.GetType سينشئ نوع من النص التالي:
SolidSample.OctalConverter
وهكذا دواليك.
الآن أنجزنا الكثير، بقي أن نقوم بتعديل طفيف على الوظيفة Convert لتستفيد من هذا الجهد الذي بذلناه، وبالتالي سيصير محتواها كما يلي:

NumberConverter.cs:

        public void Convert()
        {
            Logger.Log("Program is starting...");

            Logger.Log("Enter the number to convert:");
            DecimalNumber = Reader.ReadInteger();

            Logger.Log("Enter the base type (Ex: 2,8):");
            var baseType = (BaseType)Reader.ReadInteger();



            var type = ConverterFactory.Create(Base, DecimalNumber);

            string result = type.Convert();

            Logger.Log(result);

            Logger.Log("Program is ending..");
}

الآن لاحظ كيف صار شكلها جميلا ومتماسكا من خلال تطبيق مبدأ Inversion of Control ونموذج Factory Design Pattern.
أرجو أن تكون قد استوعبت هذا الفصل بشكل جيد، وإلا فإنك مطالب بالعودة إليه من أوله وقراءته وتطبيقه، لأن القادم يرتكز على الحالي.
إلى حدود الساعة قمنا فقط ب Refactoring للكود الموجود، ولم نتطرق بعد إلى شرح مبدأ Liskov Substitution Principle.
هذا المبدأ باختصار ينص على أن الوظائف التي تستعمل Object من نوع رئيسي Base type ينبغي أن تؤدي المطلوب منها حتى ولو تم استبدال هذا النوع الرئيسي بنوع فرعي يرث منه.
ما معنى هذا الكلام؟
تعالوا بنا نتناول مثالا نشرح من خلاله المسألة، وليكن ذلك أشهر مثال يتم به شرح مبدأ Liskov Substituion Principle وهو مشكلة Ellipse-Circle و Rectangle-Square.
هندسيا فالمربع عبارة عن مستطيل، أو بتعبير أدق حالة خاصة من المستطيل حيث يتساوى الطول والعرض.
برمجيا قد نرى أن المربع ممكن أن يكون Subtype / Derived من النوع الرئيسي: المستطيل.

مشكلة الدائرة والشكل البيضوي Circle-Ellipse Problem
نفس الكلام ينطبق على الدائرة والشكل البيضوي، فالشكل البيضوي هو شكل دائري، بينما الدائرة هي حالة خاصة من الشكل البيضوي له نفس الشعاع Radius.
وبالتالي فإن صيغة حساب مساحة الدائرة كما يلي:
AreaCircle = R * R * PI = R² * PI  (لأن الشعاع R هو نفسه في الدائرة فبالتالي كتبنا )
بينما مساحة الشكل البيضاوي تكون كما يلي:
AreaEllipse = Rx * Ry * PI (حيث Rx هو الشعاع الأول، و Ry هو الشعاع الثاني) والصورة التالية توضح بجلاء هذه العلاقات الرياضية:
حتى لا أكثر عليك الكلام، شاهد معي شكل الكلاس Ellipse:

Ellipse.cs:

    public class Ellipse
    {
        public double Rx { get; set; }
        public double Ry { get; set; }

        public virtual void SetRx(double value)
        {
            Rx = value;
        }
        public void SetRy(double value)
        {
            Ry = value;
        }
    }

لأن الدائرة Circle عبارة عن Ellipse فبرمجيا يمكننا أن نشتق الكلاس Circle من الكلاس Ellipse، كما يلي:

Circle.cs:

    public class Circle : Ellipse
    {
        public override void SetRx(double value)
        {
            base.SetRx(value);
            Ry = value;
        }
    }

على مستوى الكلاس Circle قمنا بإعادة تعريف الوظيفة SetRx لكي يصير Behavior الخاص بها مناسبا للدائرة، حيث كل من Rx و Ry سيأخذان نفس القيمة القادمة في argument.
الآن سننشئ كلاسا جديدا لحساب مساحة الأشكال الدائرية كما يلي:

AreaCalculator.cs:

    public class AreaCalculator
    {
        public double Area(Ellipse ellipse)
        {
            return ellipse.Rx * ellipse.Ry * Math.PI;
        }
    }

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

Program.cs:

            Ellipse ellipse = new Ellipse();

            ellipse.SetRx(5);
            ellipse.SetRy(4);

            AreaCalculator calculator = new AreaCalculator();

            // result = 5 * 4 * PI ==> 62.83 (True)
            double result = calculator.Area(ellipse);

أنشأنا شكلا بيضويا وأعطينا ل Rx القيمة 5، ول Ry القيمة 4،المفترض أن مساحة هذا الشكل ستكون هي 5 في 4 في الثابتة PI، أي أن النتيجة ستكون على التقريب 62.83، وهي نفس النتيجة التي يعطيها برنامجناـ أي أن العملية الحسابية تمشي بشكل جيد.
لكن المفاجأة ستكون حينما نتعامل مع الشكل الدائري Circle، ركز في المثال التالي:

Program.cs:

            Circle circle = new Circle();

            circle.SetRx(5);
            circle.SetRy(4);

            //result = 5 * 5 * PI ==> 78.54 But Actual = 62.83(False)
            result = calculator.Area(circle);

المفترض أن تكون مساحة الدائرة هي 5 في 5 (لأن الشعاع هو نفسه) في الثابتة الرياضية PI وبالتالي نتوقع أن يكون الناتج هو 78.54، فإذا بنا نفاجأ بالنتيجة 62.83 فما الذي حدث يا ترى؟
الذي حدث هو أن الكلاس Circle قامت بإعطاء Rx و Ry نفس القيمة 5 لكن الوظيفة SetRy أعطت ل Ry القيمة 4 وبالتالي تم حساب الدائرة كما لو أنها Ellipse.
هذا المشكل هو خرق لمبدأ Liskov Substitution Principle، الذي ينص على أن الفئات المشتقة Derived types ينبغي أن تعمل كالفئة الرئيسية Base type دون أن يؤثر ذلك على Behavior الخاص بالوظيفة التي تستعمله.

الحل الأول لمشكلة Circle-Ellipse Problem
لحل هذه الإشكالية يمكننا بكل بساطة أن نعيد تعريف SetRy على مستوى الكلاس Circle بنفس الشكل الذي أعدنا به تعريف SetRx أي:

Circle.cs:

        public override void SetRy(double value)
        {
            Ry = Rx;
        }
لاحظ أن كل من Rx و Ry صارت لهما نفس القيمة، لو نفذنا الآن سنحصل على نتيجة صحيحة لكن الكود الذي كتبنا ليس جيدا، لأنه لو تلاحظ معي فالوظيفة SetRy التي أعدنا تعريفها تستقبل برامتر value ولا تستعمله بتاتا.

الحل الثاني لمشكلة Circle-Ellipse Problem
الحل الثاني هو أن نعتبر Circle كلاسا مستقلا، لا يرث من الكلاس Ellipse وبالتالي في الكلاس AreaCalculator سنضطر إلى كتابة كود إضافي.
أي أن شكل الكلاس Circle سيكون كما يلي:
Circle.cs:

    public class Circle
    {
        public double Radius { get; set; }
        public void SetRadius(double radius)
        {
            Radius = radius;
        }
    }
وشكل الكلاس AreaCalculator سيكون كما يلي:

AreaCalculator.cs:

    public class AreaCalculator
    {
        public double Area(object shape)
        {
            if(shape is Ellipse)
            {
               return ((Ellipse)shape).Rx * ((Ellipse)shape).Ry * Math.PI;
            }

            else if (shape is Circle)
            {
                return ((Circle)shape).Radius * ((Circle)shape).Radius * Math.PI;
            }

            else
            {
                return 0;
            }
        }
    }

لكن هذا الحل يخرق مبادئ التصميم في عدة وجوه، أبرزها أنه يخرق مبدأ Liskov Substitution نفسه لأن التحقق من الأنواع بالشكل الذي اعتمدناه في الكلاس AreaCalculator يعد خرقا لمبدأ LSP، إضافة إلى سوء التصميم Bad design من خلال إنشاء فئتين لا علاقة بينهما، ونحن نعلم يقينا أن Circle هي عبارة عن Ellipse.

الحل الثالث لمشكلة Circle-Ellipse Problem
الحل الثالث أن نكتفي فقط بالكلاس Ellipse ونضيف إليه خاصية منطقية Boolean property نسميها مثلا IsCircle، وقبل أن نسند القيم في كل من SetRx و SetRy نتحقق من هذه الخاصية، إن كانت true نمرر القيم على أساس أننا نتعامل مع دائرة، وإن كانت false نمرر القيم على أساس أننا مع أي شكل دائري كما يبين المثال التالي:


Ellipse.cs:

    public class Ellipse
    {
        public double Rx { get; set; }
        public double Ry { get; set; }

        public bool IsCircle => Rx == Ry;

        public void SetRx(double value)
        {
            if (IsCircle)
            {
                Ry = value;
            }

            Rx = value;
        }

        public void SetRy(double value)
        {
            if (IsCircle)
            {
                Ry = Rx;
            }

            Ry = value;
        }
    }

لاحظ أن الخاصية IsCircle سهلت علينا العمل بشكل كبير، حيث تقارن Rx ب Ry فإن وجدت لديهما نفس القيمة أدركت أن الشكل عبارة عن دائرة Circle، والعكس بالعكس.
ثم داخل كل من SetRx و SetRy نقوم بإسناد القيم حسب قيمة IsCircle، الآن لو كتبنا البرنامج التالي:

Program.cs:

            Ellipse ellipse = new Ellipse();

            ellipse.SetRx(5);
            ellipse.SetRy(4);

            AreaCalculator calculator = new AreaCalculator();

            // Rx = 5; Ry = 4; Rx != Ry => IsCircle = False
            double result = calculator.Area(ellipse);

            ellipse.SetRx(5);
            ellipse.SetRy(5);

            // Rx = Ry = 5 => IsCircle = True
            result = calculator.Area(ellipse);

في المرة الأولى لدينا Rx  و Ry مختلفان إذن سيتم حساب الشكل البيضوي على أساس أنه ليس Circle، وفي المرة الثانية لأنهما متساويان سيتم حساب الشكل على أنه Circle، وهكذا نكون قد قمنا بحل المشكل بطريقة ذكية وبأقل كود حيث قمنا بالاستغناء عن الكلاس Circle.

لماذا نحتاج إلى مبدأ الاستبدال LSP
نحتاج إلى مبدأ Liskov Substitution Principle من أجل أن نضمن أن الفئات المشتقة تستطيع أن تتصرف كما لو أنها فئات رئيسية دون أن يؤثر ذلك على سلوك الوظيفة التي تستعمل هذا النوع من الفئات، وذلك لضمان أن الكائنات Objects المشتقة لا تؤثر على عمل الوظائف في حال استعمالها مكان الأنواع الرئيسية.

متى نحتاج إلى مبدأ الاستبدال LSP
نحتاج إلى مبدأ LSP حينما نجد بأن الأنواع الفرعية لا تتصرف بنفس الكيفية التي تتصرف بها الأنواع الرئيسية كما رأينا في موضوع Ellipse-Circle Problem.
كما نحتاج إلى تفعيل هذا المبدأ حينما نقوم بتطبيق واجهة Interface في كلاس معين، فنلاحظ أن العديد من الوظائف لم تأخذ Implementation وبالتالي  ستعطي استثناء من نوع NotImplementedException.
في الأماكن التي نستعمل فيها بعض الروابط التحقق من النوع مثل الرابط is أو رابط التحويل as فهنالك احتمالية كبيرة أن يكون هنالك انتهاك لمبدأ LSP.
لذلك حينما ترى مؤشرا من هذه المؤشرات فأنت مطالب بجعل الكود الخاص بك موافقا لمبدأ Liskov Substitution Principle لتتفادى المشاكل الناتجة.

كيف نطبق مبدأ الاستبدال LSP
لنعد إلى مثالنا الرئيسي وهو برنامج NumberConverter، وتحديدا إلى الكلاس ConverterFactory:

ConverterFactory.cs:

    public class ConverterFactory
    {
        public static Converter Create(BaseType baseType, int decimalNumber)
        {
            try
            {
            return (Converter)
                   Activator.CreateInstance(
                       Type.GetType($"SolidSample.{baseType.ToString()}Converter"),
                       new object[] { decimalNumber });

            }
            catch (Exception)
            {

                return null;
            }
        }
    }

لاحظ أن الكود أعلاه عند حدوث استثناء في عملية reflection سيقوم بإرجاع null، مما يعني أن الوظيفة Convert لن تشتغل بالشكل المطلوب وستعطي الخطأ الشهير:

System.NullReferenceException: 'Object reference not set to an instance of an object.'

 

والصورة أعلاه من غير شك قد صادفتها أكثر من مرة :)
تمام، الآن الخطأ هنا وارد جدا، وبالتالي إرجاع null ممكن أن يحدث مما قد يوقف برنامجنا، يمكن أن يخطر ببالك حل يتمثل في التحقق من القيمة المرجعة بواسطة ConverterFactory قبل أن تستدعي الوظيفة Convert كما يلي:

            if (type != null)
            {
                result = type.Convert();
            }

الحل سيعمل من غير شك، لكن تذكر أن التحقق من null فيما يعرف ب null checks هو انتهاك لمبدأ LSP لأنها مثل التحقق من الأنواع باستعمال الذي رأيناه في الحل الثاني لموضوع Ellipse-Circle Problem.
وبالتالي فإن الحل مرة أخرى هنا، هو أن نفكر في أحد نماذج التصميم Design Patterns التي يمكننا استعمالها لحل المشكل وفي نفس الوقت تفادي انتهاك مبدأ LSP.
والحل هذه المرة سيكون مع Null Object Design Pattern.

نموذج Null Object Design Pattern
يسمح لنا هذا النموذج بحل المشاكل المتعلقة ب Null checks عبر إنشاء نوع جديد يتفاعل في الحالات التي تولد NullReferenceException، كما هو الحال في الكلاس ConverterFactory التي من الممكن أن تتسبب في هذا الاستثناء إذا تم تمرير نوع غير صحيح للأمر GetType، وبالتالي سيتوقف البرنامج جراء هذا الاستثناء.
لتفعيل نموذج Null Object يكفي أن ننشئ نوعا جديدا يرث من الكلاس الرئيسي Converter ثم في حال حصول استثناء نقوم بإرجاع هذا النوع.
فيما يلي الكود الخاص بهذا الكلاس والذي سنسميه مثلا InvalidBaseConverter:

InvalidBaseConverter.cs:

    public class InvalidBaseConverter : Converter
    {
        public InvalidBaseConverter(int decimalNumber):base(decimalNumber)
        {
        }

        public override string Convert()
        {
            return $"This base type is not a valid base.";
        }
    }

ثم بعد ذلك نأتي إلى الكلاس ConverterFactory وأسفل الجزء catch نقوم بإرجاع instance من هذا النوع الجديد كما يلي:

ConverterFactory.cs:

    public class ConverterFactory
    {
        public static Converter Create(BaseType baseType, int decimalNumber)
        {
            try
            {
                return (Converter)
                       Activator.CreateInstance(
                           Type.GetType($"SolidSample.{baseType.ToString()}Converter"),
                           new object[] { decimalNumber });

            }
            catch (Exception)
            {

                return new InvalidBaseConverter(decimalNumber);
            }
        }
    }

بعد هذا التعديل صار الكود منظما وقمنا بتطبيق مبدأ LSP بنجاح وتفادينا القيام ب Null checks في برنامجنا.
يمكن لمبدأ LSP أن ينتهك أيضا مع مفهوم الواجهات Interfaces كما ذكرنا في وقت سابق، حينما تكون عندنا واجهة تحتوي على عدة وظائف، بينما الكلاس الذي يقوم بتطبيقها يستعمل فقط بضعة وظائف منها، مما يعني أن استدعاء باقي الوظائف سيولد NotImplementedException.
وهذا الأمر يمكن حله بالاعتماد على المبدأ الرابع Interface Segregation Principle والذي سنراه في الفصل المقبل إن شاء الله.

خلاصة مبدأ الاستبدال LSP
مبدأ LSP يسمح لنا بجعل الكود الخاص بنا more cleaner، كما يسمح بجعل الكود الخاص بنا موثوقا فيه لأن تطبيقه يعني أن الأنواع الفرعية subtypes ستتصرف كما لو أنها base type، مما يعني أن behavior الخاص بالوظيفة التي تستدعي object من النوع الرئيسي لن يتأثر في حال استبدال هذا object ب object آخر من نوع فرعي.

لا يوجد تعليقات

أضف تعليق