В чём суть принципов открытости/закрытости (Open/Closed) и подстановки Лисков (Liskov)?
Разбираю SOLID и застрял на двух средних принципах: OCP (Open/Closed) и LSP (Liskov Substitution). Что значит «открыт для расширения, но закрыт для модификации»? И что именно требует принцип подстановки Лисков — ведь наследование и так позволяет подставлять наследников? Объясните на примерах C#, в чём подвох.
2 ответа
Разберём оба принципа.
Open/Closed Principle (OCP)
«Программные сущности должны быть открыты для расширения, но закрыты для модификации». То есть добавлять новое поведение нужно, дописывая код, а не переписывая уже работающий и протестированный.
Нарушение — switch по типу, который придётся править при каждом новом случае:
// Плохо: при добавлении фигуры лезем в этот метод
public class AreaCalculator
{
public double Area(object shape)
{
if (shape is Circle c) return Math.PI * c.Radius * c.Radius;
if (shape is Square s) return s.Side * s.Side;
throw new NotSupportedException();
}
}
Исправляем через абстракцию:
public abstract class Shape { public abstract double Area(); }
public class Circle : Shape
{
public double Radius { get; set; }
public override double Area() => Math.PI * Radius * Radius;
}
public class Square : Shape
{
public double Side { get; set; }
public override double Area() => Side * Side;
}
// Этот класс больше не нужно менять при добавлении фигуры
public class AreaCalculator
{
public double Area(Shape shape) => shape.Area();
}
Новую фигуру добавляем новым классом — старый код не трогаем.
Liskov Substitution Principle (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 = base.Height = value; } }
public override int Height { set { base.Width = base.Height = value; } }
}
// Код, который верен для Rectangle, ломается для Square:
void Test(Rectangle r)
{
r.Width = 5;
r.Height = 4;
// ожидаем 20, но для Square получим 16
Console.WriteLine(r.Area());
}
Квадрат является прямоугольником математически, но не является заменяемым подтипом по поведению. LSP нарушен. Признаки нарушения: наследник усиливает предусловия, ослабляет постусловия, кидает новые исключения или возвращает NotImplementedException в переопределённом методе.
Связь принципов: LSP — это, по сути, условие корректной работы OCP. Полиморфная подстановка (на которой держится OCP) работает правильно только тогда, когда наследники честно соблюдают контракт базового типа.
Добавлю практическое наблюдение по LSP. Самый частый запах нарушения в реальном коде — это метод наследника, который выглядит так:
public override void Fly()
{
throw new NotSupportedException("Пингвин не летает");
}
Если Penguin : Bird, а Bird.Fly() обещает полёт — подстановка пингвина вместо птицы сломает любой код, ожидающий, что птица полетит. Решение — пересмотреть иерархию: вынести летающих в отдельный интерфейс IFlyingBird.
По OCP мой главный совет: не делайте абстракции заранее «на всякий случай». Применяйте OCP к тем точкам, которые реально часто меняются. Преждевременная абстракция тоже зло.