آموزش اصل Liskov Substitution از اصول SOLID

در دنیای برنامه‌نویسی همیشه به‌دنبال نوشتن کدی هستیم که خوانا، قابل توسعه و قابل نگهداری باشد. اما چطور می‌توان اطمینان داشت که کدهای ما به‌درستی اصول طراحی شیءگرا را رعایت کرده‌اند؟ اصل Liskov Substitution یا اصل جایگزینی لیسکوف یکی از مفاهیمی هست به ما در نوشتن کدهای پایدارتر و حرفه‌ای‌تر کمک می‌کند. در این آموزش می‌فهمیم این اصل چیست و چطور می‌توانیم آن را در پروژه‌های خود پیاده کنیم.

اصل جایگزینی لیسکوف سومین اصل از اصول SOLID است. برخی آن را به‌عنوان توسعه‌ی اصل Open Closed در برنامه‌نویسی می‌دانند. اگر مایل بودید می‌توانید بعداً سایر اصول را هم مرور کنید اما برای آموزش Liskov Substitution پیش‌نیاز خاصی ندارید.

اصل Liskov Substitution چیست؟

اصل Liskov Substitution می‌کند که «هر زیرکلاسی باید بتواند جایگزین کلاس والد خود شود، بدون اینکه رفتار کلی برنامه دچار اختلال شود.» به زبان ساده‌تر، وقتی از یک زیرکلاس (کلاس فرزند) به‌جای والدش استفاده می‌کنیم باید برنامه همچنان به درستی کار کند.

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

دقت کنید که اصل Liskov Substitution و در کل همهٔ اصول SOLID اجباری نیستند! اما رعایت آن‌ها می‌تواند به توسعه‌پذیرتر بودن کدهای ما و همین‌طور راحتی ما و سایر هم‌تیمی‌هایمان در توسعه کمک کند.

کدهایی که برای مثال در این آموزش نوشته‌ام به زبان PHP هستند. اما این مفاهیم به‌طور کاملاً یکسان در تمام زبان‌های شیءگرای دیگر نیز قابل پیاده‌سازی هستند. بنابراین با هر زبانی که کار می‌کنید می‌توانید از این آموزش استفاده کنید.

مثال استفاده از اصل Liskov Substitution

فرض کنید می‌خواهیم کلاس‌هایی برای مدیریت اشکال هندسی بسازیم. اکنون می‌خواهیم کلاس‌های مربع (square) و مستطیل (rectangle) را ایجاد کنیم.

یک حالت این است که کلاسی مجزا برای مربع و کلاسی مجزا برای مستطیل ایجاد کنیم. این راه‌حل کار راه‌انداز است اما خوب نیست. چرا که با کمک ارث‌بری کلاس در برنامه‌نویسی می‌توانیم کدهای بهینه‌تر و قابل توسعه‌تری بنویسیم. چطور؟

هر دو کلاس مربع و مستطیل یکسری ویژگی‌ها و متدهای مشترک دارند؛ مثلاً:

  • هر دو دارای اندازه ضلع (عرض و طول) هستند.
  • هر دو متد محاسبه محیط دارند که شبیه به‌هم عمل می‌کند.
  • هر دو متد محاسبه مساحت دارند که محاسبات شبیه به‌هم انجام می‌دهند.

از مباحث هندسه در دوران مدرسه هم یادمان هست که مربع نوعی خاص از مستطیل است. بنابراین می‌توانیم کلاس Square را به‌صورت فرزندی از Rectangle تعریف کنیم. برای کلاس Rectangle داریم:

<?php
class Rectangle {
    protected $width;
    protected $height;

    public function set_width($width){
        $this->width = $width;
    }

    public function set_height($height){
        $this->height = $height;
    }

    public function get_area(){
        return $this->width * $this->height;
    }
}

حالا کلاس Square را از Rectangle ارث برده و فقط متدهای تنظیم طول و عرض را بازنویسی می‌کنیم:

<?php
class Square extends Rectangle {
    public function set_width($width){
        $this->width = $width;
        $this->height = $width;
    }

    public function set_height($height){
        $this->width = $height;
        $this->height = $height;
    }
}
برنامه نویسی شئ گرا : مفاهیم و اصول شی گرایی

برنامه نویسی شئ گرا : مفاهیم و اصول شی گرایی

مشکل ناسازگاری رفتار

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

این مشکل را بعضاً با نام «ناسازگاری رفتار» یا «Behavioral Subtyping Violation» می‌نامند. اسمش خیلی مهم نیست! مهم این است که متوجه شوید منظورش چیست. بیایید با مثال ادامه دهیم.

تابعی می‌نویسم تا صحیح بودن عملکرد کلاس و زیرکلاس‌های Rectangle را بررسی کنیم. (در حالت اصولی‌تر، این تابع به شکل تست‌نویسی در برنامه‌نویسی نوشته می‌شود.)

<?php
function test_rectangle(Rectangle $rectangle){
    // انتظارات ما بر اساس رفتار کلاس والد:
    $rectangle->set_width(4);
    $rectangle->set_height(5);

    echo "Expected Area: 20, Actual Area: " . $rectangle->get_area() . PHP_EOL;
}

$rectangle = new Rectangle();
test_rectangle($rectangle);

$square = new Square();
test_rectangle($square);

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

Expected Area: 20, Actual Area: 20
Expected Area: 20, Actual Area: 25

برای مستطیل مقدار خروجی دقیقاً چیزی بود که انتظار داشتیم اما برای مربع چنین چیزی نشد. چرا؟

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

اما وقتی از کلاس فرزند Square استفاده می‌کنیم، به دلیل بازتعریف (بازنویسی) متدها در این کلاس، مقدار طول و عرض مستقل نیستند و با هم برابرند. چون داریم:

public function set_width($width){
    $this->width = $width;
    $this->height = $width; // عرض هم تغییر می‌کند
}

public function set_height($height){
    $this->width = $height; // طول هم تغییر می‌کند
    $this->height = $height;
}

بنابراین با تغییر طول یا عرض، مقدار دیگری نیز تغییر می‌کند. همین مسئله باعث نقض اصل Liskov Substitution می‌شود.

در نتیجه، رفتار کلاس Square با انتظاراتی که از کلاس والد (یعنی Rectangle) داریم مغایرت دارد. این همان چیزی است که اصل Loskov Susbtitution به ما هشدار می‌دهد: «کلاس‌های فرزند نباید رفتار والد را نقض کنند

اصلاح کد با رعایت اصل جایگزینی لیسکوف

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

ابتدا یک اینترفیس برای اشکال هندسی تعریف می‌کنیم. این اینترفیس به‌صورت قانونی عمل می‌کند که هر کلاسی که شیء است باید متدی برای محاسبه مساحت داشته باشد. اینکه این مساحت چگونه محاسبه می‌شود را به هر کلاس واگذار می‌کنیم.

<?php
interface Shape {
    public function get_area();
}

سپس کلاس‌های Rectangle و Square را به‌طور مجزا و درحالی‌که اینترفیس Shape را پیاده‌سازی می‌کنند تعریف می‌کنیم.

<?php
class Rectangle implements Shape {
    protected $width;
    protected $height;

    public function __construct($width, $height){
        $this->width = $width;
        $this->height = $height;
    }

    public function get_area(){
        return $this->width * $this->height;
    }
}

class Square implements Shape{
    protected $side;

    public function __construct($side){
        $this->side = $side;
    }

    public function get_area(){
        return $this->side * $this->side;
    }
}

دقت کنید که در این‌جا، برای بهبود کدهایمان، برای هر کلاس یک سازنده (Constructor) ایجاد کردم. می‌توانستم مشابه قبل، از متدهای set_width() و set_height() استفاده کنم.

اگر با ساختار کلاس در زبان‌های برنامه‌نویسی و مفاهیم object-oriented آشنا نیستید، می‌توانید آموزش کلاس در PHP یا کلاس در پایتون را ببینید.

یادآوری: از مفاهیم تعریف اینترفیس در برنامه‌نویسی می‌دانیم که در interface بدنه‌ی متدها پیاده‌سازی نمی‌شود و این کار برعهده کلاس‌هایی است که این اینترفیس را پیاده‌سازی می‌کنند.

در این طراحی، هر کلاس مستقل است اما با اینترفیس Shape برای کلاس‌هایمان متد محاسبه مساحت را اجباری کرده‌ایم. اینطوری اصل Liskov Substitution به‌طور کامل رعایت می‌شود.

یعنی برای تست تمام اشیاء زیرکلاس‌های Shape می‌توانم تابعی مشابه زیر نوشته و اشیاء کلاس‌های مختلفی نظیر Rectangle و Square را آزمایش کنم:

<?php
function test_shape(Shape $shape){
    echo "Area: " . $shape->get_area();
}

$rectangle = new Rectangle(4, 5);
test_shape($rectangle);

$square = new Square(4);
test_shape($square);

خروجی کد بالا در باکس زیر آورده شده است:

Area: 20
Area: 16
مثال انتزاعی و تصویری از اصل LSP یا جایگزینی لیسکوف در برنامه‌نویسی
مثال انتزاعی و تصویری از اصل LSP یا جایگزینی لیسکوف در برنامه‌نویسی

چرا اصل جایگزینی لیسکوف اهمیت دارد؟

به‌طور کلی ۲ مورد زیر را می‌توان جزء مزایای رعایت اصل Liskov Substitution در برنامه‌نویسی در نظر گرفت:

  • جلوگیری از رفتار غیرمنتظره: اگر کلاس فرزند رفتار متفاوتی نسبت به کلاس والد داشته باشد، ممکن است کدی که انتظار عملکرد خاصی دارد با خطاهای برنامه‌نویسی مواجه شود. اصل لیسکوف از این مشکل جلوگیری می‌کند.
  • توسعه‌پذیری بهتر: با رعایت قانون Liskov Susbtitution در برنامه نویسی، مطمئن می‌شویم که کدهای ما با افزودن یا تغییر کلاس‌های فرزند، نیاز به تغییرات اساسی نخواهند داشت. چون عملکرد و رفتار همگی یکسان بوده و متدهای اجباری نیز در اینترفیس تعریف شده‌اند.

یکی از دوستانم درباره این اصل مثالی زد که به‌عنوان یک مثال متفاوت‌تر از مربع و مستطیل بازگو می‌کنم:

فرض کنید کلاس والد ما Bird (پرنده) است و متدی به نام fly() (پرواز کردن) دارد. اگر کلاس فرزندی به نام Penguin (پنگوئن) از این کلاس ایجاد کنیم، باید بدنه متد fly() را به‌گونه‌ای تغییر دهیم که یک استثنا (Exception) ایجاد کند. چرا؟ چون پنگوئن جزء کلاس پرندگان است اما نمی‌تواند پرواز کند! 😉

الآن کلاسی داریم که فرزند Bird است اما رفتار غیرمنتظره‌ای برای متد fly() دارد. چه کاری کنیم؟ یک پیشنهاد این است که اینترفیس Bird ایجاد کنیم و متدهایی که بین تمام پرندگان (مثل صدا داشتن یا غذا خوردن) را در آن قرار داده و سپس اگر نیاز بود، کلاس‌هایی مناسب برای پرندگانی که می‌توانند پرواز کنند با رفتارهای خاص‌تر ایجاد کنیم.

جمع‌بندی آموزش

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

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

در انتها جالب است بدانید که این اصل توسط خانم باربارا لیسکوف ارائه شده است. ایشان در سال 2008 نیز جایزه تورینگ را به علت ابداع‌هایش در دنیای برنامه‌نویسی کسب کرده‌اند. می‌توانید مباحث ریاضیاتی و قاعده‌ای اصل LSP (ابتدای کلمات Liskov Substitution Principle) را در ویکی‌پدیا بخوانید.

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