Пример нарушения принципа подстановки Лисков (LSP) в ООП

Ответ

Принцип подстановки Лисков (LSP) гласит, что объекты в программе должны быть заменяемыми экземплярами их подтипов без изменения корректности программы. Нарушение LSP происходит, когда подкласс изменяет ожидаемое поведение базового класса, делая его несовместимым с кодом, который работает с базовым типом.

Пример нарушения LSP (Python):

Рассмотрим классический пример с Rectangle и Square:

class Rectangle:
    def __init__(self, width: int, height: int):
        self._width = width
        self._height = height

    @property
    def width(self) -> int:
        return self._width

    @width.setter
    def width(self, value: int):
        self._width = value

    @property
    def height(self) -> int:
        return self._height

    @height.setter
    def height(self, value: int):
        self._height = value

    def area(self) -> int:
        return self._width * self._height

class Square(Rectangle):
    def __init__(self, side: int):
        super().__init__(side, side)

    @Rectangle.width.setter
    def width(self, value: int):
        # Нарушение: изменение ширины влияет на высоту
        self._width = value
        self._height = value

    @Rectangle.height.setter
    def height(self, value: int):
        # Нарушение: изменение высоты влияет на ширину
        self._width = value
        self._height = value

def test_rectangle_area(rect: Rectangle):
    # Ожидаем, что изменение ширины не повлияет на высоту
    rect.width = 5
    rect.height = 4
    # Для Rectangle: 5 * 4 = 20
    # Для Square: 5 * 5 = 25 (после rect.width = 5), затем 4 * 4 = 16 (после rect.height = 4)
    assert rect.area() == 20  # Это утверждение будет ложным для Square!

# Тестирование с Rectangle - работает корректно
rectangle_obj = Rectangle(10, 2)
test_rectangle_area(rectangle_obj) # OK, area = 20

# Тестирование с Square - нарушает LSP
square_obj = Square(10)
try:
    test_rectangle_area(square_obj) # AssertionError
except AssertionError:
    print("Нарушение LSP: Square не может быть подставлен вместо Rectangle без изменения поведения.")

Объяснение нарушения:

Класс Square наследуется от Rectangle, но изменяет его фундаментальное поведение: при изменении ширины или высоты Square обе стороны устанавливаются одинаковыми. Это нарушает инвариант Rectangle, который предполагает независимое изменение сторон.

Функция test_rectangle_area, разработанная для работы с Rectangle, ожидает, что установка ширины и высоты будет работать независимо. При подстановке Square это ожидание нарушается, что приводит к некорректным результатам (assert rect.area() == 20 становится ложным, так как для Square площадь будет 16).

Почему это проблема и как исправить:

Нарушение LSP приводит к хрупкости кода, затрудняет его поддержку и расширение, так как подклассы не могут быть безопасно использованы там, где ожидается базовый класс. Правильным решением было бы не наследовать Square от Rectangle, поскольку Square не является 'специальным видом' Rectangle в контексте независимых размеров. Вместо этого можно использовать:

  • Общий интерфейс (абстрактный базовый класс): Определить общий интерфейс Shape с методом area(), от которого будут наследоваться как Rectangle, так и Square.
  • Композиция: Square может содержать Rectangle как внутренний компонент, управляя его размерами.

Ответ 18+ 🔞

Ну, слушай, вот есть у нас принцип, который называется принцип подстановки Лисков. Звучит, конечно, как заклинание из старой книги, но суть проста, как три копейки: если у тебя есть какая-то штука, и ты её заменяешь на её более специализированную версию, то вся программа должна продолжать работать, как ни в чём не бывало. Не должно быть такого, что подсунул ты «улучшенную» деталь, а весь механизм накрылся медным тазом.

А теперь смотри, как его можно, блядь, с треском нарушить на классическом примере с прямоугольником и квадратом. Все думают: «А, квадрат — это же частный случай прямоугольника, логично наследовать!» И вот тут-то и начинается пиздец.

class Rectangle:
    def __init__(self, width: int, height: int):
        self._width = width
        self._height = height

    @property
    def width(self) -> int:
        return self._width

    @width.setter
    def width(self, value: int):
        self._width = value

    @property
    def height(self) -> int:
        return self._height

    @height.setter
    def height(self, value: int):
        self._height = value

    def area(self) -> int:
        return self._width * self._height

Всё чинно, благородно. Прямоугольник. Ширина и высота живут своей жизнью. А теперь рождается его «сынок» — Квадрат.

class Square(Rectangle):
    def __init__(self, side: int):
        super().__init__(side, side)

    @Rectangle.width.setter
    def width(self, value: int):
        # Нарушение: изменение ширины влияет на высоту
        self._width = value
        self._height = value

    @Rectangle.height.setter
    def height(self, value: int):
        # Нарушение: изменение высоты влияет на ширину
        self._width = value
        self._height = value

С виду вроде всё логично, да? Квадрат, стороны равны. Но ёпта, вот в чём подвох! Мы же переопределили сеттеры. Теперь, когда ты меняешь ширину, у тебя заодно и высота подтягивается. И наоборот. А теперь представь, что у тебя есть функция, которая заточена под работу с прямоугольником.

def test_rectangle_area(rect: Rectangle):
    # Ожидаем, что изменение ширины не повлияет на высоту
    rect.width = 5
    rect.height = 4
    # Для Rectangle: 5 * 4 = 20
    # Для Square: 5 * 5 = 25 (после rect.width = 5), затем 4 * 4 = 16 (после rect.height = 4)
    assert rect.area() == 20  # Это утверждение будет ложным для Square!

Вот она, мать его, ловушка! Для обычного прямоугольника всё ок: поставил ширину 5, высоту 4, площадь 20. А для квадрата получается ебаный цирк: сначала ты ставишь ширину 5, и он, хитрая жопа, автоматом и высоту в 5 меняет. Площадь уже 25. Потом ты ставишь высоту 4, а он и ширину в 4 меняет! И площадь становится 16. И твой assert летит в пизду с громким треском AssertionError. Это и есть нарушение LSP в чистом виде. Код, который работал с родителем, сломался на ребёнке.

Так в чём же, блядь, корень зла? А в том, что квадрат — это не прямоугольник в мире объектов с изменяемыми сторонами. С математической точки зрения — да, квадрат — это прямоугольник. А с точки зрения поведения нашего кода — нихуя! У прямоугольника есть негласный контракт: «ширина и высота независимы». Квадрат этот контракт нарушает, потому что у него они связаны. Подставляя квадрат, мы ломаем все ожидания.

И как тогда жить, спросишь ты? А не надо пытаться впихнуть невпихуемое через наследование! Есть варианты поумнее:

  • Сделать общего предка. Например, абстрактный класс Shape с методом area(). И пусть Rectangle и Square наследуются от него, каждый со своей реализацией. И не лезут друг другу в сеттеры.
  • Использовать композицию. Сделать так, чтобы Square внутри себя содержал Rectangle и управлял им, сохраняя стороны равными, но не нарушая контракт прямоугольника для внешнего мира.

Короче, мораль такая: наследование — это не про «является с точки зрения школьного учебника», а про «ведёт себя как». Если поведение отличается — это верный признак, что наследование тут будет как волк в овечьей шкуре, только проблемы создаст. Думай, прежде чем наследовать, а то получишь такие сюрпризы, что мало не покажется.