LSP (Liskov Substitution Principle) — المبدأ الثالث من SOLID، الذي يحدد شروط الوراثة الصحيحة في البرمجة كائنة التوجه. صيغ المبدأ بواسطة باربرا ليسكوف عام 1987 وتم ترشيده كالتالي: إذا كان S نوعًا فرعيًا من T، فإن كائنات من النوع T يمكن استبدالها بكائنات من النوع S دون تغيير خصائص البرنامج. كما ورد في كتاب روبرت مارتين Clean Architecture (2017)، مبدأ الاستبدال يتطلب أن لا يُضعف الصف الفرعي عقد الصف الأساسي.
النقاط الرئيسية
LSP (Liskov Substitution Principle) — مبدأ الاستبدال الذي صاغته باربرا ليسكوف في مؤتمر OOPSLA عام 1987. التعريف الرسمي: ليكن q(x) خاصية قابلة للإثبات لكائنات x من النوع T. إذن يجب أن تكون q(y) قابلة للإثبات لكائنات y من النوع S، حيث S هو نوع فرعي من T. بعبارة أبسط: يجب أن تتصرف كائنات الصف الفرعي بحيث يستمر الكود الذي يعمل مع الصف الأساسي في العمل بشكل صحيح مع الصف الفرعي أيضًا.
في الممارسة، يعني LSP أن الصف الفرعي لا يجب أن ينتهك العقد للصف الأساسي. يتضمن العقد الشروط السابقة (ما هو مطلوب لاستدعاء دالة)، والشروط اللاحقة (ما هو مضمون بعد الاستدعاء)، والخواص الثابتة (شروط تستمر طوال عمر الكائن). قد تعزز الصف الفرعي الشروط السابقة أو تضعف الشروط اللاحقة — وهذا هو انتهاك LSP.
مثال كلاسيكي لانتهاك LSP — مربع يرث من مستطيل. تقوم دالة setWidth في المستطيل بتعيين العرض، أما في المربع فتعين كلا العرض والارتفاع. يحصل العميل الذي يتوقع سلوك المستطيل (تغيير جانب واحد لا يؤثر على الآخر) على نتيجة غير متوقعة. المربع ليس نوعًا فرعيًا صالحًا للمستطيل.
يحدد LSP ثلاثة شروط للوراثة الصحيحة: لا يمكن أن تكون الشروط السابقة للصف الفرعي أقوى من الشروط السابقة للصف الأساسي (الصف الفرعي لا يطلب المزيد)، ولا يمكن أن تكون الشروط اللاحقة للصف الفرعي أضعف من الشروط اللاحقة للصف الأساسي (الصف الفرعي يضمن لا أقل)، ويجب الحفاظ على الخواص الثابتة للصف الأساسي في الصف الفرعي. تعرف هذه الشروط باسم «قاعدة التصميم بالعقد» وفقًا لبيرتراند ماير.
إذا تم انتهاك شرط واحد على الأقل، فإن الكود المستخدم للتعدد الشكلي قد يفشل. لا يتحقق المجمع من العقود الدلالية، بل النحوية فقط. لذا، LSP هو مسألة انضباط معماري، ليس تحديد الأنواع الثابت.
آلية LSP تقوم على التوافق السلوكي للأنواع. إذا كانت الصف S ترث من الصف T، فيجب أن يتمكن للكود العميل من استخدام S حيثما يتوقع T دون تغيير سلوكه. يشمل ذلك ليس فقط توقيعات الدوال ولكن أيضًا دلالاتها.
LSP لا يمنع الصف الفرعي من إضافة سلوك جديد. ولكن منع انتهاك توقعات الكود المكتوب للصف الأساسي. إذا ضمن الصف الأساسي أن الدالة save لا ترمي استثناءات، فلا يجب على الصف الفرعي رميها. إذا كان الصف الأساسي يرجع قيمة غير سالبة، فلا يجب على الصف الفرعي إرجاع قيمة سالبة.
في المشاريع الحقيقية، يتم انتهاك LSP في أغلب الأحيان عند إضافة منطق شرطي في دوال الصف الفرعي: «إذا كان شرط — ارم استثناء»، «إذا كان شرط — أرجع null». كل واحدة من هذه ال«مفاجآت» تقوض التعدد الشكلي وتجبر الكود العميل على التحقق من نوع الكائن قبل الاستدعاء — ما يتناقض مع فكرة التصميم كائن التوجه.
في المشاريع المحمولة، يحدث انتهاك نموذجي لـ LSP عند إنشاء ViewModels أساسية. إذا ضمن BaseViewModel أن دالة onCleared تحرر جميع الموارد، وقام صف فرعي بإعادة تعريف هذه الدالة كدالة فارغة — فإن أي كود يعتمد على تنظيف الموارد من خلال استدعاء متعدد الأشكال لـ onCleared سيعمل بشكل غير صحيح. يتطلب LSP أن يستدعي الصف الفرعي super.onCleared() أو يقوم بنفس العمل نفسه. التركيب عبر LifecycleObserver — بديل يلغي انتهاك LSP في إدارة دورة الحياة.
المؤشرات الرئيسية لانتهاك LSP تشمل: التحقق من نوع الكائن عبر instanceof أو is قبل استدعاء دالة، تنفيذات فارغة للدوال (stubs)، رمي NotImplementedError أو UnsupportedOperationException، إرجاع null بدلاً من قيمة. كل نمط من هذه الأنماط يشير إلى أن الصف الفرعي ليس نوعًا فرعيًا صالحًا.
علامة شائعة أخرى — الوراثة بهدف إعادة استخدام الكود بدلاً من نمذجة علاقة «يُعدّ» (is-a). تحتوي الصف Bird على دالة fly(). ترث الصف Penguin من Bird وتعيد تعريف fly() كدالة فارغة أو مانعة. هذا انتهاك لـ LSP: البطريق ليس نوعًا فرعيًا صالحًا للطائر.
في تطوير التطبيقات المحمولة، يتم انتهاك LSP عند إنشاء كلاسات أساسية لـ ViewHolder، Fragment أو ViewController بدوال وهمية. إذا كان الصف الفرعي لا يستخدم نصف دوال الصف الأساسي — فقد اختير الوراثة بشكل غير صحيح. التركيب أو تجزئة الواجهات تحل المشكلة بشكل أصح.
اختبار بسيط للتحقق من LSP: اكتب اختبار وحدة للصف الأساسي يتحقق من عقده (القيم المعادة، الاستثناءات، الآثار الجانبية). شغل هذا الاختبار لكل صف فرعي. إذا فشل الاختبار — فقد انتهك LSP. يسمى هذا النهج «الاختبار من خلال عقد الصف الأساسي».
في مشاريع Android، يكون هذا الاختبار مفيدًا لـ ViewModel و Repository. إذا ضمن BaseViewModel حالة Loading قبل الخطأ، وقام صف فرعي برمي خطأ بدون Loading — سيكتشف الاختبار انتهاك LSP في مرحلة CI.
لننظر إلى مثال Android مع معالجة ClickListener. ينشأ انتهاك LSP عندما تضمن التنفيذ الأساسي شيئًا وينتهكه الصف الفرعي.
// صف أساسي مع ضمان: سيتم استدعاء onClick
open class BaseClickListener {
open fun onClick(view: View) {
// معالجة أساسية
}
}
// انتهاك LSP: الصف الفرعي يضيف شرطًا يرمي استثناء
class RestrictedClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (!isLoggedIn) {
throw IllegalStateException("Not logged in")
}
super.onClick(view)
}
}
// حل صحيح: العقد لم ينتهك
class ConditionalClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (isLoggedIn) {
super.onClick(view)
}
}
}
مثال iOS مع بروتوكول DataSource يوضح انتهاك LSP عبر إرجاع nil بدلاً من البيانات:
// بروتوكول مع عقد: يعيد البيانات أو الخطأ
protocol DataProvider {
func fetchData() async throws -> [String]
}
// انتهاك LSP: يعيد nil بدون خطأ
class SilentFailProvider: DataProvider {
func fetchData() async throws -> [String] {
return [] // مصفوفة فارغة بدل الخطأ
}
}
// امتثال صحيح لـ LSP
class NetworkProvider: DataProvider {
func fetchData() async throws -> [String] {
throw NetworkError.timeout
}
}
قاعدة عملية: إذا كان الصف الفرعي لا يستطيع تنفيذ عقد الصف الأساسي، فلا يجب أن يكون صفًا فرعيًا. البديل — استخلاص واجهة بعقد أدنى وتنفيذها في كل نوع بطريقته الخاصة.
التركيب أفضل من الوراثة في المواقف حيث تكون علاقة «يُعدّ» (is-a) غامضة أو مشروطة. مثال كلاسيكي: هل Manager هو Employee؟ نعم. ولكن هل Square هو Rectangle صالح؟ LSP يقول «لا». إذا كنت تشك في صحة الوراثة — اختر التركيب.
في تطوير التطبيقات المحمولة، يتم استخدام التركيب عادة من خلال حقن التبعيات: بدلاً من وراثة السلوك من صف أساسي، يتلقاه الصف من خلال الباني. لا ترث ViewModel من Repository بل تقبله كتبعية. هذا يلغي انتهاك LSP بالتعريف — لا وراثة، فلا انتهاك للعقد.
علامات أن الوراثة يجب استبدالها بالتركيب: الصف الفرعي لا يستخدم بعض دوال الصف الأساسي، الصف الفرعي يعيد تعريف الدوال بالتنفيذات الفارغة، الكود العميل يتحقق من نوع الكائن عبر instanceof. في هذه الحالات، اختير الوراثة بشكل غير صحيح وتم انتهاك LSP.
الواجهات تحل مشكلة LSP بدون وراثة: كل نوع ينفذ تمامًا الدوال التي يحتاجها. بدلاً من صف أساسي مشترك Bird بدالة fly() (حيث لا يطير Penguin) — واجهة Flyable ينفذها الطيور فقط. ينفذ Penguin واجهة Bird بدون دالة fly() — LSP لم ينتهك.
في معمارية Android، يتم تطبيق هذا النهج من خلال واجهات UseCase: بدلاً من UseCase كبير واحد بدوال getAll، getById، save، delete — واجهات منفصلة GetItemsUseCase، SaveItemUseCase. يعتمد العميل فقط على الواجهة المطلوبة، وأي صف ينفذ تلك الواجهة يكون صحيحًا من منظور LSP.
الأسئلة الشائعة
الوراثة هي آلية لغوية؛ LSP هو قاعدة لاستخدام تلك الآلية بشكل صحيح. الوراثة تضمن توافق التوقيعات (النحو)؛ LSP يتطلب توافق السلوك (الدلالة). الوراثة بدون LSP تعطي تعددًا شكليًا ينهار في وقت التنفيذ.
إذا كان الصف الأساسي يضمن عدم الإرجاع بقيمة null — نعم. إذا كان العقد يسمح بـ null (قيمة اختيارية) — لا. LSP لا يمنع null، بل يمنع إضعاف العقد. ادرس وثائق الصف الأساسي وتحقق من توافق عقد الصف الفرعي.
على البروتوكولات ينطبق LSP بنفس الطريقة التي ينطبق بها على الكلاسات. يجب أن تلتزم تنفيذات البروتوكول بالعقد الدلالي: إذا عرف البروتوكول دالة على أنها non-throwing، فلا يجب أن ترمي التنفيذ أخطاء. Swift لا يتحقق من ذلك عند التجميع — المسؤولية تقع على عاتق المطور.
Sealed class في Kotlin هي حالة خاصة لأن التسلسل مغلق ومعروف للمجمع. LSP ينطبق على sealed class بدرجة أقل لأن جميع الأنواع الفرعية مدرجة صراحة في تعبير when. خطأ الصف المغلق سيكون محليًا لا خطأ متعدد الأشكال مخفيًا.
اكتب اختبارًا معممًا للصف الأساسي يتم تشغيله لجميع الصفوف الفرعية. يتحقق الاختبار من العقود السلوكية الرئيسية: القيم المعادة، الاستثناءات، الحالات. إذا فشل الاختبار في إحدى الصفوف الفرعية — فقد انتهك LSP. في CI، مثل هذا الاختبار يمنع الانتكاس في الكود متعدد الأشكال.
الملخص
سنقوم بتطوير تطبيق جوال جاهز
تقدم IT Sectr تطبيقات iOS وAndroid للشركات الناشئة والشركات منذ عام 2017. سوف نقدم لك النصح ونقترح أفضل حل.