Pułapki subtypingu i Liskov substitution principle
Najbardziej popularny paradygmat w programowaniu — OOP (object-oriented programming) — daje możliwość definiowania własnych typów, które następnie mogą być rozszerzane przez podtypy. Taki mechanizm nazywamy subtypingiem. Typy tworzymy poprzez klasy, które z kolei są rozszerzane przez podklasy, stanowiące podtypy. W związku z tym w aplikacji powstaje hierarchia tych elementów. Kiedy tworzy się taką strukturę kodu, należy stosować zasadę podstawienia Liskov (ang. Liskov substitution principle, LSP). W przeciwnym razie aplikacja może zacząć działać w sposób niepożądany, to znaczy jej zachowanie będzie się różnić w zależności od zastosowanego typu.
Wspomniana zasada została sformułowana w 1988 roku przez Amerykankę Barbarę Liskov. W 1994 roku opisała ją razem z Jeannettą Wing w pracy pt. „A Behavioral Notion of Subtyping”. LSP jest trzecią z zasad wyrażonych akronimem SOLID.
Definicję tej reguły można sparafrazować w ten sposób: typy bazowe i ich podtypy są wymienne, czyli w miejscu, w którym używany jest typ bazowy, może zostać wykorzystany jego typ pochodny, który nie zmienia zachowania tego pierwszego. Sprawa stanie się jaśniejsza po przedstawieniu przykładu.
Subtyping a dziedziczenie
Zanim jednak omówiony zostanie przykład, należy poruszyć temat różnicy między subtypingiem i dziedziczeniem, ponieważ LSP dotyczy tego pierwszego terminu, a nie drugiego.
Subtyping jest koncepcją, która dotyczy relacji między typami. Typy mogą mieć swoje podtypy, a te z kolei mogą być bazowe dla kolejnych typów pochodnych. Podtyp stanowi zawężenie typu bazowego. Można to zobrazować na przykładzie pojazdów. Załóżmy, że definiujemy typ o nazwie Vehicle. Obiekty tego typu reprezentują wszystkie pojazdy. Następnie określamy podtyp o nazwie Car. Taki typ będą miały wyłącznie samochody osobowe. Wszędzie tam, gdzie użyty będzie obiekt typu Vehicle, będzie można wymienić go na inny obiekt podtypu Car. Zasada podstawienia Liskov oznacza tutaj, że po zamianie obiektu, system będzie funkcjonował zgodnie z oczekiwaniami.
Mechanizm dziedziczenia natomiast polega na wykorzystaniu już istniejącego kodu klasy bazowej w podklasie. Jest to sposób na przekazanie pól i metod do innej klasy. W związku z tym, że poprzez dziedziczenie realizuje się subtyping, zagadnienia te są ze sobą bardzo powiązane i różnica między nimi jest trudna do uchwycenia.
Przykład
Załóżmy, że tworzymy aplikację, która wykonuje pewne operacje na figurach geometrycznych. W pierwszej wersji programu został zdefiniowany typ Rectangle, który reprezentuje prostokąt:
class Rectangle
{
private float $width;
private float $height;
public function setWidth(float $width): void
{
$this->width = $width;
}
public function getWidth(): float
{
return $this->width;
}
public function setHeight(float $height): void
{
$this->height = $height;
}
public function getHeight(): float
{
return $this->height;
}
}
W pewnym miejscu aplikacji mamy funkcję, która oblicza pole prostokąta:
function calculateArea(Rectangle $rectangle): float
{
return $rectangle->getWidth() * $rectangle->getHeight();
}
Użycie takiego kodu może wyglądać następująco:
$rectangle = new Rectangle();
$rectangle->setWidth(5);
$rectangle->setHeight(4);
$area = calculateArea($rectangle);
Funkcja calculateArea w tym wypadku zwróci wynik 20.
Po pewnym czasie powstaje konieczność wprowadzenia nowej figury tj. kwadratu. Wiemy, że z matematycznego punktu widzenia kwadrat jest prostokątem. Zatem może się wydawać dobrym pomysłem rozszerzenie klasy Rectangle w następujący sposób:
class Square extends Rectangle
{
public function setWidth(float $width): void
{
parent::setWidth($width);
parent::setHeight($width);
}
public function setHeight(float $height): void
{
parent::setHeight($height);
parent::setWidth($height);
}
}
W związku z tym, że szerokość i wysokość kwadratu są równe, metody klasy bazowej Rectangle zostały nadpisane w taki sposób, żeby ustawienie dowolnego parametru zmieniało też drugi. Użycie nowej figury może wyglądać następująco:
$square = new Square();
$square->setWidth(5);
$area = calculateArea($square);
Zwrócony wynik będzie równy 25. Do tego momentu wszystko działa poprawnie, ale przez to, że klasa Square dziedziczy po Rectangle możemy napisać następujący kod:
$square = new Square();
$square->setWidth(5);
$square->setHeight(4);
$area = calculateArea($square);
Co się okazuje? Otóż zwrócony zostanie wynik 16. Mamy zatem sytuację, w której dla tych samych wartości, czyli 5 i 4 otrzymujemy iloczyn 20 w przypadku instancji klasy Rectangle oraz 16 w przypadku instancji klasy Square. Świadczy to, o złamaniu zasady LSP.
Rozwiązanie
W związku z zaistniałymi problemami należy zmodyfikować kod aplikacji. Wiemy, że przede wszystkim klasa Square nie może dziedziczyć po klasie Rectangle. Można natomiast utworzyć wspólny interfejs dla figur geometrycznych Shape i umieścić w nim sygnaturę metody odpowiedzialnej za obliczanie pola. W ten sposób odpowiedzialność za wykonanie takiego działania zostanie przekazana z funkcji, która przyjmowała jako argument obiekt figury do poszczególnych implementacji interfejsu. Poniżej znajduje się propozycja, jak taki kod mógłby wyglądać:
interface Shape
{
public function calculateArea(): float;
}
class Rectangle implements Shape
{
private float $width;
private float $height;
public function setWidth(float $width): void
{
$this->width = $width;
}
public function getWidth(): float
{
return $this->width;
}
public function setHeight(float $height): void
{
$this->height = $height;
}
public function getHeight(): float
{
return $this->height;
}
public function calculateArea(): float
{
return $this->width * $this->height;
}
}
class Square implements Shape
{
private float $width;
public function setWidth(float $width): void
{
$this->width = $width;
}
public function getWidth(): float
{
return $this->width;
}
public function calculateArea(): float
{
return $this->width * $this->width;
}
}
$rectangle = new Rectangle();
$rectangle->setWidth(5);
$rectangle->setHeight(4);
$area = $rectangle->calculateArea();
$square = new Square();
$square->setWidth(5);
$area = $square->calculateArea();
W pierwszym przypadku, w którym obliczamy pole prostokąta, otrzymamy poprawny wynik 20, natomiast w przypadku kwadratu powierzchnia będzie wynosić 25.
Podsumowanie
Jak zostało pokazane znajomość zasad SOLID pozwala uniknąć błędów, które bez nich łatwo moglibyśmy popełnić. Zasada podstawienia Liskov jest niewątpliwie ważną regułą i warto poświęcić jej nieco uwagi, by dobrze ją zrozumieć. Osiągniemy dzięki temu korzyść w postaci systemu, który będzie działał zgodnie z naszymi oczekiwaniami.
Autorem tekstu jest Szymon Czembor