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ę:
- Single Responsibility Principle,
- Open/Closed Principle,
- Liskov Substitution Principle,
- Interface Segregation Principle,
- Dependency Inversion Principle.
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:
1 2 3 4 5 |
export class Duck { quack(): void { console.log(‘kwa’); } } |
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):
1 2 3 4 5 6 7 8 9 10 11 12 13 |
export class ElectricDuck extends Duck { constructor(battery: Battery = null) { this.battery = battery; } quack(): void { if (!this.battery) { throw new Error(‘Need battery to duck’); } console.log(electric kwa’); } } |
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:
1 2 3 4 5 6 7 8 9 10 11 |
type Mapper = (value: string | number) => string; // covariance broken, return type widden const myMapperBreaksCovariance: Mapper = (value: string | number): string | number => { return parseInt(value.toString()) ? parseInt(value.toString()) : value; } // covariance ok, return type narrowed const myMapperCovarianceOk: Mapper = (value: string | number): 'someString' => { return 'someString'; } |
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:
1 2 3 4 5 6 7 8 9 10 11 |
type Mapper = (value: string | number) => string; // contravariance broken, argument type narrowed const myMapperBreaksContravariance: Mapper = (value: string): string => { return value; } // contravariance ok, argument type widden const myMapperContravarianceOk: Mapper = (value: string | number | Array<string>): string => { return value.toString(); } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class ArgumentException {} class NullReferenceException {} class Base { call(): void { // ... throw new ArgumentException(); } } class Derived extends Base { call(): void { // ... throw new NullReferenceException(); } } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Base { calculateFeeForBooks(books: Book[]): number { return books.length * FEE; } } class Derived extends Base { calculateFeeForBooks(books: Book[]): number { if (books.length > 3) { return books.length * FEE; } } } |
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):
1 2 3 4 5 6 7 8 9 |
class Derived extends Base { calculateFeeForBooks(books: Book[]): number { if (books.length > 3) { return books.length * FEE; } return 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Base { calculateFeeForBooks(books: Book[]): number { const result = ...; return Math.max(result, 10); } } class Derived extends Base { calculateFeeForBooks(books: Book[]): number { const result = ...; return Math.max(result, 0); } } |
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 🙂
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.