اصول SOLID (سالید) که مخفف 5 اصل است، اصولی قانونمند در برنامهنویسی شیءگرا هستند و در تمامی زبانهای برنامهنویسی که از شیگرایی پشتیبانی میکنند، میتوانند پیادهسازی شوند.
این اصول توسط مهندسی به نام Robert Martin که به عنوان Uncle Bob یا «عمو باب» هم شناخته میشود، در اوایل سال 2000 ابداع شد. هدف این اصول این است که نرمافزارها قابل درکتر، انعطافپذیرتر و بیشتر قابل نگهداری باشند، توسعهدهنده یا برنامهنویس با سهولت بیشتری به توسعهی نرمافزار بپردازد و علاوه بر این نرمافزارهای خود را با رویکرد چابک توسعه دهد، مرتکب اشتباهات کوچک نشود و هنگام نیاز به سادگی کدهای خود را بازنویسی کند. این اصول، اصولی بسیار مهم در مدیریت وابستگی (Dependency Managment) در روند توسعهی برنامههای شیءگراست و درواقع میتوان گفت یادگیری این اصول جزء «بایدها» است.
اصول SOLID بر پایه 5 اصل زیر است:
Single Responsibility Principle
Open/Closed Principle
Liskov Substitution Principle
Interface Segregation Principle
Dependency Inversion Principle
اگر دقت کرده باشید، هنگامی که حرف اول هر کدام از این کلمات را در کنار هم قرار دهیم، کلمهی SOLID ایجاد میشوند. با پیادهسازی این اصول در روند توسعهی برنامههای خود، میتوانیم به یک طراحی شیگرا پاک و درست دست پیدا کنیم.
تعریف رسمی این اصل به این شکل است:
A class should have one and only one reason to change, meaning that a class should have only one job.
درواقع همانطور که از نام این اصل (که اصل اول از اصول SOLID است و به طور خلاصه S.R.P هم نامیده میشوند) مشخص است، هر کلاس فقط و فقط باید یک وظیفه و یک دلیل برای تغییر داشته باشد. هر کلاس میتواند ویژگیهای مختلفی داشته باشد که همهی آن باید مربوط با یک حوزه بوده و با هم مرتبط باشند. با رعایت این قانون، برنامهنویسان دیگر نمیتوانند کلاسهای اصطلاحا همهفنحریف بنویسند! به طور سادهتر یک کلاس باید تنها مسئول یک عملکرد در برنامه باشد. اگر 2 دلیل مختلف برای تغییر یک کلاس وجود داشته باشد، امکان دارد 2 تیم مختلف این کار را انجام دهند و در نهایت کلاس به واسطهی 2 تیم مختلف ویرایش شده و باعث میشود تا فرایند سوال و جواب برای هماهنگی به طول بینجامد.
بگذارید با یک مثال این موضوع را شفافتتر و قابل درکتر کنیم! به مثال زیر دقت کنید:
class User { public information() {} public sendEmail() {} public orders() {} }
در این کلاس سه متُد information که برای ارائه اطلاعات کاربر و sendٍEmail برای ارسال ایمیل به کاربر و orders سفارشهای کاربر را ارئه میدهد. به نظر شما هدف کلاسی مانند User چیست؟ احتمالا این که اطلاعات کاربر را ذخیره یا نمایش دهد و اگر دقت کرده باشید، تنها مسئولیت این کلاس در حوزهی مربوط به یک کاربر است اما در کلاس User تنها متد information است که با آن (کلاس User) مرتبط است و مابقی متدها (sendEmail و orders) وظیفههایی با کلاس User دارند. اگر کلاس User مسئولیتهای دیگری ( مانند ارسال ایمیل و مدیریت کردن سفارشات کاربر) داشه باشد، کلاس دیگر با عملکردهای ذاتی خودش (مانند ارائه اطلاعات کاربر) محصور شده نیست و یا به طور سادهتر، کلاس User با عملکردهایی که ربطی به آن ندارند، ترکیب شده است.
مشکل زمانی خودش را نشان خواهد داد که قصد گسترش کلاس را داشته باشیم. به عنوان مثال، ایمیلهای مختلف و اختصاصیتر بفرستیم. که در انتها نمیدانیم این کلاس User است یا Email! که برای حل این مسئله باید عملکردهای اضافی که در کلاس User است را به یک کلاس اختصاصی انتقال دهیم، به این شکل:
class User { public information() {} } class Email { public send(user: User) {} } class Order { public show(user: User) {} }
الان کلاس User خلوتتر، مرتبتر و تمیزتر شده است و همینطور توسعهی کلاس User و کلاسهای دیگر راحتتر صورت میگیرد.
این نکته را مدنظر داشته باشید که اصل تک مسئولیتی تنها محدود به کلاسها نیست و توسط متُدها و توابع هم میتواند اعمال شود. مثال زیر را مشاهده کنید که در آن متد send این اصل را اعمال نکرده است یا به عبارتی آن را نقض کرده:
class Mailer { public send(text) { mailer = new Mail(); mailer.login(); mailer.send(text); } } mail = new Mailer; mail.send('Salut');
اگر دقت کرده باشید، میبینید که متد send مسئولیت 2 کار را بر عهده گرفته است:
علاوه بر این اصل که در اینجا توسط متد send نقض شده است، اصل دوم از اصول SOLID را که در ادامه آن را توضیح خواهیم داد، هم نقض شده است!
برای اینکه این متد را بهتر بنویسیم و از اصل Single Responsibility Principle هم پیروی کنیم، به این شکل خواهیم نوشت:
class Mailer { private mailer; public constructor(mailer) { this.mailer = mailer; } public send(text) { this.mailer.send(text); } } myEmail = new MyEmailService; myEmail.login(); mail = new Mailer(myEmail); mail.send('Salut');
متد send در اینجا دیگر فقط یک وظیفه دارد و آن هم ارسال ایمیل است!
مثالی دیگر میتواند تعدادی اشکال هندسی (مربع، دایره و ...) باشد که بخواهیم مجموع محیطهای آنها را حساب کنیم. چیزی که ابتدا به ذهنمان میرسد که یک کلاس برای هر شکل در نظر گرفته و با استفاده از متد سازنده ( ()construct ) زاوبه و طول هر ضلع را مشخض کنیم:
class Circle { public $radius; public function __construct($radius) { $this->radius = $radius; } } class Square { public $length; public function __construct($length) { $this->length = $length; } }
همانطور که در قطعه کد بالا مشاهده میکنید، 2 کلاس برای دایره و مربع ایجاد کردیم. سپس با استفاده از کلاس AreaCalculator به صورت زیر، محیطهای اشکال هندسی را محاسبه میکنیم:
class AreaCalculator { protected $shapes; public function __construct($shapes = array()) { $this->shapes = $shapes; } public function sum() { // logic to sum the areas } public function output() { return implode('', array( " ", "Sum of the areas of provided shapes: ", $this->sum(), " PHP " )); } }
در تابع سازنده کلاسی که در بالا مشاهده کردید، اشکال مختلف را به صورت آرایه دریافت میکنیم و با استفاده از متد sum مجموع آنها را محاسبه و در نهایت با استفاده از متد output نتیجه را در قالب HTML و CSS چاپ میکنیم که به صورت زیر است:
$shapes = array( new Circle(2), new Square(5), new Square(6) ); $areas = new AreaCalculator($shapes); echo $areas->output();
حال اگر به جای خروجی HTML، خروجی JSON مدنظر ما باشد، چه کاری باید انجام داد؟ با توجه به اصل Single responsiblity principle که هر کلاس باید تنها یک کار انجام دهد، محاسبهی محیط در داخل کلاس AreaCalculator صورت میپذیرد و نمایش خروجی به کلاسی دیگر به عنوان مثال SumCalculatorOutputter واگذار میشود که به صورت زیر میتوانید از کلاسها استفاده کنید:
$shapes = array( new Circle(2), new Square(5), new Square(6) ); $areas = new AreaCalculator($shapes); $output = new SumCalculatorOutputter($areas); echo $output->JSON(); echo $output->HAML(); echo $output->HTML(); echo $output->JADE();
مثال آخر هم میتواند کلاسی به نام Book باشد که وظیفهاش مدیریت کردن عناوین و محتوای کتاب است که در زیر میتوانید مشاهده کنید:
function getTitle() { return "A Great Book"; } function getAuthor() { return "John Doe"; } function turnPage() { // pointer to next page } function printCurrentPage() { echo "current page content"; } }
کلاس که در بالا مشاهده میکنید کاری که از آن میخواهیم را انجام میدهد و به ظاهر کلاس خوبی نوشتهایم. اما مسئله اینجاست که وظیفهی پرینت کردن صفحه با وظیفهی مدیریت یک کتاب بسیار متفاوت هستند و این ساختار، اصل S.R.P را نقض کرده است. حال همین مثال در قالب S.R.P به شکل زیر است:
class Book { function getTitle() { return "A Great Book"; } function getAuthor() { return "John Doe"; } function turnPage() { // pointer to next page } function getCurrentPage() { return "current page content"; } } interface Printer { function printPage($page); } class PlainTextPrinter implements Printer { function printPage($page) { echo $page; } } class HtmlPrinter implements Printer { function printPage($page) { echo ' ' . $page . ' PHP '; } }
با این کار، ساختار مدیریت کتاب از بخش پرینت جدا شده است و همینطور ممکن است بخواهیم به انواع فرمتهای مختلف خروجی پرینت داشته باشیم. با تعریف یک اینترفیس (interface) میتوانیم ساختار مشترکی برای کلاسهای پرینت ایجاد کنیم و هر کلاس به طور مستقل وظیفهی پرینت هر فرمت را بر عهده بگیرد. آیا با این کار، برنامهنویس انطعافپذیری بیشتری ندارد؟ به این صورت اگر روزی پرینت با فرمت جدید به پروژه اضافه شود، دیگر نیازی به تغییر کلاسهای دیگر نیست و تنها کلاس جدید به مجموعه اضافه میشود و همین امر باعث حفظ ساختار، تفکیک وظایف مدیریت بهتر و خطای کمتر خواهد بود.
در ادامه آموزش اصول SOLID به دومین اصل که Open/Closed Principle یا به اختصار OCP نام دارد خواهیم پرداخت.
تعریف رسمی آن به این صورت است:
Objects or entities should be open for extension, but closed for modification.
این به این معنی است که برنامهنویس باید کلاسها و اشیاء را به نحوهی ایجاد کند که همیشه امکان گسترش (Extend) آنها وجود داشته باشد و برای گسترش نیازی به تغییر در کلاس اصلی نباشد. این اصل در برنامهنویسی شیءگرا باعث میشود که ارتباط بین کلاسها و ماژولها کاهش پیدا کند که در نهایت این امکان فراهم خواهد شد تا توسعه از طریق اضافه کردن کلاسهای جدید صورت بگیرد نه تغییر کلاسهای موجود.
به طور سادهتر هر کلاس برای اینکه قابلیتهایش توسعه پیدا کنند، باز یا Open باشد و برنامهنویس بتواند فیچرهای (ویژگیهای) جدید را اضافه کند. اما اگر او خواست در کلاس تغییری ایجاد کند، این امکان باید Closed باشد و اجازهی این کار را نداشته باشد.
اگر ما نرمافزاری را توسعه داده باشیم که چندین کلاس مختلف دارد و درواقع این کلاسهای نیازهای نرمافزار ما را فراهم میکنند، زمانی میرسد که بخواهیم قابلیتهای جدیدی به نرمافزار خود اضافه کنیم. حال طبق این قانون، میتوانیم کلاس مدنظرمان را تغییر یا بهتر بگوییم، فیچرهای جدید را به کلاسمان اضافه کنیم که این فیچرهای جدید باید به عنوان کدهای جدید به کلاس اضافه شوند نه ریفکتور کردن یا تغییر کدهای قبلی! حال به کلاسی میگویم بسته، که کامل باشد! به این معنی که 100 درصد تست شده باشد و بتواند توسط دیگر کلاسها استفاده شود. پایدار باشد و در آینده تغییری نکند.
در بعضی از زبانهای برنامهنویسی کلمهی کلیدی final یکی از راههای بسته نگهداشتن کلاس است. به این ترتیب اصل OCP به این شکل تعریف میشود که ما باید طوری کد را بنویسیم که هنگامی که خواستیم آن را توسعه دهیم و ویژگیهای جدید را اضافه کنیم، مجبور به تغییر و دستکاری آن نشویم و بتوان به راحتی بدون اینکه قسمتهای دیگر دستکاری شوند، ویژگیهای جدید را اضافه کرد. با توجه به این اصل، کلاس به طور همزمان هم باید بسته باشد هم باز! به این معنی که همان موقع که توسعه داده میشود (باز بودن)، تغییری نکند و دستکاری نشود (بسته بودن).
اگر کمی دقت کنیم، متوجه خواهیم شد که OCP و SRP مکمل همدیگر هستند. این به این معنی نیست که هر کجا SRP را رعایت کرده باشیم، پس OCP را هم رعایت کردهایم. اما رعایت هر یک از اصول دستیبانی به اصول دیگر را راحتتر و سادهتر میکند.
بیاید این اصل را با چند مثال نمایش دهیم تا درک بهتری از آن داشته باشیم.
اگر کلاسی مانند BankAccount داشته باشیم که دو کلاس به نامهای SavingAccount و InverstmentAccount از کلاس BankAccount ارثبری کنند، میخواهیم کلاسی جدیدی با نام CurrentAccount ایجاده کرده و از BankAccount ارثبری کند. اما کلاس CurrentAccount قابلیتهای جدیدی دارد که در کلاس والد خود (BankAccount)، وجود ندارد. در این موقعیت، به جای اضافه کردن قابلیتهای جدید به کلاس والد، قابلیتهای جدید را در همان کلاس فرزند (CurrentAccount) اضافه خواهیم کرد. به طور سادهتر، کدهای موجود را تغییر نمیدهیم و قانون Open/Closed را هم رعایت کردهایم. به این شکل، کلاس مد نظرمان برای توسعه باز، اما برای اعمال هر گونه تغییر بسته است.
قطعه کد زیر را در نظر بگیرید:
class Hello { public say(lang) { if (lang == 'pr') { return 'درود'; } else if (lang == 'en') { return 'Hi'; } } } let obj = new Hello; console.log(obj.say('pr'));
کلاس Hello، با توجه به زبان وردی که دارد، خروجی را تعیین می کند. 2 زبان توسط متد say پشیبانی میشود. اگر زبانهای دیگر را بخواهیم اضافه کنیم، چه کار باید انجام دهیم؟ متد say را باید ویرایش کنیم:
class Hello { public say(lang) { if (lang == 'pr') { return 'درود'; } else if (lang == 'en') { return 'Hi'; } else if (lang == 'fr') { return 'Bonjour'; } else if (lang == 'de') { return 'Hallo'; } } } let obj = new Hello; console.log(obj.say('de'));
حال اگر بخواهیم 150 زبان اضافه کنیم چطور؟ همانطور که مشاهده میکنید، هنگامی که ویژگیهای جدید اضافه میشوند، کلاس Hello با توجه به نیازها دستکاری میشود. از آنجایی که متد say در برابر تغییرات بسته نیست و همواره از سمت بیرون در معرض دستکاری است، بنابراین این اصلا خوب نیست.
برای حل این مسئله، ما متد say را کلیتر و عمومیتر مینویسیم. به این شکل، بدون توجه به تغییرات و نیازهای جدید، مستقل و بدون تغییر باقی میماند و به اصطلاح Abstract کنیم.
مثال بالا را به شکل زیر تغییر میدهیم:
class Persian { public sayHello() { return 'درود'; } } class French { public sayHello() { return 'Bonjour'; } } class Hello { public say(lang) { return lang.sayHello(); } } myHello = new Hello(); myHello.say(new Persian());
همانطور که مشاهده میکنید، هر زبان را به یک کلاس جدید انتقال دادهایم و به این ترتیب هر زمانی که بخواهیم زبان جدید اضافه کنیم، کافی است کلاسی برای زبان جدید ایجاد کنیم و به این شکل کلاس Hello و متد آن یعنی say دیگر مورد دستکاری قرار نمیگیرند.
این مثال میتواند با استفاده از interfaceها هم بهینهتر نوشته شود:
interface LanguageInterface { sayHello(): string; } class Persian implements LanguageInterface { public sayHello(): string { return 'درود'; } } class French implements LanguageInterface { public sayHello(): string { return 'Bonjour'; } } class Hello { public say(lang: LanguageInterface): string { return lang.sayHello(); } } myHello = new Hello(); myHello.say(new Persian());
class AreaCalculator { protected $shapes; public function __construct($shapes = array()) { $this->shapes = $shapes; } public function sum() { foreach($this->shapes as $shape) { if(is_a($shape, 'Square')) { $area[] = pow($shape->length, 2); } else if(is_a($shape, 'Circle')) { $area[] = pi() * pow($shape->radius, 2); } } return array_sum($area); } public function output() { return implode('', array( " ", "Sum of the areas of provided shapes: ", $this->sum(), " PHP " )); } }
در اینجا کلاسی به نام AreaCalculator وجود دارد که مسئولیت محاسبهی جمع محیطهای اشکال هندسی را بر عهده دارد.
اگر در آینده بخواهیم که متد sum محیطهای اشکال بیشتری را با هم جمع کند، در این صورت باید بلوکهای if و else بیشتری اضافه شود، چرا که فعلا تنها به Square و Circle اشاره شده است. اگر دقت کنیم، اضافه کردن بلوکهای if و else بیشتر، خلاف قانون OCP یا همان Open Close Principle است.
برای حل این مسئله، یک از راههای خوب این است که منطق محاسباتی محیطها را از متد sum جدا کرده و به کلاسهای خود اشکال هندسی انتقال دهیم.
به عنوان مثال برای کلاس Square:
class Square { public $length; public function __construct($length) { $this->length = $length; } public function area() { return pow($this->length, 2); } }
همانطور که در قطعه کد بالا مشاهده میکنید، منطق محاسبه محیط در قالب متد area به کلاس square اضافه شده است. این کار را برای کلاس Circle نیز باید انجایم دهیم. زمانی که منطق محاسبه محیط به به کلاسها انتقال داده شد، متد sum موجود در کلاس AreaCalculator را به این صورت تغییر خواهیم داد:
public function sum() { foreach($this->shapes as $shape) { $area[] = $shape->area; } return array_sum($area); }
در اینجا مسئله حل شده است و برای هر شکل جدید یک کلاس اضافه خواهیم کرد و سپس متد area را در آن اضافه میکنیم.
مسئلهای دیگری که وجود دارد، این است که چطور میتوان متوجه شد کلاسی که به AreaCalculator ارسال میشود، کلاسی مربوط به shape و دارای متد area است؟ برای حل این مسئله، کافیست یک interface ساخته و کلاسها که اشکال هندسی هستند، از این interface درواقع implements شوند:
interface ShapeInterface { public function area(); } class Circle implements ShapeInterface { public $radius; public function __construct($radius) { $this->radius = $radius; } public function area() { return pi() * pow($this->radius, 2); } }
همانطور که میتوانید مشاهده کنید، یک interface تعریف شده است و کلاسها را از این اینترفیس implements کردهایم تا مجبور به داشتن متد area و موارد دیگری که لازم است، باشد.
الان با استفاده از اینترفیس ShapeInterface میتوانیم بررسی کنیم که آیا کلاسهای وارد شده یک نوع shape هستند یا نه:
public function sum() { foreach($this->shapes as $shape) { if(is_a($shape, 'ShapeInterface')) { $area[] = $shape->area(); continue; } throw new AreaCalculatorInvalidShapeException; } return array_sum($area); }
با این کار، اصل Open Close Principle در کلاسمان ایجاد شده است و وابستگی از بین رفته است.
مثال چهارم
کلاسهای زیر را در نظر بگیرید:
class TXTFile { public $length; public $sent; } class Progress { private $file; function __construct(TXTFile $file) { $this->file = $file; } function getAsPercent() { return $this->file->sent * 100 / $this->file->length; }
در این مورد، اصل OCP نقض شده است! به این دلیل که کلاس Progress متغیر file از نوع TXTFile را به عنوان ورودی دریافت خواهد کرد و نوع فایلهای دیگر را نخواهد شناخت. اگر در آینده بخواهیم غیر از فایل txt فایل دیگری به عنوان مثال mp3 به این بخش اضافه کنیم، بنابراین کلاس Progress را باید تغییر داد تا بتواند این نوع فایل را هم تشخیص دهد.
حال ممکن است با خودتان بگویید، خُب، اگر در ورودی Progress نوع متغییر را مشخص نکنیم، میتوان در آینده کلاسی به عنوان مثال mp3 را به عنوان ورودی برای Progress ارسال کرد. به این شکل:
class Progress { private $file; function __construct($file) { $this->file = $file; } function getAsPercent() { return $this->file->sent * 100 / $this->file->length; } }
اما این مورد قابل قبول نیست به خاطر اینکه دیباگکردن این کد کمی سخت است. به این خاطر که امکان دارد ورودی اشتباه ( مانند یک رشته ) برای Progress ارسال شود و خطایی مشابه زیر دریافت کنید:
Trying to get property of non-object.
در اینجا متغیر sent یا length از چیزی به غیر از کلاس (که در اینجا رشته است) دریافت میشود که اشتباه است و باعث خطا میشود. اینجا باید 2 چیز را بررسی کنید.
در این حالت، اگر نوع ورودی مشخص باشد، مانند کلاس Progress، این امکان که ورودی اشتباه ارسال شود، وجود نخواهد و به طور واضح خطا مشخص خواهد کرد که نوع ورودی اشتباه است. در این صورت دیباگکردن در وهلهی اول راحتتر و علاوه بر آن، انجام unit test روی این کلاس آسانتر خواهد بود.
پس درواقع حذف نوع ورودی از متد سازنده کلاس Progress بهترین کار نیست. در روش دیگری که میتوان انجام داد، این است که تمام ورودیهای که به کلاس Progress داده میشود، از یک استاندارد پیروی کنند! هنگامی که میگوییم استاندارد در بین کلاسها، از یک اینترفیس ( interface ) یا یک کلاس انتزاعی ( Abstract ) استفاده میشود:
interface MeasurableInterface { function getLength(); function getSent(); } class File implements MeasurableInterface { private $length; private $sent; public $filename; public $owner; function setLength($length) { $this->length = $length; } function getLength() { return $this->length; } function setSent($sent) { $this->sent = $sent; } function getSent() { return $this->sent; } function getRelativePath() { return dirname($this->filename); } function getFullPath() { return realpath($this->getRelativePath()); } } class Progress { private $measurableContent; function __construct(MeasurableInterface $measurableContent) { $this->measurableContent = $measurableContent; } function getAsPercent() { return $this->measurableContent->getSent() * 100 / $this->measurableContent->getLength(); } }
در حال حاضر، این روش، بهترین روشی است که هردو قانون SRP و OCP را به خوبی رعایت میکند. البته برخیها از یک کلاس انتزاعی به جای اینترفیس استفاه میکنند که بستگی که به نوع الگوی طراحی دارد که انتخاب میکنند. این روش باعث میشود که بتوان انواع فایلهای دیگر را بدون تغییر در کلاس اصلی به پروژه اضافه کرد.
در این قسمت با سومین اصل یعنی Liskov Substitution (که به اختصار به آن LSP هم گفته میشود) که اصلی ساده و با اهمیت است از اصول SOLID است، آشنا خواهیم شد.
اصل LSP میگوید که زیرکلاسها، باید بتوانند جایگزین نوع پایه خود باشند. به این معنی که کلاسهای فرزند آنقدر کامل و جامع از کلاس والد خود ارثبری کرده باشند که رفتاری را که با کلاس والد میکنیم با کلاسهای فررند هم داشته باشیم. به گونه ای که اگر در موقعیتی قرار گرفتید که با خودتان گفتید؛ کلاس فرزند قادر است تمام کارهای کلاس والدش را انجام دهد به جزء موارد خاصی، در اینجا این اصل را نقض کردهاید.
تعریف آکادمیک آن به این صورت است که اگر S یک زیر کلاس از T باشد، آبجکتهای نوع T باید بتوانند بدون تغییر دادن کد برنامه، با آبجکتهای نوع S جایگزین شوند.
مقایسهای با دنیای واقعی:
پدری شغلش تجارت املاک است، اما پسرش میخواهد فوتبالیست شود. در اینجا پسر هرگز نمیتواند جایگزین پدرش شود. با وجود اینکه پسر و پدر به یک سلسله مراتب خانوادگی تعلق دارند.
این موضوع را در قالب یک مثال عمومیتر بررسی میکنیم:
هنگامی که درمورد اشکال هندسی صحبت میکنیم، کلاس مستطیل را یک کلاس پایه برای مربع میدانیم، به این صورت:
public class Rectangle { public int Width { get; set; } public int Height { get; set; } } public class Square:Rectangle { //codes specific to //square will be added }
و به این صورت میتوان از کلاس Rectangle شیء ساخت:
Rectangle o = new Rectangle(); o.Width = 5; o.Height = 6;
حال، طبق اصل LSP باید بتوانیم مستطیل را با مربع جایگزین کنیم، به این شکل:
Rectangle o = new Rectangle(); o.Width = 5; o.Height = 6;
اما، آیا مربع میتواند طول و عرض متفاوت داشته باشد؟ چنین چیزی امکان ندارد.
این به این معنی است که نمیتوانیم کلاس پایه را با کلاس مشتقشده جایگزین کرده و باز هم به این معنی است که اصل LSP را نقض کردهایم.
آیا میتوان طول و عرض را در کلاس Square به صورت زیر بازنویسی کرد:
public class Square : Rectangle { public override int Width { get{return base.Width;} set { base.Height = value; base.Width = value; } } public override int Height { get{return base.Height;} set { base.Height = value; base.Width = value; } } }
در قطعه کد بالا، دوباره اصل LSP نقض میشود! چرا که داریم خاصیتهای طول و عرض را در کلاس مشتق شد، تغییر میدهیم. در قطعه کد بالا، یک مستطیل نمیتواند طول و عرض یکسان داشته باشد، چون در این صورت، دیگر مستطیل نیست!
برای حل این مسئله، ابتدا یک کلاس انتزاعی (abstract) به صورت زیر ایجاد کرده و سپس 2 کلاس Square و Rectangle را از این کلاس مشتق میکنیم:
public abstract class Shape { public virtual int Width { get; set; } public virtual int Height { get; set; } }
در حال حاضر، ما 2 کلاس مستقل از یکدیگر خواهیم داشت. یکی Square و دیگری هم Rectangle که هر دوی آنها از کلاس Shape مشتق شدهاند.
اکنون میتوانیم به این صورت بنویسیم:
Shape o = new Rectangle(); o.Width = 5; o.Height = 6; Shape o = new Square(); o.Width = 5; //both height and width become 5 o.Height = 6; //both height and width become
هنگامی که درمورد اشکال هندسی صحبت میکنیم، قاعدهی خاصی برای اندازهی طول و عرض نیست. ممکن است برابر یا نابرابر باشند.
بیاید فرض کنیم که کلاس C از کلاس B مشتق شده باشد، طبق اصل LSP، در هر جای برنامه که شیء از نوع B به کار گفته شده است، باید بتوان شیءای از نوع C را جایگزین کرد. بدون تغییر در روند اجرای برنامه یا دریافت پیغام خطا. شاید درک این مفهوم کمی سخت باشد، که در ادامه مثالی را بیان میکنیم تا از پیچیدگی این موضوع کاسته شود و توضیح شفافی از اصل LSP باشد. ابتدا قطعه کدی را پیادهسازی خواهیم کرد، که قاعده LSP را نقض میکند و سپس کد را اصلاح خواهیم کرد که مطابق قاعده LSP باشد.
قطعهکد زیر را در نظر بگیرید:
public class CollectionBase { public int Count { get; set; } } public class Array : CollectionBase { }
طبق قواعد OOP، از کد بالا به صورت زیر میتوانید استفاده کنید:
CollectionBase collection = new Array(); var items = collection.Count;
در اینجا، شیء Array داخل متغییری از نوع CollectionBase ذخیره شده است. تا به اینجا مسئلهای نیست، اما فرض کنیم کلاسهای دیگری از CollectionBase مشتق شوند که این قابلیت را دارند که آیتم اضافه کنند. به دلیل اینکه کلاس Array طول ثابتی دارد، نمیتوان به آن آیتم جدیدی اضافه کرد. قطعه کد بالا را به صورت زیر تغییر میکند:
public class CollectionBase { public int Count { get; set; } public virtual void Add(object item) { } } public class List : CollectionBase { public override void Add(object item) { // add item to list } } public class Array : CollectionBase { public override void Add(object item) { throw new InvalidOperationException(); } }
به این مورد دقت کنید که متد add در کلاس CollectionBase تعریف شده است. کلاس List از متد Add پشتیبانی خواهد کرد، اما به همان دلیلی که برای کلاس آرایه گفته شده، با فراخوانی متد Add، باعث به وجود آمدن خطا میشود:
CollectionBase array = new Array(); CollectionBase list = new List(); list.Add(2); // works array.Add(3); // throw exception
قطعه کد بالا بدون هیچ مشکلی کامپایل خواهد شد، اما زمان اجرای برنامه، هنگام اضافه کردن آیتم به آرایه، پیغام خطا را دریافت خواهیم کرد که در نتیجه اصل LSP در اینجا نقض شده است. چرا که همانگونه که بالاتر گفته شده، اگر از کلاس پایه به عنوان Data Type استفاده شود و شیءای از نوع فرزند در آن قرار گیرد، برنامه بدون مشکلی باید کار کند. برای حل این مسئله، راهحلهای مختلفی وجود دارد. در اینجا مکانیزم استفاده از interfaceها را مطرح خواهیم کرد. بنابراین قطعه کد بالا را به صورت زیر تغییر خواهیم داد:
public interface IList { void Add(object item); } public class CollectionBase { public int Count { get; set; } } public class List : CollectionBase, IList { public void Add(object item) { // add item to list } } public class Array : CollectionBase { }
همانطور که در قطعه کد بالا مشاهده میکنید، متد Add، به جای اینکه در کلاس CollectionBase تعریف شود، در داخل یک interface با نام IList تعریف شده است و کلاس List اینترفیس IList را پیادهسازی کرده است. به این ترتیب، امکان فراخوانی متد Add برای کلاس Array وجود نخواهد داشت. قطعهکُد بالا، اصل LSP را رعایت کرده و دیگر آن را نقض نمیکند.
کلاسی به نام A داریم، به این صورت:
class A { ... }
از کلاس A قرار است در جاهای مختلف برنامه، آبجکت ساخته و استفاده شود:
x = new A; // ... y = new A; // ... z = new A;
حال، قرار است کلاس A توسعه داده شود. برای این منظور، کلاسی با نام B را ساخته و از کلاس A مشتق میشود:
class B extends A { ... }
تا اینجا، کلاس B، یک زیرکلاس از کلاس A است. در بالا، آبجکتهایی از کلاس A ساخته و استفاده شده است. از آنجایی که کلاس B یک زیرکلاس از کلاس A است، میخواهیم در برنامه و در جاهایی که از کلاس A استفاده شده، به جای آن، از کلاس B استفاده کنیم، به این شکل:
x = new A new B; // ... y = new A new B; // ... z = new A new B;
در اینجا جایگزینی انجام داده شده است. کلاس B با کلاس A جایگزین شده است و با توجه به اصل LSP، هنگامی که جایگزینی صورت میگیرد، برنامه به خاطر جایگزینی نه تنها نباید دچار خطا شود، بلکه کد برنامه هم نباید تغییر کند. این اصل LSP است.
حال، این قانون نقض خواهیم کرد تا آن را بهتر درک کنیم. کلاسی با نام Note داریم که عملیاتهای مختلفی را انجام میدهد، مانند خواندن، بروزرسانی و حذف یادداشتهای شخصی:
class Note { public constructor(id) { // ... } public save(text): void { // save process } }
حال، کاربری میخواهد از این کلاس در برنامهی خودش استفاده کند، به این شکل:
let note = new Note(429); note.save("Let's do this!");
حال، میخواهیم کلاس Note را توسعه دهیم و ویژگی اضافه شود که بتوان یادداشتهای فقط خواندنی ساخت. که باید متد save را رونوشت کرد و اجازهی ذخیره کردن یادداشت را به آن ندهیم. بنابراین یک زیرکلاس از Note به نام ReadonlyNote ساخته و متد save را رونوشت میکنیم:
class ReadonlyNote extends Note { public save(text): void { throw new Error("Can't update readonly notes"); } }
در کلاس اصلی Note متد save به کاربر void را برگشت میدهد، اما در کلاس جدید ReadonlyNote یک Exception برگشت داده میشود که عملیات save امکانپذیر نیست.
در برنامه، جایی که از Note استفاده شده است، جایگزینی را انجام میدهیم! به این صورت که از ReadonlyNote به جای Note استفاده میکنیم، به این صورت:
let note = new ReadonlyNote(429); note.save("Let's do this!");
در اینجا چه اتفاقی خواهد افتاد؟
از آنجایی که کاربر از تغییراتِ رخ داده، اطلاع ندارد، به ناگهان یک Exception در برنامهاش رخ خواهد داد که مجبور میشود، تغییراتی در برنامهاش اعمال کند.
اینجاست که اصل LSP نقض شده است آن هم به این دلیل که کلاس ReadonlyNote، رفتار و ویژگیهای کلاس والد را تغییر داده است و کاربر مجبور به تغییر کد برنامهاش میشود.
برای نوشتن بهتر این قسمت، کلاسی جدا برای یادداشتهای قابل نوشتن ایجاد میکنیم و نام آن را هم WritableNote میگذاریم که یادداشتهایی با قابلیت بروزرسانی؛ و درنهایت متد save را از کلاس Note به کلاس جدید که در اینجا WritableNote است انتقال میدهیم:
class Note { public constructor(id) { // ... } } class WritableNote extends Note { public save(text): void { // save process } }
پس این را در نظر داشته باشید که زمانی که بخواهیم کلاسی را با مشتق کردن توسعه دهیم، کلاس وارد که در جاهایی از برنامه استفاده شده است، باید بتواند با کلاسهای فرزند بدون مشکلی کار کند. این به این معنی است که کلاس فرزند نباید ویژگیها و رفتار کلاس والد را تغییر دهد. به عنوان مثال کلاس والدی را در نظر بگیرید که یک متد دارد و خروجی آن عددی است، در این صورت کلاس فرزند نباید متد کلاس والد را به صورتی رونوشت کند که خروجی آن آرایه باشد.
در این قسمت از آموزش اصول SOLID به اصل چهارم آن با نام Interface Segregation Principle یا اصل جداسازی اینترفیسها که به اختصار ISP نیز گفته میشود، خواهیم پرداخت.
تعریف رسمی آن به این صورت است:
A client should never be forced to implement an interface that it doesn’t use or clients shouldn’t be forced to depend on methods they do not use.
که به معنی این است که برای اینکه از اینترفیسها استفاده کرد، باید آنها را به اجزای کوچکتر تقسیم کرد. درواقع همانطور که مشخص است، اصل LSP، میگوید که باید اینترفیسها (Interface) به شکلی نوشته شوند که زمانی که یک کلاس از آن استفاده میکند، مجبور به پیادهسازی متدهایی که به آنها احتیاجی ندارد، نباشد.
درواقع این اصل بسیار شبیه به اصل اول از اصول SOLID که SRP نام دارد است! چرا که متدهای غیرمرتبط، نباید در یک اینترفیس در کنار یکدیگر باشند. اینترفیسها تنها مشخصکنندهی این هستند که یک کلاس حتما باید چه متدهایی را داشته باشد. به همینمنظور و طبق این قانون، چند اینترفیس تکمنظوره بهتر از یک اینترفیس چندمنظوره است. اگر یک اینترفیس چندمنظوره داشته باشیم که کامل و جامع باشد و دیگر کلاسها از آن به اصطلاح implements کنند، در این حالت امکان دارد، بعضی از خصوصیات، متدها و رفتارها را به برخی کلاسها تحمیل کنیم که اصلا نیازی به آنها ندارند. اما در حالتی دیگر، اگر از چند اینترفیس تخصصی استفاده کنیم، به راحتی میتوانیم هر کدام از اینترفیسها را که نیاز داشتیم، در کلاسی که میخوایم استفاده کنیم. حتی اگر هم کلاسی بود که باید از چندین اینترفیس استفاده کند، میتوانیم آن کلاس را از چندین اینترفیس implements کنیم. گرچه به این صورت تعداد اینترفیسها بیشر شده و امکان دارد که تکرار صورت بگیرد، اما از آنجایی که منطق برنامه در اینترفیسها اجرا نمیشود، میتوان این مسئله را نادیده گرفت. اگر این اصل رعایت شود، سرعت بررسی کردن کدها و دیباگ، بیشتر میشود.
در ادامه مثالهایی را برای درک بهتر، آوردهایم.
ابتدا اینترفیس زیر را در نظر بگیرید:
interface Animal { fly(); run(); eat(); }
اینترفیس فوق (Animal) دارای 3 متد است که کلاسهایی که از آن استفاده میکنند باید این متدها را پیادهسازی کنند. حال کلاسی با نام Dolphin از این اینترفیس استفاده کرده است:
class Dolphin implements Animal { public fly() { return false; } public run() { // Run } public eat() { // Eat } }
اگر دقت کرده باشید، میدانید که دلفینها نمیتوانند پرواز کنند! اما به اجبار متد fly را برای آن پیادهسازی و آن را return false کردهایم. که قانون ISP در اینجا نقض شده است. چرا که کلاس دلفین به اجبار متدی را پیادهسازی کرده است که از آن استفاده نمیکند.
برای رعایت اصل ISP میبایست اینترفیسها را جدا کنیم و متد fly را به اینترفیس دیگری انتقال دهیم، به این صورت:
interface Animal { run(); eat(); } interface FlyableAnimal { fly(); }
در این حالت، کلاس Dolphin دیگر مجبور به پیادهسازی متد fly نیست و کلاسهایی که به متد fly هم نیاز دارند، اینترفیس FlyableAnimal را هم پیادهسازی خواهند کرد:
class Dolphin implements Animal { public run() { // Run } public eat() { // Eat } } class Bird implements Animal, FlyableAnimal { public run() { /* ... */ } public eat() { /* ... */ } public fly() { /* ... */ } }
برخی از اشکال هندسی (solid shapes) جامد هستند و حجم دارند. که برای محاسبهی حجم آنها به صورت زیر به ShapeInterface یک متد اضافه کنیم:
interface ShapeInterface { public function area(); public function volume(); }
زمانی که متد volume در اینترفیس ShapeInterface اضافه شد، کلاسهایی که از این اینترفیس (ShapeInterface) به اصطلاح implement شوند، به اجبار باید متد volume را پیادهسازی کنند، حتی اگر به این متد نیازی نداشته باشند.
در اینجا، خلاف اصل LSP یا Interface segregation principle عمل شده است. اگر متدهایی داشته باشیم که در بعضی از کلاسها لازم هستند و در برخی دیگر نه، آن متدها را در interface مختلفی قرار دهید و هر زمان که لازم شد، آنها را implement کنید.
بنابراین برای اشکال جامد، اینترفیسی جدا با نام SolidShapeInterface ایجاده کرده و متد volume در درون آن قرار میدهیم:
interface ShapeInterface { public function area(); } interface SolidShapeInterface { public function volume(); } class Cuboid implements ShapeInterface, SolidShapeInterface { public function area() { // calculate the surface area of the cuboid } public function volume() { // calculate the volume of the cuboid }
تصویر زیر را در نظر داشته باشید:
اینترفیس VEHICLE برای کلاسهای که با حمل و نقل در ارتباط هستند. اما کلاسهایی مثل HighWay و BusStation که از اینترفیس VEHICLE میکنند به متدهای مانند stopRadio و break ندارند. بنابراین طبق اصل چهارم SOLID که LSP است، اینترفیسهای بزرگ، میبایست به اینترفیسهای کوچکتر تبدیل شوند.
تصویر زیر را در نظر بگیرید:
همانطور که در تصویر بالا مشاهده میکنید، به جای اینکه یک اینترفیس بزرگ ایجاد شده باشد، 2 اینترفیس کوچک ایجاد شده است. اگر دقت کرده باشید، بعضی از متدها تکرار شدهاند که البته همانطور که در ابتدا گفته شده، از آنجایی که این اینترفیسها منطق برنامه تشکیل نمیدهند، بنابراین اصل اول از اصول SOLID را نقض نمیکند.
همانطور که در ابتدا هم ذکر شد، علاوه بر این که استفاده کردن از اینترفیسهای کوچک به ما در شناسایی سریع مشکلات کمک میکند، راحتتر میتوان تست گرفت و راحتتر کدهای نوشته شده را درک کرد. همچنین به خواناتر و تمیزتر شدن کدها کمک میکند. در برنامهنویسی شیءگرا نکتهای که باید در نظر داشته باشید این است که هر چقدر از کلینویسی (عمومینویسی) دوره کرده و کدهایی مجزا و تفکیک داده شده داشته باشید، برنامهای منسجمتر و ساختاریافتهتر خواهید داشت. در این صورت کدها قابل استفاده مجدد خواهند بود و تست و Refactor هم راحتتر انجام خواهد شد.
در این قسمت از اصول SOLID اصل پنجم و آخر از این اصول را بررسی خواهیم کرد. اصل وارونگی وابستگی یا Dependency Inversion Principle اصلی است که به اختصار به آن DIP هم گفته میشود.
توضیح رسمی اصل DIP به این صورت است:
کلاسهایی که سطح بالا هستند، به کلاسهای سطح پایین وابستگی نداشته باشند؛ هردوی آنها باید وابسته به انتزاع (Abstractions) باشند. موارد انتزاعی وابسته به جزئیات نباشند، جزئیات باید وابسته به انتزاع باشند.
شاید درک کردن اصل DIP از اصول SOLID کمی دشوار باشد، بعضی از تازهکار این اصل را با Dependency Injection اشتباه میگیرند. در برنامهنویسی شیءگرا (OOP) باید همهی سعی خود را بکنیم تا وابستگی (Dependency) بین کلاسها، ماژولها و آبجکتهایی که سطح بالا هستند را با ماژولهایی که سطح پایین هستند را کم کنیم. به این ترتیب تغییراتی که در آینده صورت میگیرد، راحتتر است. مثالی در دنیای واقعی به این صورت است که زمانی که شخصی به شما میگوید به مشهد بروید، شما با ابزارهای در دسترسی مانند اتوبوس، تاکسی، هواپیما، به آنجا خواهید رفت. به این ترتیب کُد سطح بالا، رفتن به مشهد است و کد سطح پایین، استفاده کردن از ابزارها که همان تاکسی و ... است، میباشد که این تصمیم شما، فارغ از این ابزار است.
ابتدا اجازه دهید کلاسهای سطح پایین، سطح بالا و انتزاع را توضیح دهیم، تا این اصل را بهتر درک کنید.
کلاسهایی که عهدهدار عملیاتهای اساسی و پایه در نرمافزار هستند. به عنوان مثال کلاسی که با دیتابیس یا هارددیسک ارتباط برقرار میکند یا کلاسی که برای ارسال ایمیل استفاده میشود و ...
کلاسهایی که عهدهدار عملیاتهای پیچدهتر و خاصتر هستند و از کلاسهای سطح پایین برای انجام این عملیاتها، استفاده میکنند. به عنوان مثال کلاس گزارشگیری که از کلاس دیتابیس یا هارددیسک برای ثبت و خواندن گزارش استفاه میکند. یا کلاس Users که از کلاس ایمیل برای اطلاعرسانی به کاربرها استفاده میکند.
این نوع کلاسها قابل پیادهسازی نیستند، کلاسهای انتزاعی به عنوان الگو و طرحی برای دیگر کلاسها هستند. کلاسی مانند Animal را در نظر بگیرید. این کلاس طرح و الگوئی برای کلاسهایی مانند گربه، زرافه، پلنگ و پنگوئن است. این کلاس ( Animal ) یک طرح کلی برای حیواناتی که مثالزده شده است که همانطور که گفته شد، خودش قابل پیادهسازی نیست. حیوانات بالا، نسخهی کلیتری به نام Animal دارند.
در اصل DIP، جزئيات، همان نام، ویژگیها و پراپرتی و متدها در یک کلاس است.
قطعه کد زیر را در نظر بگیرید تا در ادامه به اصل DIP بپردازیم:
class MySql { public insert() {} public update() {} public delete() {} } class Log { private database; constructor() { this.database = new MySql; } }
کلاس سطح پایینی به نام دیتابیس MySql وجود دارد که کلاسهایی سطح بالا مانند گزارشگیری Log از آن استفاده میکنند. در اینجا یک تغییر در کلاس دیتابیس، امکان اینکه به طور مستقیم روی کلاسهایی که از آن استفاده میکند، تاثیر بگذارد، وجود دارد. به عنوان مثال اگر در کلاس MySql نام متد را تغییر داده یا اینکه پارامترها را کم و زیاد کنیم، درنهایت این تغییرات باید در کلاس Log اعمال شوند.
علاوه بر آن، قابل استفاده مجدد نبودن کلاسهای سطح بالا، مسئلهای دیگری است که وابسته بودن یک کلاس سطح بالا به یک کلاس سطح پایین به وجود میآورد. به عنوان مثال اگر کلاس Log بخواهد از دیتابیسهایی مانند MongoDB یا هارددیسک استفاده کند، در این صورت کلاس Log را باید تغییر داد یا کلاسی جدا برای هر نوع دیتابیسی ایجاد کنیم.
این مشکلات، به دلیل وابسته بودن یک کلاس سطح بالا به یک کلاس سطح پایین است.
حال، در ادامه برای حل این مسئله، با استفاده از یک اینترفیس، یک لایهی انتزاعی ایجاد میکنیم. به این ترتیب کلاس Log به کلاس خاصی که اطلاعات را ذخیره و بخواند، وابسته نیست و میتوانیم از هرگونه دیتابیسی استفاده کنیم و از آنجایی که وابسته به انتزاع است، اهمیتی ندارد که کلاس Log با چه نوع دیتابیسی کار میکند.
قطعه کد زیر نحوهی ایجاد یک اینترفیس است و به این صورت کلاسهای سطح بالا و سطح پایین را به این اینترفیس وابسته خواهیم کرد:
interface Database { insert(); update(); delete(); }
در قطعه کد زیر، کلاسهای سطح پایین، اینترفیس Database را پیادهسازی میکنند تا به انتزاع وابسته باشند:
class MySql implements Database { public insert() {} public update() {} public delete() {} } class FileSystem implements Database { public insert() {} public update() {} public delete() {} } class MongoDB implements Database { public insert() {} public update() {} public delete() {} }
در کلاسهای سطح بالا، وابستگی به کلاس خاصی را به اینترفیس واگذار خواهیم کرد. هرگاه کلاسهای سطح بالا به جای اینکه مستقیم از کلاسهای سطح پایین استفاده کنند، از یک اینترفیس استفاده کردند، وابسته به انتزاع خواهند شد:
class Log { private db: Database; public setDatabase(db: Database) { this.db = db; } public update() { this.db.update(); } }
اگر دقت کرده باشید، از آنجایی که الان وابستگی به یک کلاس خاص از بین رفته است، میتوان هر نوع دیتابیسی برا کلاس Log استفاده کرد:
logger = new Log; logger.setDatabase(new MongoDB); // ... logger.setDatabase(new FileSystem); // ... logger.setDatabase(new MySql); logger.update();
در این قسمت، به پرسشهای تخصصی شما دربارهی محتوای مقاله پاسخ داده نمیشود. سوالات خود را اینجا بپرسید.
در این قسمت، به پرسشهای تخصصی شما دربارهی محتوای مقاله پاسخ داده نمیشود. سوالات خود را اینجا بپرسید.
در این قسمت، به پرسشهای تخصصی شما دربارهی محتوای مقاله پاسخ داده نمیشود. سوالات خود را اینجا بپرسید.