Будь SOLID

22 Червня 2018

наступна стаття
Олекксандр Гоцалюк

Back End Developer

Олекксандр Гоцалюк
Будь SOLID

Проектування в програмуванні — це окрема і велика тема. Для якісного проектування додатків потрібні знання ООП, шаблонів проектування і досвід якісної розробки.

Освоєння і застосування п’яти принципів SOLID в розробці додатків допоможе зробити швидкий перехід на рівень вище в якості і функціоналі. Вони дають можливість розробляти програми, які буде простіше і дешевше підтримувати і розвивати.

Часто буває, що завдання програми досить просте і швидке в реалізації. При її реалізації розробник може не замислюватися про можливі доповнення функціоналу або перенесення на інші фреймворки. Йому взагалі можуть бути байдужі всі перераховані вище моменти, все зводитися до «отримав ТЗ» — «виконав» — «працює» — «здав» — «клієнт задоволений». І ось коли настане час щось додати або переробити, спливуть проблеми проектування в додатку.

Якщо Ви хочете писати якісний, підтримуваний і масштабований код, знання SOLID принципів це мінімум, який потрібно не тільки знати і використовувати, але ще мислити відповідно до цих принципів.

Що таке SOLID?

SOLID — це акронім для п'яти принципів проектування, названих Робертом Мартіном на початку 2000-х.

Розшифровка акроніма:

Принцип єдиної відповідальності (SRP) 

“A class should have only one reason to change.”
Robert C. Martin

Клас повинен мати тільки одну причину для редагування. Іншими словами це означає, що об'єкт повинен мати одну відповідальність і вона повинна бути повністю реалізована в класі (Інкапсульована).

Наприклад, в системі є об'єкт «Post», який тримає в собі дані про статтю нашого блогу. У певні моменти нам потрібно отримувати його дані в різних форматах, для створення різних типів звітностей (html, xml, csv, json). Недосвідчені програмісти можуть описати наступний клас:

class Post
{
    private $title;
    private $content;
    private $author;

    public function getTitle()
    {
        return $this->title;
    }

    public function getContent()
    {
        return $this->content;
    }

    public function getAuthor()
    {
        return $this->author;
    }

    // сеттеры упущено для экономии места в примере

    public function toHTML()
    {
        //code
    }

    public function toXML()
    {
        //code
    }

    public function toCSV()
    {
        //code
    }

    public function toJSON()
    {
        //code
    }
} 

Насправді, дуже незручно вносити зміни, після того, як наш клас введений в проект. Наприклад, додавання / видалення нового формату звітності. З точки зору логіки і SRP, потрібно «розбити» клас на два підтипи: «Post» і «Report». З обов'язками управління даними статті і формуванням звіту, відповідно.

class Post
{
    private $title;
    private $content;
    private $author;

    public function getTitle()
    {
        return $this->title;
    }

    public function getContent()
    {
        return $this->content;
    }

    public function getAuthor()
    {
        return $this->author;
    }

    // сеттеры упущено для экономии места в примере

    /**
     * Возвращает массив данных объекта нужных для формирования отчета
     *
     * @return array
     */
    public function getData(){
        //code
    }
}

class Report
{
    private $data;

    /**
     * @param array $data
     */
    public function __construct($data)
    {
        $this->data = $data;
    }

    public function toHTML()
    {
        //code
    }

    public function toXML()
    {
        //code
    }

    public function toCSV()
    {
        //code
    }

    public function toJSON()
    {
        //code
    }
}

Тепер, коли потрібно буде додати / видалити якийсь формат звітності, потрібно редагувати тільки клас «Report», а для зміни даних статті змінювати тільки «Post». Це і відповідає за принцип єдиної відповідальності.

Не можу не відзначити, що сліпе слідування цьому принципу може привести проект до надлишкової складності під час підтримки і тестування. Так що SRP краще застосовувати тільки якщо це виправдано. Наприклад, якщо в об'єкта занадто багато різних обов'язків; будь-яка зміна логіки в одному класі призводить до потреби зміни інших класів; неможливо відокремити клас для застосування в іншій сфері проекту, оскільки це потягне непотрібні залежності.

Принцип відкритості / закритості (OCP)

“Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification”
Bertrand Meyer

Програмні суті (класи, модулі, функції і т. п.) повинні бути відкриті для розширення, але закриті для зміни. Якщо розібратися детальніше, то суті повинні бути відкриті для розширення шляхом створення їх нових типів. В результаті, зміни не повинні вноситись в код, який використовує ці сутності.

Для демонстрації цього принципу розглянемо наступний приклад коду:

class User
{
    protected $logger;

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

    public function setName($name)
    {
        try{
            //save in DB
        } catch (Exception $e) {
            $this->logger->log($e->getMessage());
        }
    }
}

class Logger
{
    private function saveToFile($message) {
        //...
    }
    public function log($message) {
        //...
        $this->saveToFile($message);
    }
}

$logger  = new Logger();
$user = new User($logger);
$user>setName('Валера');

В даному прикладі у нас є клас «User», призначений для роботи з даними користувачів, і клас «Logger», який зберігає помилки в файл. І тут раптом замовник ставить завдання: логи по користувачеві повинні зберігатися в базу даних замість файлу (логи по іншим класам, як і раніше, повинні зберігатись в файл). Щоб реалізувати завдання, нам доведеться знехтувати принципом відкритості / закритості. Оскільки будуть модифікуватися вже готові класи. У разі простої системи, на це можна закрити очі. Але при розробці масштабного проекту або роботі в команді, це може призвести до фатальних помилок. Для відповідності принципу, який ми розглядаємо, наведений вище код можна спроектувати таким прикладом:

interface ILogger {
    public function log();
}

class User
{
    protected $logger;

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

    public function setName($name)
    {
        try{
            //save in DB
        } catch (Exception $e) {
            $this->logger->log($e->getMessage());
        }
    }
}

class FileLogger implements ILogger {
 
    private function saveToFile($message) {
        //...
    }
    public function log($message) {
        //...
        $this->saveToFile($message);
    }
}
 
class DBLogger implements ILogger {
 
    private function saveToDB($message) {
        //...
    }
    public function log($message) {
        //...
        $this->saveToDB($message);
    }
}

$logger  = new DBLogger();
$user = new User($logger);
$user>setName('Валера');

Принцип підставлення Барбари Лісков (LSP)

В оригіналі даний принцип звучить так: Нехай q (x) є властивістю, вірною щодо об'єктів x деякого типу T. Тоді q (y) також має бути вірним для об'єктів y типу S, де S є підтипом типу T.

Роберт С. Мартін перефразував цей принцип так: Функції, які використовують базовий тип, повинні мати можливість використовувати підтипи базового типу, не знаючи про це.

Оскільки цей принцип досить складний для розуміння, в багатьох посібниках є різні варіанти його перефразування для кращого розуміння читача, з них усіх можна виділити, що:

Поведінка успадкованих класів не повинна суперечити поведінці, заданій базовим класом, тобто поведінка успадкованих класів повинна бути очікуваною для коду, який використовує базовий клас.

Наведемо зрозумілий життєвий приклад. Отже, якийсь зоопарк з ссавцями повинен отримати сповіщення, якщо хтось із них втече. Тобто, повинні бути параметри їх швидкості переміщення щодо умов міста. Мовою SOLID це буде виглядати так:

abstract class mammal
{
    protected $speed;

    public function getSpeed()
    {
        return $this->speed;
    }

    // ...
} 

Наприклад, мавпа буде тікати по місту приблизно зі швидкістю 10 км / год.

class monkey extends mammal
{
    public function __construct()
    {
        $this->speed = 10;
    }

    public function getSpeed()
    {
        // ...
        return $this->speed;
    }
}

Якщо описувати ситуацію з дельфіном (а він ссавець, якщо що), то метод отримання швидкості повинен видавати помилку, оскільки він не зможе переміщатися по місту.

class dolphin extends  mammal
{
    public function getSpeed()
    {
        throw new Exception("dolphin can't run away");
    }
}

У класі 'monkey' метод 'getSpeed' повертає такий же тип даних, як і супер-клас 'mammal' — принцип LSP дотриманий.

У класі 'dolphin' метод 'getSpeed' повертає 'Exception', що порушує принцип LSP.

Дотримання цього принципу важливе при проектуванні нових класів з успадкуванням. Зміна поведінки дочірнього класу дуже ризикована, а цей принцип попереджає про це.

Принцип поділу інтерфейсу (ISP)

Формулювання:

Клієнти не повинні залежати від методів, які вони не використовують.

Якщо перефразувати:

Багато спеціалізованих інтерфейсів краще, ніж один універсальний.

Приклад:

interface ITransformer
{
    public function toCar();
    public function toPlane();
    public function toShip();
}

class MegaTransformer implements ITransformer {
    public function toCar(){
   	 echo 'transform to car';
    }
     
    public function toPlane(){
   	 echo 'transform to plane';
    }
     
    public function toShip(){
   	 echo 'transform to ship';
    }
}
 
class OptimusTransformer implements ITransformer {
    public function toCar(){
   	 echo 'transform to car';
    }
     
    public function toPlane(){
   	 throw new Exception('i can`t transform to plane');
    }
     
    public function toShip(){
   	 throw new Exception('i can`t transform to ship');
    }
}

В результаті використання складного інтерфейсу, доводиться блокувати непотрібні методи. Для відповідності розглянутого принципу потрібно розділити і спростити клас "OptimusTransformer".

interface ICarTransformer
{
    public function toCar();
}

interface IPlaneTransformer
{
    public function toPlane();
}

interface IShipTransformer
{
    public function toShip();
}

class MegaTransformer implements ICarTransformer, IPlaneTransformer, IShipTransformer
{
	//code
}

class OptimusTransformer implements ICarTransformer {
    public function toCar(){
   	 echo 'transform to car';
    }
}

Принцип інверсії залежностей (DIP)

Є два формулювання принципу інверсії залежностей:

  • Модулі верхніх рівнів не повинні залежати від модулів нижніх рівнів. Обидва типи модулів повинні залежати від абстракцій.

  • Абстракції не повинні залежати від деталей. Деталі повинні залежати від абстракцій.

Насправді цей принцип був реалізований в попередніх прикладах. Але наведемо ще один приклад з життя, який допоможе легко зрозуміти принцип.

Наприклад, у дітей (клас 'Kid') джерело грошей на кишенькові витрати — батьки.

class Kid
{
    public function getMoney()
    {
        $parents = new Parents();
        return $parents->getMoney();
    }
}

Коли дитина виростає ('Man') і потребує грошей, його джерелом доходів стає робота. Яку ще і можна вибрати, ґрунтуючись на перевагах.

class Man
{
    private $job;

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

    public function getMoney()
    {
        return $this->job->getMoney();
    }
} 

Але якщо людина досить освічена ('IMan'), то крім роботи він може організувати власний бізнес ('Business'), або ж людині дістався більший банківський рахунок у спадок, варіантів джерела доходу може бути досить багато..

class IMan
{
    private $moneySource;

    public function __construct(MoneyProvider $moneyProvider)
    {
        $this->moneySource = $moneyProvider;
    }

    public function getMoney()
    {
        return $this->moneySource->getMoney();
    }
}

interface MoneyProvider {
    public function getFood();
}

class Business implements moneyProvider
{
    public function getFood()
    {
        // code
    }
} 

В результаті, людина ('IMan') прив'язується не до якихось-то класів, а до абстракції. Це дозволяє йому вибирати будь-яке джерело грошей, яке реалізовує інтерфейс 'MoneyProvider'.


Розібравшись і застосовуючи розібрані принципи на практиці, Ви зможете вберегти свій додаток від таких проблем як:

  1. Закутість. Система складно піддається змінам, тому що будь-яка, навіть мінімальна, зміна може викликати ефект «снігової лавини», який зачіпає інші компоненти системи;

  2. Нестійкість. Зміни в системі руйнують роботу в тих місцях, які не мають прямого відношення до компонента, що безпосередньо вимірюється;

  3. Непорушність. В системі складно виділити елементи, які можна повторно використовувати в інших додатках;

  4. Складність рефакторінга. Важко читати і простежувати послідовність виконання модулів проекту в системі.


Схожі статті
Записатись на консультацію

Ми зв'яжемось з Вами протягом 10 хвилин