Wprowadzenie
Zbiór zasad regulujących sposób projektowania kodu w paradygmacie obiektowym. Wyróżniamy pięć zasad, które zdaniem Roberta Martina ,,pozwalają na projektowanie bardziej zrozumiałych i łatwiejszych w utrzymaniu programów”. W tym artykule pokrótce omówię każdą z tych zasad i zobrazuję je na przykładzie – dostarczania przesyłek różnymi środkami transportu.
Zasada pojedynczej odpowiedzialności
Zasada pojedynczej odpowiedzialności (Single Responsibility Principle, SRP) mówi, że klasa powinna mieć jeden i tylko jeden powód do zmiany. Nie chodzi o to, aby klasa miała jedną metodę, ale o to, aby odpowiadała za jeden obszar odpowiedzialności w systemie.
Oznacza to, że nie powinniśmy mieszać w jednej klasie logiki biznesowej, zapisu do bazy danych, wysyłania powiadomień czy logowania. Każda z tych rzeczy może zmieniać się z innego powodu.
Przykład złego podejścia – naruszenie SRP
Poniższa klasa odpowiada za zbyt wiele rzeczy:
- realizację dostawy,
- obliczanie kosztu transportu,
- zapis do bazy danych,
- wysyłanie powiadomienia e-mail.
Każda z tych funkcjonalności może zmienić się niezależnie od pozostałych, co oznacza wiele powodów do zmiany jednej klasy.
class LandShipment
{
public function deliver(): string
{
return "Delivering by land.";
}
public function calculateCost(): float
{
return 2500;
}
public function saveToDatabase(): void
{
// saving shipment information
}
public function sendEmailNotification(): void
{
// sending a notification to the customer
}
}
Zmiana sposobu zapisu do bazy, zmiana sposobu liczenia kosztów lub zmiana sposobu powiadamiania klienta wymusi modyfikację tej samej klasy.
Przykład dobrego podejścia – spełnienie SRP
Rozdzielamy odpowiedzialności na osobne klasy:
class LandShipment
{
public function deliver(): string
{
return "Delivering by land.";
}
}
class ShipmentCostCalculator
{
public function calculate(DeliveryInterface $delivery): float
{
// cost calculation logic
return 2500;
}
}
class ShipmentRepository
{
public function save(LandShipment $shipment): void
{
// saving to the database
}
}
class NotificationService
{
public function send(): void
{
// sending an email
}
}
Teraz:
- zmiana sposobu dostawy wpływa tylko na klasę
LandShipment, - zmiana algorytmu liczenia kosztów wpływa tylko na
ShipmentCostCalculator, - zmiana sposobu zapisu danych dotyczy tylko
ShipmentRepository, - zmiana sposobu powiadamiania klienta dotyczy tylko
NotificationService.
Każda klasa ma jeden powód do zmiany, dzięki czemu kod jest bardziej czytelny, łatwiejszy w utrzymaniu i rozwijaniu.
Zasada otwarte-zamknięte
Zasada otwarte-zamknięte (Open-closed principle, OCP) zwraca uwagę na to, że klasa powinna być otwarta na rozszerzanie, ale zamknięta na modyfikację istniejącego kodu. Dodawanie nowych funkcjonalności i ingerencja w istniejący kod zawsze niesie ze sobą ryzyko popełnienia błędu.
Rozszerzenie klasy powinno być realizowane poprzez dodanie nowej klasy. Przydaje się do tego wzorzec Strategia, który pozwala na wymianę klas, nie wpływając w żaden sposób na istniejący kod.
Poniższy przykład pokazuje różne rodzaje dostaw (lądowa, powietrzna, wodna), które realizowane są jako różne strategie. Możemy łatwo rozszerzyć serwis dostaw, dodając nowe środki transportu bez zmian w istniejącej implementacji.
interface DeliveryInterface
{
public function deliver(): string;
}
class LandShipment implements DeliveryInterface
{
public function deliver(): string
{
return "Delivering by land.";
}
}
class AirShipment implements DeliveryInterface
{
public function deliver(): string
{
return "Delivering by air.";
}
}
class WaterShipment implements DeliveryInterface
{
public function deliver(): string
{
return "Delivering by water.";
}
}
class ShipmentService
{
private DeliveryInterface $delivery;
public function __construct(DeliveryInterface $delivery)
{
$this->delivery = $delivery;
}
public function ship(): string
{
return $this->delivery->deliver();
}
}
$landDelivery = new ShipmentService(new LandShipment());
echo $landDelivery->ship() . "<br/>";
$airDelivery = new ShipmentService(new AirShipment());
echo $airDelivery->ship();
Wynik działania:
Delivering by land.
Delivering by air.
Obiekt klasy ShipmentService najpierw przyjął pierwszą przesyłkę, która została dostarczona drogą lądową. Następnie przyjął drugą przesyłkę, którą dostarczono drogą powietrzną. W ten sposób realizujemy pewną strategię, w zależności od rodzaju przesyłki, serwis realizuje dostawę w odpowiedni sposób. Nic nie stoi na przeszkodzie, aby rozszerzyć wachlarz dostępnych sposobów dostawy.
Zasada podstawienia Liskov
Zasada podstawienia Liskov (Liskov Substitution Principle, LSP) mówi, że obiekty klas pochodnych powinny móc zastępować obiekty klasy bazowej (lub implementowanego interfejsu) bez zmiany poprawności działania programu.
Innymi słowy: jeżeli korzystamy z abstrakcji (np. interfejsu), to każda jego implementacja powinna zachowywać się zgodnie z oczekiwaniami wynikającymi z tej abstrakcji.
Przykład złego podejścia – naruszenie ISP
Załóżmy, że ShipmentService oczekuje, że każda implementacja DeliveryInterface zawsze poprawnie realizuje dostawę.
interface DeliveryInterface
{
public function deliver(): string;
}
class LandShipment implements DeliveryInterface
{
public function deliver(): string
{
return "Delivering by land.";
}
}
class AirShipment implements DeliveryInterface
{
public function deliver(): string
{
throw new Exception("Cannot deliver in bad weather.");
}
}
class ShipmentService
{
private DeliveryInterface $delivery;
public function __construct(DeliveryInterface $delivery)
{
$this->delivery = $delivery;
}
public function ship(): string
{
return $this->delivery->deliver();
}
}
$service = new ShipmentService(new AirShipment());
echo $service->ship();
W tym przypadku ShipmentService zakłada, że metoda deliver() zawsze zwróci informację o dostawie. Jednak implementacja AirShipment zmienia to oczekiwane zachowanie, rzucając wyjątek.
Oznacza to, że nie możemy bezpiecznie podmienić LandShipment na AirShipment, mimo że obie klasy implementują ten sam interfejs. Program przestaje działać zgodnie z oczekiwaniami, co jest złamaniem zasady LSP.
Przykład dobrego podejścia – spełnienie LSP
Każda implementacja powinna zachowywać się zgodnie z kontraktem interfejsu i nie zmieniać oczekiwań wobec metody deliver().
class AirShipment implements DeliveryInterface
{
public function deliver(): string
{
return "Delivering by air.";
}
}
Teraz każda klasa implementująca DeliveryInterface może zostać bezpiecznie użyta w ShipmentService, bez ryzyka zmiany logiki działania programu.
Zasada segregacji interfejsów
Zasada segregacji interfejsów (Interface segregation principle, ISP) zwraca uwagę na to, że lepiej posiadać więcej chudszych interfejsów, niż jeden gruby. Obszerny interfejs wypchany metodami do zaimplementowania przez klasy pochodne powoduje czasami implementacje tychże metod na siłę.
Rozwiązaniem jest tworzenie bardziej specyficznych interfejsów z niezbędnymi metodami i danymi wejściowymi.
Przykład złego podejścia – naruszenie LSP
Interfejs posiadający metody, które klasy implementujące nie będą w 100% realizować. Klasa BikeShipment nie będzie wykorzystywać metody refuel() i jej implementacja musiałaby np. rzucić wyjątek ,,Roweru nie można zatankować”.
interface DeliveryInterface
{
public function deliver(): string;
public function refuel(): string;
}
Przykład dobrego podejścia – spełnienie ISP
Utworzenie kilku mniejszych interfejsów, które będą w pełni wykorzystywane przez konkretne klasy.
interface DeliveryInterface
{
public function deliver(): string;
}
interface FuelPoweredInterface
{
public function refuel(): string;
}
class WaterShipment implements DeliveryInterface, FuelPoweredInterface
{
public function deliver(): string
{
return "Delivering by water.";
}
public function refuel(): string
{
return "Refueling the ship.";
}
}
Zasada odwrócenia zależności
Zasada odwrócenia zależności (Dependency Inversion Principle, DIP) mówi, że:
moduły wysokiego poziomu nie powinny zależeć od modułów niskiego poziomu – obie grupy powinny zależeć od abstrakcji.
Oznacza to, że logika biznesowa (np. serwis realizujący dostawy) nie powinna znać szczegółów implementacyjnych takich jak konkretne środki transportu. Powinna operować na abstrakcjach, czyli interfejsach.
Przykład złego podejścia – naruszenie DIP
Serwis dostaw zależy bezpośrednio od konkretnej klasy LandShipment.
class ShipmentService
{
private LandShipment $landShipment;
public function __construct()
{
$this->landShipment = new LandShipment();
}
public function ship(): string
{
return $this->landShipment->deliver();
}
}
W tym przypadku:
- moduł wysokiego poziomu (
ShipmentService) zna szczegóły implementacyjne, - nie możemy użyć innego środka transportu bez modyfikacji tej klasy,
- zależność biegnie od góry do dołu – od logiki biznesowej do detali.
Przykład dobrego podejścia – spełnienie DIP
Odwracamy zależność – ShipmentService zna tylko abstrakcję.
interface DeliveryInterface
{
public function deliver(): string;
}
class ShipmentService
{
private DeliveryInterface $delivery;
public function __construct(DeliveryInterface $delivery)
{
$this->delivery = $delivery;
}
public function ship(): string
{
return $this->delivery->deliver();
}
}
Teraz:
ShipmentServicenie wie nic oLandShipment,AirShipmentczyWaterShipment,- zależy wyłącznie od interfejsu,
- to klasy niskiego poziomu (środki transportu) dostosowują się do abstrakcji.
Zależność została odwrócona – to implementacje zależą od interfejsu, a nie odwrotnie. Dzięki temu logika biznesowa jest całkowicie odseparowana od szczegółów technicznych.
