مبدأ الـ Liskov Substitution
السلام عليكم ورحمة الله وبركاته
يمكنك متابعة السلسلة بالترتيب أو الانتقال مباشرة إلى أي مقال:
المقدمة
مبدأ الـ Liskov Substitution هو المبدأ الثالث من مبادئ الـ SOLID
هذا المبدأ يقوم بتعريف وتحسين كيف ومتى نقوم بعملية الوراثة بشكل صحيح
أو كيف نقوم ببناء والتعامل مع أي الـ interface بطريقة صحيحة
بمعنى أن مبدأ الـ Liskov جاء ليعلمنا كيف نستعمل الوراثة في الكلاسات والـ interface بشكل صحيح
ينص مبدأ الـ Liskov Substitution على أنه إذا كان لديك كلاس أساسي وكلاسات ترث منه أو تقوم ببناءه، فيجب أن يكون الـ object من الكلاس الأساسي قابل للتبديل مع أي object من الكلاسات التي ترثه أو تبنيه دون نقصان
قد تقول لي الآن بأن هذا ما يحدث عندما يرث كلاس ما كلاس آخر فأنه سوف يرثه دون نقصان
هذا ما نراه في مفهوم الـ Inheritance بالفعل أو عندما نقوم بعمل implement لـ interface، إذًا أين المشكلة ؟
المشكلة أنه أحيانًا قد تجد كلاس يرث من كلاس معين لكن لا يستطيع تنفيذ دالة موجودة في الكلاس الأساسي
أو لا يستطيع عمل override لها لانه لا يمتلك الوظيفة التي تقدمها هذه الدالة
فمثلا لو كان لديك Interface يدعى IBird يمثل الطيور وبه دوال عديدة ومنها دالة fly
ثم قمنا بإنشاء كلاس يدعى Penguin أي بطريق وجعلناه يقوم بعمل implement للـ IBird
هنا ستجد أن الـ Penguin لا يستطيع تنفيذ دالة fly التي يقدمها الـ IBird لأن البطريق لا يستطيع الطيران
إذا كان الـ object لا يقوم بعمل implementation لدالة واحدة من الـ Interface فهذا يدل أن نوع هذا الـ object لا يستحق أن يكون تابع لهذا الـ Interface
إذا فتلك العلاقة ما بين الـ Penguin والـ IBird لا تتبع مبدأ الـ Liskov Substitution لأن الـ Penguin لا يستطيع تنفيذ دالة fly التي يقدمها الـ IBird
أن ننشيء interface مخصص للطيور التي تطير IFlyableBird و interface آخر للطيور التي لا تسطيع الطيران IUnFlyableBird
الـ Liskov يعطيك شرط معين لتقيم العلاقة بين كلاس وكلاس آخر أو كلاس و Interface
وتقيس هل هذه العلاقة صحيحة أم لا أو هل سيتم تطبيقها بشكل صحيح أم لا
وهكذا تبدأ في تنظيم الكلاسات والـ interface التي لديك بشكل منطقي وصحيح
مثال يناقض المبدأ
لنفترض أننا نمتلك كلاس عادي جدًا جميل يدعى Product يحتوى على بعض المتغيرات والدوال المتعلقة بالمنتجات بشكل عام
مثل عنوان المنتج تصنيفه وتاريخ الصلاحية
أنا جعلته كلاس عادي للتبسيط لكن يمكنك تخيل نفس المثال على Abstract Class أو Interface
class Product {
constructor(
private title: string,
private price: number,
private expiredDate: Date,
) {}
public getTitle(): string {
return this.title;
}
public getPrice(): number {
return this.price;
}
public getExpiredDate(): Date {
return this.expiredDate;
}
}
ثم لنتخيل أننا نملك دالة تدعى printProductInfo تستقبل object من نوع الـ Product وتقوم بطباعة بعض المعلومات عنه
function printProductInfo(product: Product) {
console.log({
title: product.getTitle(),
price: product.getPrice(),
expiredDate: product.getExpiredDate(),
});
}
هذه الدالة تستقبل object من نوع الـ Product ثم تستدعي الدوال التي تتوقعها من الـ Product مثل getTitle و getPrice و getExpiredDate
وركز على كلمة التي تتوقعها لأن هذا هو مربط الفرس في الموضوع
لنقل أننا لدينا أنواع من المنتجات مثل ألبان ولحوم وملابس وأجهزة كهربائية وغيرها
class MilkProduct extends Product {
constructor(title: string, price: number) {
// set expired date to 7 days from now
let expirationDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
super(title, price, expirationDate);
}
}
class MeatProduct extends Product {
constructor(title: string, price: number) {
// set expired date to 30 days from now
let expirationDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
super(title, price, expirationDate);
}
}
let milkProduct = new MilkProduct("حليب جهينة", 12);
let meatProduct = new MeatProduct("لحم بقري", 350);
لا يوجد أي مشاكل حتى الآن، فقط قمنا بعمل كلاسات جديدة ترث من الـ Product وهما MilkProduct و MeatProduct ووضعنا في كل منهما تاريخ صلاحية مختلف
ثم أنشأنا منتجات منهما مثل milkProduct و meatProduct
الآن دعونا نستخدم الدالة printProductInfo لطباعة بعض المعلومات عن المنتجات
printProductInfo(milkProduct);
/*
{
title: 'حليب جهينة',
price: 12,
expiredDate: 2024-01-08T00:00:00.000Z
}
*/
printProductInfo(meatProduct);
/*
{
title: 'لحم بقري',
price: 350,
expiredDate: 2024-01-30T00:00:00.000Z
}
*/
الأمور جيدة ولا تحتوي على أي مشاكل كلا الـ MilkProduct والـ MeatProduct يقومان بوراثة Product دون مشاكل أو نقصان
لكن أنظر إلى ماذا سيحدث عندما ننشيء كلاس خاص بالمنتجات الملابس أو الأجهزة الكهربائية
class ClothesProduct extends Product {
constructor(title: string, price: number) {
// clothes do not have an expiration date
super(title, price, null);
}
// clothes can't use getExpiredDate method
// so we need to override it and throw an error
public getExpiredDate(): Date {
throw new Error("Clothes does not have an expired date");
}
}
let clothesProduct = new ClothesProduct("قميص رجالي", 150);
printProductInfo(clothesProduct); // Error: Clothes does not have an expired date
هنا ستظهر مشكلة كلاس الـ ClothesProduct يقوم بعمل بوراثة الـ Product لكن الملابس لا تمتلك تاريخ صلاحية
بالتالي لن يستطيع وراثة أو تنفيذ الدالة getExpiredDate بشكل صحيح
والدالة printProductInfo تتوقع أن تكون الدالة getExpiredDate موجودة في أي object ينتمي إلى الـ Product وتستطيع استدعائها بدون مشاكل وتنفذ ما هو متوقع منها
لكنها استقبلت clothesProduct وهو من نوع ClothesProduct والذي هو بالفعل من نوع Product لكنه لا يستطيع تنفيذ الدالة getExpiredDate
بالتالي عندما حاولت الدالة printProductInfo استدعاء الدالة getExpiredDate وجدت تستقبل خطأ لم تتوقع ولم يتم إرجاع التاريخ الصلاحية كما كان متوقعًا
وهذا يعني أن العلاقة بين الـ ClothesProduct والـ Product لا تتبع مبدأ الـ Liskov Substitution لأن الـ ClothesProduct لا يستطيع تنفيذ دالة getExpiredDate التي يقدمها الـ Product
سترى نفس المشكلة عندما تنشيء كلاس للأجهزة الكهربائية وغيرها
ملحوظة: قد يكون كلاس يرث كلاس آخر وينفذ كل شيء فيه لكن ينفذها بطريقة تتعارض مع الفكرة الأساسية للكلاس الأساسي
فمثلًا دالة موجودة في الكلاس الأساسي ترجع أرقام موجبة فقط ثم يأتي كلاس يرث منه ويجعل الدالة ترجع أرقام سالبة فقط، كهذا هو خالف الشرط الأساسي لاننا نتوقع من الكلاس الأساسي أرقام موجبة
لكن لو كانت الدالة في الكلاس الأساسي ترجع أرقام موجبة وسالبة ثم أتى كلاس آخر ورث منه وجعل الدالة ترجع أرقام موجبة فقط فلا يوجد مشاكل
لأننا في المثال الثاني نتوقع في الكلاس الأساسي أرقام بشكل عام سواء موجبة وسالبة ولو حصلنا على أرقام سالبة فقط من الكلاس الذي يرثه فهذا ضمن توقعنا لذا فهو يوافق مبدأ الـLiskov
أما في المثال الأول فأننا نتوقع أرقام موجبة فقط في الكلاس الأساسي فعندما نحصل على أرقام سالبة من الكلاس الذي يرثه فهذا خارج عن توقعنا ويعارض مبدأ الـLiskov
مثال يوافق المبدأ
الحل قد يكون في تغير جزري للكود سواء بتغير الـ Product أو بإنشاء interface مختلفة لكل نوع من المنتجات
مثلا IFoodProduct و IClothesProduct و IElectricProduct وغيرها
نحن سنجعل الأمور بسيطة أولًا يمكننا إبقاء Product وجعله Abstract Class ليمثل الشكل العام للمنتجات والمتغيرات العامة التي تحتويها
abstract class Product {
constructor(
private title: string,
private price: number,
) {}
public getTitle(): string {
return this.title;
}
public getPrice(): number {
return this.price;
}
public abstract printInfo(): void;
}
قمنا بعمل تغيرات بسيطة على الـ Product وتخلصنا من الـ expirationDate لأنها اتضح لنا ان ليس كل المنتجات لها تاريخ صلاحية
ثم قمنا بإنشاء دالة printInfo داخل الـ Product وجعلناها abstract لكي يتم تنفيذها في كل كلاس يرث من الـ Product
قد تقوم أنت بتغيرات جزرية وهذا وارد حتى تصل لشكل أفضل وتنظيم مقبول للكود الخاص بك
الآن لنعرف الكلاسات التي ستمثل المنتجات المختلفة
class MilkProduct extends Product {
private expiredDate: Date;
constructor(title: string, price: number) {
super(title, price);
// set expired date to 7 days from now
this.expiredDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
}
public printInfo(): void {
console.log({
title: this.getTitle(),
price: this.getPrice(),
expiredDate: this.expiredDate,
});
}
}
let milkProduct = new MilkProduct("حليب جهينة", 12);
milkProduct.printInfo();
/*
{
title: 'حليب جهينة',
price: 12,
expiredDate: 2024-01-08T00:00:00.000Z
}
*/
لاحظ كيف أنشأنا المتغير expirationDate داخل كلاس الـ MilkProduct فقط
ثم قمنا بتعديل الدالة printInfo لتطبع المعلومات الخاصة بالـ MilkProduct بشكل صحيح
وهكذا يمكنك أن تنشئ كلاسات أخرى للمنتجات الأخرى مثل اللحوم والملابس والأجهزة الكهربائية وغيرها
class ClothesProduct extends Product {
private brand: string;
constructor(title: string, price: number, brand: string) {
super(title, price);
this.brand = brand;
}
public printInfo(): void {
console.log({
title: this.getTitle(),
price: this.getPrice(),
brand: this.brand,
});
}
}
let clothesProduct = new ClothesProduct("قميص رجالي", 150, "Sutra");
clothesProduct.printInfo();
/*
{
title: 'قميص رجالي',
price: 150,
brand: 'Sutra'
}
*/
لاحظ أن كلاس منتجات الملابس ClothesProduct لم يعد به أي مشاكل لأنه لم يعد مجبرًا على استعمال أو تنفيذ getExpiredDate كما في السابق لانه لم تعد ضمن الـ Product
وأصبح لديه دالة printInfo الخاصة به والتي تقوم بطباعة المعلومات الخاصة بالملابس
وهكذا يمكنك أن تنشئ كلاسات أخرى للمنتجات الأخرى بدون أي مشاكل
خلاصة مبدأ الـ Liskov Substitution
المبدأ يركز على تقليل المشاكل التي قد تحدث عند استبدال كلاس بآخر يرث منه
فهو ينص على أن يكون الكلاس الفرعي قادرًا على القيام بنفس الوظائف التي يقوم بها الكلاس الأساسي دون ان ينقص منه شيء
وأن لا يحدث أي مشاكل عند استبدال الكلاس الأساسي بالكلاس الفرعي لأنه بطبيعة الحال يرث منه ويرث كل شيء يقوم به
فالمبدأ يحسن منطقنا في استخدامنا لمفهوم الوراثة وبناء الـ interface المختلفة بشكل منطقي ونحدد من ينتمي لمن وهل يصلح أن يكون الكلاس الفرعي يرث من الكلاس الأساسي أم لا
بمعنى هل من المنطقي أن ينتمي كلاس مثل Student إلى عائلة Employee ؟ بالطبع لا
لأن الـ Employee قد يحتوى على أمور لن يستطيع الـ Student القيام بها مثل work أو salary وغيرها لأن الـ Employee متخصص وليس عام
لكن هل الـ Student يمكن أن ينتمي إلى عائلة Person ؟ بالطبع نعم
بشرط أن يكون Person يحتوي على الأمور الأساسية فقط والعامة التي ستتواجد في جميع الأشخاص دون استثناء
مثل name و age و gender وغيرها من الأمور العامة التي لا تتعارض مع أي نوع من الأشخاص
ويمكننا ضرب مثال آخر فمثلًا قد يكون لديك كلاسات مثل CoffeeMachine و TeaMachine و CacaoMachine يقومون ببناء وتنفيذ كل شيء في interface يدعى IMachine بشكل كامل بدون نقصان
لكن أحد الكلاسات قرر تنفيذ دالة معينة بشكل خاطئ
بمعنى أنه قد يكون هناك دالة تدعى makeZeroSugarCup وهي دالة داخل الـ IMachine تقوم بعمل كوب ما بدون سكر
ثم تجد كلاس الـ CoffeeMachine يقوم بتنفيذ هذه الدالة بشكل خاطئ وبكل بجاحة يضيف عليه سكر
إذا فتلك الدالة منطقيًا لا تتبع مبدأ الـ Liskov لأن أي شخص يقوم بالتعامل مع IMachine
فهذا الشخص يتوقع أن الدالة makeZeroSugarCup تقوم بعمل كوب بدون سكر وليس بسكر ويقوم ببناء تطبيقه على هذا الأساس
ثم يتفاجيء أن هناك مشكلة غير متوقعة بسبب أن object عندما يكون من نوع CoffeeMachine يقوم بإضافة سكر ويفسد عليه كل شيء
function makeCupWithoutSugar(machine: IMachine) {
return machine.makeZeroSugarCup();
}
let coffeeMachine = new CoffeeMachine();
let teaMachine = new TeaMachine();
let cacaoMachine = new CacaoMachine();
makeCupWithoutSugar(teaMachine); // it will work fine
makeCupWithoutSugar(cacaoMachine); // it will work fine
makeCupWithoutSugar(coffeeMachine); // Unexpected bug, it will add sugar
صاحب الدالة makeCupWithoutSugar لم يتوقع أن يحدث هذا الأمر لأنه يعتمد على الـ interface الـ IMachine ويتوقع أن يكون كل شيء على ما يرام
الآن سيسهر الليلة ليبحث عن هذا الخطأ الخفي الغير متوقع
فالمبدأ الـ Liskov Substitution قد يتم مخالفته عن طريق عدم بناء الدالة وتنفيذها أو عن طريقة تنفيذها لكن بشكل خاطئ من حيث المنطق
التعليقات
شاركنا رأيك في هذه المقالة أو اسأل عن أي شيء يخصها