Wróć do strony głównej
Angular

Angular & Liskov Substitution Principle

Wstęp

To już trzeci artykuł w serii dotyczącej skrótu SOLID. Jest to zbiór zasad, dzięki którym możemy pisać kod, który łatwiej będzie nam skalować, oraz zmieniać zachowanie naszej aplikacji, bez ruszania kodu dużej części aplikacji.

 

Na zbiór zasad składają się:

 

Dzisiaj zajmiemy się Liskov Substitution Principle 🙂

Liskov Substitution Principle

Zasada Liskov to według mnie najbardziej złożona reguła składająca się na SOLID. Jej formalna definicja brzmi tak: 

 

Jeżeli S jest podtypem T, to obiekty typu S mogą być użyte w miejsce obiektów typu T,

a program zachowa swoją poprawność. Aby lepiej to zobrazować, spójrzmy na poniższy obrazek:

Credit: https://devexperto.com/principio-de-sustitucion-de-liskov/principio-sustitucion-liskov-meme/

Zatem jeśli coś wygląda jak kaczka, zachowuje się jak kaczka, ale potrzebuje baterii do działania, to prawdopodobnie mamy złą abstrakcję, źle zamodelowaliśmy relację typów. Spójrzmy na taki pseudokod:

Mamy więc typ „kaczka”, który może wydawać dźwięki (kwaczeć).

Stwórzmy teraz na stworzony przez nas podtyp kaczki, elektryczną kaczkę (na baterie):

Załóżmy, że w naszym kodzie wszędzie korzystaliśmy z typu naturalnej kaczki, ale w jednym miejscu użyliśmy nowo zdefiniowanej kaczki elektrycznej. Z przyzwyczajenia do zwykłej kaczki, zapomnieliśmy zainicjować ją z bateriami. Przez to nasz kod nie działa (zgłaszany jest wyjątek), a kaczka nie wydaje dźwięku. Jest to właśnie złamanie zasady Liskov. 

 

Jak zatem mogłaby wyglądać prawidłowa relacja typów? Moglibyśmy stworzyć kaczkę płci męskiej i kaczkę płci damskiej. Wtedy obie wydawałyby dźwięki, niezależnie od płci. Obiekty klasy pochodnej powinny uzupełniać, a nie zastępować zachowanie klasy bazowej.

 

Wydaje się więc, że przestrzeganie tej reguły jest trudne. Na szczęście autorki tej reguły zebrały warunki, które muszą być spełnione, aby pozostała ona nienaruszona.

Kowariancja typów wyjściowych

Pierwszym warunkiem jest kowariancja typów wyjściowych.

 

Kowariancja to konwersja z typu bardziej ogólnego, do bardziej szczegółowego, np. z typu Car na typ Rolls-Royce.

 

Spójrzmy na przykład:

W pierwszym przypadku implementacja funkcji Mapper łamie kowariancję typów wyjściowych, ponieważ typy argumentów są rozszerzone (metoda zwraca nie tylko string, ale i number).

W drugim przypadku wszystko jest w porządku, ponieważ metoda zawęża zwracane typy (zwraca typ someString zamiast string).

Kontrawariancja typów wejściowych

Drugim warunkiem jest kontrawariancja typów wejściowych. 

 

Kontrawariancja jest przeciwieństwem kowariancji, czyli jest to konwersja z typu bardziej szczegółowego do bardziej ogólnego, np. z typu Rolls-Royce do typu Car.

 

Spójrzmy na przykład:

W pierwszym przypadku implementacja funkcji Mapper łamie kontrawariancję typów wejściowych, ponieważ typy argumentów są zawężone (metoda przyjmuje tylko typ string).

W drugim przypadku wszystko jest w porządku, ponieważ metoda rozszerza przyjmowane typy (przyjmuje dodatkowo tablicę wartości typu string).

Wyjątki

Kolejnym warunkiem są wyjątki. Jeśli podtyp S (Derived) wprowadza nowe wyjątki, to muszą być one podtypem wyjątków rzucanych w typie ogólnym T (Base).

 

Spójrzmy na taki kod:

Mamy tu problem – podtyp Derived rzuca wyjątek, który nie jest podtypem wyjątku z typu ogólnego Base.

 

Rozwiązaniem jest powiązanie relacją typowania wyjątków rzucanych przez podtyp.

Kontrakty – warunki wstępne

Kolejnym warunkiem są tzw. kontrakty dotyczące warunków wstępnych. 

 

Definicja brzmi tak: podtyp nie może być bardziej wybredny niż typ bazowy Base, musi umieć obsłużyć przynajmniej taki sam zakres danych.

 

Załóżmy, że mamy system biblioteki. Spójrzmy więc na poniższy kod serwisów, które służą do wyliczania opłaty za wypożyczone książki:

Pierwszy z nich służy do wyliczenia opłaty dla zwykłych użytkowników. Po wypożyczeniu jednej książki, musi zapłacić on opłatę za wypożyczenie. Drugi z serwisów (Derived), służy do wyliczenia opłaty dla użytkowników VIP.  Użytkownik VIP opłaca cyklicznie abonament, dlatego płaci opłatę za wypożyczenie dopiero od 4. wypożyczonej książki. 

 

Widzimy więc, że serwis dla użytkowników VIP nie jest w stanie obsłużyć liczby książek mniejszej niż 4. Jest to złamanie warunków wstępnych. Możemy to naprawić, po prostu zwracając wartość 0 dla mniejszej liczby książek (co jest zgodne z logiką biznesową – opłata w takim wypadku powinna wynieść 0):

Kontrakty – warunki końcowe

Kolejnym warunkiem są tzw. kontrakty dotyczące warunków końcowych. 

 

Definicja brzmi tak: podtyp musi zwrócić dane, które nie złamią warunków narzuconych na dane zwracane przez typ bazowy Base.

 

Spójrzmy na kod:

Załóżmy, że nadal jesteśmy w kontekście aplikacji bibliotecznej. Mamy te 2 metody, które zwracają wysokość opłaty za wypożyczenie. Jak widać, metoda w serwisie Base zwróci zawsze wartość min. 10. Natomiast metoda w Derived (dla użytkowników VIP), minimalnie zwróci 0. Mamy więc naruszenie warunków końcowych – podtyp Derived łamie warunek (wysokość opłaty >= 10) na dane ustawiony przez typ bazowy Base. Poprawa tego może wymagać skonsultowania logiki biznesowej (czasami możemy godzić się na świadome złamanie tej zasady Liskov).

Cechy – zachowanie niezmiennika

Kolejnym warunkiem jest zachowanie niezmiennika. Co to oznacza? Czym jest niezmiennik?

 

Zacznijmy od przykładu. Np.:

  • użytkownik ma zawsze imię i nazwisko,
  • prostokąt ma zawsze bok A i bok B.

 

Zatem niezmiennik to coś nienaruszalnego, coś co jest esencją danego typu.

 

Bardziej formalna definicja niezmiennika to:

funkcja ze zbioru stanów klasy w zbiór {true, false}.

 

Tak więc podsumowując, niezmiennik to funkcja, która pozwala nam stwierdzić, czy stan obiektu jest legalny, czy nie.

 

Aby zachować niezmiennik, musimy zapewnić, że stan obiektu jest legalny.

Cechy – zasada historii

Kolejnym warunkiem jest przestrzeganie zasad historii. Czym jest zasada historii?

 

Tak jak w poprzednim podpunkcie, zacznijmy od przykładu.

 

Niezmiennikiem jest zdanie: rozmiar struktury jest zawsze mniejszy niż maksymalny rozmiar.

 

W tym wypadku zasada historii mówi, że maksymalny rozmiar struktury nie zmienia się.

 

Bardziej formalna definicja zasady historii:

funkcja ze zbioru par stanów w zbiór {true, false}.

 

Jest to więc funkcja, która pozwala nam stwierdzić, czy przejście ze stanu A do stanu B jest legalne czy nie.

 

Aby przestrzegać zasady historii, musimy zapewnić, że przejścia między stanami obiektu są legalne.

Podsumowanie

Liskov Substitution Principle to najbardziej złożona reguła w zbiorze SOLID. Aby jej przestrzegać, należy pamiętać przede wszystkim o jednym zdaniu: obiekty pochodnego typu powinny uzupełniać, a nie zastępować zachowanie obiektu typu bazowego. Pozostałe warunki pomagają sprawdzić, czy na pewno zachowujemy zasadę Liskov w naszym kodzie.

 

W kolejnym artykule zajmiemy się Interface Segregation Principle 🙂

 

O autorze

Wojciech Janaszek

Jestem Angular i NestJS developerem. Swoją przygodę zaczynałem od pierwszych wersji „nowego” Angulara (2 wzwyż). Od niedawna korzystam również z NestJS (którego nauka znając dobrze Angulara przychodzi łatwo – wszystko jest bardzo podobne). W swojej pracy dużą uwagę przykładam do tzw. clean code i clean architecture. Lubię mieć “porządek” w kodzie 🙂 W wolnym czasie interesuję się sportowymi samochodami, ogólnie pojętym motorsportem. Gram również amatorsko w siatkówkę.

Zapisz się do naszego newslettera. Bądź na bieżąco z najnowszymi trendami, poradami, meetupami i stań się częścią społeczności Angulara w Polsce. Rynek pracy docenia członków społeczności.

Jeden komentarz

  1. papa smerf

    Formalna definicja zasady jest inna. Opiera się na terminach dowodliwość i prawdziwość. Jak coś jest prawdziwe to nie oznacza że jest dowodliwe. Formalnie brzmi: niech dla obiektów t typu T pewna własność W(t) jest dowodliwa wtedy dla obiektów s typu S własność W(s) jest prawdziwa. Pociąga to za sobą, że typ S jest podtypem typu T.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *