Что такое принцип подстановки Барбары Лисков (Liskov Substitution Principle, LSP)?

Ответ

Принцип подстановки Барбары Лисков (LSP) — третий принцип SOLID. Он гласит: объекты в программе должны быть заменяемыми экземплярами их базовых типов без изменения корректности этой программы.

Простыми словами: Если у вас есть функция, работающая с классом Родитель, то вы должны иметь возможность передать ей любой класс-Ребенок, и программа продолжит работать правильно. Наследник не должен ужесточать предусловия или ослаблять постусловия родительского метода.

Классический пример нарушения LSP:

public class Bird
{
    public virtual void Fly()
    {
        Console.WriteLine("I'm flying!");
    }
}

public class Penguin : Bird // Пингвин — птица, но не летает!
{
    public override void Fly()
    {
        throw new NotImplementedException("Penguins can't fly!");
    }
}

// Клиентский код, ожидающий работу с Bird
public class BirdAviary
{
    public void ReleaseBird(Bird bird)
    {
        bird.Fly(); // ВЫБРОСИТ ИСКЛЮЧЕНИЕ, если bird — Penguin!
    }
}

Здесь Penguin нарушает контракт, заданный базовым классом Bird (метод Fly), и ломает клиентский код.

Исправление через пересмотр иерархии (соблюдение LSP):

// Базовый класс не навязывает необщее поведение
public abstract class Bird { }

// Интерфейс определяет способность, которой обладают не все птицы
public interface IFlyable
{
    void Fly();
}

public class Sparrow : Bird, IFlyable // Воробей — птица и умеет летать
{
    public void Fly() => Console.WriteLine("Sparrow is flying!");
}

public class Penguin : Bird // Пингвин — птица, но не реализует IFlyable
{
    // Специфичные для пингвина методы, например, Swim()
    public void Swim() => Console.WriteLine("Penguin is swimming!");
}

// Клиентский код теперь корректен
public class FlightSimulator
{
    public void StartFlight(IFlyable flyableBird) // Принимает только то, что может летать
    {
        flyableBird.Fly();
    }
}

Почему LSP важен:

  • Обеспечивает надежность полиморфизма. Вы можете безопасно использовать коллекции базового типа.
  • Упрощает тестирование. Можете использовать mock-объекты, наследующие от того же базового класса.
  • Предотвращает появление неочевидных ошибок, когда подстановка подкласса приводит к неожиданному поведению.

Нарушение LSP часто сигнализирует о неправильно спроектированной иерархии наследования.

Ответ 18+ 🔞

А, слушай, про этот принцип Лисков! Ну, тот, который все нарушают, а потом удивляются, почему всё ебнулось.

Представь себе, есть у тебя класс Птица. И в нём метод Лететь(). Логично же? Птицы вроде как летают. А потом ты наследуешь от него класс Пингвин. И тут начинается пиздец. Потому что пингвин — он да, птица, орнитологи подтвердят. Но летать он, сука, не умеет! А метод-то Лететь() у него есть, потому что он унаследовал его от родителя.

И вот твой клиентский код, который работает с Птицей, вызывает у объекта Лететь(). А если это пингвин — получает исключение NotImplementedException прямо в ебало. Программа падает, и все сидят и чешут репу: "Ну как же так, мы же всё по учебнику делали, наследование использовали!"

А принцип Лисков как раз и кричит: "Эй, мудаки! Если вы где-то в коде используете Птицу, то вы должны иметь возможность подсунуть туда ЛЮБУЮ птицу — воробья, орла, страуса, пингвина — и всё должно продолжать работать, а не вылетать в ошибку!"

Нарушение этого принципа — это классический признак, что иерархия наследования спроектирована через жопу. Наследовали не поведение, а просто данные. "Пингвин — птица, значит наследуем от Птицы". А по факту получили говно.

Как надо делать по-человечески?

Не надо всем птицам впихивать умение летать. Пусть базовый класс Птица будет просто птицей. А умение летать — это отдельная фича, способность. Выносим её в интерфейс ILетающий.

// Просто птица. Без наворотов.
public abstract class Bird { }

// Интерфейс для тех, кто реально может летать
public interface IFlyable
{
    void Fly();
}

// Воробей — птица И летающая хрень
public class Sparrow : Bird, IFlyable
{
    public void Fly() => Console.WriteLine("Воробей шпарит по воздуху!");
}

// Пингвин — птица, но не летает. Зато плавает.
public class Penguin : Bird
{
    public void Swim() => Console.WriteLine("Пингвин гребёт под водой!");
}

И тогда твой код, который отвечает за полёты, будет принимать не просто Птицу, а именно ILетающего. И он нахуй не примет пингвина, потому что пингвин не реализует этот интерфейс. Исключения не будет, логика не сломается. Всё тип-топ.

public class Авиадиспетчер
{
    // Этот метод принимает только то, что гарантированно умеет летать
    public void РазрешитьВзлёт(IFlyable летающийОбъект)
    {
        летающийОбъект.Fly(); // Здесь спокойно, никаких сюрпризов
    }
}

Зачем это всё? Да чтобы не было сюрпризов, блядь! Чтобы можно было спокойно использовать полиморфизм, не боясь, что какой-нибудь урод-наследник всё похерит. Чтобы мокать объекты в тестах. Чтобы система была предсказуемой. Короче, чтобы жить было проще, а не геройствовать, исправляя костыли на каждом шагу.