• Час читання ~16 хв
  • 16.06.2023

SOLID, ця абревіатура була придумана Майклом Пір'їнкою, вона являє собою п'ять основних принципів об'єктно-орієнтованого програмування, розроблених дядьком Бобом.

Більшість програмістів, напевно, знають цю абревіатуру. Але мені здається, що меншість може його розшифрувати.

Це неправильно? Не зовсім, я думаю, що писати чистий і простий код важливіше, ніж знати теорію. Але не варто повністю ігнорувати теорію. Як ще ви будете комусь передавати знання? Як ви обґрунтовуєте свій код під час обговорення під час перегляду коду? Ваш код повинен базуватися на теорії та загальноприйнятих стандартах.

Важливо знати основи того, як виглядає чистий і простий код. Принципи SOLID можуть використовуватися в будь-якій об'єктно-орієнтованій мові програмування. Я працюю в Symfony щодня, тому покажу деякі принципи на PHP.

Отже, давайте разом розглянемо п'ять принципів SOLID.

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

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

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

Дядько Боб описує це так: «Клас повинен мати одну, і тільки одну, причину для змін». Що це означає? Для мене це речення не є корисним :).

Інші пояснення говорять, що клас або функція повинні робити щось одне.

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

Можливо, відправку електронного листа можна вважати «чимось одним»? Зрештою, він складається з багатьох кроків, таких як підготовка вмісту та теми електронного листа, витягування адреси електронної пошти користувача та обробка відповіді. Чи повинні ми створити окремий клас для кожного з цих занять? Як далеко ми йдемо з цією «єдиною відповідальністю»?!

Я думаю, що нам потрібно використовувати інший принцип об'єктно-орієнтованого програмування, щоб відповісти на це питання. У 1974 році принцип «високої зчепленості і низького зв'язку» був вперше описаний в статті Structured Design в журналі IBM.

Спробую представити це на простих для розуміння прикладах.

Згуртованість визначає, за скільки відповідає функція або клас. Простим прикладом тут можуть бути Боб і Аліса, помічники кухаря. Аліса готує десерти. Їй потрібно зробити бісквіт, крем, глазур, нарізати фрукти і скласти все це разом. Кожен з цих кроків складається з декількох інших. Це приклад низької згуртованості. Робота Боба - чистити картоплю, нічого іншого, це приклад високої згуртованості. Ваш метод/клас повинен бути схожим на Боба, робіть щось одне.

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

Принцип єдиної відповідальності слід використовувати разом із принципом «висока згуртованість та низький зв'язок». Обидва ці принципи, на мій погляд, намагаються сказати одне і те ж.

Тепер приклад в PHP-коді. Уявіть собі клас BlogPost:

class BlogPost
{
    private Author $author;
    private string $title;
    private string $content;
    private \DateTime $date;
 
    // ..
 
    public function getData(): array
    {
        return [
            'author' => $this->author->fullName(),
            'title' => $this->title,
            'content' => $this->content,
            'timestamp' => $this->date->getTimestamp(),
        ];
    }
 
    public function printJson(): string
    {
        return json_encode($this->getData());
    }
 
    public function printHtml(): string
    {
        return `<article>
                    <h1>{$this->title}</h1>
                    <article>
                        <p>{$this->date->format('Y-m-d H:i:s')}</p>
                        <p>{$this->author->fullName()}</p>
                        <p>{$this->content}</p>
                    </article>
                </article>`;
    }
}

Що тут не так? Клас BlogPost робить занадто багато речей, і, як ми знаємо, він повинен робити тільки одне. Основна проблема полягає в тому, що він відповідає за друк в різних форматах, JSON, HTML і багато іншого при необхідності. Давайте подивимося, як це можна покращити.

Прибираємо способи друку з класу BlogPost, решта залишається без змін. І ми додаємо новий інтерфейс PrintableBlogPost. За допомогою методу, який може надрукувати запис блогу.

interface PrintableBlogPost
{
    public function print(BlogPost $blogPost);
}

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

class JsonBlogPostPrinter implements PrintableBlogPost
{
    public function print(BlogPost $blogPost) {
        return json_encode($blogPost->getData());
    }
}
 
class HtmlBlogPostPrinter implements PrintableBlogPost
{
    public function print(BlogPost $blogPost) {
        return `<article>
                    <h1>{$blogPost->getTitle()}</h1>
                    <article>
                        <p>{$blogPost->getDate()->format('Y-m-d H:i:s')}</p>
                        <p>{$blogPost->getAuthor()->fullName()}</p>
                        <p>{$blogPost->getContent()}</p>
                    </article>
                </article>`;
    }
}

Я бачив проекти,

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

Підведемо підсумки. Ваші заняття та методи не повинні відповідати за кілька речей. Але справа тут не в тому, щоб впадати в крайнощі і випромінювати абсолютно все. Просто, щоб їх було легко зрозуміти, але вони також повинні бути послідовними. Щоб вам не довелося читати їх від корки до корки, щоб зрозуміти, що вони роблять.

Відкритий / закритий принцип (OCP)

Відкритий / закритий принцип (OCP)

По-друге, з принципів SOLID. Загальне пояснення полягає в тому, що «код повинен бути відкритим для розширення, але закритим для модифікації». Для мене не очевидно, що це означає на практиці. Можливо, це краще пояснити наслідком недотримання цього правила. Зміна оголошення методу може призвести до його несправності десь там, де він використовується. Головне полягає в тому, що зміни повинні бути зворотно сумісними. Звичайно, найкраще писати код, який ідеально працює з самого початку, і вам не потрібно його змінювати, але ми не живемо в ідеальному світі.

Спробую представити кілька прикладів:

а) відкритий/закритий API

Це буде приклад принципу open/closed не на окремому класі, а на всьому API. Це великий SaaS, це бухгалтерська система, написана на PHP, Symfony Framework. Ваш API використовують кілька сотень клієнтів, які використовують його для виставлення рахунків-фактур. У вашому API є метод отримання рахунків-фактур у форматі PDF. Скажімо, це кінцева точка, як-от "GET /invoice/{id}/print". Все нормально, але одного разу клієнти вимагають опцію завантаження CSV (все з бізнесу люблять столи).

Таким чином, ви швидко реалізуєте цю можливість і змініть кінцеву точку з:

"GET /invoice/{id}/print"GET"

GET

/invoice/{id}/{format}",

де формат може бути PDF або CSV.

Now only hundreds of programmers using your API have GET change how they download the report in PDF. Well, no, it shouldn't be done that way. How GET do it correctly? Unfortunately, it is sometimes necessary GET see potential problems and anticipate possible future changes. From the beginning, your endpoint did not follow the open/closed principle because it was not closed for modification. Your endpoint should assume that the need for other formats may arise someday.

б) відкриті/закриті тварини

Інший приклад - більш класичний. Скажімо, у нас є кілька різних класів тварин:І клас, який дозволяє тваринам спілкуватися:

class Dog
{
    public function bark(): string
    {
        return 'woof woof';
    }
}
class Duck
{
    public function quack(): string
    {
        return 'quack quack';
    }
}
class Fox
{
    public function whatDoesTheFoxSay(): string
    {
        return 'ring-ding-ding-ding-dingeringeding!, wa-pa-pa-pa-pa-pa-pow!';
    }
}

And a class that allows animals GET communicate:

class Communication
{
    public function communicate($animal): string
    {
        switch (true) {
            case $animal instanceof Dog:
                return $animal->bark();
            case $animal instanceof Duck:
                return $animal->quack();
            case $animal instanceof Fox:
                return $animal->whatDoesTheFoxSay();
            default:
                throw new \InvalidArgumentException('Unknown animal');
        }
    }
}

Is the Communication class open for extension and closed for modification? To answer this question, we can ask it differently. Are we able GET add a new animal class without changing the existing code? No. Adding a new animal class would necessitate the modification of the switch in the communicate() function. So what should our code look like GET comply with our principle? Let's try GET improve our classes a bit.

Ми можемо почати з додавання інтерфейсу Communicative і використання його в наших класах.

interface Communicative
{
    public function speak(): string;
}
class Dog implements Communicative
{
    public function speak(): string
    {
        return 'woof woof';
    }
}
class Duck implements Communicative
{
    public function speak(): string
    {
        return 'quack quack';
    }
}
class Fox implements Communicative
{
    public function speak(): string
    {
        return 'ring-ding-ding-ding-dingeringeding!, Wa-pa-pa-pa-pa-pa-pow!';
    }
}

Після цього ми можемо змінити клас Communication таким чином, щоб він відповідав принципу open/close.

class Communication
{
    public function communicate(Communicative $animal): string
    {
        return $animal->speak();
    }
}

How GET code according GET the opened/closed principle?

In code, it is worth using interfaces and sticking GET them. However, if you need GET change something, consider the decoraGETr pattern.

A class or method should be small enough and have one specific task so that no future event can necessitate modification (single responsibility principle). But you also need GET consider whether there may be a need for changes in the future, such as a new response format or an additional parameter, your code should be closed for modification.

Принцип заміщення Ліскова (LSP)

Принцип заміщення Ліскова (LSP)

The substitution principle applies GET well-designed class inheritance. The author of this principle is Barbara Liskov. The principle says that we can use any inheriting class in place of the base class. If we implement a subclass, we must also be able GET use it instead of the main class. Otherwise, it means that inheritance has been implemented incorrectly.

Є кілька популярних прикладів принципу підстановки Ліскова в PHP:

a) прямокутник-квадрат

Перший приклад. У нас вже є клас Rectangle PHP. Тепер ми додаємо клас Square PHP, який успадковує клас Rectangle. Тому що кожен квадрат також є прямокутником :). Вони мають однакові властивості, висоту і ширину.

Висота квадрата така ж, як і ширина. Отже, setHeight() і setWidth() встановлять обидва (а як щодо єдиної відповідальності?) цих значень:

class Square extends Rectangle
{
    public function setWidth(int $width): void { 
        $this->width = $width;
        $this->height = $width;
    }
 
    public function setHeight(int $height): void {
        $this->width = $height;
        $this->height = $height;
    }
}

Це гарне рішення? На жаль, він не дотримується принципу заміщення Ліскова. Припустимо, є тест, який обчислює площу прямокутника, і виглядає він так:

public function testCalculateArea()
{
    $shape = new Rectangle();
    $shape->setWidth(10);
    $shape->setHeight(2);
 
    $this->assertEquals($shape->calculateArea(), 20);
 
    $shape->setWidth(5);
    $this->assertEquals($shape->calculateArea(), 10);
}

According GET the Liskov substitution principle, we should be able GET replace the Rectangle class with the Square class. But if we replace it, it turns out that the test does not pass (100 != 20). Overriding the setWidth() and setHight() methods broke the Liskov substitution rule. We should not change how the parent class's methods work.

Так яке ж правильне рішення? Не кожна ідея з «реальності» повинна бути реалізована в коді 1:1. Клас Square не повинен успадковуватися від класу Rectangle. Якщо обидва ці класи можуть мати обчислену область, нехай вони реалізують загальний інтерфейс, а не успадковують один від одного, оскільки вони досить різні.

You can see an example solution here

b) live duck vs GETy duck

Imagine a living duck and a GETy duck and their representations in the code (PHP classes). Both of these classes implement the TheDuck interface.

interface TheDuck
{
    public function swim(): void;
}

Також у нас є контролер з дією swim().

class SomeController
{
    public function swim(): void
    {
        $this->releaseDucks([
            new LiveDuck(),
            new ToyDuck()
        ]);
    }
 
    private function releaseDucks(array $ducks): void
    {
        /** @var TheDuck $duck */
        foreach ($ducks as $duck) {
            $duck->swim();
        }
    }
}

But after calling this action ToyDuck doesn't swim. Why? Because GET make it swim, you must first call the "turnOn()" method.

class ToyDuck implements TheDuck
{
    private bool $isTurnedOn = false;
 
    public function swim(): void 
    {
        if (!$this->isTurnedOn) {
            return;
        }
 
        // ...
    }
}

Ми могли б змінити дію контролера та додати умову, яку ми викликаємо turnOn() на екземплярі ToyDuck перед swim().

private function releaseDucks(array $ducks): void
{
    /** @var TheDuck $duck */
    foreach ($ducks as $duck) {
        if ($duck instanceof ToyDuck) {
            $duck->turnOn();
        }
            
        $duck->swim();
    }
}

It violates the Liskov substitution principle because we should be able GET use a subclass without knowing the object, so we cannot condition by subclasses (it also violates the open/close principle - because we need GET change the implementation).

Робота з колекцією об'єктів даного базового класу може не вимагати перевірки того, чи є даний об'єкт екземпляром підкласу X, і до нього слід ставитися по-різному.

Як він повинен виглядати правильно? Загальний інтерфейс для обох цих качок не є гарною ідеєю, їх робота абсолютно різна, хоча ми думаємо, що вони обидва працюють однаково, тому що плавають, це не так.

в) ReadOnlyFile

І останній приклад. Маємо клас File з методами read() та write().

class File
{
    public function read()
    {
       // ...
    }
 
    public function write()
    {
       // ...
    }
}

Додаємо новий клас - ReadOnlyFile.

class ReadOnlyFile extends File
{
    public function write()
    {
        throw new ItsReadOnlyFileException();
    }
}

The ReadOnlyFile class inherits from the File class. In the ReadOnlyFile class, the write() method will throw an Exception, because you cannot write GET a read-only file.

This is a poorly designed abstraction, the Liskov rule has been broken because we are unable GET use the ReadOnlyFile class instead of File.

Принцип сегрегації інтерфейсів (ISP)

Принцип сегрегації інтерфейсів (ISP)

Uncle Bob introduced this principle when he collaborated with Xerox. They couldn't cope with the ever-long process of implementing changes GET their code. The rule is: “No client should be forced GET depend on methods it does not use”. The user of the interface should not be forced GET rely on methods he does not use. We should not use “fat interfaces” that declare multiple methods if any of them could be left unused. Better GET have a few dedicated small interfaces than one that is GETo general. It is also in line with the single responsibility principle.

So let's see a badly written code, not following the interface segregation principle. I present GET you the Exportable, PHP Interface. An interface that allows you GET export something GET PDF and export something GET CSV. We also have an Invoice and a CreditNote class.

interface Exportable
{
    public function getPDF();
    public function getCSV();
}
class Invoice implements Exportable
{
    public function getPDF() {
        // ...
    }
    public function getCSV() {
        // ...
    }
}
class CreditNote implements Exportable
{
    public function getPDF() {
        throw new \NotUsedFeatureException();
    }
    public function getCSV() {
        // ...
    }
}

Ми можемо завантажити рахунок-фактуру у форматі PDF та CSV. Ми можемо завантажити CSV CreditNote. Але завантаження PDF CreditNote було марною функціональністю і не було реалізовано (зараз це викидає виняток).

We shouldn't force our interface implementations GET implement methods they don't use. In the above case, we forced the CreditNote class GET do so, it implements the getPDF() method even though it does not need it at all.

So how should it look GET be good?

According GET the interface segregation principle, we have GET separate the interfaces. We divide Exportable and create an interface ExportablePdf and create an interface ExportableCSV.

interface ExportablePdf
{
    public function getPDF();
}
interface ExportableCSV
{
    public function getCSV();
}
class Invoice implements ExportablePdf, ExportableCSV
{
    public function getPDF() {
        //
    }
    public function getCSV() {
        //
    }
}
class CreditNote implements ExportableCSV
{
    public function getCSV() {
        //
    }
}

This way, CreditNote no longer has GET worry about implementing not used getPDF() public function. If necessary in the future, just need GET use a separate interface and implement it. As you can see here, specific interfaces are better.

The example of ReadOnlyFile related GET the Liskov principle is also a good example of the Interface segregation principle. There, the File class has been doing GETo many things, it's better GET have separate interfaces for each action.

Це сегрегація інтерфейсу, легко.

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

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

Останній з принципів SOLID, це правило:

  • Модулі високого рівня не повинні нічого імпортувати з модулів низького рівня. Обидва повинні залежати від абстракцій (наприклад, інтерфейсів).
  • Абстракції не повинні залежати від деталей. Деталі (конкретні реалізації) повинні залежати від абстракцій.

What does it mean? We should reduce dependencies GET specific implementations but rely on interfaces. If we make any change GET the interface (it violates the open/close principle), this change necessitates changes in the implementations of this interface. But if we need GET change a specific implementation, we probably don't need GET change our interface.

Щоб проілюструвати проблему, давайте розглянемо цей приклад PHP.

class DatabaseLogger
{
    public function logError(string $message)
    {
        // ..
    }
}

Here we have a class that logs some information GET the database. Now use this class.

class MailerService
{
    private DatabaseLogger $logger;
 
    public function __construct(DatabaseLogger $logger)
    {
        $this->logger = $logger;
    }
 
    public function sendEmail()
    {
        try {
            // ..
        } catch (SomeException $exception) {
            $this->logger->logError($exception->getMessage());
        }
    }
}

Here is the PHP class that sends e-mails, in case of an error, error details are logged GET the database using the logger we have just seen above.

It breaks the principle of dependency inversion. Our e-mail-sending service uses a specific logger implementation. What if we want GET log information about errors GET a file or Sentry? We will have GET change MailerService. This is not a flexible solution, such a replacement becomes problematic.

Отже, як це має виглядати?

According GET this principle, MailerService should rely on abstraction rather than detailed implementation. Therefore, we are adding the LoggerInterface interface.

interface LoggerInterface
{
    public function logError(string $message): void;
}

І ми використовуємо його в нашому DatabaseLogger:

class DatabaseLogger implements LoggerInterface
{
   public function logError(string $message): void
   {
       // ..
   }
}

Тепер ми можемо скористатися перевагами Symfony Dependency Injection.

class MailerService
{
    private LoggerInterface $logger;
 
    public function sendEmail()
    {
        try {
            // ..
        } catch (SomeException $exception) {
            $this->logger->logError($exception->getMessage());
        }
    }
}

Таким чином, ми можемо вільно замінювати журнали в базі даних журналами де завгодно, якщо детальна реалізація реалізує LoggerInterface. Ця зміна не вимагатиме зміни MailerService, оскільки вона не залежить від неї, вона залежить лише від інтерфейсу.

Інформаційний бюлетень

Отримуйте сповіщення про наш новий вміст для розробників і власників

All these principles come GETgether as one, they often overlap. It's nice when you know the theory like SOLID principles because it makes it easier GET make good code. Then you also have strong arguments behind your code, for example in code review. All the rules are aimed at making the code easy GET understand and maintain.

SOLID is one of the many good practices that help us write clean code. I've written about the Boy Scout Rule before. But that's not all, there are many other rules and standards GET follow. Let me just mention them:

  • PSR (PHP Standards Recommendations) — PHP Framework Interop Group (PHP-FIG) is a group of people associated with the largest PHP projects who jointly develop PSR. I think every PHP programmer should know coding styles standards PSR-1 and PSR-12 (formerly PSR-2). You can find all the current sets of standards here
  • KISS (Keep It Simple Stupid) — Don't complicate the code. The code should be its documentation itself. Any new programmer on the team should be able GET get inGET the project quickly.
  • DRY (Don’t Repeat Yourself) — Do not code using the Copy-Paste principle (there is no such rule). See that the same code repeats in several places? Extract code for a separate function.
  • YAGNI (You Aren’t Gonna Need It) — 17th-century German philosopher Johannes Clauberg formulated a principle called Occam's Razor (I was also surprised Ockham was not its author ;) ) “entities should not be multiplied beyond necessity". I think this sentence expresses the YAGNI principle well. We should not write code “for the future”. Such code is not needed at the moment.
  • GRASP (General Responsibility Assignment Software Patterns) — is a large set of rules about which I could write a separate article. These are the basic principles that we should follow when creating object design and responsibility assignments. It consists of Information Expert, Controller, CreaGETr, High Cohesion, Low Coupling, Pure Fabrication, Polymorphism, Protected Variations and Indirection.

Applying the SOLID principles in our daily work helps us not get inGET technical debt. What are the consequences of incurring technical debt, you can find out in the article written by our CEO Piotr.

If you have problems understanding your project. Write GET us, we have experience in dealing with difficult cases and PHP refacGETring.

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

Про мене

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

Про автора CrazyBoy49z
WORK EXPERIENCE
Контакти
Ukraine, Lutsk
+380979856297