В чем суть принципа подстановки Лисков (LSP) и приведи пример его соблюдения и нарушения?

«В чем суть принципа подстановки Лисков (LSP) и приведи пример его соблюдения и нарушения?» — вопрос из категории Архитектура, который задают на 10% собеседований IOS Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

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

  1. Выполнять все контракты (обещания) родительского класса.
  2. Не накладывать более строгих предусловий (требований к входным данным).
  3. Не ослаблять постусловий (гарантий на выходные данные или состояние).

Пример нарушения LSP

class Rectangle {
    var width: Double = 0
    var height: Double = 0

    var area: Double {
        return width * height
    }

    func setDimensions(width: Double, height: Double) {
        self.width = width
        self.height = height
    }
}

class Square: Rectangle {
    // Нарушение LSP: Квадрат изменяет ожидаемое поведение родителя.
    // Для Rectangle width и height независимы, для Square — связаны.
    override func setDimensions(width: Double, height: Double) {
        let side = min(width, height) // Неожиданное поведение!
        super.setDimensions(width: side, height: side)
    }
}

// Функция, работающая с базовым классом
func doubleWidth(of rectangle: Rectangle) {
    rectangle.setDimensions(width: rectangle.width * 2, 
                           height: rectangle.height) // Ожидается, что изменится только ширина.
}

let rect = Rectangle()
rect.setDimensions(width: 5, height: 4)
doubleWidth(of: rect)
print("Rectangle area: (rect.area)") // Ожидаемо: 5*2 * 4 = 40

let sq: Rectangle = Square() // Подстановка Square вместо Rectangle
sq.setDimensions(width: 5, height: 4)
doubleWidth(of: sq) // Внутри вызовется переопределенный метод Square
print("Square area (treated as Rectangle): (sq.area)") // Неожиданно: 4*4 = 16 (а не 40)!

Почему это нарушение: Код, использующий Rectangle, ожидает, что width и height изменяются независимо. Square ломает это предположение, что приводит к ошибкам. С точки зрения LSP, Square не является подтипом Rectangle в поведенческом смысле.

Пример соблюдения LSP

// Абстракция через протокол
protocol Shape {
    var area: Double { get }
}

// Конкретные реализации, не связанные наследованием
struct RectangleShape: Shape {
    let width, height: Double
    var area: Double { width * height }
}

struct SquareShape: Shape {
    let side: Double
    var area: Double { side * side }
}

struct CircleShape: Shape {
    let radius: Double
    var area: Double { .pi * radius * radius }
}

// Функция работает с абстракцией Shape и корректна для любой подстановки.
func printArea(of shape: Shape) {
    print("The area is (shape.area)")
}

let shapes: [Shape] = [RectangleShape(width: 5, height: 4),
                       SquareShape(side: 5),
                       CircleShape(radius: 3)]

for shape in shapes {
    printArea(of: shape) // Всегда корректно, LSP соблюден.
}

Ключевой вывод: LSP поощряет проектирование через абстракции (протоколы/интерфейсы) и композицию, а не через глубокие иерархии наследования, которые могут нарушать поведенческие контракты.