LSP (Liskov Substitution Principle) — a SOLID harmadik elve, amely meghatározza a helyes öröklődés feltételeit az objektumorientált programozásban. Az elvet Barbara Liskov fogalmazta meg 1987-ben, és a következőképpen formalizálták: ha S altípusa T-nek, akkor a T objektumai helyettesíthetők S objektumaival a program tulajdonságainak megváltoztatása nélkül. Ahogy Robert Martin Clean Architecture (2017) című könyvében megjegyzi, a helyettesítési elv megköveteli, hogy az alosztály ne gyengítse az alaposztály szerződését.
Főbb pontok
LSP (Liskov Substitution Principle) — a helyettesítési elv, amelyet Barbara Liskov fogalmazott meg az OOPSLA konferencián 1987-ben. Formális definíció: legyen q(x) a T típusú x objektumok bizonyítható tulajdonsága. Ekkor q(y)-nak bizonyíthatónak kell lennie az S típusú y objektumokra, ahol S T altípusa. Egyszerűbben: az alosztály objektumainak úgy kell viselkedniük, hogy az alaposztállyal dolgozó kód továbbra is helyesen működjön az alosztállyal.
A gyakorlatban az LSP azt jelenti, hogy az alosztály nem sértheti meg az alaposztály szerződését. A szerződés magában foglalja az előfeltételeket (mi szükséges a metódus meghívásához), az utófeltételeket (mi garantált a hívás után) és az invariánsokat (a feltételek, amelyek az objektum élettartama alatt fennmaradnak). Az alosztály erősítheti az előfeltételeket vagy gyengítheti az utófeltételeket — ez az LSP megsértése.
Az LSP megsértésének klasszikus példája — a téglalapból öröklődő négyzet. A setWidth metódus a téglalapnál a szélességet állítja be, a négyzetnél — mind a szélességet, mind a magasságot. A téglalap viselkedését váró kliens (az egyik oldal változtatása nem befolyásolja a másikat) váratlan eredményt kap. A négyzet nem helyes altípusa a téglalapnak.
Az LSP három feltételt állít fel a helyes öröklődéshez: az alosztály előfeltételei nem lehetnek erősebbek az alaposztály előfeltételeinél (az alosztály nem követel többet), az alosztály utófeltételei nem lehetnek gyengébbek az alaposztály utófeltételeinél (az alosztály nem garantál kevesebbet), az alaposztály invariánsait meg kell őrizni az alosztályban. Ezek a feltételek Bertrand Meyer szerződéses tervezési szabályaként ismertek.
Ha legalább egy feltétel sérül — a polimorfizmust használó kód hibásan működhet. A fordító nem ellenőrzi a szemantikai szerződéseket, csak a szintaktikaiakat. Ezért az LSP építészeti fegyelem kérdése, nem statikus típusosztásé.
Az LSP mechanizmusa a típusok viselkedési kompatibilitásán alapul. Ha az S osztály T osztályból öröklődik, a kliens kódnak használnia kell tudnia S-t mindenhol, ahol T várható, anélkül hogy megváltoztatná a viselkedését. Ez nemcsak a metódusok aláírásait, hanem azok szemantikáját is magában foglalja.
Az LSP nem tiltja az alosztálynak, hogy új viselkedést adjon hozzá. Tilos megsérteni az alaposztály számára írt kód elvárásait. Ha az alaposztály garantálja, hogy a save metódus nem dob kivételt, az alosztály nem dobhatja azokat. Ha az alaposztály nem negatív értéket ad vissza, az alosztály nem adhat vissza negatívot.
Valós projektekben az LSP leggyakrabban feltételes logika hozzáadásával sérül az alosztály metódusaiban: „ha feltétel — dobj kivételt”, „ha feltétel — adj vissza null-t”. Minden ilyen „meglepetés” aláássa a polimorfizmust, és arra kényszeríti a kliens kódot, hogy a hívás előtt ellenőrizze az objektum típusát — ami ellentmond az objektumorientált tervezés alapgondolatának.
Mobil projektekben tipikus LSP-sértés fordul elő az alap ViewModel létrehozásakor. Ha a BaseViewModel garantálja, hogy az onCleared metódus felszabadítja az összes erőforrást, és az alosztály felülírja ezt a metódust üresen — bármely kód, amely az onCleared polimorfikus hívásán keresztüli erőforrás-felszabadításra támaszkodik, helytelenül fog működni. Az LSP megköveteli, hogy az alosztály vagy hívja meg a super.onCleared()-et, vagy maga végezze el ugyanazt a munkát. Kompozíció LifecycleObserver-en keresztül — olyan alternatíva, amely kizárja az LSP megsértését az életciklus kezelésében.
A fő indikátorok az LSP megsértésének a következők: az objektum típusának ellenőrzése instanceof vagy is segítségével a metódushívás előtt, metódusok üres implementációi (stubok), NotImplementedError vagy UnsupportedOperationException kivétel dobása, null visszaadása érték helyett. Ezen minták mindegyike azt jelzi, hogy az alosztály nem helyes altípus.
Egy másik gyakori jel — az öröklődés kód újrafelhasználása céljából, nem pedig az „egy” (is-a) kapcsolat modellezésére. A Bird osztálynak van egy fly() metódusa. A Penguin osztály öröklődik Bird-ből, és felülírja a fly()-t üresként vagy kivételt dobóként. Ez az LSP megsértése: a pingvin nem helyes altípusa a madárnak.
A mobilfejlesztésben az LSP sérül az alap ViewHolder, Fragment vagy ViewController stub metódusokkal történő létrehozásakor. Ha az alosztály nem használja az alaposztály metódusainak felét — az öröklődés helytelenül lett kiválasztva. Kompozíció vagy interfész szétválasztása oldja meg a problémát helyesebben.
Egyszerű teszt az LSP ellenőrzésére: írjon egységtesztet az alaposztályhoz, amely ellenőrzi annak szerződését (visszaadott értékek, kivételek, mellékhatások). Futtassa ezt a tesztet minden egyes alosztályra. Ha a teszt sikertelen — az LSP sérült. Ezt a megközelítést „tesztelés az alaposztály szerződésén keresztül”-nek nevezik.
Android projektekben egy ilyen teszt hasznos a ViewModel és Repository számára. Ha a BaseViewModel garantálja a Loading állapotot a hiba előtt, és az alosztály hibát dob Loading nélkül — a teszt rögzíti az LSP megsértését a CI szakaszban.
Vizsgáljuk meg az Android példát a ClickListener kezelésével. Az LSP megsértése akkor következik be, amikor az alap implementáció garantál valamit, és az alosztály megsérti azt.
// Alaposztály garanciával: onClick meghívásra kerül
open class BaseClickListener {
open fun onClick(view: View) {
// alap feldolgozás
}
}
// LSP megsértése: az alosztály olyan feltételt ad hozzá, amely kivételt dob
class RestrictedClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (!isLoggedIn) {
throw IllegalStateException("Not logged in")
}
super.onClick(view)
}
}
// Helyes megoldás: a szerződés nem sérült
class ConditionalClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (isLoggedIn) {
super.onClick(view)
}
}
}
Az iOS példa a DataSource protokollal az LSP megsértését mutatja be nil visszaadásával adat helyett:
// Szerződéses protokoll: adatot vagy hibát ad vissza
protocol DataProvider {
func fetchData() async throws -> [String]
}
// LSP megsértése: nil-t ad vissza hiba nélkül
class SilentFailProvider: DataProvider {
func fetchData() async throws -> [String] {
return [] // üres tömb hiba helyett
}
}
// Az LSP helyes betartása
class NetworkProvider: DataProvider {
func fetchData() async throws -> [String] {
throw NetworkError.timeout
}
}
Gyakorlati szabály: ha az alosztály nem tudja teljesíteni az alaposztály szerződését — ne legyen alosztály. Alternatíva — hozzon létre egy interfészt minimális szerződéssel, és implementálja azt minden típusban a saját módján.
Kompozíció előnyösebb az öröklődésnél olyan helyzetekben, ahol az „egy” (is-a) kapcsolat nem egyértelmű vagy feltételes. Klasszikus példa: A Manager Employee? Igen. De vajon a Square helyes Rectangle? Az LSP azt mondja: „nem”. Ha kétségei vannak az öröklődés helyességével kapcsolatban — válassza a kompozíciót.
A mobilfejlesztésben a kompozíciót gyakran függőséginjektáláson keresztül használják: ahelyett, hogy az alaposztálytól örökölné a viselkedést, az osztály a konstruktoron keresztül kapja meg. A ViewModel nem öröklődik a Repository-ból, hanem függőségként fogadja el. Ez definíció szerint kizárja az LSP megsértését — nincs öröklődés, nincs szerződésszegés.
Jelek arra, hogy az öröklődést kompozícióra kell cserélni: az alosztály nem használja az alaposztály metódusainak egy részét, az alosztály felülírja a metódusokat üres stubokkal, a kliens kód instanceof segítségével ellenőrzi az objektum típusát. Ezekben az esetekben az öröklődés helytelenül lett kiválasztva, és az LSP sérül.
Interfészek megoldják az LSP problémáját öröklődés nélkül: minden típus pontosan azokat a metódusokat implementálja, amelyekre szüksége van. A közös Bird alaposztály helyett a fly() metódussal (ahol a Penguin nem repül) — a Flyable interfész, amelyet csak a repülő madarak implementálnak. A Penguin a Bird-öt implementálja a fly() metódus nélkül — az LSP nem sérül.
Az Android architektúrában ezt a megközelítést szegregált interfészek UseCase segítségével alkalmazzák: egy nagy UseCase helyett a getAll, getById, save, delete metódusokkal — külön GetItemsUseCase, SaveItemUseCase interfészek. A kliens csak a szükséges interfésztől függ, és minden osztály, amely implementálja ezt az interfészt, helyes az LSP szempontjából.
Gyakran ismételt kérdések
Öröklődés — nyelvi mechanizmus, LSP — a mechanizmus helyes használatának szabálya. Az öröklődés garantálja az aláírások kompatibilitását (szintaxis), az LSP a viselkedés kompatibilitását (szemantika) követeli meg. Az LSP nélküli öröklődés olyan polimorfizmust ad, amely futásidőben elromlik.
Ha az alaposztály non-null visszatérést garantál — igen. Ha a szerződés megengedi a null-t (opcionális érték) — nem. Az LSP nem tiltja a null-t, a szerződés gyengítését tiltja. Tanulmányozza az alaposztály dokumentációját, és ellenőrizze, hogy az alosztály szerződése kompatibilis-e.
A protokollokra az LSP ugyanúgy alkalmazandó, mint az osztályokra. A protokoll implementációjának be kell tartania a szemantikai szerződést: ha a protokoll egy metódust non-throwing-ként definiál, az implementáció nem dobhat hibát. A Swift ezt nem ellenőrzi fordítási szinten — a felelősség a fejlesztőé.
A Kotlin Sealed class — speciális eset, mivel a hierarchia zárt és ismert a fordító számára. Az LSP kisebb mértékben alkalmazható a sealed class-ra, mivel az összes altípus explicit módon fel van sorolva a when kifejezésben. A sealed-alosztály hibája lokális lesz, nem rejtett polimorfikus hiba.
Írjon egy paraméterezett tesztet az alaposztályhoz, amely az összes alosztályára lefut. A teszt ellenőrzi a kulcsfontosságú viselkedési szerződéseket: visszaadott értékeket, kivételeket, állapotokat. Ha a teszt az egyik alosztálynál sikertelen — az LSP sérült. CI-ban egy ilyen teszt megakadályozza a polimorf kód regresszióját.
Összefoglalás
Kulcsrakész mobilalkalmazást fejlesztünk
Az IT Sectr 2017 óta készít iOS és Android alkalmazásokat induló vállalkozásoknak és vállalkozásoknak. Tanácsot adunk, és a legjobb megoldást javasoljuk.
Olvassa el is