مرة أخرى حول مبدأ استبدال Lisk ، أو دلالات الميراث في OOP

الوراثة هي واحدة من أركان OOP. يستخدم الوراثة لإعادة استخدام الكود الشائع. لكن الكود الشائع ليس ضروريًا دائمًا لإعادة الاستخدام ، والميراث ليس دائمًا أفضل طريقة لإعادة استخدام الكود. غالبًا ما يتضح ، بحيث يكون هناك كود مشابه في جزأين مختلفين من التعليمات البرمجية (الفئات) ، ولكن متطلباتها مختلفة ، أي الطبقات ترث في الواقع من بعضها البعض وربما لا يستحق كل هذا العناء.

عادةً ، لتوضيح هذه المشكلة ، يستخدمون مثالًا عن وراثة الفئة المربعة من فئة المستطيل ، أو العكس.

دعنا لدينا فئة مستطيل:

class Rectangle: def __init__(self, width, height): self._width = width self._height = height def set_width(self, width): self._width = width def set_height(self, height): self._height = height def get_area(self): return self._width * self._height ... 

الآن أردنا كتابة فئة Square ، ولكن لإعادة استخدام رمز حساب المنطقة ، يبدو من المنطقي أن نرث Square من المستطيل:

 class Square(Rectangle): def set_width(self, width): self._width = width self._height = width def set_height(self, height): self._width = height self._height = height 

يبدو أن كود فئتي Square و Rectangle ثابت. يبدو أن المربع يحتفظ بالخصائص الرياضية للمربع ، أي ومستطيل. هذا يعني أنه يمكننا تمرير كائنات مربعة بدلاً من المستطيل.

ولكن إذا فعلنا ذلك ، يمكننا انتهاك سلوك فئة المستطيل:

على سبيل المثال ، هناك رمز عميل:

 def client_code(rect): rect.set_height(10) rect.set_width(20) assert rect.get_area() == 200 

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

إذا كانت الفئة المربعة هي سليل فئة المستطيل ، ثم العمل مع المربع وإجراء طرق المستطيل ، يجب ألا نلاحظ أنه ليس مستطيلًا.

يمكنك حل هذه المشكلة ، على سبيل المثال ، مثل هذا:

  1. تأكد من مطابقة الفصل تمامًا ، أو حدد ما إذا كان سيعمل بشكل مختلف لفئات مختلفة
  2. في Square ، قم بإجراء الأسلوب set_size () وتجاوز set_height ، وطرق set_width بحيث يرمون الاستثناءات
    الخ

ستعمل هذه الشفرة وهذه الفئات ، بمعنى أن الشفرة ستعمل.

سؤال آخر هو أن رمز العميل الذي يستخدم فئة Square أو فئة Rectangle سيحتاج إلى معرفة إما عن الفئة الأساسية وسلوكها ، أو عن الفئة المنحدرة وسلوكها.

مع مرور الوقت ، يمكننا الحصول على ما يلي:

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

اتضح أن كود العميل المكتوب للفئة الأساسية يصبح يعتمد على تطبيق الفئة الأساسية والفئة النازل. مما يعقد التنمية إلى حد كبير مع مرور الوقت. تم إنشاء OOP فقط بحيث يمكنك تحرير الفئة الأساسية والفئة المنحدرة بشكل مستقل عن بعضها البعض.

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

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

وفي القانون الصناعي الحقيقي ، يوصى بشدة باتباع هذا المبدأ والالتزام بمعاني دلالات الميراث الموصوفة. ومع هذا المبدأ هناك العديد من التفاصيل الدقيقة.

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

إذا كانت فئة Rectangle لا تتضمن سوى طريقتين - حساب المساحة والعرض ، دون إمكانية إعادة الرسم والتحجيم ، في هذه الحالة ، فإن Square مع مُنشئ مُتجاوز سيتم تلبية مبدأ Lisky الخاص بالاستبدال.

أي هذه الفئات تلبي مبدأ الاستبدال:

 class Rectangle: def draw(): ... def get_area(): ... class Square(Rectangle): pass 

على الرغم من أن هذا بالطبع ليس رمزًا جيدًا جدًا ، بل وربما ، منضد تصميم الطبقة ، ولكنه من وجهة نظر رسمية يفي بمبدأ Liskov.

مثال آخر المجموعة هي نوع فرعي لمجموعة متعددة. هذه هي نسبة تجريد المجال. ولكن يمكن كتابة الكود حتى نرث فئة Set من Bag ويتم انتهاك مبدأ الاستبدال ، أو يمكننا الكتابة بحيث يتم احترام المبدأ. مع نفس دلالات المجال الموضوع.

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

لا يتم تثبيت القيود ، والمتغيرات ، وعقد الفئة الأساسية في الكود ، ولكن يتم إصلاحها في رؤوس المطورين الذين يقومون بتحرير الكود وقراءته. ما هو "كسر" ، ما هو كسر "العقد" يتم تحديده ليس عن طريق الكود ، ولكن عن طريق دلالات الفئة في رأس المطور.

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

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

على سبيل المثال ، يحتوي المستطيل على طريقة أخرى تُرجع طريقة عرض في json

 class Rectangle: def to_dict(self): return {"height": self.height, "width": self.width} 

وفي سكوير ، نعيد تعريفها:

 class Square: def to_dict(self): return {"size": self.height} 

إذا أخذنا في الاعتبار أن العقد الأساسي لسلوك الفئة Rectangle to_json يكون له الطول والعرض ، فإن الكود

 r = rect.to_dict() log(r['height'], r['width']) 

ستكون ذات معنى لكائن مستطيل الفئة الأساسية. عند استبدال كائن من فئة أساسية بفئة ، يغير رمز ولي العهد المربع سلوكه وينتهك العقد ، وبالتالي ينتهك مبدأ استبدال Lisk.

إذا اعتقدنا أن العقد الأساسي لسلوك الفئة Rectangle هو أن تعيد إرجاع قاموس يمكن تسلسله دون وضع حقول محددة ، عندها ستكون طريقة to_dict هذه مقبولة.

بالمناسبة ، هذا مثال جيد ، يدمر الأسطورة القائلة بأن الثبات يحفظ من انتهاك المبدأ.

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

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

عندما يستخدم أحد مطوري البرامج تعليمات برمجية مكتوبة بواسطة مطورين آخرين ، يمكنه فهم ماهية دلالات الفصل الدراسي بشكل مباشر فقط عن طريق الكود وأسماء الطرق والوثائق والتعليقات. لكن على أي حال ، غالبًا ما تكون الدلالات هي مجال بشري ، وبالتالي فهي خاطئة. النتيجة الأكثر أهمية: فقط عن طريق الشفرة - بناءً على جملة - هي أنه من المستحيل التحقق من الامتثال لمبدأ Liskov ، ويجب عليك الاعتماد (في كثير من الأحيان) على دلالات غامضة. لا توجد وسيلة (رياضية) رسمية لطريقة يمكن التحقق منها ومضمونة للتحقق من الكتابة السلوكية القوية.

لذلك ، في كثير من الأحيان بدلاً من مبدأ Liskov ، يتم استخدام القواعد الرسمية للشروط المسبقة والشروط التالية من برمجة العقود:

  • لا يمكن تعزيز الشروط المسبقة في فئة فرعية - يجب ألا تتطلب الفئة الفرعية أكثر من الفئة الأساسية
  • لا يمكن استرخاء الشروط البريدية للفئة الفرعية - يجب ألا توفر الفئة الفرعية (الوعد) أقل من الطبقة الأساسية
  • يجب الحفاظ على ثوابت الفئة الأساسية في الفئة الهبوطية.

على سبيل المثال ، في طريقة الصف التنازلي ، لا يمكننا إضافة معلمة مطلوبة لم تكن في الفئة الأساسية - لأن هذه هي الطريقة التي نعزز بها الشروط المسبقة. أو لا يمكننا رمي الاستثناءات في الطريقة الغالبة ، لأن تنتهك المتغيرات من الطبقة الأساسية. إلخ

ما يهم ليس السلوك الحالي للفصل ، ولكن التغييرات في الفصل تعني مسؤولية أو دلالات الفصل.

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

لنفترض أن هناك مطورًا لفئة مكتبة Rectangle ، ومطور تطبيق يرث Square من Rectangle. في الوقت الذي ورث فيه مطور التطبيق Square من Rectangle - كان كل شيء على ما يرام ، فقد استوفت الفئات مبدأ الاستبدال.

وفي مرحلة ما ، أضاف المطور المسئول عن المكتبة طريقة إعادة التشكيل أو set_width / set_height إلى الفئة الأساسية المستطيل. من وجهة نظره ، حدث امتداد للطبقة الأساسية. ولكن في الواقع ، كان هناك تغيير في الدلالات والعقود التي تعتمد عليها الفئة المنحدرة. الآن الطبقات لم تعد تفي بهذا المبدأ.

بشكل عام ، عند التوارث في OOP ، فإن التغييرات في الفئة الأساسية التي تبدو امتدادًا للواجهة - ستتم إضافة طريقة أو حقل آخر قد تنتهك العقود "الطبيعية" السابقة ، وبالتالي تتغير فعليًا الدلالات أو المسؤوليات. لذلك ، يعد إضافة أي طريقة إلى الفئة الأساسية أمرًا خطيرًا. يمكنك بطريق الخطأ تغيير العقد.

ومن وجهة نظر عملية ، في المثال مع مستطيل وفئة ، من المهم ما إذا كان هناك الآن طريقة إعادة تشكيل أو set_width / set_height. من وجهة نظر عملية ، من المهم مدى ارتفاع احتمالية حدوث مثل هذه التغييرات في كود المكتبة. هل يعني دلالات أو حدود المسؤولية الطبقية مثل هذه التغييرات. إذا كان ذلك ضمنيًا ، فإن احتمال حدوث خطأ و / أو مزيد من الحاجة لإعادة بناء المساكن يزداد بشكل كبير. وإذا كان هناك احتمال ضئيل ، فمن الأفضل ألا يرث مثل هذه الفئات من بعضها البعض.

من الصعب الحفاظ على تعريفات الأنواع الفرعية بناءً على السلوك ، حتى بالنسبة للفصول البسيطة ذات الدلالات الواضحة ، ألا نذكر شيئًا عن المشروع ذي المنطق التجاري المعقد. على الرغم من حقيقة أن الطبقة الأساسية وفئة الوريث جزءان مختلفان من التعليمات البرمجية ، فبالنسبة لهما ، يجب عليك التفكير بعناية وعناية في الواجهات والمسؤولية. وحتى مع حدوث تغيير طفيف في دلالات الفصل - والتي لا يمكن تجنبها بأي شكل من الأشكال ، يتعين علينا إلقاء نظرة على رمز الفئات ذات الصلة ، والتحقق لمعرفة ما إذا كان العقد الجديد أو الثابت يخالف ما هو مكتوب بالفعل (!) واستخدامه. مع أي تغيير تقريبًا في التسلسل الهرمي لفئة الفروع ، نحتاج إلى البحث والتحقق من الكثير من الأكواد الأخرى.

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

في الإنصاف ، هناك بعض القواعد التي من المحتمل ألا تنتهك مبدأ الاستبدال. يمكنك حماية نفسك قدر الإمكان إذا حظرت جميع الهياكل الخطرة. على سبيل المثال ، لـ C ++ ، كتب Oleg عن هذا. لكن بشكل عام ، هذه القواعد لا تحول الطبقات إلى فئات بالمعنى الكلاسيكي.

باستخدام الأساليب الإدارية ، فإن المهمة ليست كذلك بشكل جيد للغاية. هنا يمكنك قراءة كيف فعل العم مارتن في C ++ وكيف لم ينجح.

ولكن في القانون الصناعي الحقيقي ، في كثير من الأحيان ، يتم انتهاك مبدأ Liskov ، وهذا ليس مخيفًا . من الصعب اتباع هذا المبدأ ، لأنه 1) غالبًا ما تكون مسؤولية الفصل الدراسي ودلالاته غير واضحة ولا يتم التعبير عنها في التعليمات البرمجية 2) يمكن تغيير مسؤولية الفصل الدراسي - سواء في الفئة الأساسية أو في الفصل المنحدر. ولكن هذا لا يؤدي دائما إلى بعض العواقب الرهيبة حقا. الانتهاك الأكثر شيوعًا والأبسط والأكثر أساسية هو أن الطريقة الغالبة تعدل السلوك. كما هو الحال على سبيل المثال هنا:

 class Task: def close(self): self.status = CLOSED ... class ProjectTask(Task): def close(self): if status == STARTED: raise Exception("Cannot close a started Project Task") ... 

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

في الواقع ، في هذه الحالة ، ينظر المطور إلى الميراث ليس كتطبيق لعلاقة "IS" ، ولكن ببساطة كوسيلة لإعادة استخدام الكود. أي الفئة الفرعية هي مجرد فئة فرعية ، وليست نوعًا فرعيًا. في هذه الحالة ، من وجهة نظر عملية وعملية ، فإن الأمر أكثر أهمية - ولكن ما هو احتمال وجود أو بالفعل وجود رمز عميل يلاحظ دلالات مختلفة لأساليب الفئة المنحدرة والفئة الأساسية؟

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

متى يؤدي انتهاك LSP إلى مشاكل كبيرة؟ عندما ، بسبب الاختلافات في السلوك ، سيتعين إعادة كتابة رمز العميل بالتغييرات في الفئة المنحدرة والعكس. هذا يصبح مشكلة خاصة إذا كان رمز العميل هذا رمز مكتبة لا يمكن تغييره. إذا كان إعادة استخدام الرمز لن يكون قادرًا على إنشاء تبعيات بين رمز العميل ورمز الفصل في المستقبل ، فحتى رغم انتهاك مبدأ استبدال Liskov ، فإن هذا الرمز قد لا يسبب مشاكل كبيرة.

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

Source: https://habr.com/ru/post/ar463385/


All Articles