#3 SOLID – Liskov substitution principle
Kolejną z zasad SOLID pozwalających na tworzenie dobrej jakości rozwiązań jest zasada Liskov substitution principle(Zasada podstawiania Liskov). Sformułowana ona została przez Barbarę Liskov w książce Data Abstraction and Hierarch.
Definicja prezentuje się w następujący sposób.
Let f(x) be a property provable about objects x of type T. Then f(y) should be true for objects y of type S where S is a subtype of T.
Przekładając to na prosty język, po prostu musi istnieć możliwość podstawienia typów pochodnych za ich typy bazowe. Jeśli jakaś funkcja przyjmuje jako argument obiekt klasy A, to musi również działać prawidłowo dla obiektów pochodnych klasy A, czyli dziedziczących po niej.
Przykład złamania
<?php /** * Class Rectangle */ class Rectangle { /** * @var float */ protected $width; /** * @var float */ protected $height; /** * @return float */ public function getWidth(): float { return $this->width; } /** * @param float $width * @return Rectangle */ public function setWidth(float $width): Rectangle { $this->width = $width; return $this; } /** * @return float */ public function getHeight(): float { return $this->height; } /** * @param float $height * @return Rectangle */ public function setHeight(float $height): Rectangle { $this->height = $height; return $this; } /** * @return float */ public function calcArea(): float { return $this->width * $this->height; } }
<?php /** * Class Square */ class Square extends Rectangle { /** * @param float $height * @return Rectangle */ public function setHeight(float $height): Rectangle { $this->height = $height; $this->width = $height; return $this; } /** * @param float $width * @return Rectangle */ public function setWidth(float $width): Rectangle { $this->width = $width; $this->height = $width; return $this; } }
<?php function getArea(Rectangle $rectangle): float { $rectangle->setHeight(2); $rectangle->setWidth(3); return $rectangle->calcArea(); } $square = new Square(); $area = getArea($square); // 9
W powyższym przykładzie mamy klasę Rectangle oraz klasę pochodną Square. Oczywiście wszystko wydaje się być w porządku, przecież kwadrat jest prostokątem, więc dziedziczenie wydaje się uzasadnione. Na ostatnim listingu znajduje się funkcja getArea() przyjmująca jako argument obiekt typu Rectangle, co znaczy, że w myśl zasady LSP powinna ona działać poprawnie dla obiektów klasy Rectangle oraz obiektów pochodnych klasy Rectangle, czyli w tym wypadku Square. Jednak po ustawieniu odpowiednich wartości boków, które wskazują, że operujemy na prostokącie okazuje się, że zwracana jest niepoprawna wartość pola, dlatego iż parametrem przekazanym do funkcji był obiekt klasy Square.
Jak to poprawnie zaimplementować?
Rozwiązanie tego problemu wcale nie jest prostym zadaniem. Również do takiego wniosku doszedłem przeglądając niektóre rozwiązania znalezione w Google. Jedno z nich prezentuje się następująco:
abstract class Shape { private $width, $height; abstract public function getArea(); public function setColor($color) { // ... } public function render($area) { // ... } } class Rectangle extends Shape { public function __construct { parent::__construct(); $this->width = 0; $this->height = 0; } public function setWidth($width) { $this->width = $width; } public function setHeight($height) { $this->height = $height; } public function getArea() { return $this->width * $this->height; } } class Square extends Shape { public function __construct { parent::__construct(); $this->length = 0; } public function setLength($length) { $this->length = $length; } public function getArea() { return $this->length * $this->length; } } function renderLargeRectangles($rectangles) { foreach($rectangle in $rectangles) { if ($rectangle instanceof Square) { $rectangle->setLength(5); } else if ($rectangle instanceof Rectangle) { $rectangle->setWidth(4); $rectangle->setHeight(5); } $area = $rectangle->getArea(); $rectangle->render($area); }); } $shapes = [new Rectangle(), new Rectangle(), new Square()]; renderLargeRectangles($shapes);
Ten kod zawiera kilka błędów, wygląda jakby autor przepisywał rozwiązanie z innego języka, bo znajdują się tam wstawki składni niepoprawnej w php. Problem naruszenia LSP został niby zlikwidowany, jednak pojawia się kilka wątpliwości co do takiej implementacji. W abstrakcyjnej klasie mamy właściwości width oraz height, jednak nie wszystkie figury posiadają takie atrybuty, w okręgu mamy na przykład promień. Nawet w samym kwadracie używana jest inna właściwość – length, więc jaki jest sens pakowania tego do Shape?
W funkcji renderLargeRectangles() mamy sprawdzanie typu obiektu. W ten sposób moglibyśmy również załatwić problem w sytuacji, gdy Square dziedziczy po Rectangle, wystarczyłoby sprawdzanie czy obiekt jest typu Square i wiadomo jakiej wartości pola się spodziewać. Ten fragment również narusza zasadę OCP, w przypadku gdy dodamy kolejną figurę trzeba będzie dodać kolejnego ifa. Wiem, że ta funkcja jest przykładowa i ma prezentować działanie, jednak zdaję sobie sprawę, że mogłaby ona znaleźć się gdzieś w systemie, więc warto o tym wspomnieć.
Przykład poprawnego kodu
<?php /** * Interface AreaCalculableInterface */ interface AreaCalculableInterface { /** * @return float */ public function calcArea(): float; }
<?php /** * Class Rectangle */ final class Rectangle implements AreaCalculableInterface { /** * @var float */ private $width; /** * @var float */ private $height; /** * Rectangle constructor. * @param float $width * @param float $height */ public function __construct(float $width, float $height) { $this->width = $width; $this->height = $height; } /** * @return float */ public function getWidth(): float { return $this->width; } /** * @return float */ public function getHeight(): float { return $this->height; } /** * @return float */ public function calcArea(): float { return $this->width * $this->height; } }
<?php /** * Class Square */ final class Square implements AreaCalculableInterface { /** * @var float */ private $width; /** * Square constructor. * @param float $width */ public function __construct(float $width) { $this->width = $width; } /** * @return float */ public function getWidth(): float { return $this->width; } /** * @return float */ public function calcArea(): float { return $this->width * $this->width; } }
Kluczem do rozwiązania tego problemu jest immutability, czyli niezmienność. Odpowiednie wartości boków ustawiane są przy tworzeniu obiektu, późniejsza ich modyfikacja nie jest możliwa, gdyż nie mamy odpowiednich setterów. Dodatkowo te klasy(Rectangle, Square) raczej nie powinny być dziedziczone, więc ważne jest dodanie słowa kluczowego final, tak aby dać sygnał innym deweloperom, że nie powinni dziedziczyć z tych klas.
W javascripcie taka implementacja będzie stanowiła kłopot, bo tam nadal nie ma modyfikatorów dostępu, więc brak setterów nie rozwiązuje sprawy. Może masz na to jakiś sposób? Zachęcam do podzielenia się 😉
Pozostałe wpisy o SOLID
- SRP – Single responsibility principle (Zasada pojedynczej odpowiedzialności)
- OCP – Open/closed principle (Zasada otwarte/zamknięte)
- LSP – Liskov substitution principle (Zasada podstawienia Liskov)
- ISP – Interface segregation principle (Zasada segregacji interfejsów)
- DIP – Dependency inversion principle (Zasada odwrócenia zależności)
Subscribe and master unit testing with my FREE eBook (+60 pages)! 🚀
In these times, the benefits of writing unit tests are huge. I think that most of the recently started projects contain unit tests. In enterprise applications with a lot of business logic, unit tests are the most important tests, because they are fast and can us instantly assure that our implementation is correct. However, I often see a problem with good tests in projects, though these tests’ benefits are only huge when you have good unit tests. So in this ebook, I share many tips on what to do to write good unit tests.
Świetnie, że pokazałeś zasadę na faktycznym przykładem. Wydaje mi się, że jednak nieco wybiegłeś postem w szczegóły implementacyjne. Bo sednem samej zasady jest to założenie, aby: wszystkie podklasy implementowały w metody klasy bazowej w oczekiwany sposób. Jeśli jakaś podklasa nie implementuje metody bazowej to jest to naruszenie tej zasady. Jeśli implementuje ją w nieoczekiwany sposób – to również jest naruszenie zasady.
Tylko właśnie szczegóły implementacyjne są bardzo istotne, co nam z samej zasady skoro nie wiemy jak ją poprawnie zaimplementować.
Ten problem w ogóle nie jest trywialny. Samo jego sformułowanie jest podejrzane – oryginalny kod (ten ustawiający najpierw szerokość, potem wysokość) jest _nieintuicyjny_, ale nie jest _złamaniem kontraktu_. Nic w kontrakcie na (modyfikowalny) prostokąt nie mówi bowiem, że zmiana szerokości nie może pociągać za sobą zmiany wysokości. Gdyby mówiło, to kwadrat nie mógłby być prostokątem. Naprawienie tego poprzez zablokowanie części funkcjonalności (możliwość zmiany rozmiarów) jest trochę wylaniem dziecka z kąpielą.
Ogólnie problem z dziedziczeniem jest taki, że dobrze się w nim wyraża wartości pozytywne, ale nie da się wyrazić wartości negatywnych. Nie ma czegoś takiego, jak „metody negatywne” – można co najwyżej dawać asserty / rzucać wyjątki, ale tego nie widać w sygnaturze.
Dla mnie intuicyjnym rozwiązaniem tutaj byłoby zastąpienie metod setWidth() i setHeight() metodą resize(x, y), która zmienia oba wymiary i rzuca wyjątkiem dla kwadratu, dla którego ustalono różne x i y. Tyle tylko, że to rozwiązanie, na pierwszy rzut oka pozwalające uniknąć większej liczby błędów, jest właśnie niezgodne z zasadą LSP. Ale tego typu specjalizacja jest dość kanonicznym przykładem OOP i ogólnie specjalizacja jest tym aspektem OOP, który zasadę LSP narusza. Tak więc nie ma łatwo :>