درک اصول طراحی SOLID یکبار برای همیشه

Image for post
Image for post

SOLID مخفف پنج اصل طراحی در برنامه‌نویسی شی‌گرا (OOD - object-oriented design) است و مجموعه‌ای از دستورالعمل‌هاست که توسعه‌دهندگان از آن برای ساخت نرم افزار به روشی آسان و پایدار استفاده می‌کنند. درک این مفاهیم شما را به یک توسعه‌دهنده بهتر تبدیل خواهد کرد.

 

SOLID:

  • S مخفف اصل: Single-responsibility 
  • O مخفف اصل: Open-closed 
  • L مخفف اصل: Liskov substitution 
  • I مخفف اصل: Interface segregation 
  • D مخفف اصل: Dependency Inversion

 

Single-responsibility Principle

یک کلاس باید یک و تنها یک دلیل برای تغییر داشته باشد، به این معنی که یک کلاس باید فقط یک وظیفه اصلی داشته باشد.

به این معنی که اگر کلاس ما بیش از یک مسئولیت را به عهده بگیرد، کوپلینگ بالایی خواهیم داشت. علت این است که کد ما در هر تغییری شکننده خواهد بود.
فرض کنید کلاس User مانند زیر داشته باشیم:

class User {
  
    private $email;
    
    // Getter and setter...
    
    public function store() {
        // Store attributes into a database...
    }
}

در اینجا متد ‍store خارج از محدوده کلاس کاربر هست چرا که معمولا وظیفه کار با دیتابیس متعلق به کلاسی هست که کار با داده‌ها را مدیریت می‌کند. راه حل در اینجا ایجاد دو کلاس با مسئولیت‌های مناسب است. برای نمونه:

class User {
  
    private $email;
    
    // Getter and setter...
}

class UserDB {
  
    public function store(User $user) {
        // Store the user into a database...
    }
}

 

Open-closed Principle

اشیا یا موجودیت‌ها بایستی نسبت به گسترش باز، اما برای اصلاح بسته باشند.

طبق این اصل، یک موجودیت باید به راحتی با ویژگی‌های جدید بدون نیاز به هر گونه تغییری در کد، قابل گسترش باشد.
فرض کنید باید مساحت کل یکسری از اشیاء را محاسبه کنیم و برای این کار به یک کلاس مانند AreaCalculator نیاز داریم که فقط مجموع مساحت هر شکل را انجام می‌دهد. اما مسئله اینجاست که هر شکل روشی متفاوت برای محاسبه مساحت خود دارد. به مثال زیر توجه کنید:

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

class Square {
  
    public $length;
    
    public function __construct($length) {
        $this->length = $length;
    }
}


class AreaCalculator {
  
    protected $shapes;
    
    public function __construct($shapes = array()) {
        $this->shapes = $shapes;
    }
    
    public function sum() {
        $area = [];
        
        foreach($this->shapes as $shape) {
            if($shape instanceof Square) {
                $area[] = pow($shape->length, 2);
            } else if($shape instanceof Rectangle) {
                $area[] = $shape->width * $shape->height;
            }
        }
    
        return array_sum($area);
    }
}

در اینجا اگر بخواهیم برنامه را گسترش دهیم و شکل دیگری مانند یک دایره را اضافه کنیم، باید برای محاسبه مساحت شکل جدید، AreaCalculator را تغییر دهیم و این پایدار نیست!!! شما چطور این مساله رو مدیریت می‌کنید؟ کمی فکر کنید؟! 🤔

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

interface Shape {
    public function area();
}

class Rectangle implements Shape {
  
    private $width;
    private $height;
    
    public function __construct($width, $height) {
        $this->width = $width;
        $this->height = $height;
    }
    
    public function area() {
        return $this->width * $this->height;
    }
}

class Square implements Shape {
  
    private $length;
    
    public function __construct($length) {
        $this->length = $length;
    }
    
    public function area() {
        return pow($this->length, 2);
    }
}


class AreaCalculator {
  
    protected $shapes;
    
    public function __construct($shapes = array()) {
        $this->shapes = $shapes;
    }
    
    public function sum() {
        $area = [];
        
        foreach($this->shapes as $shape) {
            $area[] = $shape->area();
        }
    
        return array_sum($area);
    }
}

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

نمای UML برای درک بهتر ارتباط کلاس‌ها پس از اعمال اصل open-closed
نمای UML برای درک بهتر ارتباط کلاس‌ها پس از اعمال اصل open-closed
نمای  UML برای درک بهتر ارتباط کلاس‌ها پس از اعمال اصل open-closed

Liskov Substitution Principle

فرض کنید q(x) یک ویژگی قابل اثبات در مورد اشیاء x از نوع T باشد. سپس q(y) باید برای اشیاء y از نوع S که در آن S زیرگروه T است قابل اثبات باشد.

این قاعده اولین بار توسط باربارا لیسکوف و جینت وینگ در سال ۱۹۹۴ ارائه شد و بیان میکنه که رفتار زیرکلاس‌ها باید با والدین‌شان سازگاری داشته باشد. زمانی که یک متد را از کلاس والد توسعه می‌دهید نباید بطورکلی آنرا به چیز دیگری تبدیل کنید! 

برای نمونه به مثال زیر توجه کنید. در اینجا ما یک کلاس Document داریم که وظیفه ذخیره یک فایل رو بعهده داره. ولی همانطور که مشاهده می‌کنید زیرکلاس ReadOnlyDocumentرفتار کلی والد خود را تغییر داده است. در اینجا اگر کسی بخواهد از کد ما استفاده کند و تابع save از کلاس ReadOnlyDocument را صدا بزند با خطا مواجه خواهد شد محدودیتی  که در کلاس والد تعریف نشده.

class Document
{
    private $filename;

    public function __construct($filename)
    {
        $this->filename = $filename;
    }
    
    public function save()
    {
        echo "saving...";
    }
}

class ReadOnlyDocument extends Document
{
    public function save()
    {
        throw new Exception("Can't save read-only document.");
    }
}

class Project
{
    private array $documents;

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

    public function saveAll()
    {
        foreach ($this->documents as $doc) {
            if (!($doc instanceof ReadOnlyDocument)) {
                $doc->save();
            }
        }
    }
}

این مساله با انتقال قابلیت ذخیره کردن به یک زیر کلاس از کلاس Document حل می‌شود. در اینجا فایل‌هایی که خواندنی و نوشتنی هستند همگی در کلاس والد قرار می‌گیرند و رفتار ذخیره کردن با گسترش کلاس والد به کلاس WritableDocument منتقل می‌شود.

class Document
{
    private $filename;

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

class WritableDocument extends Document
{
    public function save()
    {
        return 'document is saved!';
    }
}

class Project
{
    private array $documents;

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

    public function saveAll()
    {
        foreach ($this->documents as $doc) {
            if ($doc instanceof WritableDocument) $doc->save();
        }
    }
}

Interface Segregation Principle

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

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


تصور کنید ما می‌خواهیم سرویس‌های Amazon و DropBox رو در یک برنامه مدیریت کنیم. همانطور که می‌دانید هر سرویس‌ خاصیت‌های متفاوتی داره و اگر بخواهیم برای هر کلاس‌ تنها یک interface رو پیاده کنیم با توابعی مواجه می‌شیم که کاربردی در یک کلاس ندارند.

interface CloudProvider
{
    public function storeFile(string $name);

    public function getFile(string $name);

    public function createServer(string $region);

    public function getCDNAddress();
}

class Amazon implements CloudProvider
{
    public function storeFile(string $name)
    {
        echo 'file stored';
    }

    public function getFile(string $name)
    {
        echo 'this is your file';
    }

    public function createServer(string $region)
    {
        echo 'server created';
    }

    public function getCDNAddress()
    {
        echo 'this is CDN address';
    }
}

class DropBox implements CloudProvider
{
    public function storeFile(string $name)
    {
        echo 'file stored';
    }

    public function getFile(string $name)
    {
        echo 'this is your file';
    }

    // useless function
    public function createServer(string $region)
    {
        // TODO: Implement createServer() method.
    }

    // useless function
    public function getCDNAddress()
    {
        // TODO: Implement getCDNAddress() method.
    }
}

برای حل این مشکل بهتر هست که براساس هر قابلیت interface مشخصی داشته باشیم:

interface CDNProvider
{
    public function getCDNAddress();
}

interface CloudHostingProvider
{
    public function createServer(string $region);
}

interface CloudStorageProvider
{
    public function storeFile(string $name);

    public function getFile(string $name);

}

class Amazon implements CloudStorageProvider, CDNProvider, CloudHostingProvider
{
    public function storeFile(string $name)
    {
        echo 'file stored';
    }

    public function getFile(string $name)
    {
        echo 'this is your file';
    }

    public function createServer(string $region)
    {
        echo 'server created';
    }

    public function getCDNAddress()
    {
        echo 'this is CDN address';
    }
}

class DropBox implements CloudStorageProvider
{
    public function storeFile(string $name)
    {
        echo 'file stored';
    }

    public function getFile(string $name)
    {
        echo 'this is your file';
    }
}

 

Dependency Inversion Principle

ماژول سطح بالا نباید به ماژول سطح پایین بستگی داشته باشد، بلکه آنها باید به انتزاعات وابسته باشند.

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

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

class BudgetReport
{
    private MySQLDatabase $database;

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

    public function save($data)
    {
        $this->database->insert($data);
    }
}

class MySQLDatabase
{
    public function insert($data)
    {
        echo 'data is inserted!';
    }

    public function update($data)
    {
        echo 'data is updated!';
    }

    public function delete($data)
    {
        echo 'data is deleted!';
    }
}

برای رفع این مشکل ما یک interface به نام Database را تعریف می‌کنیم تا امکان گسترش و تعریف دیتابیس‌های مختلف برای برنامه فراهم بشه!

class BudgetReport
{
    private Database $database;

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

    public function save($data)
    {
        $this->database->insert($data);
    }
}

interface Database
{
    public function insert($data);

    public function update($data);

    public function delete($data);
}

class MySQLDatabase implements Database
{
    public function insert($data)
    {
        echo 'data is inserted!';
    }

    public function update($data)
    {
        echo 'data is updated!';
    }

    public function delete($data)
    {
        echo 'data is deleted!';
    }
}

class Mongo implements Database
{
    public function insert($data)
    {
        echo 'data is inserted!';
    }

    public function update($data)
    {
        echo 'data is updated!';
    }

    public function delete($data)
    {
        echo 'data is deleted!';
    }
}

در تصویر زیر می‌توانید تاثیر اصل Dependency Inversion و نحوه ارتباط این کلاس‌ها را با هم بهتر درک کنید

Image for post
Image for post

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