LSP: същност, принцип на заместване на Барбара Лисков в разработката

Автор: IT Sectr Публикувано: 2026-05-12 Време за четене: 9 мин

LSP (Liskov Substitution Principle) — третият принцип на SOLID, който определя условията за правилно наследяване в обектно-ориентираното програмиране. Принципът е формулиран от Барбара Лисков през 1987 г. и е формализиран като: ако S е подтип на T, то обектите от тип T могат да бъдат заменени с обекти от тип S без промяна на свойствата на програмата. Както се отбелязва в книгата на Робърт Мартин Clean Architecture (2017), принципът на заместване изисква подкласът да не отслабва договора на базовия клас.

Основни точки

  • LSP — принцип на заместване на Лисков, третият принцип на SOLID за правилно наследяване
  • Подкласът трябва да запази договора на базовия клас — предварителни условия и последващи условия
  • Нарушение на LSP се проявява в проблема с квадрат и правоъгълник и хвърлените изключения
  • Композицията често е за предпочитане пред наследяването за спазване на LSP
  • Проектиране по договор (Design by Contract) — формален начин за проверка на LSP

Какво е LSP (Liskov Substitution Principle)?

LSP (Liskov Substitution Principle) — принцип на заместване, формулиран от Барбара Лисков на конференцията OOPSLA през 1987 г. Формална дефиниция: нека q(x) е доказуемо свойство на обекти x от тип T. Тогава q(y) трябва да бъде доказуемо за обекти y от тип S, където S е подтип на T. По-просто казано: обектите на подкласа трябва да се държат така, че кодът, работещ с базовия клас, да продължи да работи коректно и с подкласа.

На практика LSP означава, че подкласът не трябва да нарушава договора на базовия клас. Договорът включва предварителни условия (какво е необходимо за извикване на метод), последващи условия (какво се гарантира след извикване) и инварианти (условия, които се запазват през целия живот на обекта). Подкласът може да усилва предварителните условия или да отслабва последващите условия — това е нарушение на LSP.

Класически пример за нарушение на LSP — квадрат, наследяващ от правоъгълник. Методът setWidth в правоъгълник задава ширината, в квадрат — и ширината, и височината. Клиент, който очаква поведението на правоъгълник (промяната на една страна не влияе на другата), получава неочакван резултат. Квадратът не е правилен подтип на правоъгълник.

Формални условия на LSP

LSP поставя три условия за правилно наследяване: предварителните условия на подкласа не могат да бъдат по-силни от предварителните условия на базовия клас (подкласът не изисква повече), последващите условия на подкласа не могат да бъдат по-слаби от последващите условия на базовия клас (подкласът гарантира не по-малко), инвариантите на базовия клас трябва да се запазят в подкласа. Тези условия са известни като правило за проектиране по договор на Бертран Мейер.

Ако поне едно условие е нарушено — кодът, използващ полиморфизъм, може да работи неправилно. Компилаторът не проверява семантични договори, а само синтактични. Затова LSP е въпрос на архитектурна дисциплина, а не на статично типизиране.

Как работи принципът на заместване на Лисков

Механизмът на LSP се основава на поведенческа съвместимост на типовете. Ако клас S наследява от клас T, клиентският код трябва да може да използва S навсякъде, където се очаква T, без да променя поведението си. Това включва не само сигнатурите на методите, но и тяхната семантика.

LSP не забранява на подкласа да добавя ново поведение. Забранено е нарушаването на очакванията на кода, написан за базовия клас. Ако базовият клас гарантира, че методът save не хвърля изключения, подкласът не трябва да ги хвърля. Ако базовият клас връща неотрицателна стойност, подкласът не трябва да връща отрицателна.

В реални проекти LSP най-често се нарушава чрез добавяне на условна логика в методите на подкласа: „ако условие — хвърли изключение”, „ако условие — върни null”. Всяка такава „изненада” подкопава полиморфизма и принуждава клиентския код да проверява типа на обекта преди извикване — което противоречи на самата идея на обектно-ориентираното проектиране.

В мобилни проекти типично нарушение на LSP възниква при създаване на базов ViewModel. Ако BaseViewModel гарантира, че методът onCleared освобождава всички ресурси, а подкласът презаписва този метод като празен — всеки код, който разчита на освобождаване на ресурси чрез полиморфно извикване на onCleared, ще работи неправилно. LSP изисква подкласът или да извика super.onCleared(), или сам да извърши същата работа. Композиция чрез LifecycleObserver — алтернатива, която изключва нарушение на LSP при управление на жизнения цикъл.

Признаци за нарушение на LSP в кода

Основните индикатори за нарушение на LSP включват: проверка на типа на обекта чрез instanceof или is преди извикване на метод, празни реализации на методи (стъбове), хвърляне на изключение NotImplementedError или UnsupportedOperationException, връщане на null вместо стойност. Всеки от тези модели сигнализира, че подкласът не е правилен подтип.

Друг често срещан признак — наследяване с цел повторно използване на код, а не за моделиране на връзката „е” (is-a). Клас Bird има метод fly(). Клас Penguin наследява от Bird и презаписва fly() като празен или хвърлящ изключение. Това е нарушение на LSP: пингвинът не е правилен подтип на птица.

В мобилната разработка LSP се нарушава при създаване на базов ViewHolder, Fragment или ViewController с методи-стъбове. Ако подкласът не използва половината от методите на базовия клас — наследяването е избрано неправилно. Композиция или разделяне на интерфейс решават проблема по-правилно.

Тест за LSP

Прост тест за проверка на LSP: напишете unit тест за базовия клас, който проверява неговия договор (върнати стойности, изключения, странични ефекти). Пуснете този тест за всеки подклас. Ако тестът се провали — LSP е нарушен. Този подход се нарича „тестване чрез договора на базовия клас”.

В Android проекти такъв тест е полезен за ViewModel и Repository. Ако BaseViewModel гарантира състояние Loading преди грешка, а подкласът хвърля грешка без Loading — тестът ще засече нарушение на LSP на етап CI.

Примери за LSP в мобилната разработка

Нека разгледаме Android пример с обработка на ClickListener. Нарушение на LSP възниква, когато базовата реализация гарантира нещо, а подкласът го нарушава.

kotlin
// Базов клас с гаранция: 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 вместо данни:

swift
// Протокол с договор: връща данни или грешка
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
    }
}

Практическо правило: ако подкласът не може да изпълни договора на базовия клас — не трябва да бъде подклас. Алтернатива — извличане на интерфейс с минимален договор и реализирането му във всеки тип по свой начин.

LSP и наследяване: кога да изберем композиция

Композиция е за предпочитане пред наследяването в ситуации, където връзката „е” (is-a) е двусмислена или условна. Класически пример: Manager е Employee? Да. Но Square правилен Rectangle ли е? LSP казва „не”. Ако се съмнявате в правилността на наследяването — изберете композиция.

В мобилната разработка композицията често се използва чрез впръскване на зависимости: вместо да наследява поведение от базов клас, класът го получава чрез конструктор. ViewModel не наследява от Repository, а го приема като зависимост. Това изключва нарушение на LSP по дефиниция — няма наследяване, няма нарушение на договор.

Признаци, че наследяването трябва да бъде заменено с композиция: подкласът не използва част от методите на базовия клас, подкласът презаписва методи с празни стъбове, клиентският код проверява типа на обекта чрез instanceof. В тези случаи наследяването е избрано неправилно и LSP е нарушен.

Решение чрез интерфейси

Интерфейсите решават проблема с LSP без наследяване: всеки тип реализира точно онези методи, от които се нуждае. Вместо общ базов клас Bird с метод fly (където Penguin не лети) — интерфейс Flyable, който се реализира само от летящи птици. Penguin реализира Bird без метод fly — LSP не е нарушен.

В Android архитектурата този подход се прилага чрез сегрегирани интерфейси UseCase: вместо един голям UseCase с методи getAll, getById, save, delete — отделни интерфейси GetItemsUseCase, SaveItemUseCase. Клиентът зависи само от необходимия интерфейс и всеки клас, който реализира този интерфейс, е правилен от гледна точка на LSP.

Често задавани въпроси

По какво LSP се различава от обикновеното наследяване?

Наследяването — езиков механизъм, LSP — правило за правилно използване на този механизъм. Наследяването гарантира съвместимост на сигнатурите (синтаксис), LSP изисква съвместимост на поведението (семантика). Наследяване без LSP дава полиморфизъм, който се разваля по време на изпълнение.

Null в подклас винаги ли нарушава LSP?

Ако базовият клас гарантира non-null връщане — да. Ако договорът допуска null (опционална стойност) — не. LSP не забранява null, забранява отслабването на договора. Проучете документацията на базовия клас и проверете дали договорът на подкласа е съвместим.

Как LSP се прилага към протоколи в Swift?

Към протоколите LSP се прилага по същия начин, както към класовете. Реализацията на протокол трябва да спазва семантичния договор: ако протоколът дефинира метод като non-throwing, реализацията не трябва да хвърля грешки. Swift не проверява това на ниво компилация — отговорността е на разработчика.

Може ли LSP да бъде нарушен при използване на sealed class?

Sealed class в Kotlin — специален случай, тъй като йерархията е затворена и известна на компилатора. LSP се прилага в по-малка степен към sealed class, защото всички подтипове са изрично изброени в израза when. Грешката на sealed-подклас ще бъде локална, а не скрита полиморфна грешка.

Как да тестваме спазването на LSP в проект?

Напишете параметризиран тест за базовия клас, който се изпълнява за всички негови подкласове. Тестът проверява ключови поведенчески договори: върнати стойности, изключения, състояния. Ако тестът се провали за един от подкласовете — LSP е нарушен. В CI такъв тест предотвратява регресия на полиморфен код.

Резюме

  • LSP (Liskov Substitution Principle) — принцип на заместване, трети в SOLID, за семантична съвместимост на наследяването
  • Подкласът трябва да запази договора на базовия клас: предварителни условия, последващи условия и инварианти
  • Проверка instanceof и празни презаписвания на методи — основни признаци за нарушение на LSP
  • Композиция и интерфейси решават проблема с LSP там, където наследяването е неправилно
  • Проблемът с квадрат и правоъгълник — класически пример за несъвместимост на подтипове
  • Тест на договора за базовия клас, изпълняван за всички подкласове, открива нарушение на LSP в CI
  • Sealed class в Kotlin намалява рисковете от LSP благодарение на затворена йерархия, известна на компилатора

Ще разработим мобилно приложение под ключ

IT Sectr създава iOS и Android приложения за стартъпи и бизнеси от 2017 г. Ще ви консултираме и ще предложим най-доброто решение.

Обсъдете проекта

Прочетете също