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

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

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

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

Как выглядит нарушение

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)
}

Геометрия не соврала: квадрат действительно прямоугольник. Соврал код – наследник переопределил сеттеры и тем самым отменил обещание родителя «ширину и высоту можно менять независимо». Что из этого следует:

  • ломается чужой код, а не наследник – Resize написан честно и по контракту, падает он на объекте, о котором ничего не знал;
  • компилятор доволен – с точки зрения типов всё в порядке, ошибка вылезет только на конкретных данных;
  • лечится проверками типа – в Resize появляется if (r is Square), и полиморфизм, ради которого затевалось наследование, пропадает;
  • чинить приходится в вызывающих – а их может быть двадцать, и все нужно найти.

Как починить

Наследование здесь лишнее: общее у фигур – только площадь, и именно её стоит вынести в контракт. Заодно уберём сеттеры – без них ломать нечего:

// Общее у фигур только одно – площадь
public interface IShape
{
    int Area { get; }
}

public class Rectangle : IShape
{
    public Rectangle(int width, int height) => (Width, Height) = (width, height);

    public int Width { get; }
    public int Height { get; }
    public int Area => Width * Height;
}

// Квадрат не наследник: у него своя форма и свой конструктор
public class Square : IShape
{
    public Square(int side) => Side = side;

    public int Side { get; }
    public int Area => Side * Side;
}
// Общее у фигур только одно – площадь
class IShape {
public:
    virtual int Area() const = 0;
    virtual ~IShape() = default;
};

class Rectangle : public IShape {
public:
    Rectangle(int width, int height) : width_(width), height_(height) {}

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

private:
    int width_;
    int height_;
};

// Квадрат не наследник: у него своя форма и свой конструктор
class Square : public IShape {
public:
    explicit Square(int side) : side_(side) {}

    int Area() const override { return side_ * side_; }

private:
    int side_;
};
// Общее у фигур только одно – площадь
type Shape interface {
	Area() int
}

type Rectangle struct {
	Width  int
	Height int
}

func (r Rectangle) Area() int { return r.Width * r.Height }

// Квадрат не «наследник»: это отдельный тип со своим полем
type Square struct {
	Side int
}

func (s Square) Area() int { return s.Side * s.Side }
# Общее у фигур только одно – площадь
class Shape(Protocol):
    @property
    def area(self) -> int: ...

@dataclass(frozen=True)
class Rectangle:
    width: int
    height: int

    @property
    def area(self) -> int:
        return self.width * self.height

# Квадрат не наследник: у него своя форма и свой конструктор
@dataclass(frozen=True)
class Square:
    side: int

    @property
    def area(self) -> int:
        return self.side * self.side
// Общее у фигур только одно – площадь
interface Shape {
    readonly area: number;
}

class Rectangle implements Shape {
    constructor(
        readonly width: number,
        readonly height: number,
    ) {}

    get area(): number {
        return this.width * this.height;
    }
}

// Квадрат не наследник: у него своя форма и свой конструктор
class Square implements Shape {
    constructor(readonly side: number) {}

    get area(): number {
        return this.side * this.side;
    }
}
// Общее у фигур только одно – площадь
public interface Shape {
    int area();
}

public record Rectangle(int width, int height) implements Shape {
    @Override
    public int area() {
        return width * height;
    }
}

// Квадрат не наследник: у него своя форма и свой конструктор
public record Square(int side) implements Shape {
    @Override
    public int area() {
        return side * side;
    }
}
// Общее у фигур только одно – площадь
interface Shape {
    val area: Int
}

data class Rectangle(val width: Int, val height: Int) : Shape {
    override val area get() = width * height
}

// Квадрат не наследник: у него своя форма и свой конструктор
data class Square(val side: Int) : Shape {
    override val area get() = side * side
}

Функции, которой нужна площадь, теперь достаточно Shape – и она работает с любой фигурой, включая те, которых ещё нет. А функция, которой нужно менять стороны по отдельности, просит Rectangle – и квадрат ей просто не передадут: это поймает компилятор, а не тестировщик.

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

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

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

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

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