فهمیدن LSP - یک‌بار برای همیشه!

مقدمهاگر به دنبال یادگیری اصول SOLID بوده باشید، احتمالا فهم اصل سوم برایتان دشوار بوده است. مقالات بسیاری در فضای مجازی فارسی وجود دارند که غالبا حق مطلب را ادا نکرده و تنها مطالب سایت‌های دیگر را تکرار می‌کنند و همین باعث شده که بعد از خواندنشان دچار سرگردانی و گیجی شویم یا اینکه به اشتباه فکر کنیم که این اصول را فهمیده‌ایم، با اینکه چیزی دست‌گیرمان نشده است!این اصل، نام عجیبی دارد که احتمالا آدم را بترساند، اما حداقل فهمیدن نام آن سخت نیست! منظور از کلمۀ اول این عبارت (Liskov)، باربارا لسکوف است که یک دانشمند حوزۀ کامپیوتر بود و کلمۀ دوم (Substitution) به معنای استفاده‌کردن چیزی به جای چیزی دیگر است. کلمۀ دوم برای ما مهم است، زیرا کلید فهم این اصل در همین‌جاست: جایگزینی!تعریفLet Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.یا واضح‌تر از آن، اینکه:If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program.بیایید T را superclass (مثل Animal) و S را یک subclass فرض کنیم (مثل Cat)؛ اگر آبجکتی از T را در جایی استفاده می‌کنیم، باید آبجکتی از S هم بتواند جایگزین آن شود و کار همچنان ادامه بیابد. اگر این جابه‌جایی دردسرساز شد، یعنی LSP نقض شده است.مثال‌هامثال 1: مستطیل و مربعمثال مشهور مستطیل و مربع! به کد زیر دقت کنید:<?php class Rectangle { private $width; private $height; public function __construct($width, $height) { $this->width = $width; $this->height = $height; } public function setWidth($width) { $this->width = $width; } public function setHeight($height) { $this->height = $height; } public function getArea() { return $this->width * $this->height; } } $shape = new Rectangle(2, 3); $shape->setWidth(4); $shape->setHeight(6) assert($shape->getArea() == 24); // 4 x 6 = 24همه‌چیز خوب کار می‌کند و همه خوشحال‌اند، تا اینکه مشتری تماس می‌گیرد و پشتیبانی از مربع را هم می‌خواهد! با خودمان فکر می‌کنیم که هر مربع را می‌شود مستطیل هم در نظر گرفت، پس کلاس مربع می‌تواند از کلاس مستطیل که قبلاً ساخته بودیم، ارث‌بری کند. از آنجا که طول و عرض مربع برابر است، نباید به کاربر اجازه داد تا از مربع، مستطیل بسازد! کد مربوط به کلاس مربع را ببینیم:<?php class Square extends Rectangle { public function setWidth($width) { $this->width = $width; $this->height = $width; } public function setHeight($height) { $this->height = $height; $this->width = $height; } }سپس... جابه‌جایی مستطیل با مربع اتفاق می‌افتد:$shape = new Square (2, 2); $shape->setWidth(4); $shape->setHeight(6); assert($shape->getArea() == 24); // fatal error, 6 x 6 = 36کلاس مربع، رفتار کلاس پدر خود یعنی مستطیل را تغییر داده است و چنین اتفاق غیر منتظره‌ای افتاد. این یعنی آبجکتی از Square نمی‌تواند جایگزین آبجکتی از Rectangle شود و همین نشان می‌دهد که در طراحی اشتباه کرده‌ایم!مثال 2: اسلحه وسیلۀ بازی نیست!این، شکل دیگری از مثال معروف اردک است. به کد زیر دقت کنید:<?php class Gun { public function shoot() { // 50 lines of code } } class ToyGun extends Gun { public function shoot() { throw new Exception('We don\'t do that here!'); } }کد دیگری، از سوپرکلاس Gun استفاده می‌کرده است:public function use(Gun $gun) { $gun->shot(); }کد استفاده‌کننده از Gun، انتظار هیچ Exceptionای را نمی‌کشیده است و تغییر رفتار Gun توسط ToyGun اصل سوم SOLID را نقض کرد. اصلاً چه کسی گفته که تفنگ اسباب‌بازی باید از تفنگ ارث‌بری کند؟ ارتباط در Inheritance، یک is-a است اما:Toy gun is not a gun!چنین مواردی به ما گوشزد می‌کنند که is-a راه‌حل همیشگی نیست و گاهی باید سراغ has-a برویم، یعنی: Composition! اما بگذریم... نکتۀ مثال دوم این است که اگر کلاس پدر یک نوع از Exception را throw کرد، کلاس‌های فرزند نباید چیزی فراتر از آن را throw کنند زیرا کد استفاده‌کننده (Client) آمادگی چنین چیزی را ندارد! آقای Alexander Shvets می‌گوید:Types of exceptions should match or be subtypes of the ones that the base method is already able to throw.یعنی نوع Exceptionها باید دقیقاً همان‌هایی باشد که بوده، یا زیرمجموعۀ آنها باشد و دیگر از Exception جدیدی استفاده نشود. اگر تا الآن خبر نداشتید که در PHP خودمان می‌توانیم Exception بسازیم و از آنها ارث‌بری کنیم، این بخش از داکیومنتیشن را بخوانید!مثال 3: آپلودر جدید!به کد زیر دقت کنید:<?php class File { } class Document extends File { } class PDFDocument extends Document { } class Uploader { public function upload(Document $file) { // 100 lines of code } } $file = new Document; $uploader = new Uploader; $uploader->upload($file);این کد به خوبی کار می‌کند، اما تصور کنید کلاس جدیدی بر اساس کلاس Uploader به نام FTPUploader بسازیم و متد upload را override کنیم. LSP به ما می‌گوید که نوع آرگومانی که به این متد داده می‌شود، باید همان Document یا چیزی عام‌تر از آن باشد.کلاس File از کلاس Document عام‌تر است؛ زیرا هر Document یک File حساب می‌شود اما هر File لزوماً یک Document نیست. همچنین کلاس Document از کلاس PDFDocument عام‌تر است، زیرا هر PDFDocument یک Document هم حساب می‌شود، اما Document می‌تواند علاوه بر pdf، فرمت docx یا txt هم داشته باشد.بعد از فهمیدن پاراگراف بالا، کلاس جدید را ببینید:class FTPUploader extends Uploader { public function upload(... $file) { // 100 lines of code } }جای یک Type در ... خالی است! با توضیحاتی که گفته شد، متدِ کلاسِ جدید می‌تواند Document و همچنین File را بپذیرد (File از Document عام‌تر است)، اما PDFDocument از نوع پارامتری که کلاس پدر می‌گرفت خاص‌تر است و LSP را نقض می‌کند. اگر PDFDocument را به جای Type قرار دهیم، و کلاس FTPUploader را جایگزین کلاس Uploader کنیم:$file = new Document; $uploader = new FTPUploader; // Look at here $uploader->upload($file);با خطای زیر مواجه خواهیم شد:Fatal error: Declaration of FTPUploader::upload(PDFDocument $file) must be compatible with Uploader::upload(Document $file)اما اگر Type را File در نظر بگیریم، این خطا را نمی‌بینیم؛ زیرا Document هم یک File است و در نتیجه کد به درستی کار خواهد کرد!مثال 4: کالکشن به جای آرایه!این مثال را در ویدئویی از Jeffrey Way دیده بودم و شاید از مثال‌های گذشته قابل‌درک‌تر باشد. در لاراول، کلاسی به نام Collection داریم که چیزی شبیه آرایه اما بسیار قدرت‌مندتر ارائه می‌کند. اگر superclass یک متد با خروجی array دارد، همان متد در subclass هم باید خروجی array داشته باشد؛ زیرا اگر چنین نباشد، در صورت جایگزین‌کردن subclass با superclass، امکان بروز خطا وجود خواهد داشت:$scores = (new SubClass())->getScore(); // Returns a collection return array_sum($scores);از آنجا که فانکشن array_sum با آرایه‌ها کار می‌کند و کدی که از کلاس استفاده می‌کرد، توقع یک آرایه را از متد getScore داشت، با گذاشتن subclass به جای superclass، خطای زیر را خواهیم دید:array_sum(): Argument #1 ($array) must be of type array, Illuminate\Support\Collection givenمثال 5: اما و اگر نداریم!کلاس A متدی به نام submitPoints دارد که یک آرگومان int را می‌پذیرد. اگر کلاس B طوری آن را override کند که تنها اعداد مثبت را بپذیرد:public function submitPoints(int $points) { if ($points < 1) { throw new Exception('I want you to give me positive numbers!'); } // Logic here }قوی‌کردن پیش‌شرط‌ها خوب نیست و کدی که با کلاس قبلی کار می‌کرده و اعداد منفی هم وارد آن می‌شده، با جایگزین‌شدن کلاس جدید، به مشکل خواهد خورد!مثال 6: کانکشن‌های باز و کلاس دردسرساز!این مثال شبیه مثال قبلی است، اما به پس‌شرط‌ها (post-conditions) مربوط می‌شود. مثال Shvet این است که کلاس A کانکشن‌های دیتابیس را بعد از استفاده می‌بسته، اما کلاس جدید چنین نمی‌کند و کانکشن‌ها را برای استفادۀ مجدد، زنده نگه می‌دارد! کدی که از کلاس استفاده می‌کرده، از قصد برنامه‌نویس خبر ندارد و وقتی جایگزینی B به جای A صورت بگیرد، کانکشن‌های باز زیادی باقی خواهد ماند!البته ما اهمیت خاصی به بستن کانکشن‌های MySQL نمی‌دادیم و دلیلش را هم می‌دانید، اما به نظرم مثال خوب و واضحی است.نتیجه‌گیریبا اصل سوم SOLID یعنی Liskov Substitution Principle طراحی‌های بهتری خواهیم داشت و ارث‌بری را صرفاً برای افزودن متدهای جدید امتحان نخواهیم کرد. در یک زنجیره (برای نمونه، زنجیرۀ حیوانات را تصور کنید)، آبجکت هر کلاس باید بدون مشکل با آبجکتی از کلاس دیگری که subtype است جایگزین شود و کد به کار خود ادامه دهد. امیدوارم این مقاله به شما کمک کرده باشد و فهم بهتری از LSP به دست آورده باشید.منابع:Dive Into Design Patterns, by Alexander Shvets.Clean Architecture, by Robert Martin.https://laracasts.com/series/solid-principles-in-php/episodes/3