LSP (Liskov Substitution Principle) — трећи принцип SOLID-а који одређује услове исправног наслеђивања у објектно-оријентисаном програмирању. Принцип је формулисала Барбара Лисков 1987. године и формализован је као: ако је S подтип од T, онда објекти T могу бити замењени објектима S без промене својстава програма. Како се наводи у књизи Роберта Мартина Clean Architecture (2017), принцип супституције захтева да подкласа не слаби уговор базне класе.
Главно
LSP (Liskov Substitution Principle) — принцип супституције који је формулисала Барбара Лисков на конференцији OOPSLA 1987. године. Формална дефиниција: нека је q(x) доказиво својство објеката x типа T. Тада q(y) мора бити доказиво за објекте y типа S, где је S подтип од T. Једноставније речено: објекти подкласе морају да се понашају тако да код који ради са базном класом настави исправно да ради и са подкласом.
У пракси LSP значи да подкласа не сме да крши уговор базне класе. Уговор укључује предуслове (шта је потребно за позив метода), послеуслове (шта се гарантује након позива) и инваријанте (услови који се одржавају током живота објекта). Подкласа може да ојача предуслове или ослаби послеуслове — то је кршење LSP.
Класичан пример кршења LSP — квадрат који наслеђује од правоугаоника. Метод setWidth код правоугаоника поставља ширину, код квадрата — и ширину и висину. Клијент који очекује понашање правоугаоника (промена једне стране не утиче на другу) добија неочекивани резултат. Квадрат није исправан подтип правоугаоника.
LSP поставља три услова за исправно наслеђивање: предуслови подкласе не могу бити јачи од предуслова базне класе (подкласа не захтева више), послеуслови подкласе не могу бити слабији од послеуслова базне класе (подкласа гарантује не мање), инваријанте базне класе морају бити очуване у подкласи. Ови услови су познати као правило пројектовања по уговору Бертрана Мејера.
Ако је бар један услов прекршен — код који користи полиморфизам може да ради неисправно. Компајлер не проверава семантичке уговоре, само синтаксне. Зато је LSP питање архитектонске дисциплине, а не статичког типизовања.
Механизам LSP се заснива на бихејвиоралној компатибилности типова. Ако класа S наслеђује од класе T, клијентски код мора моћи да користи S свуда где се очекује T, без промене свог понашања. Ово укључује не само сигнатуре метода, већ и њихову семантику.
LSP не забрањује подкласи да додаје ново понашање. Забрањено је кршење очекивања кода написаног за базну класу. Ако базна класа гарантује да метод save не баца изузетке, подкласа не сме да их баца. Ако базна класа враћа ненегативну вредност, подкласа не сме да враћа негативну.
У реалним пројектима LSP се најчешће крши додавањем условне логике у методе подкласе: «ако услов — баци изузетак», «ако услов — врати null». Свако такво «изненађење» нарушава полиморфизам и приморава клијентски код да проверава тип објекта пре позива — што је у супротности са самом идејом објектно-оријентисаног пројектовања.
У мобилним пројектима типично кршење LSP се јавља при креирању базних ViewModel. Ако BaseViewModel гарантује да метод onCleared ослобађа све ресурсе, а подкласа преписује овај метод као празан — било који код који се ослања на ослобађање ресурса путем полиморфног позива onCleared радиће неисправно. LSP захтева да подкласа или позове super.onCleared(), или сама обави исти посао. Композиција путем LifecycleObserver — алтернатива која искључује кршење LSP при управљању животним циклусом.
Главни индикатори кршења LSP укључују: проверу типа објекта путем instanceof или is пре позива метода, празне имплементације метода (стубове), бацање изузетка NotImplementedError или UnsupportedOperationException, враћање null уместо вредности. Сваки од ових образаца сигнализира да подкласа није исправан подтип.
Још један чест знак — наслеђивање ради поновног коришћења кода, а не ради моделирања односа «јесте» (is-a). Класа Bird има метод fly(). Класа Penguin наслеђује од Bird и преписује fly() као празан или који баца изузетак. Ово је кршење LSP: пингвин није исправан подтип птице.
У мобилном развоју LSP се крши при креирању базних ViewHolder, Fragment или ViewController са stub методима. Ако подкласа не користи половину метода базне класе — наслеђивање је погрешно изабрано. Композиција или раздвајање интерфејса решавају проблем исправније.
Једноставан тест за проверу LSP: напишите unit-тест за базну класу који проверава њен уговор (повратне вредности, изузеци, споредни ефекти). Покрените овај тест за сваку подкласу. Ако тест падне — LSP је прекршен. Овај приступ се назива «тестирање кроз уговор базне класе».
У Android пројектима такав тест је користан за ViewModel и Repository. Ако BaseViewModel гарантује стање Loading пре грешке, а подкласа баца грешку без Loading — тест ће забележити кршење LSP у фази CI.
Размотримо Android пример са обрадом ClickListener. Кршење LSP настаје када базна имплементација нешто гарантује, а подкласа то крши.
// Базна класа са гаранцијом: onClick ће бити позван
open class BaseClickListener {
open fun onClick(view: View) {
// базна обрада
}
}
// Кршење LSP: подкласа додаје услов који баца изузетак
class RestrictedClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (!isLoggedIn) {
throw IllegalStateException("Not logged in")
}
super.onClick(view)
}
}
// Исправно решење: уговор није прекршен
class ConditionalClickListener : BaseClickListener() {
override fun onClick(view: View) {
if (isLoggedIn) {
super.onClick(view)
}
}
}
iOS пример са протоколом DataSource демонстрира кршење LSP кроз враћање nil уместо података:
// Протокол са уговором: враћа податке или грешку
protocol DataProvider {
func fetchData() async throws -> [String]
}
// Кршење LSP: враћа nil без грешке
class SilentFailProvider: DataProvider {
func fetchData() async throws -> [String] {
return [] // празан низ уместо грешке
}
}
// Исправно поштовање LSP
class NetworkProvider: DataProvider {
func fetchData() async throws -> [String] {
throw NetworkError.timeout
}
}
Практично правило: ако подкласа не може да испуни уговор базне класе — не треба да буде подкласа. Алтернатива — издвојити интерфејс са минималним уговором и имплементирати га у сваком типу на свој начин.
Композиција је пожељнија од наслеђивања у ситуацијама где је однос «јесте» (is-a) двосмислен или услован. Класичан пример: Manager је Employee? Да. Али да ли је Square исправан Rectangle? LSP каже «не». Ако сумњате у исправност наслеђивања — изаберите композицију.
У мобилном развоју композиција се често користи кроз убризгавање зависности: уместо да наслеђује понашање од базне класе, класа га добија кроз конструктор. ViewModel не наслеђује од Repository, већ га прима као зависност. Ово искључује кршење LSP по дефиницији — нема наслеђивања, нема кршења уговора.
Знаци да наслеђивање треба заменити композицијом: подкласа не користи део метода базне класе, подкласа преписује методе празним stub-овима, клијентски код проверава тип објекта кроз instanceof. У овим случајевима наслеђивање је погрешно изабрано и LSP је прекршен.
Интерфејси решавају проблем LSP без наслеђивања: сваки тип имплементира тачно оне методе које су му потребне. Уместо заједничке базне класе Bird са методом fly (где Penguin не лети) — интерфејс Flyable, који имплементирају само птице које лете. Penguin имплементира Bird без метода fly — LSP није прекршен.
У Android архитектури овај приступ се примењује кроз сегрегиране интерфејсе UseCase: уместо једног великог UseCase са методима getAll, getById, save, delete — одвојени интерфејси GetItemsUseCase, SaveItemUseCase. Клијент зависи само од потребног интерфејса и свака класа која имплементира тај интерфејс је исправна са становишта LSP.
Често постављана питања
Наслеђивање — механизам језика, LSP — правило исправног коришћења тог механизма. Наслеђивање гарантује компатибилност сигнатура (синтакса), LSP захтева компатибилност понашања (семантика). Наслеђивање без LSP даје полиморфизам који се ломи у runtime-у.
Ако базна класа гарантује non-null повратак — да. Ако уговор дозвољава null (опциона вредност) — не. LSP не забрањује null, забрањује слабљење уговора. Проучите документацију базне класе и проверите да ли је уговор подкласе компатибилан.
На протоколе LSP се примењује исто као на класе. Имплементација протокола мора да поштује семантички уговор: ако протокол дефинише метод као non-throwing, имплементација не сме да баца грешке. Swift ово не проверава на нивоу компилације — одговорност је на програмеру.
Sealed class у Kotlin-у — посебан случај, јер је хијерархија затворена и позната компајлеру. LSP се у мањој мери примењује на sealed class, јер су сви подтипови експлицитно наведени у when изразу. Грешка sealed-подкласе биће локална, а не скривена полиморфна грешка.
Напишите параметризовани тест за базну класу који се покреће за све њене подкласе. Тест проверава кључне бихејвиоралне уговоре: повратне вредности, изузетке, стања. Ако тест падне на једној од подкласа — LSP је прекршен. У CI-ју такав тест спречава регресију полиморфног кода.
Завршни преглед
Развићемо мобилну апликацију под кључ
IT Sectr креира iOS и Android апликације за стартапе и предузећа од 2017. године. Саветоваћемо вас и предложити најбоље решење.
Прочитајте такође