LSP (Liskov Substitution Principle) — den tredje SOLID-principen som definierar villkoren för korrekt arv inom objektorienterad programmering. Principen formulerades av Barbara Liskov 1987 och formaliserades som: om S är en undertyp av T, kan objekt av T ersättas med objekt av S utan att programmets egenskaper ändras. Som noteras i Robert Martins bok Clean Architecture (2017), kräver substitutionsprincipen att underklassen inte försvagar basklassens kontrakt.
Huvudpunkter
LSP (Liskov Substitution Principle) — substitutionsprincipen formulerad av Barbara Liskov vid OOPSLA-konferensen 1987. Formell definition: låt q(x) vara en bevisbar egenskap hos objekt x av typ T. Då måste q(y) vara bevisbar för objekt y av typ S, där S är en undertyp av T. Enklare uttryckt: objekt i underklassen måste bete sig så att kod som arbetar med basklassen fortsätter att fungera korrekt med underklassen.
I praktiken innebär LSP att underklassen inte får bryta basklassens kontrakt. Kontraktet omfattar förvillkor (vad som krävs för att anropa en metod), eftervillkor (vad som garanteras efter anropet) och invarianter (villkor som upprätthålls under objektets livstid). En underklass kan förstärka förvillkor eller försvaga eftervillkor — detta är ett brott mot LSP.
Det klassiska exemplet på LSP-brott — en kvadrat som ärver från en rektangel. Metoden setWidth i rektangel sätter bredden, i kvadrat — både bredd och höjd. En klient som förväntar sig rektangelbeteende (ändring av en sida påverkar inte den andra) får ett oväntat resultat. En kvadrat är inte en korrekt undertyp av en rektangel.
LSP fastställer tre villkor för korrekt arv: underklassens förvillkor får inte vara starkare än basklassens förvillkor (underklassen kräver inte mer), underklassens eftervillkor får inte vara svagare än basklassens eftervillkor (underklassen garanterar inte mindre), basklassens invarianter måste bevaras i underklassen. Dessa villkor är kända som regeln för kontraktsbaserad design av Bertrand Meyer.
Om minst ett villkor bryts — kod som använder polymorfism kan fungera felaktigt. Kompilatorn kontrollerar inte semantiska kontrakt, endast syntaktiska. Därför är LSP en fråga om arkitektonisk disciplin, inte statisk typning.
LSP-mekanismen bygger på beteendekompatibilitet hos typer. Om klass S ärver från klass T, måste klientkod kunna använda S överallt där T förväntas, utan att ändra sitt beteende. Detta omfattar inte bara metodsignaturer utan även deras semantik.
LSP förbjuder inte underklassen att lägga till nytt beteende. Det är förbjudet att bryta förväntningarna hos kod skriven för basklassen. Om basklassen garanterar att metoden save inte kastar undantag, bör underklassen inte kasta dem. Om basklassen returnerar ett icke-negativt värde, bör underklassen inte returnera ett negativt.
I verkliga projekt bryts LSP oftast genom att lägga till villkorlig logik i underklassens metoder: „om villkor — kasta undantag”, „om villkor — returnera null”. Varje sådan „överraskning” undergräver polymorfism och tvingar klientkod att kontrollera objektets typ före anropet — vilket strider mot själva idén med objektorienterad design.
I mobilprojekt förekommer typiskt LSP-brott vid skapande av grundläggande ViewModel. Om BaseViewModel garanterar att metoden onCleared frigör alla resurser, och underklassen åsidosätter denna metod som tom — kommer all kod som förlitar sig på resursfrigöring via polymorft anrop av onCleared att fungera felaktigt. LSP kräver att underklassen antingen anropar super.onCleared() eller själv utför samma arbete. Komposition via LifecycleObserver — ett alternativ som utesluter LSP-brott vid livscykelhantering.
Huvudindikatorerna för LSP-brott inkluderar: kontroll av objekttyp via instanceof eller is före metodanrop, tomma metodimplementationer (stubbar), kastande av NotImplementedError eller UnsupportedOperationException, returnering av null istället för värde. Var och en av dessa mönster signalerar att underklassen inte är en korrekt undertyp.
Ett annat vanligt tecken — arv i syfte att återanvända kod, inte för att modellera relationen „är” (is-a). Klass Bird har en metod fly(). Klass Penguin ärver från Bird och åsidosätter fly() som tom eller undantagskastande. Detta är LSP-brott: en pingvin är inte en korrekt undertyp av en fågel.
Inom mobilutveckling bryts LSP vid skapande av grundläggande ViewHolder, Fragment eller ViewController med stubmetoder. Om underklassen inte använder hälften av basklassens metoder — har arv valts felaktigt. Komposition eller gränssnittssegregering löser problemet mer korrekt.
Enkelt test för att kontrollera LSP: skriv ett enhetstest för basklassen som kontrollerar dess kontrakt (returvärden, undantag, sidoeffekter). Kör detta test för varje underklass. Om testet misslyckas — är LSP brutet. Detta tillvägagångssätt kallas „testning via basklassens kontrakt”.
I Android-projekt är ett sådant test användbart för ViewModel och Repository. Om BaseViewModel garanterar Loading-status före fel, och underklassen kastar fel utan Loading — kommer testet att fånga LSP-brottet i CI-steget.
Låt oss titta på Android-exemplet med ClickListener-hantering. LSP-brott uppstår när basimplementationen garanterar något och underklassen bryter det.
// Basklass med garanti: onClick kommer att anropas
open class BaseClickListener {
open fun onClick(view: View) {
// grundläggande bearbetning
}
}
// LSP-brott: underklassen lägger till ett villkor som kastar undantag
class RestrictedClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (!isLoggedIn) {
throw IllegalStateException("Not logged in")
}
super.onClick(view)
}
}
// Korrekt lösning: kontraktet är inte brutet
class ConditionalClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (isLoggedIn) {
super.onClick(view)
}
}
}
iOS-exemplet med DataSource-protokollet demonstrerar LSP-brott genom att returnera nil istället för data:
// Protokoll med kontrakt: returnerar data eller fel
protocol DataProvider {
func fetchData() async throws -> [String]
}
// LSP-brott: returnerar nil utan fel
class SilentFailProvider: DataProvider {
func fetchData() async throws -> [String] {
return [] // tom array istället för fel
}
}
// Korrekt efterlevnad av LSP
class NetworkProvider: DataProvider {
func fetchData() async throws -> [String] {
throw NetworkError.timeout
}
}
Praktisk regel: om en underklass inte kan uppfylla basklassens kontrakt — bör den inte vara en underklass. Alternativ — extrahera ett gränssnitt med minimalt kontrakt och implementera det i varje typ på sitt eget sätt.
Komposition är att föredra framför arv i situationer där relationen „är” (is-a) är tvetydig eller villkorlig. Klassiskt exempel: Manager är Employee? Ja. Men är Square en korrekt Rectangle? LSP säger „nej”. Om du tvivlar på arvets korrekthet — välj komposition.
Inom mobilutveckling används komposition ofta via beroendeinjektion: istället för att ärva beteende från en basklass, får klassen det via konstruktorn. ViewModel ärver inte från Repository, utan accepterar det som ett beroende. Detta utesluter LSP-brott per definition — inget arv, inget kontraktsbrott.
Tecken på att arv bör ersättas med komposition: underklassen använder inte en del av basklassens metoder, underklassen åsidosätter metoder med tomma stubbar, klientkod kontrollerar objekttyp via instanceof. I dessa fall har arv valts felaktigt och LSP är brutet.
Gränssnitt löser LSP-problemet utan arv: varje typ implementerar exakt de metoder den behöver. Istället för en gemensam basklass Bird med metoden fly (där Penguin inte flyger) — gränssnittet Flyable, som endast implementeras av flygande fåglar. Penguin implementerar Bird utan metoden fly — LSP är inte brutet.
I Android-arkitekturen tillämpas detta tillvägagångssätt via segregerade gränssnitt UseCase: istället för en stor UseCase med metoderna getAll, getById, save, delete — separata gränssnitt GetItemsUseCase, SaveItemUseCase. Klienten är endast beroende av det nödvändiga gränssnittet och varje klass som implementerar detta gränssnitt är korrekt ur LSP-synpunkt.
Vanliga frågor
Arv — språkmekanism, LSP — regel för korrekt användning av denna mekanism. Arv garanterar kompatibilitet av signaturer (syntax), LSP kräver kompatibilitet av beteende (semantik). Arv utan LSP ger polymorfism som går sönder vid körning.
Om basklassen garanterar non-null-retur — ja. Om kontraktet tillåter null (valfritt värde) — nej. LSP förbjuder inte null, det förbjuder försvagning av kontraktet. Studera basklassens dokumentation och kontrollera om underklassens kontrakt är kompatibelt.
På protokoll tillämpas LSP på samma sätt som på klasser. Implementeringen av ett protokoll måste följa det semantiska kontraktet: om protokollet definierar en metod som non-throwing, bör implementeringen inte kasta fel. Swift kontrollerar inte detta på kompileringsnivå — ansvaret ligger hos utvecklaren.
Sealed class i Kotlin — ett specialfall eftersom hierarkin är sluten och känd för kompilatorn. LSP tillämpas i mindre utsträckning på sealed class, eftersom alla undertyper explicit listas i when-uttrycket. Ett fel i en sealed-underklass kommer att vara lokalt, inte ett dolt polymorft fel.
Skriv ett parametriserat test för basklassen som körs för alla dess underklasser. Testet kontrollerar viktiga beteendekontrakt: returvärden, undantag, tillstånd. Om testet misslyckas för en av underklasserna — är LSP brutet. I CI förhindrar ett sådant test regression av polymorf kod.
Sammanfattning
Vi utvecklar en mobil applikation nyckelfärdigt
IT Sectr skapar iOS- och Android-applikationer för startups och företag sedan 2017. Vi ger dig råd och föreslår den bästa lösningen.
Läs också