اصل جایگزینی لیسکوف به زبان ساده
محمد کلاته
1405/05/20
اسم «اصل جایگزینی لیسکوف» از خود مفهومش خیلی سختتره!
وقتی اولین بار تعریف LSP رو میخونی، احتمالاً با یه جمله شبیه این روبهرو میشی:
هر کلاس فرزند باید بتونه جای کلاس والد قرار بگیره، بدون اینکه رفتار برنامه خراب بشه.
تعریف اشتباهی نیست؛ ولی راستش رو بخوای، چیز زیادی هم دستگیرمون نمیشه.
جایگزینشدن یعنی چی؟ کدوم رفتار نباید تغییر کنه؟ اصلاً چه اتفاقی میافته که برنامه خراب میشه؟
فعلاً کدنویسی رو کنار بذاریم. اول با یه مثال خیلی ساده ببینیم LSP دقیقاً دنبال حل چه مشکلیه.
فرض کن یه صندلی سفارش دادی
تصور کن وارد یه فروشگاه اینترنتی شدی و از بخش «صندلیها» یه محصول سفارش دادی.
انتظار عجیبوغریبی هم نداری. وقتی چیزی به اسم صندلی میخری، طبیعتاً باید بشه روش نشست.
حالا مدلش هر چیزی میتونه باشه:
- صندلی اداری
- صندلی چوبی
- صندلی تاشو
- صندلی آشپزخانه
ظاهر و جنس این صندلیها با هم فرق داره. یکی میچرخه، یکی جمع میشه و یکی هم فقط چهار تا پایه ساده داره.
ولی همهشون یه قول مشترک بهت میدن:
میتونی روشون بشینی.
حالا فرض کن سفارشت میرسه و یه برچسب روی صندلی میبینی:
صندلی تزئینی است؛ لطفاً روی آن ننشینید!
احتمالاً اولین چیزی که میگی اینه:
«خب اگه نمیشه روش نشست، چرا گذاشتینش توی دسته صندلیها؟»
همین سؤال ساده، اصل ماجرای LSP رو روشن میکنه.
صندلی تزئینی لزوماً محصول بدی نیست. شاید خیلی هم قشنگ باشه. مشکل اینه که گذاشتیمش داخل دستهای که یه انتظار مشخص برای مشتری ساخته.
حالا تصور کن فروشنده قبل از تحویل هر صندلی مجبور بشه بپرسه:
«ببخشید، صندلی شما از اون مدل تزئینیها نیست؟ واقعاً میشه روش نشست؟»
وقتی به این سؤال نیاز پیدا میکنیم، یعنی دیگه نمیتونیم به دستهبندی «صندلی» اعتماد کنیم.
توی برنامهنویسی هم دقیقاً همین اتفاق میافته.
یه کلاس فرزند میسازیم که از نظر اسم و ظاهر به کلاس والد میخوره؛ ولی وقتی میخوایم واقعاً ازش استفاده کنیم، نمیتونه به قولهای والد عمل کنه.
اینجاست که میگیم:
اصل جایگزینی لیسکوف نقض شده.
اصل جایگزینی لیسکوف بالاخره چی میگه؟
حالا که مشکل رو دیدیم، تعریف LSP خیلی قابلفهمتر میشه:
هرجا برنامه منتظر یه نمونه از کلاس والده، باید بتونیم یکی از فرزندهاش رو بهش بدیم، بدون اینکه برنامه غافلگیر یا خراب بشه.
اگه بخوایم از این هم سادهترش کنیم:
کلاس فرزند نباید زیر قول کلاس والد بزنه.
اگه کلاس والد گفته «من این کار رو انجام میدم»، فرزندش نمیتونه وسط اجرای برنامه بگه:
«خب... من یکی انجامش نمیدم!»
البته قرار نیست همه فرزندها مو به مو مثل هم رفتار کنن.
مثلاً صندلی اداری میتونه بچرخه، صندلی تاشو میتونه جمع بشه و صندلی چوبی هیچکدوم از این قابلیتها رو نداشته باشه.
این تفاوتها هیچ ایرادی ندارن، چون قول اصلی هنوز سر جاشه:
همهشون برای نشستن قابلاستفادهان.
کلاس فرزند میتونه امکانات بیشتری داشته باشه؛ ولی نباید چیزی رو که والد قول داده، از ما بگیره.
حالا همین ماجرا رو توی یه برنامه بانکی ببینیم
فرض کن داریم یه برنامه بانکی ساده میسازیم.
توی برنامه یه کلاس به اسم BankAccount داریم. این کلاس نماینده حساب بانکیه و یه متد به اسم Withdraw داره که کارش برداشت پوله.
هنوز سراغ کد نریم. اول خود سناریو رو ببینیم.
برنامه یه بخش برای پرداخت قبض داره. این بخش یه حساب بانکی میگیره و مبلغ قبض رو از اون حساب برداشت میکنه.
پس برنامه با خودش میگه:
هر حسابی که از نوع BankAccount باشه، باید بشه ازش پول برداشت کرد.
تا اینجای کار همهچیز عادیه.
حساب پسانداز رو به برنامه میدیم؛ برداشت انجام میشه.
حساب جاری رو میدیم؛ اون هم بدون مشکل کار میکنه.
حالا یه حساب جدید وارد داستان کنیم:
حساب سپرده بلندمدت
این هم واقعاً یه نوع حساب بانکیه؛ ولی یه تفاوت مهم داره. تا وقتی مدت قرارداد تموم نشده، اجازه برداشت ازش رو نداریم.
برنامهنویس میخواد این حساب رو هم وارد خانواده حسابها کنه، پس اون رو فرزند BankAccount قرار میده.
ولی یه مشکل داریم: با متد Withdraw چیکار کنیم؟
یه راه سریع اینه که بگیم اگر کسی خواست از حساب سپرده بلندمدت برداشت کنه، برنامه خطا بده:
public class BankAccount
{
public virtual void Withdraw(int amount)
{
Console.WriteLine("برداشت انجام شد.");
}
}
public class SavingsAccount : BankAccount
{
}
public class FixedDepositAccount : BankAccount
{
public override void Withdraw(int amount)
{
throw new NotSupportedException(
"برداشت از حساب سپرده بلندمدت امکانپذیر نیست."
);
}
}
اگه C# بلد نیستی، اصلاً نگران این کد نباش. اتفاقی که افتاده خیلی سادهست:
BankAccountکلاس اصلی یا همون والده.SavingsAccountحساب پساندازه و ازBankAccountارث برده.FixedDepositAccountحساب سپرده بلندمدته و اون هم فرزندBankAccountشده.- متد
Withdrawکار برداشت پول رو انجام میده. overrideیعنی کلاس فرزند میخواد رفتار متد والد رو عوض کنه.
حساب پسانداز با برداشت پول مشکلی نداره.
ولی حساب سپرده بلندمدت وقتی به متد برداشت میرسه، خطای NotSupportedException میده.
یعنی خیلی ساده داره میگه:
من این کار رو پشتیبانی نمیکنم.
حالا بخش پرداخت قبض رو ببین:
public void PayBill(BankAccount account)
{
account.Withdraw(500000);
}
این متد یه BankAccount میگیره و ۵۰۰ هزار تومان ازش برداشت میکنه.
اگه حساب پسانداز رو بهش بدیم، همهچیز خوب پیش میره:
PayBill(new SavingsAccount());
حالا حساب سپرده بلندمدت رو جاش بذاریم:
PayBill(new FixedDepositAccount());
کد نوشته میشه، برنامه هم کامپایل میشه؛ ولی موقع اجرا خطا میخوریم.
چرا؟
چون بخش پرداخت قبض به قول BankAccount اعتماد کرده بود. انتظار داشت هر حسابی که دریافت میکنه، امکان برداشت داشته باشه.
ولی یکی از فرزندها این انتظار رو خراب کرد.
اینجا دقیقاً LSP رو نقض کردیم.
اشتباه اصلی طراحی کجا بود؟
شاید در نگاه اول فکر کنی مشکل فقط همون NotSupportedExceptionـیه که داخل FixedDepositAccount نوشتیم.
ولی اون خطا فقط داره مشکل رو لو میده. خودش ریشه مشکل نیست.
اشتباه اصلی خیلی زودتر اتفاق افتاده؛ همون جایی که با خودمون گفتیم:
هر چیزی که حساب بانکیه، حتماً باید بشه ازش پول برداشت کرد.
ولی این فرض همیشه درست نیست.
از حساب پسانداز میشه برداشت کرد. از حساب جاری هم میشه. ولی حساب سپرده بلندمدت ممکنه تا پایان قرارداد اجازه برداشت نداشته باشه.
ما قابلیت برداشت رو داخل کلاس والد گذاشتیم و بعد همه فرزندها رو مجبور کردیم این قابلیت رو داشته باشن؛ حتی فرزندی که واقعاً نمیتونه چنین قولی بده.
اینجا همون نقطهایه که ارثبری داره بهمون دروغ میگه.
از نظر اسم، این جمله کاملاً درست به نظر میرسه:
حساب سپرده بلندمدت، یه حساب بانکیه.
ولی یه جمله دیگه لزوماً درست نیست:
از هر حساب بانکی، هر وقت خواستیم میتونیم پول برداشت کنیم.
LSP دقیقاً حواسش به همین تفاوتهاست.
براش مهم نیست اسم کلاسها چقدر به هم میخوره یا نمودار ارثبری چقدر مرتب و قشنگه. مهم اینه که فرزند واقعاً بتونه به رفتاری که والد قول داده عمل کنه.
چطور این طراحی رو درست کنیم؟
راهحل اینه که بخش پرداخت قبض فقط حسابهایی رو قبول کنه که واقعاً امکان برداشت دارن.
نه هر چیزی که صرفاً اسمش حساب بانکیه.
برای این کار یه قرارداد کوچیک به اسم IWithdrawableAccount میسازیم:
public interface IWithdrawableAccount
{
void Withdraw(int amount);
}
اگه با interface آشنا نیستی، فعلاً مثل یه قول بهش نگاه کن.
هر کلاسی که این Interface رو قبول میکنه، داره واضح میگه:
من امکان برداشت پول رو دارم.
حالا فقط حسابهایی که واقعاً امکان برداشت دارن، این قول رو میدن:
public class SavingsAccount : IWithdrawableAccount
{
public void Withdraw(int amount)
{
Console.WriteLine("برداشت انجام شد.");
}
}
public class CurrentAccount : IWithdrawableAccount
{
public void Withdraw(int amount)
{
Console.WriteLine("برداشت انجام شد.");
}
}
حساب سپرده بلندمدت این Interface رو پیادهسازی نمیکنه.
نه به این خاطر که حساب بد یا ناقصیه؛ فقط قرار نیست وانمود کنه قابلیتی داره که واقعاً نداره.
حالا متد پرداخت قبض رو هم تغییر میدیم:
public void PayBill(IWithdrawableAccount account)
{
account.Withdraw(500000);
}
این بار متد PayBill دیگه هر حسابی رو قبول نمیکنه. فقط حسابی رو میگیره که امکان برداشت داشته باشه.
پس حساب سپرده بلندمدت اصلاً نمیتونه اشتباهی وارد این بخش بشه.
برنامه دیگه وسط اجرا غافلگیر نمیشه، چون از همون اول قرارداد رو درست و دقیق تعریف کردیم.
کل حرف این بخش توی یه جمله خلاصه میشه:
طراحی خوب درباره توانایی کلاسها دروغ نمیگه.
یه تعریف علمی کوتاه؛ بدون اینکه سختش کنیم
ایده اصلی این ماجرا رو باربارا لیسکوف مطرح کرد. بعدتر هم باربارا لیسکوف و جنت وینگ مفهوم زیرنوع رفتاری یا Behavioral Subtyping رو دقیقتر توضیح دادن.
اگه بخوایم حرف اصلیشون رو خیلی ساده کنیم، میشه این:
رفتارهایی که از نوع اصلی انتظار داریم، باید توی زیرنوعهای اون هم برقرار بمونن.
یعنی رابطه والد و فرزند فقط به اسم کلاسها، متدهای مشترک یا شکل ظاهری کد ربط نداره.
فرزند باید از نظر رفتاری هم پای قول والدش بمونه.
اگه دوست داری نسخه علمیتر ماجرا رو ببینی، میتونی سراغ مقاله باربارا لیسکوف و جنت وینگ درباره Behavioral Subtyping بری.
ولی برای استفاده توی پروژههای روزمره، لازم نیست تعریفهای ریاضی رو حفظ کنی.
همین یه سؤال معمولاً کارت رو راه میاندازه:
اگه این فرزند رو جای والد بذارم، کدی که به والد اعتماد کرده بود غافلگیر میشه؟
اگه جوابت «آره»ـست، احتمال زیادی وجود داره که LSP رو خراب کرده باشی.
از کجا بفهمیم احتمالاً LSP رو خراب کردیم؟
لازم نیست برای پیدا کردن نقض LSP همیشه دنبال طراحیهای خیلی پیچیده بگردی.
بعضی نشونهها تقریباً دارن فریاد میزنن که یه جای کار ایراد داره:
- کلاس فرزند یکی از متدهای والد رو خالی گذاشته یا عملاً بیاستفاده کرده.
- یه متد توی بعضی از فرزندها همیشه
NotSupportedExceptionمیده. - قبل از استفاده از آبجکت، مجبور میشی نوع واقعی اون رو با
ifیاswitchبررسی کنی. - فرزند ورودیای رو رد میکنه که برای کلاس والد کاملاً قابلقبول بوده.
- توضیحات متد پر شده از جملههایی مثل «بهجز این کلاس» یا «برای این نوع قابلاستفاده نیست».
- رابطه ارثبری از نظر اسم منطقیه، ولی موقع استفاده واقعی به مشکل میخوره.
- همین که یکی از فرزندها رو جای والد میذاری، کد قبلی خطا میده یا رفتار عجیبی نشون میده.
دیدن یکی از این نشونهها همیشه ثابت نمیکنه که حتماً LSP نقض شده؛ ولی دلیل خوبیه که یه بار دیگه طراحی رو بررسی کنی.
جمعبندی؛ فرزند نباید زیر قول والد بزنه
اصل جایگزینی لیسکوف نمیگه همه کلاسهای فرزند باید دقیقاً یه کار رو، با یه روش و یه نتیجه یکسان انجام بدن.
حرفش خیلی سادهتره:
هر فرزند باید به قولی که والدش داده پایبند بمونه.
صندلی اداری میتونه بچرخه و صندلی تاشو میتونه جمع بشه. هیچ ایرادی هم نداره.
ولی وقتی محصولی رو به اسم صندلی تحویل میگیری، نباید موقع نشستن تازه بفهمی که فقط برای تزئین ساخته شده.
توی کدنویسی هم همین قاعده وجود داره.
اگه یه کلاس فرزند بعضی از متدهای والد رو پشتیبانی نمیکنه، شرایط عجیبتری میخواد یا کد رو مجبور میکنه براش استثنا قائل بشه، احتمالاً جاش توی اون خانواده نیست.
برای تشخیص LSP هم لازم نیست یه تعریف سخت و رسمی رو حفظ کنی.
فقط از خودت بپرس:
میتونم این فرزند رو جای والد بذارم، بدون اینکه برنامه غافلگیر بشه؟
اگه جواب «نه»ـه، احتمالاً یه جای طراحی نیاز به بازنگری داره.