LSP: istota, zasada podstawienia Barbary Liskov w programowaniu

Autor: IT Sectr Opublikowano: 2026-05-12 Czas czytania: 9 min

LSP (Liskov Substitution Principle) — trzecia zasada SOLID, która określa warunki poprawnego dziedziczenia w programowaniu obiektowym. Zasada została sformułowana przez Barbarę Liskov w 1987 roku i sformalizowana jako: jeśli S jest podtypem T, to obiekty T mogą być zastąpione obiektami S bez zmiany właściwości programu. Jak zauważono w książce Roberta Martina Clean Architecture (2017), zasada podstawienia wymaga, aby podklasa nie osłabiała kontraktu klasy bazowej.

Najważniejsze

  • LSP — zasada podstawienia Liskov, trzecia zasada SOLID dotycząca poprawnego dziedziczenia
  • Podklasa musi zachować kontrakt klasy bazowej — warunki wstępne i końcowe
  • Naruszenie LSP objawia się w problemie kwadratu i prostokąta oraz rzucanych wyjątkach
  • Kompozycja jest często lepsza od dziedziczenia dla zachowania LSP
  • Projektowanie kontraktowe (Design by Contract) — formalny sposób weryfikacji LSP

Czym jest LSP (Liskov Substitution Principle)?

LSP (Liskov Substitution Principle) — zasada podstawienia sformułowana przez Barbarę Liskov na konferencji OOPSLA w 1987 roku. Formalna definicja: niech q(x) będzie udowodnioną właściwością obiektów x typu T. Wtedy q(y) powinno być udowodnione dla obiektów y typu S, gdzie S jest podtypem T. Mówiąc prościej: obiekty podklasy powinny zachowywać się tak, aby kod działający z klasą bazową nadal działał poprawnie z podklasą.

W praktyce LSP oznacza, że podklasa nie może naruszać kontraktu klasy bazowej. Kontrakt obejmuje warunki wstępne (co jest wymagane do wywołania metody), warunki końcowe (co jest gwarantowane po wywołaniu) i niezmienniki (warunki utrzymujące się przez cały czas istnienia obiektu). Podklasa może wzmacniać warunki wstępne lub osłabiać warunki końcowe — to właśnie jest naruszenie LSP.

Klasyczny przykład naruszenia LSP — kwadrat dziedziczący po prostokącie. Metoda setWidth w prostokącie ustawia szerokość, w kwadracie — i szerokość, i wysokość. Klient oczekujący zachowania prostokąta (zmiana jednego boku nie wpływa na drugi) otrzymuje nieoczekiwany wynik. Kwadrat nie jest poprawnym podtypem prostokąta.

Formalne warunki LSP

LSP ustanawia trzy warunki dla poprawnego dziedziczenia: warunki wstępne podklasy nie mogą być silniejsze niż warunki wstępne klasy bazowej (podklasa nie wymaga więcej), warunki końcowe podklasy nie mogą być słabsze niż warunki końcowe klasy bazowej (podklasa gwarantuje nie mniej), niezmienniki klasy bazowej muszą być zachowane w podklasie. Te warunki znane są jako reguła projektowania kontraktowego Bertranda Meyera.

Jeśli choć jeden warunek jest naruszony — kod używający polimorfizmu może działać nieprawidłowo. Kompilator nie sprawdza kontraktów semantycznych, tylko składniowe. Dlatego LSP to kwestia dyscypliny architektonicznej, a nie statycznego typowania.

Jak działa zasada podstawienia Liskov

Mechanizm LSP opiera się na behavioralnej zgodności typów. Jeśli klasa S dziedziczy po klasie T, kod kliencki powinien móc używać S wszędzie tam, gdzie oczekiwane jest T, bez zmiany swojego zachowania. Obejmuje to nie tylko sygnatury metod, ale także ich semantykę.

LSP nie zabrania podklasie dodawania nowego zachowania. Zabronione jest naruszanie oczekiwań kodu napisanego dla klasy bazowej. Jeśli klasa bazowa gwarantuje, że metoda save nie rzuca wyjątków, podklasa nie powinna ich rzucać. Jeśli klasa bazowa zwraca wartość nieujemną, podklasa nie powinna zwracać ujemnej.

W rzeczywistych projektach LSP najczęściej naruszane jest przez dodawanie logiki warunkowej w metodach podklasy: «jeśli warunek — rzuć wyjątek», «jeśli warunek — zwróć null». Każda taka «niespodzianka» podważa polimorfizm i zmusza kod kliencki do sprawdzania typu obiektu przed wywołaniem — co jest sprzeczne z samą ideą projektowania obiektowego.

W projektach mobilnych typowe naruszenie LSP występuje przy tworzeniu podstawowych ViewModel. Jeśli BaseViewModel gwarantuje, że metoda onCleared zwalnia wszystkie zasoby, a podklasa nadpisuje tę metodę jako pustą — każdy kod polegający na zwalnianiu zasobów przez polimorficzne wywołanie onCleared będzie działać nieprawidłowo. LSP wymaga, aby podklasa albo wywoływała super.onCleared(), albo sama wykonywała tę samą pracę. Kompozycja przez LifecycleObserver — alternatywa wykluczająca naruszenie LSP przy zarządzaniu cyklem życia.

Oznaki naruszenia LSP w kodzie

Główne wskaźniki naruszenia LSP obejmują: sprawdzanie typu obiektu przez instanceof lub is przed wywołaniem metody, puste implementacje metod (zaślepki), rzucanie wyjątku NotImplementedError lub UnsupportedOperationException, zwracanie null zamiast wartości. Każdy z tych wzorców sygnalizuje, że podklasa nie jest poprawnym podtypem.

Innym częstym objawem jest dziedziczenie w celu ponownego użycia kodu, a nie modelowania relacji «jest» (is-a). Klasa Bird ma metodę fly(). Klasa Penguin dziedziczy po Bird i nadpisuje fly() jako pustą lub rzucającą wyjątek. To naruszenie LSP: pingwin nie jest poprawnym podtypem ptaka.

W programowaniu mobilnym LSP naruszane jest przy tworzeniu podstawowych ViewHolder, Fragment lub ViewController z metodami-zaślepkami. Jeśli podklasa nie używa połowy metod klasy bazowej — dziedziczenie zostało wybrane nieprawidłowo. Kompozycja lub podział interfejsu rozwiązują problem poprawniej.

Test na LSP

Prosty test do weryfikacji LSP: napisz test jednostkowy dla klasy bazowej sprawdzający jej kontrakt (zwracane wartości, wyjątki, efekty uboczne). Uruchom ten test dla każdej podklasy. Jeśli test nie przejdzie — LSP jest naruszony. To podejście nazywa się «testowanie przez kontrakt klasy bazowej».

W projektach Android taki test jest przydatny dla ViewModel i Repository. Jeśli BaseViewModel gwarantuje stan Loading przed błędem, a podklasa rzuca błąd bez Loading — test wychwyci naruszenie LSP na etapie CI.

Przykłady LSP w programowaniu mobilnym

Rozważmy przykład z Androida z obsługą ClickListener. Naruszenie LSP występuje, gdy podstawowa implementacja coś gwarantuje, a podklasa to narusza.

kotlin
// Klasa bazowa z gwarancją: onClick zostanie wywołany
open class BaseClickListener {
    open fun onClick(view: View) {
        // podstawowe przetwarzanie
    }
}

// Naruszenie LSP: podklasa dodaje warunek rzucający wyjątek
class RestrictedClickListener : BaseClickListener() {
    override fun onClick(view: View) {
        if (!isLoggedIn) {
            throw IllegalStateException("Not logged in")
        }
        super.onClick(view)
    }
}

// Poprawne rozwiązanie: kontrakt nie jest naruszony
class ConditionalClickListener : BaseClickListener() {
    override fun onClick(view: View) {
        if (isLoggedIn) {
            super.onClick(view)
        }
    }
}

Przykład iOS z protokołem DataSource demonstruje naruszenie LSP przez zwracanie nil zamiast danych:

swift
// Protokół z kontraktem: zwraca dane lub błąd
protocol DataProvider {
    func fetchData() async throws -> [String]
}

// Naruszenie LSP: zwraca nil bez błędu
class SilentFailProvider: DataProvider {
    func fetchData() async throws -> [String] {
        return [] // pusta tablica zamiast błędu
    }
}

// Poprawne przestrzeganie LSP
class NetworkProvider: DataProvider {
    func fetchData() async throws -> [String] {
        throw NetworkError.timeout
    }
}

Praktyczna zasada: jeśli podklasa nie może spełnić kontraktu klasy bazowej — nie powinna być podklasą. Alternatywą jest wydzielenie interfejsu z minimalnym kontraktem i zaimplementowanie go w każdym typie na swój sposób.

LSP a dziedziczenie: kiedy wybrać kompozycję

Kompozycja jest lepsza od dziedziczenia w sytuacjach, gdzie relacja «jest» (is-a) jest niejednoznaczna lub warunkowa. Klasyczny przykład: Manager jest Employee? Tak. Ale czy Square jest poprawnym Rectangle? LSP mówi «nie». Jeśli masz wątpliwości co do poprawności dziedziczenia — wybierz kompozycję.

W programowaniu mobilnym kompozycja jest często używana przez wstrzykiwanie zależności: zamiast dziedziczyć zachowanie po klasie bazowej, klasa otrzymuje je przez konstruktor. ViewModel nie dziedziczy po Repository, ale przyjmuje je jako zależność. To wyklucza naruszenie LSP z definicji — nie ma dziedziczenia, nie ma naruszenia kontraktu.

Oznaki, że dziedziczenie należy zastąpić kompozycją: podklasa nie używa części metod klasy bazowej, podklasa nadpisuje metody pustymi zaślepkami, kod kliencki sprawdza typ obiektu przez instanceof. W tych przypadkach dziedziczenie zostało wybrane nieprawidłowo, a LSP jest naruszony.

Rozwiązanie przez interfejsy

Interfejsy rozwiązują problem LSP bez dziedziczenia: każdy typ implementuje dokładnie te metody, których potrzebuje. Zamiast wspólnej klasy bazowej Bird z metodą fly (gdzie Penguin nie lata) — interfejs Flyable, który implementują tylko latające ptaki. Penguin implementuje Bird bez metody fly — LSP nie jest naruszony.

W architekturze Androida to podejście jest stosowane przez zsegregowane interfejsy UseCase: zamiast jednego dużego UseCase z metodami getAll, getById, save, delete — osobne interfejsy GetItemsUseCase, SaveItemUseCase. Klient zależy tylko od potrzebnego interfejsu, a każda klasa implementująca ten interfejs jest poprawna z punktu widzenia LSP.

Często zadawane pytania

Czym LSP różni się od zwykłego dziedziczenia?

Dziedziczenie — mechanizm języka, LSP — reguła poprawnego używania tego mechanizmu. Dziedziczenie gwarantuje zgodność sygnatur (składnia), LSP wymaga zgodności zachowania (semantyka). Dziedziczenie bez LSP daje polimorfizm, który psuje się w czasie wykonania.

Czy null w podklasie zawsze narusza LSP?

Jeśli klasa bazowa gwarantuje zwrot nie-null — tak. Jeśli kontrakt dopuszcza null (wartość opcjonalna) — nie. LSP nie zabrania null, zabrania osłabiania kontraktu. Przestudiuj dokumentację klasy bazowej i sprawdź, czy kontrakt podklasy jest zgodny.

Jak LSP stosuje się do protokołów w Swift?

Do protokołów LSP stosuje się tak samo jak do klas. Implementacja protokołu musi przestrzegać kontraktu semantycznego: jeśli protokół definiuje metodę jako non-throwing, implementacja nie powinna rzucać błędów. Swift nie sprawdza tego na poziomie kompilacji — odpowiedzialność leży po stronie programisty.

Czy LSP może być naruszony przy użyciu sealed class?

Sealed class w Kotlin — szczególny przypadek, ponieważ hierarchia jest zamknięta i znana kompilatorowi. LSP ma mniejsze zastosowanie do sealed class, ponieważ wszystkie podtypy są jawnie wymienione w wyrażeniu when. Błąd sealed-podklasy będzie lokalny, a nie ukrytym błędem polimorficznym.

Jak testować przestrzeganie LSP w projekcie?

Napisz test parametryzowany dla klasy bazowej, który uruchamia się dla wszystkich jej podklas. Test sprawdza kluczowe kontrakty behavioralne: zwracane wartości, wyjątki, stany. Jeśli test nie przejdzie dla jednej z podklas — LSP jest naruszony. W CI taki test zapobiega regresji kodu polimorficznego.

Podsumowanie

  • LSP (Liskov Substitution Principle) — zasada podstawienia, trzecia w SOLID, o semantycznej zgodności dziedziczenia
  • Podklasa musi zachować kontrakt klasy bazowej: warunki wstępne, końcowe i niezmienniki
  • Sprawdzanie instanceof i puste nadpisania metod — główne oznaki naruszenia LSP
  • Kompozycja i interfejsy rozwiązują problem LSP tam, gdzie dziedziczenie jest niepoprawne
  • Problem kwadratu i prostokąta — klasyczny przykład niezgodności podtypów
  • Test kontraktu dla klasy bazowej, uruchamiany dla wszystkich podklas, wykrywa naruszenie LSP na CI
  • Sealed class w Kotlin zmniejsza ryzyko LSP dzięki zamkniętej hierarchii znanej kompilatorowi

Opracujemy aplikację mobilną pod klucz

IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.

Omów projekt

Przeczytaj również