SOLID: Liskov Substitution

Cover Image for SOLID: Liskov Substitution
Paweł
Paweł

Mamy już półmetek serii o SOLID. Tym razem na tapet trafia zasada podstawienia Liskov, znana jako Liskov Substitution Principle. Mówi ona o tym, że obiekt klasy potomnej powinien być w stanie zastąpić obiekt klasy bazowej bez wprowadzania niepożądanych efektów. Co dokładnie oznacza? Dowiesz się tego w tym wpisie!

Superbohaterowie w Akcji

W celu lepszego zrozumienia zaczniemy od wyjaśnienia założeń, a następnie przedstawimy przykład jej złamania w kodzie.

Oryginalna zasada , sformułowana przez Barbare Liskov wygląda następująco:

“Jeśli S jest podtypem T, to obiekty typu T mogą być zastąpione obiektami typu S, bez powodowania awarii programu.”

Jej popularniejsza wersja, ta która dostała się do szerszego grona programistycznej społeczności została sformułowana przez Roberta C. Martina w jego artykule The Liskov Substitution Principle:

"Funkcje, które używają wskaźników lub referencji do klas bazowych, muszą być w stanie używać również obiektów klas dziedziczących po klasach bazowych, bez dokładnej znajomości tych obiektów."

Brzmi jak czarna magia?

Mówiąc prościej, jeśli klasa B jest podklasą klasy A, to obiekty klasy B powinny być w stanie zastąpić obiekty klasy A bez negatywnego wpływu na działanie programu.

Poniższy przykład powinien wszystko rozjaśnić. Wyobraźmy sobie, że mamy klasę opisującą człowieka. Nazwijmy ją Human i nadajmy jej odpowiednie cechy:

class Human { private name: string; private age: number; private gender: string; constructor(name: string, age: number, gender: string) { this.name = name; this.age = age; this.gender = gender; } eat(): void { console.log(`${this.name} is eating.`); } sleep(): void { console.log(`${this.name} is sleeping.`); } }

Przyjęliśmy, że każdy człowiek je i śpi. Teraz stwórzmy klasę potomną i nazwijmy ją Programmer (w końcu programista też człowiek, je i śpi ;) ).

class Programmer extends Human { constructor(name: string, age: number, gender: string) { super(name, age, gender); } work(): void { console.log(`${this.name} is working.`); } }

Nasz Programista posiada nową metodę work. Dodajmy jeszcze jedną klasę, tym razem niech będzie to BirdManSuperHero , klasa opisująca superbohatera:

class BirdManSuperHero extends Human { private superPower: string; constructor(name: string, age: number, gender: string) { super(name, age, gender); } eat(): void { throw new Error("Superhero doesn't need eat") } sleep(): void { throw new Error("Superhero doesn't need sleep") } useSuperPower(): void { console.log(`${this.name} is using the superpower`); } }

Ponownie dochodzi nam nowa metoda, tym razem wskazująca na użycie super mocy, ale nadpisuje również dwa podstawowe zachowania czyli jedzenia i spanie. Jak widać superbohaterowie nie potrzebują jeść i spać ;).

Jakie będą tego konsekwencje?

Tworzymy dwie nowe zmienne, które powinny przyjąć typ Human. Inicjalizujemy je kolejno jako typ Programmer oraz BirdManSuperHero:

const human: Human = new Programmer("Jan Kowalski", 30, "male"); const birdman: Human = new BirdManSuperHero("Birdman", 100, "male");

Zgodnie z opisywaną zasadą podstawienia Liskov, powinniśmy być w stanie zastąpić klasę Human jej klasami potomnymi. Czy to się uda?

W przypadku pierwszej zmiennej jak najbardziej, wywołanie:

human.eat();

zwróci nam na konsoli: 'Jan Kowalski is eating.'

Niestety to samo wywołanie dla drugiej zmiennej

birdman.eat();

wyrzuci nam wyjątek:** Error: Superhero doesn't need eat**

Zasada została złamana.

Reguły gry

W celu książkowego zachowania zgodności z LSP musimy przestrzegać kilku reguł:

  • Kontrawariancja parametrów metody

Kontrawariancja oznacza, że w kontekście dziedziczenia klas w programowaniu obiektowym, parametry przekazywane do metod w klasach potomnych muszą być mniej szczegółowe niż w klasach bazowych.

Innymi słowy, jeśli klasa bazowa przyjmuje parametr typu "Osoba", to klasa potomna nie może przyjąć parametru bardziej szczegółowego, takiego jak "Programmer". To zapewnia, że obiekty klas potomnych zachowują się w sposób kompatybilny z oczekiwaniami klas bazowych.

  • Kowariancja typów zwracanych

Odwrotna sytuacja występuje w przypadku kowariancji. Typ zwracany przez metodę w klasie potomnej musi być taki sam lub bardziej szczegółowy od tego zwracanego przez metodę w klasie bazowej.

Na przykład, jeśli nadklasa zwraca obiekt typu "Osoba", to podklasa może zwrócić taki sam lub bardziej szczegółowy typ, np. "Programmer". W języku Java takim złamaniem reguły mógłby być zwrócony w takim przypadku obiektu klasy Object.

  • Zachowanie kontraktów

Klasy potomne muszą utrzymać kontrakt określony przez klasę bazową. Czyli powinny spełniać te same warunki, a także nie powinny naruszać żadnych innych założeń i oczekiwań co do zachowania klasy nadrzędnej.

  • Zachowanie warunków wstępnych

Podklasy nie mogą narzucać bardziej restrykcyjnych warunków co do parametrów wejściowych lub początkowego stanu obiektów niż to jest to zdefiniowane w klasie bazowej. W praktyce oznacza to, że klientów korzystających z klasy bazowej nie powinniśmy zaskakiwać wymaganiami, które są ostrzejsze, gdy używają podklasy.

  • Zachowanie warunków końcowych

W kontekście LSP, podklasy nie mogą naruszać warunków końcowych ustalonych przez klasę bazową. Czyli tego, czego można oczekiwać po wykonaniu metody. Oznacza to, że po zakończeniu wykonania funkcji w klasie potomnej, klient musi nadal być w stanie oczekiwać tych samych warunków końcowych, co po wykonaniu metody w klasie bazowej. Zachowanie ich jest kluczowe dla zapewnienia poprawnego działania klientów korzystających z hierarchii dziedziczenia.

Jako najprostszy przykład jak zastosować tę zasadę w swoim kodzie, możemy ponownie posłużyć się kodem, który pojawił się wyżej:

class Programmer extends Human { constructor(name: string, age: number, gender: string) { super(name, age, gender); } work(): void { console.log(`${this.name} is working.`); } }

Klasa Programmer w prawidłowy sposób dziedziczy po klasie Human, nie naruszając żadnego z jej kontraktów i warunków.

Plusy i minusy

Liskov Wady i Zalety

Podsumowanie

Zasada Liskov na pierwszy rzut oka może wydawać się skomplikowana. Jej głównym celem jest zapewnienie, że podklasy zachowują się zgodnie z interfejsem swojej nadklasy, co przyczynia się do konsystencji i przewidywalności zachowania obiektów. Oznacza to, że obiekty powinny być wymienne. Dzięki temu nasze oprogramowanie jest bardziej elastyczne.