Course program← Объектно ориентированное программирование
1История ООП
2Объект и Класс
3Базовые концепции ООП
4Принцип проектирования GRASP
5Принцип проектирования SOLID
6Паттерны GOF

LSP – принцип подстановки Лисков

This lesson is not translated yet – the Russian original is below.

TODO: отвалидировать – текст собран автоматически из материалов курса на Google Drive, проверьте формулировки и код.

Объекты дочерних классов должны полностью заменять объекты базовых классов без изменения правильности программы.

Если где-то используется базовый тип, подтип должен работать там же, не ломая логику. Наследник не должен ужесточать контракты родителя и нарушать его поведение.

Классический пример

public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }

    public int Area => Width * Height;
}

// Квадрат «является» прямоугольником в геометрии, но не в коде
public class Square : Rectangle
{
    public override int Width
    {
        set { base.Width = value; base.Height = value; }
    }

    public override int Height
    {
        set { base.Width = value; base.Height = value; }
    }
}

// Код, написанный для Rectangle, ломается на Square
void Resize(Rectangle r)
{
    r.Width = 4;
    r.Height = 5;

    // Ожидаем 20, для квадрата получим 25
    Console.WriteLine(r.Area);
}
class Rectangle {
public:
    virtual void set_width(int value) { width_ = value; }
    virtual void set_height(int value) { height_ = value; }

    int Area() const { return width_ * height_; }

    virtual ~Rectangle() = default;

protected:
    int width_ = 0;
    int height_ = 0;
};

// Квадрат «является» прямоугольником в геометрии, но не в коде
class Square : public Rectangle {
public:
    void set_width(int value) override {
        width_ = value;
        height_ = value;
    }

    void set_height(int value) override {
        width_ = value;
        height_ = value;
    }
};

// Код, написанный для Rectangle, ломается на Square
void Resize(Rectangle& r) {
    r.set_width(4);
    r.set_height(5);

    // Ожидаем 20, для квадрата получим 25
    std::cout << r.Area() << std::endl;
}
// В Go наследования нет, и подменить поведение через встраивание не выйдет:
// метод базового типа продолжит работать с базовыми полями.
// Нарушение LSP здесь проявляется на интерфейсах.
type Shape interface {
	Resize(width, height int)
	Area() int
}

type Rectangle struct {
	width, height int
}

func (r *Rectangle) Resize(w, h int) { r.width, r.height = w, h }
func (r *Rectangle) Area() int       { return r.width * r.height }

type Square struct {
	side int
}

// Квадрат вынужден игнорировать один из аргументов – контракт нарушен
func (s *Square) Resize(w, h int) { s.side = h }
func (s *Square) Area() int       { return s.side * s.side }

func Resize(s Shape) {
	s.Resize(4, 5)

	// Ожидаем 20, для квадрата получим 25
	fmt.Println(s.Area())
}
class Rectangle:
    def __init__(self):
        self._width = 0
        self._height = 0

    @property
    def width(self):
        return self._width

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

    @property
    def height(self):
        return self._height

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

    @property
    def area(self):
        return self._width * self._height

# Квадрат «является» прямоугольником в геометрии, но не в коде
class Square(Rectangle):
    @Rectangle.width.setter
    def width(self, value):
        self._width = value
        self._height = value

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

def resize(r: Rectangle) -> None:
    r.width = 4
    r.height = 5

    # Ожидаем 20, для квадрата получим 25
    print(r.area)
class Rectangle {
    protected w = 0;
    protected h = 0;

    set width(value: number) {
        this.w = value;
    }

    set height(value: number) {
        this.h = value;
    }

    get area(): number {
        return this.w * this.h;
    }
}

// Квадрат «является» прямоугольником в геометрии, но не в коде
class Square extends Rectangle {
    set width(value: number) {
        this.w = value;
        this.h = value;
    }

    set height(value: number) {
        this.w = value;
        this.h = value;
    }
}

function resize(r: Rectangle): void {
    r.width = 4;
    r.height = 5;

    // Ожидаем 20, для квадрата получим 25
    console.log(r.area);
}
public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

// Квадрат «является» прямоугольником в геометрии, но не в коде
public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

void resize(Rectangle r) {
    r.setWidth(4);
    r.setHeight(5);

    // Ожидаем 20, для квадрата получим 25
    System.out.println(r.getArea());
}
open class Rectangle {
    protected var w = 0
    protected var h = 0

    open var width: Int
        get() = w
        set(value) {
            w = value
        }

    open var height: Int
        get() = h
        set(value) {
            h = value
        }

    val area: Int get() = w * h
}

// Квадрат «является» прямоугольником в геометрии, но не в коде
class Square : Rectangle() {
    override var width: Int
        get() = w
        set(value) {
            w = value
            h = value
        }

    override var height: Int
        get() = h
        set(value) {
            w = value
            h = value
        }
}

fun resize(r: Rectangle) {
    r.width = 4
    r.height = 5

    // Ожидаем 20, для квадрата получим 25
    println(r.area)
}

Что даёт соблюдение

  • Полиморфизм действительно работает. Код, написанный под базовый тип, пишется один раз и не требует правок при появлении нового наследника.
  • Исчезают проверки типа. Пропадает if (obj is Square) – а вместе с ним и вопрос «все ли места я поправил, когда добавил новый подкласс».
  • Тесты базового типа проверяют всех наследников. Один набор проверок на контракт, прогоняемый для каждой реализации, ловит нарушения автоматически.
  • Иерархию можно читать сверху вниз. Достаточно знать базовый класс, чтобы понимать, что делает любой из его потомков.

Как это ловить

Нарушение LSP почти всегда выглядит одинаково: в коде появляется проверка типа наследника или метод, который у одного из наследников выбрасывает «не поддерживается». Оба признака означают, что иерархия построена неверно, а отношение между классами – не «является».