پرش به مطلب اصلی

هر چیزی که شبیه چیز دیگر است، جایگزین آن نیست

· ۱۰ دقیقه مطالعه
مهدی مالوردی
مهندس نرم‌افزار و نویسندهٔ این سایت

چند قطعه‌ی شبیه به هم که فقط یکی از آن‌ها واقعاً در جای قطعه‌ی اصلی می‌نشیند.

فرض کنید در یک سامانه‌ی پرداخت، کلاسی داریم که قرار است رسید پرداخت را تولید و ذخیره کند. برای همین، یک قرارداد ساده تعریف کرده‌ایم: هر «ذخیره‌ساز رسید» باید بتواند رسید را بگیرد و بدون غافل‌گیری آن را ذخیره کند.

interface ReceiptStore {
save(receipt: Receipt): void
}

حالا یک پیاده‌سازی معمولی داریم:

class DatabaseReceiptStore implements ReceiptStore {
save(receipt: Receipt) {
db.receipts.insert(receipt)
}
}

تا این‌جا همه‌چیز روشن است. اما بعدتر کسی می‌گوید: «ما یک نسخه‌ی فقط‌خواندنی هم لازم داریم. همان را هم از همین اینترفیس ارث ببریم.» و نتیجه چیزی شبیه این می‌شود:

class ReadOnlyReceiptStore implements ReceiptStore {
save(receipt: Receipt) {
throw new Error('This store is read-only')
}
}

از نظر نام و ساختار، این کلاس شبیه یک ReceiptStore است. همان اینترفیس را پیاده‌سازی کرده، همان متد را دارد، و حتی شاید از نظر ابزارهای ایستا هیچ خطایی هم نداشته باشد. اما از نظر رفتاری، قرارداد را شکسته است. مصرف‌کننده‌ای که به ReceiptStore اعتماد کرده بود، انتظار داشت save رسید را ذخیره کند، نه اینکه در زمان اجرا غافلگیر شود.

اینجا مسئله فقط یک استثنا یا یک خطای کوچک نیست. مسئله این است که ما چیزی ساخته‌ایم که شبیه نوع اصلی است، اما جایگزین آن نیست.

اصل جایگزینی لیسکوف دقیقاً از همین‌جا آغاز می‌شود.

یادداشت

وقتی از «قرارداد رفتاری» حرف می‌زنیم، فقط امضای تابع یا نام متد منظور نیست. قرارداد رفتاری یعنی مصرف‌کننده بر اساس چه انتظارهایی از یک نوع استفاده می‌کند.

برای نمونه، اگر یک تابع save نام دارد، مصرف‌کننده معمولاً انتظار دارد:

  • با گرفتن ورودی معتبر، عملیات اصلی را انجام دهد.
  • به‌دلیل محدودیت پنهان و نامنتظر شکست نخورد.
  • نتیجه‌ای بدهد که با معنای آن قرارداد سازگار باشد.

پس قرارداد، فقط آن چیزی نیست که در اینترفیس نوشته شده؛ بخشی از آن در رفتار مورد انتظار نهفته است.

شباهت ظاهری کافی نیست

در طراحی شیءگرا، به‌ویژه وقتی از ارث‌بری یا پیاده‌سازی اینترفیس استفاده می‌کنیم، وسوسه‌ای پنهان وجود دارد: اگر دو چیز از نظر شکل شبیه‌اند، پس حتماً می‌توان یکی را جای دیگری گذاشت.

اما این فقط نیمی از ماجراست.

ممکن است دو کلاس:

  • نام‌های مشابه داشته باشند،
  • متدهای یکسانی ارائه دهند،
  • از یک والد مشترک ارث ببرند،
  • یا یک اینترفیس مشترک را پیاده‌سازی کنند،

ولی همچنان از نظر رفتار، ناسازگار باشند.

اصل جایگزینی لیسکوف (Liskov Substitution Principle) می‌گوید اگر B زیرنوع A است، باید بتوان B را هر جا که A انتظار می‌رود قرار داد، بی‌آنکه درستی برنامه به‌هم بخورد. به بیان ساده‌تر، زیرنوع باید قول‌های نوع پایه را نگه دارد.

این اصل ما را از طراحی‌هایی دور می‌کند که فقط از نظر ظاهری منظم‌اند، اما در عمل مصرف‌کننده را فریب می‌دهند.

مثال آشناتر: مستطیل و مربع

نمونه‌ی کلاسیک این اصل، رابطه‌ی مستطیل و مربع است. در نگاه نخست، خیلی طبیعی به نظر می‌رسد که بگوییم «مربع نوعی مستطیل است». از نظر هندسی شاید چنین باشد. اما در طراحی نرم‌افزار، مسئله فقط شباهت مفهومی نیست؛ مسئله رفتار قراردادی است.

فرض کنید چنین مدلی داریم:

class Rectangle {
protected width = 0
protected height = 0

setWidth(width: number) {
this.width = width
}

setHeight(height: number) {
this.height = height
}

getArea() {
return this.width * this.height
}
}

حالا کسی می‌گوید مربع هم که مستطیل است؛ پس از آن ارث می‌برد:

class Square extends Rectangle {
setWidth(width: number) {
this.width = width
this.height = width
}

setHeight(height: number) {
this.width = height
this.height = height
}
}

از نظر ظاهر، همه‌چیز مرتب است. اما حالا به این تابع نگاه کنید:

function resizeAndMeasure(rectangle: Rectangle) {
rectangle.setWidth(5)
rectangle.setHeight(4)

return rectangle.getArea()
}

اگر یک Rectangle معمولی بدهیم، خروجی 20 می‌شود. اما اگر Square بدهیم، خروجی 16 می‌شود. چرا؟ چون Square قرارداد رفتاری ضمنی Rectangle را تغییر داده است. مصرف‌کننده انتظار داشت تغییر عرض و ارتفاع مستقل باشند، اما این انتظار در زیرنوع شکسته شد.

این همان جایی است که باید از خودمان بپرسیم: آیا این واقعاً یک زیرنوع صادق است، یا فقط یک شباهت ظاهری ما را فریب داده است؟

هشدار

ارث‌بری دروغین معمولاً از جایی آغاز می‌شود که می‌خواهیم یک رابطه‌ی مفهومی یا زبانی را به رابطه‌ی فنی تبدیل کنیم، بدون اینکه رفتار را بررسی کنیم.

جمله‌هایی مثل این‌ها خطرناک‌اند:

  • «خب این هم یک نوع از همان است.»
  • «اسمش شبیه است، پس از همان ارث ببرد.»
  • «فقط برای استفاده‌ی دوباره از کد، این را زیرکلاس آن می‌کنیم.»

اگر زیرنوع ناچار است بخشی از رفتار والد را خراب کند، محدود کند، یا با استثنا و شرط‌های عجیب دور بزند، احتمال زیادی دارد که با یک ارث‌بری نادرست روبه‌رو باشیم.

چرا اعتماد مصرف‌کننده مهم‌تر از شباهت ظاهری است؟

مصرف‌کننده‌ی یک نوع، معمولاً بر پایه‌ی قرارداد آن تصمیم می‌گیرد؛ نه بر پایه‌ی جزئیات پنهان پیاده‌سازی.

وقتی تابعی یک ReceiptStore می‌گیرد، می‌خواهد رسید را ذخیره کند. وقتی بخشی از سامانه Rectangle می‌گیرد، انتظار رفتاری دارد که با معنای آن سازگار است. وقتی ما زیرنوعی می‌سازیم که این انتظارها را نقض می‌کند، در واقع اعتماد مصرف‌کننده را می‌شکنیم.

این شکست معمولاً خودش را در چند نشانه نشان می‌دهد:

  • مصرف‌کننده ناچار می‌شود قبل از استفاده، نوع واقعی شیء را بررسی کند.
  • بخشی از کد برای بعضی زیرنوع‌ها کار می‌کند و برای بعضی دیگر نه.
  • شرط‌های if instance of ... یا شاخه‌های استثنایی زیاد می‌شوند.
  • آزمون‌هایی که باید یکسان باشند، برای زیرنوع‌ها رفتارهای جداگانه می‌خواهند.
  • نام کلاس‌ها می‌گویند «من جایگزین‌پذیرم»، ولی رفتارشان می‌گوید «با احتیاط از من استفاده کن».

وقتی به این نقطه می‌رسیم، مشکل فقط در یک کلاس نیست. طراحی دارد به ما هشدار می‌دهد که مرز نوع‌ها درست انتخاب نشده است.

پیاده‌سازی اینترفیس هم می‌تواند اصل را بشکند

گاهی فکر می‌کنیم اصل جایگزینی لیسکوف فقط درباره‌ی ارث‌بری است. اما این اصل در مورد هر نوع رابطه‌ی جانشینی صادق است؛ چه ارث‌بری باشد، چه پیاده‌سازی اینترفیس.

برای نمونه، فرض کنید قراردادی برای ارسال اعلان داریم:

interface NotificationSender {
send(message: Message): void
}

یک فرستنده‌ی ایمیل داریم:

class EmailSender implements NotificationSender {
send(message: Message) {
emailClient.send(message)
}
}

بعد یک پیاده‌سازی دیگر اضافه می‌شود:

class DisabledNotificationSender implements NotificationSender {
send(message: Message) {
throw new Error('Sending is disabled')
}
}

اگر هدف این کلاس فقط این است که در بعضی محیط‌ها چیزی ارسال نکند، شاید بهتر باشد به‌جای شکستن قرارداد، رفتاری سازگارتر ارائه دهد؛ مثلاً یک پیاده‌سازی بی‌اثر داشته باشیم که پیام را نادیده بگیرد، یا اصلاً قرارداد جداگانه‌ای تعریف کنیم که با این سناریو سازگار باشد.

در هر صورت، نکته این است که صرف پیاده‌سازی یک اینترفیس، جایگزین‌پذیری را تضمین نمی‌کند. باید از نظر رفتاری هم راست‌گو باشیم.

زیرنوع صادق چه ویژگی‌ای دارد؟

زیرنوع صادق، نوعی است که اگر آن را جای نوع پایه بگذاریم، مصرف‌کننده مجبور نشود منطقش را عوض کند. به بیان عملی، زیرنوع نباید:

  • پیش‌شرط‌ها را سخت‌تر کند،
  • پس‌شرط‌ها را ضعیف‌تر کند،
  • رفتار مورد انتظار را نقض کند،
  • یا محدودیت‌های تازه‌ای تحمیل کند که در نوع پایه وجود نداشته‌اند.

مثلاً اگر نوع پایه می‌گوید «این تابع با هر فایل معتبر کار می‌کند»، زیرنوع نباید ناگهان بگوید «من فقط با فایل‌های کوچک‌تر از یک مگابایت کار می‌کنم»، مگر اینکه این محدودیت بخشی از همان قرارداد اصلی بوده باشد.

اگر نوع پایه قول می‌دهد «این متد داده را ذخیره می‌کند»، زیرنوع نباید بگوید «من در بعضی حالت‌ها عمداً هیچ کاری نمی‌کنم» یا «برای من ذخیره‌سازی ممنوع است»، مگر اینکه قرارداد از ابتدا چنین چیزی را مجاز دانسته باشد.

اگر ارث‌بری درست نیست، چه کنیم؟

وقتی می‌فهمیم رابطه‌ی ارث‌بری یا پیاده‌سازی فعلی صادقانه نیست، معمولاً چند راه بهتر داریم.

۱. قرارداد را دقیق‌تر تعریف کنیم

گاهی مشکل از این است که نوع پایه بیش از حد کلی یا مبهم تعریف شده است. شاید چیزی که ما ReceiptStore نامیده‌ایم، در واقع دو مفهوم متفاوت را در یک قرارداد ریخته است.

برای نمونه، شاید بهتر باشد به‌جای این:

interface ReceiptStore {
save(receipt: Receipt): void
}

چنین مرزبندی‌ای داشته باشیم:

interface ReceiptWriter {
save(receipt: Receipt): void
}

interface ReceiptReader {
findById(id: string): Receipt | null
}

حالا یک پیاده‌سازی فقط‌خواندنی مجبور نیست خودش را به‌زور Writer جا بزند.

۲. به‌جای ارث‌بری، از ترکیب استفاده کنیم

گاهی شباهت رفتاری کافی نیست، ولی بخشی از پیاده‌سازی مشترک است. در این حالت، ترکیب معمولاً از ارث‌بری سالم‌تر است.

برای نمونه، به‌جای اینکه Square را از Rectangle ارث بدهیم، می‌توانیم هر دو را نوع‌های مستقل با قراردادهای روشن‌تر بدانیم، یا از یک مؤلفه‌ی مشترک برای محاسبه‌ی مساحت و ویژگی‌های هندسی استفاده کنیم.

۳. نوع‌های عمومی را بیش از حد عمومی نکنیم

بعضی وقت‌ها تلاش می‌کنیم یک نوع را آن‌قدر کلی کنیم که همه‌چیز را پوشش دهد. نتیجه این می‌شود که زیرنوع‌ها ناچارند بخشی از آن قرارداد را بشکنند. طراحی خوب همیشه با «عام‌کردن بیشتر» به دست نمی‌آید. گاهی باید نوع‌های کوچک‌تر، دقیق‌تر و صادق‌تری بسازیم.

آزمون رفتاری، نه فقط آزمون واحد

یکی از بهترین راه‌ها برای تشخیص شکستن اصل جایگزینی لیسکوف، آزمودن رفتار مشترک زیرنوع‌هاست.

اگر یک قرارداد واقعاً درست تعریف شده باشد، باید بتوانیم مجموعه‌ای از آزمون‌های مشترک برای همه‌ی پیاده‌سازی‌ها بنویسیم. اگر بعضی پیاده‌سازی‌ها ناگهان از این آزمون‌ها رد نمی‌شوند، احتمال خوبی هست که زیرنوع صادقی نباشند.

برای نمونه، درباره‌ی ReceiptStore می‌توانیم چنین آزمونی داشته باشیم:

function shouldBehaveLikeReceiptStore(store: ReceiptStore) {
const receipt = {id: 'r-1', amount: 1000}

store.save(receipt)

const saved = db.receipts.findById('r-1')
expect(saved).toEqual(receipt)
}

اگر DatabaseReceiptStore از این آزمون رد شود ولی ReadOnlyReceiptStore نه، مسئله روشن است: پیاده‌سازی دوم قرارداد را نگه نداشته است.

نکته

برای آزمودن اصل جایگزینی لیسکوف، به‌جای آنکه فقط هر کلاس را جداگانه بررسی کنید، یک مجموعه آزمون مشترک برای قرارداد اصلی بنویسید و آن را روی همه‌ی پیاده‌سازی‌ها اجرا کنید.

اگر یک زیرنوع برای عبور از این آزمون‌ها نیاز به استثنا، شاخه‌ی ویژه، یا تغییر انتظارها داشته باشد، این نشانه‌ی خوبی نیست. معمولاً یعنی رابطه‌ی جانشینی صادقانه تعریف نشده است.

یک نشانه‌ی مهم: آیا مصرف‌کننده باید «حواسش جمع باشد»؟

یکی از بهترین پرسش‌ها برای بررسی این اصل این است:

آیا مصرف‌کننده باید بداند دقیقاً با کدام زیرنوع کار می‌کند تا بتواند درست از آن استفاده کند؟

اگر پاسخ مثبت است، احتمالاً جایگزین‌پذیری شکسته شده است.

برای نمونه، اگر جایی در کد به این برسیم:

function persistReceipt(store: ReceiptStore, receipt: Receipt) {
if (store instanceof ReadOnlyReceiptStore) {
return
}

store.save(receipt)
}

این کد عملاً اعتراف می‌کند که ReadOnlyReceiptStore جایگزین صادقی برای ReceiptStore نیست. چون مصرف‌کننده دیگر نمی‌تواند فقط به قرارداد تکیه کند؛ مجبور است نوع واقعی را بشناسد و برای آن شاخه‌ی جدا بسازد.

این همان چیزی است که اصل جایگزینی لیسکوف می‌خواهد از آن پرهیز کنیم.

پیشنهاد عملی

اگر در کدتان ارث‌بری یا اینترفیس‌های زیادی دارید، لازم نیست یک‌باره همه‌چیز را بازطراحی کنید. از جاهایی شروع کنید که نشانه‌های ناسازگاری رفتاری در آن‌ها دیده می‌شود.

چیزهایی که باید بررسی شوند:

  • آیا یک زیرنوع بعضی متدهای والد را با throw new Error، not supported یا رفتار بی‌اثر پیاده‌سازی کرده است؟
  • آیا مصرف‌کننده‌ها مجبورند قبل از استفاده، نوع واقعی شیء را بررسی کنند؟
  • آیا یک اینترفیس بیش از حد کلی شده و پیاده‌سازی‌ها فقط بخشی از آن را واقعاً پشتیبانی می‌کنند؟
  • آیا آزمون مشترکی می‌توان برای همه‌ی پیاده‌سازی‌های یک قرارداد نوشت؟
  • آیا نام نوع‌ها از جانشینی خبر می‌دهد، ولی رفتارشان چیز دیگری می‌گوید؟

چیزهایی که نباید بی‌دلیل تغییر کنند:

  • قراردادهای سالم و جاافتاده‌ای که مصرف‌کننده‌ها به آن‌ها تکیه کرده‌اند.
  • نوع‌های کوچک و دقیق، فقط برای اینکه سلسله‌مراتب ظاهری «تمیزتر» شود.
  • پیاده‌سازی‌های مستقلی که با ترکیب بهتر کار می‌کنند.
  • طراحی‌هایی که با حذف ارث‌بری نادرست ساده‌تر و صادق‌تر می‌شوند.

برای اعتبارسنجی تغییر، دست‌کم این گام‌ها را اجرا کنید:

npm test
npm run lint
npm run typecheck

اگر پروژه‌ی شما این فرمان‌ها را ندارد، معادل همان‌ها را اجرا کنید. در این موضوع، به‌ویژه مهم است که آزمون‌های رفتاری مشترک داشته باشید؛ یعنی آزمون‌هایی که قرارداد پایه را بررسی می‌کنند، نه فقط جزئیات هر کلاس را.

جمع‌بندی

اصل جایگزینی لیسکوف درباره‌ی این نیست که سلسله‌مراتب ارث‌بری زیبایی بسازیم. درباره‌ی این است که وقتی می‌گوییم «این نوع، گونه‌ای از آن نوع است»، واقعاً از نظر رفتاری هم راست بگوییم.

هر چیزی که نام مشابه دارد، متدهای مشابه دارد، یا از یک والد مشترک ارث می‌برد، لزوماً جایگزین آن نیست. اگر مصرف‌کننده نتواند با اطمینان از قرارداد اصلی استفاده کند، جایگزین‌پذیری از بین رفته است.

در طراحی خوب، شباهت ظاهری ارزش دارد، اما کافی نیست. آنچه واقعاً مهم است، اعتماد رفتاری است. اگر یک زیرنوع این اعتماد را نگه می‌دارد، ارث‌بری یا پیاده‌سازی آن احتمالاً صادقانه است. اگر نه، بهتر است به‌جای پافشاری بر شباهت ظاهری، مرز نوع‌ها را دوباره و صادقانه‌تر تعریف کنیم.