Krótko o SOLID

Podstawowych zasad programowania obiektowego jest wiele. W dzisiejszym wpisie rozważymy jedną z nich, zaproponowaną przez Roberta C. Martina – SOLID.

SOLID to mnemonik pięciu zasad:

S – SRP – Single responsibility principle,
O – OCP – Open/Closed principle,
L – LSP – Liskov substitution principle,
I – ISP – Interface segregation principle,
D – DIP – Dependency inversion principle.

W kilku zdaniach chciałbym przedstawić podstawowe założenia i przykłady dla każdej z tych reguł.

S – zasada jednej odpowiedzialności

Zgodnie z tą zasadą każda klasa powinna mieć tylko jedną odpowiedzialność (innymi słowy: powinien istnieć tylko jeden powód na modyfikację klasy).

Spróbujmy zatem przeanalizować przykładową klasę:

class Person {
    /** string */
    private $firstName;
    /** string */
    private $lastName;

    /** string */
    private $email;

    /** string */
    private $streetName;
    /** string */
    private $buildingNumber;
    /** string */
    private $apartmentNumber;
    /** string */
    private $postalCode;
    /** string */
    private $city;

    public function __construct(string $firstName, string $lastName, string
$email, string $streetName, string $buildingNumber, string $apartmentNumber, string $postalCode, string $city)
    {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
        $this->email = $this->validateEmail($email);
        $this->streetName = $streetName;
        $this->buildingNumber = $buildingNumber;
        $this->apartmentNumber = $apartmentNumber;
        $this->postalCode = $postalCode;
        $this->city = $city;
}

    private function validateEmail(string $email) : string {
    if (false === strpos($email, '.') || false === strpos($email, '@'))
{

        throw new Exception('Invalid email');
    }
        return $email;
    }
}

Klasa Person łamie zasadę jednej odpowiedzialności w przynajmniej kilku miejscach. Po pierwsze – pola dotyczące adresu. Klasa nie powinna zawierać pól, które nie są z nią powiązane. Bez problemu można wydzielić je do osobnego obiektu co znacznie ułatwi przyszły refactoring tej klasy (np. dodanie adresu zameldowania, dostawy etc.).

Drugim błędem jest walidator adresu e-mail. Sprawdzenie poprawności adresu nie leży w obowiązku klasy Person. Do tego sama metoda rzuca wyjątek co również jest błędem.

Poprawny kod wygląda np. tak:

class Address {
    /** string */
    private $streetName;
    /** string */
    private $buildingNumber;
    /** string */
    private $apartmentNumber;
    /** string */
    private $postalCode;
    /** string */
    private $city;

    public function __construct(string $streetName, string $buildingNumber,
string $apartmentNumber, string $postalCode, string$city)
    {
        $this->streetName = $streetName;
        $this->buildingNumber = $buildingNumber;
        $this->apartmentNumber = $apartmentNumber;
        $this->postalCode = $postalCode;
        $this->city = $city;
   } 
}

class Person
{
    /** string */
    private $firstName;
    /** string */
    private $lastName;

    /** string */
    private $email;

    /** @var Address */
    private $address;

    public function __construct(string $firstName, string $lastName, string
$email, Address $address)
    {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
        $this->email = $email;
        $this->address = $address;
    }

    public function getEmail() : string {
        return $this->email;
    } 
}

class EmailValidator
{
    public static function isValid(string $email): bool
    {
        $hasDot = false !== strpos($email, '.');
        $hasAt = false !== strpos($email, '@');

        return $hasDot && $hasAt;
    }
}

Poprzednia klasa została rozbita na trzy nowe. Każda z nich odpowiada tylko za jedną rzecz. Dodatkowo klasa metoda walidująca poprawność adresu e-mail nie zawiera logiki rzucania wyjątku. Dzięki temu można ją wykorzystać ponownie.

O – zasada otwarte-zamknięte

Zasada OCP oznacza, że klasa powinna być „otwarta na rozszerzenia a zamknięta na modyfikacje”. Uściślając – nie powinno dojść do sytuacji w której trzeba modyfikować kod. Jest to zabronione, gdyż zmiana deklaracji metody może spowodować błędne działanie w innych miejscach systemu.

Rozważmy taki fragment kodu:

class Square {
    /** @var float */
    public $a;


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

class Circle{
    /** @var float */
    public $r;

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

class AreaCalculator {

    public function calculate($shape) : float {
        if ($shape instanceof Square) {
            return $shape->a * $shape->a;
        }
        elseif ($shape instanceof Circle) {
            return M_PI * $shape->r * $shape->r;
        }

        return 0.0; 
    }
}

$square = new Square(3);
$circle = new Circle(5);
$calculator = new AreaCalculator;

echo $calculator->calculate($square) . PHP_EOL;
echo $calculator->calculate($circle);

Metoda calculate() z klasy Calculator jest błędna. Dodanie każdej nowej figury wymusza zmianę tej metody, co jest niezgodne z zasadą OCP.

Poprawna implementacja może wyglądać tak:

interface Shape {
    public function area() : float;
}

class Square implements Shape {
    /** @var float */
    public $a;

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

    public function area(): float
    {
         return $this->a * $this->a;
    }
}

class Circle implements Shape {
    /** @var float */
    public $r;
 
    public function __construct(float $r)
    {
        $this->r = $r;
    }

    public function area(): float
    {
        return M_PI * $this->r * $this->r;
    }
}

class AreaCalculator {
    public function calculate(Shape $shape) : float {
        return $shape->area();
    }
}

$square = new Square(3);
$circle = new Circle(5);
$calculator = new AreaCalculator;

echo $calculator->;calculate($square) . PHP_EOL;
echo $calculator->;calculate($circle);

Został dodany interfejs, który wymusza implementacje metody area(). Dzięki temu dodając np. trójkąt możemy przekazać go naszemu kalkulatorowi a ten poprawnie zwróci pole figury. Nie wymaga to żadnej zmiany w kalkulatorze.

L – zasada podstawienia Liskov

Ta zasada mówi o tym, że w miejscu klasy bazowej możemy użyć dowolnej klasy, która po niej dziedziczy. Oznacza to, że klasa pochodna musi zachować 100% interfejsu klasy bazowej (wszystkie metody muszą przyjmować te same argumenty i zwracać te same typy).

Spójrzmy na przykład:

class Rectangle {
    private $width;
    private $height;

    public function getWidth() {
        return $this->width;
}

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

    public function getHeight() {
        return $this->height;
}

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

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

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

Zgodnie z zasadami matematyki wszystko jest w porządku. Każdy kwadrat jest przecież prostokątem. Ale czy w programowaniu rzeczywiście tak jest?

Przeanalizujmy przykładowe użycie:

class Client {
    public function areaVerifier(Rectangle $rectangle)
    {
        $rectangle->setWidth(5);
        $rectangle->setHeight(4);

        return $rectangle->getArea() == 20;
    }
}

class LspTest extends PHPUnit_Framework_TestCase {
    public function testRectangleArea()
    {
        $rectangle = new Rectangle;
        $client = new Client;

        $this->assertTrue($client->areaVerifier($rectangle));
    }

    public function testSquareArea()
    {
        $square = new Square;
        $client = new Client;
 
        $this->assertTrue($client->areaVerifier($square));
    }
}

Pierwszy test przejdzie prawidłowo. Drugi nie. Nasz kwadrat nie zachowuje się jak prostokąt. Łamie prawa geometrii. Ten przykład przy okazji pokazuje, że została złamana nie tylko zasada LSP. Widać tutaj, że programowanie zorientowane obiektowo nie polega na zobrazowaniu prawdziwego życia obiektów. Jeśli będziemy próbować odwzorować jeden do jednego rzeczywistość, prawie zawsze się zawiedziemy.

Zobaczmy jeszcze taki przykład:

class BlackCoffeMachine {
    public function brew() {
        echo 'Pour coffe to the cup' . PHP_EOL;
        echo 'Pour water to the cup' . PHP_EOL;
    }
}

class WhiteCoffeMachine extends BlackCoffeMachine {
    public function brew() {
        parent::brew();
        echo 'Pour milk to the cup' . PHP_EOL;
    }
}

$machines = [
    new BlackCoffeMachine,
    new WhiteCoffeMachine
];

foreach ($machines as $machine) {
    $machine->brew();
}

Zachowuje on zasadę podstawienia. Metoda brew() rozszerza metodę z klasy bazowej zachowując kontrakt, w związku z tym obie klasy mogą być używane wymiennie.

I – Segregacja interfejsów

Zasada mówi, że nie powinno się wymuszać implementacji interfejsów które nie są używane. Innymi słowy: klienci nie powinni zależeć od interfejsów, których nie używają. Oznacza to, że lepiej zdefiniować wiele mniejszych interfejsów niż jeden wielki.

 interface WorkerInterface {
    public function work();
    public function sleep();
}

class HumanWorker implements WorkerInterface {
    public function work() {
        return 'human working';
}

    public function sleep() {
        return 'human sleeping';
    }
}

class AndroidWorker implements WorkerInterface {
    public function work() {
        return 'android working';
    }
    public function sleep() {
        return null;
    }
}

class Capitan {
    public function manage(WorkerInterface $worker) {
        $worker->work();
        $worker->sleep();
    }
}

Robot nie potrzebuje snu, więc implementacja metody sleep() zwraca null. Można tu przy okazji zauważyć złamanie zasady LSP (różne typy zwracanych danych dla różnych implementacji).

Spróbujmy przeanalizować poprawiony kod:

interface WorkableInterface {
    public function work();
}

interface SleepableInterface {
    public function sleep();
}

class HumanWorker implements WorkableInterface, SleepableInterface {
    public function work() {
        return 'human working';
    }

    public function sleep() {
        return 'human sleeping';
    }
}

class AndroidWorker implements WorkableInterface {
    public function work() {
        return 'android working';
    }
}

Interfejsy zostały podzielone, każdy obiekt implementuje tylko to, czego rzeczywiście wymaga. Ale co z klasą kapitana? Czy może ona wyglądać tak:

class Capitan {
    public function manage($worker) {
        $worker->work();
        if ($worker instanceof SleepableInterface) {
            $worker->sleep();
        } 
    }
}

Oczywiście, że nie. Jest tu złamana zasada OCP. Przykładowa poprawna implementacja może wyglądać tak:

interface WorkableInterface {
    public function work();
}

interface SleepableInterface {
    public function sleep();
}

interface ManageableInterface {
    public function beManaged();
}

class HumanWorker implements WorkableInterface, SleepableInterface,
ManageableInterface {
    public function work() {
        return 'human working';
    }

    public function sleep() {
        return 'human sleeping';
    }

    public function beManaged() {
        $this->work();
        $this->sleep();
    }
}

class AndroidWorker implements WorkableInterface, ManageableInterface {
    public function work() {
        return 'android working';
    }

    public function beManaged() {
        $this->work();
    }
}

class Capitan {
    public function manage(ManageableInterface $worker) {
        $worker->beManaged();
    }
}

D – Dependency Inversion

Zasada Odwrócenia Zależności mówi o tym, że moduły wysokopoziomowe nie powinny zależeć od modułów niskopoziomowych. Wszystkie zależności powinny w jak największym stopniu zależeć od abstrakcji, a nie od konkretnego typu.

Spójrzmy na ten prosty przykład:

class MySQLConnection {
   public function connect()
   {
       //
   }
}

class PasswordReminder {
    private $dbConnection;

    public function __construct(MySQLConnection $connection)
    {
        $this->dbConnection = $connection;
    }
}

Nasza klasa do przypominania haseł wymaga połączenia do bazy danych. W tym przypadku wymagany jest obiekt klasy MySQLConnection. A co, gdybyśmy chcieli zmienić silnik bazy danych? Dla klasy PasswordReminder nie powinno mieć znaczenia, jaki jest typ bazy danych. Można to w łatwy sposób naprawić:

interface DbConnectionInterface {
    public function connect();
}

class MySQLConnection implements DbConnectionInterface {
    public function connect()
    {
        // 
    }
}

class InFileConnection implements DbConnectionInterface {
    public function connect()
    {
        // 
    }
}

class PostgresConnection implements DbConnectionInterface {
    public function connect()
    {
        //
    }
}

class PasswordReminder {
    private $dbConnection;

    public function __construct(DbConnectionInterface $connection)
    {
        $this->dbConnection = $connection;
    }
}

W ten prosty sposób uniezależniliśmy przypominanie haseł od jakiegokolwiek silnika. Teraz jego zależnością jest połączenie z bazą a nie konkretny silnik. Sprawiliśmy, że zależność wymaga abstrakcji a nie konkretnej implementacji.

Podsumowanie

Znajomość zasad SOLID na etapie projektowania aplikacji pozwala uniknąć wielu błędów. Stosując je znacząco ułatwimy sobie rozwój naszej aplikacji w przyszłości. Wyrobienie dobrych praktyk programistycznych z pewnością zapunktuje w przyszłym życiu zawodowym.

Tym wpisem chciałem tylko zasygnalizować, że jest coś takiego jak SOLID. Pokazałem kilka prostych przykładów, jednak temat jest dużo szerszy. Mam nadzieję, że zaciekawiłem Cię na tyle, że zaczniesz stosować je w swoim życiu.

Opracowano na podstawie https://laracasts.com/series/solid-principles-in-php/

Autorem tekstu jest Łukasz Wojtyczka.

0 0 votes
Article Rating
Subscribe
Powiadom o
3 komentarzy
najstarszy
najnowszy oceniany
Inline Feedbacks
View all comments
Kuba
Kuba
6 lat temu

Dobra robota. Jako jeden z niewielu artykułów obrazuje SOLID w prosty sposób. Moje pytanie jak poprawnie rozwiązać źle zaprojektowany przykład z Rectangle i Square?

ayeo
ayeo
6 lat temu
Reply to  Kuba

Wydaje mi się, że jedną z ciekawych opcji byłoby wymodelowanie tych obiektów jako ValueObject. Rożniłyby się tylko konstruktorem (kwadrat dostawałby jeden bok, prosotkąt dwa) – dla użyć w kodzie klienta api pozostałoby niezmienne (kwadrat ma i wysokość i szeorkość – wiedza domeny to fakt, że takie same)

Zbyszek
Zbyszek
5 lat temu

Też uważam, że świetna robota. Ze swej strony mogę uzupełnić o technikę Design By Contract wykorzystywanej w zasadzie podstawień Liskov która opisana jest na https://javadeveloper.pl/solid/.