Liskov Substitution Principle
Liskov Substitution Principle – LSP
Ta zasada mówi, że obiekty w programie powinny być zastępowalne instancjami ich podklas bez wpływu na prawidłowość działania programu.
Innymi słowy, jeśli klasa B jest podklasą klasy A, to powinniśmy być w stanie zastąpić obiekt klasy A obiektem klasy B, a program powinien działać poprawnie.
Korzyści z zastosowania zasady LSP:
- Stabilność kodu: Podklasy mogą zastępować klasy bazowe bez ryzyka błędów, co czyni kod bardziej niezawodnym.
- Łatwiejsze testowanie: Można testować podklasy niezależnie, co upraszcza proces testowania.
- Elastyczność i rozszerzalność: Kod jest łatwiejszy do rozszerzania bez modyfikacji istniejących klas.
- Czytelność i zrozumiałość: Klasy mają jasno zdefiniowane role, co ułatwia zrozumienie kodu.
- Unikanie błędów: Zapobiega niespójnościom w zachowaniu podklas, które mogłyby prowadzić do trudnych do wykrycia błędów.
Przykład z życia codziennego:
Klasa bazowa: Urządzenie do komunikacji
- Każde urządzenie do komunikacji pozwala na prowadzenie rozmów telefonicznych.
Podklasy: Telefon stacjonarny i Smartfon
- Telefon stacjonarny: Umożliwia rozmowy telefoniczne, ale tylko w jednym miejscu.
- Smartfon: Umożliwia rozmowy telefoniczne, ale także inne funkcje, takie jak SMS-y, aplikacje, internet.
Zasada Liskov w działaniu
Zgodnie z zasadą Liskov, Telefon stacjonarny
i Smartfon
mogą być używane zamiennie w miejscach, gdzie oczekuje się urządzenia do komunikacji, ponieważ oba pozwalają na wykonywanie rozmów telefonicznych.
Przykład łamania zasady Liskov
Załóżmy, że ktoś tworzy nową podklasę Walkie-Talkie, która dziedziczy po klasie Urządzenie do komunikacji
. Walkie-Talkie umożliwia komunikację, ale działa na zupełnie innych zasadach (ma ograniczony zasięg, nie działa przez sieć telefoniczną).
Jeśli spróbujemy użyć Walkie-Talkie
w kontekście, gdzie oczekujemy standardowego urządzenia telefonicznego, które może połączyć się z dowolnym numerem, system nie będzie działać prawidłowo. Na przykład, funkcja wybierania numeru czy historia połączeń nie ma sensu w przypadku Walkie-Talkie.
To łamie zasadę Liskov, ponieważ Walkie-Talkie
nie może być używane zamiennie z innymi urządzeniami do komunikacji telefonicznej, co prowadzi do niespójności i problemów w systemie.
Przykład programu przed użyciem zasady LSP:
Załóżmy, że mamy klasy Kaczka
i GumowaKaczka
, gdzie GumowaKaczka
dziedziczy po Kaczka
. Klasa Kaczka
ma metodę latanie()
, ale GumowaKaczka
nie jest w stanie latać, choć dziedziczy tę metodę.
class Kaczka {
public void latanie() {
System.out.println("Kaczka lata.");
}
public void kwakanie() {
System.out.println("Kaczka kwacze.");
}
}
class GumowaKaczka extends Kaczka {
@Override
public void latanie() {
// Gumowa kaczka nie potrafi latać
throw new UnsupportedOperationException("Gumowa kaczka nie lata.");
}
@Override
public void kwakanie() {
System.out.println("Gumowa kaczka nie kwacze, tylko piszczy.");
}
}
public class Main {
public static void main(String[] args) {
Kaczka kaczka = new Kaczka();
kaczka.latanie(); // Outputs: Kaczka lata.
kaczka.kwakanie(); // Outputs: Kaczka kwacze.
Kaczka gumowaKaczka = new GumowaKaczka();
gumowaKaczka.latanie(); // Throws UnsupportedOperationException
gumowaKaczka.kwakanie(); // Outputs: Gumowa kaczka nie kwacze, tylko piszczy.
}
}
Co się tutaj dzieje?
W tym przykładzie GumowaKaczka
dziedziczy po Kaczka
, ale implementuje metodę latanie()
w sposób, który łamie zasady Liskov, ponieważ gumowa kaczka nie jest w stanie latać. Wywołanie metody latanie()
na obiekcie GumowaKaczka
prowadzi do wyjątku, co jest niezgodne z oczekiwaniami wobec obiektu klasy Kaczka
.
Poprawiony kod zgodny z zasadą LSP
interface Kwakanie {
void kwakanie();
}
interface Latanie {
void latanie();
}
class Kaczka implements Kwakanie, Latanie {
@Override
public void latanie() {
System.out.println("Kaczka lata.");
}
@Override
public void kwakanie() {
System.out.println("Kaczka kwacze.");
}
}
class GumowaKaczka implements Kwakanie {
@Override
public void kwakanie() {
System.out.println("Gumowa kaczka piszczy.");
}
}
public class Main {
public static void main(String[] args) {
Latanie kaczka = new Kaczka();
kaczka.latanie(); // Outputs: Kaczka lata.
Kwakanie gumowaKaczka = new GumowaKaczka();
gumowaKaczka.kwakanie(); // Outputs: Gumowa kaczka piszczy.
}
}
Co się zmieniło?
W tym poprawionym przykładzie:
- Klasa
Kaczka
implementuje oba interfejsyKwakanie
iLatanie
, co oznacza, że może kwakać i latać. - Klasa
GumowaKaczka
implementuje tylko interfejsKwakanie
, ponieważ gumowa kaczka nie lata, ale może kwakać (czy właściwie, piszczeć).
Zalety podejścia
- Obiekty
Kaczka
iGumowaKaczka
są używane zgodnie z ich specyfikacjami.Kaczka
wspiera latanie i kwakanie, natomiastGumowaKaczka
wspiera tylko kwakanie. - Interfejsy pomagają oddzielić odpowiedzialności i uniknąć problemów związanych z dziedziczeniem metod, które nie są adekwatne dla danej klasy.