Введение в SOLID принципы в C#
Разработка на C# в экосистеме .NET требует не только знания синтаксиса, но и умения проектировать гибкие, поддерживаемые и тестируемые системы. Именно для этого были созданы SOLID принципы — пять фундаментальных правил объектно-ориентированного программирования, сформулированных Робертом Мартином (дядюшкой Бобом).
Акроним SOLID расшифровывается как:
- S — Single Responsibility Principle (Принцип единственной ответственности)
- O — Open/Closed Principle (Принцип открытости/закрытости)
- L — Liskov Substitution Principle (Принцип подстановки Лисков)
- I — Interface Segregation Principle (Принцип разделения интерфейсов)
- D — Dependency Inversion Principle (Принцип инверсии зависимостей)
В этой статье мы разберем каждый принцип на практике с примерами на C#, покажем, как их нарушение приводит к проблемам, и как правильное применение делает код чище.
1. Принцип единственной ответственности (SRP)
Суть: Класс должен иметь только одну причину для изменения. Другими словами, каждый класс отвечает за одну четко определенную задачу.
Нарушение SRP в C#
Рассмотрим типичный пример — класс OrderService, который делает слишком много:
public class OrderService
{
public void ProcessOrder(Order order)
{
// 1. Сохранение в БД
SaveToDatabase(order);
// 2. Отправка email
SendEmail(order.CustomerEmail, "Заказ оформлен");
// 3. Логирование
File.WriteAllText("log.txt", $"Заказ {order.Id} обработан");
}
private void SaveToDatabase(Order order) { /* ... */ }
private void SendEmail(string email, string message) { /* ... */ }
}
Проблема: Изменение логики отправки email или способа логирования приведет к изменению класса OrderService. Это нарушает SRP.
Правильная реализация SRP
Разделяем ответственность на отдельные сервисы:
public class OrderProcessor
{
private readonly IOrderRepository _repository;
private readonly INotificationService _notificationService;
private readonly ILogger _logger;
public OrderProcessor(
IOrderRepository repository,
INotificationService notificationService,
ILogger logger)
{
_repository = repository;
_notificationService = notificationService;
_logger = logger;
}
public void ProcessOrder(Order order)
{
_repository.Save(order);
_notificationService.SendConfirmation(order.CustomerEmail);
_logger.Log($"Заказ {order.Id} обработан");
}
}
public interface IOrderRepository { void Save(Order order); }
public interface INotificationService { void SendConfirmation(string email); }
public interface ILogger { void Log(string message); }
Теперь каждый класс отвечает только за свою задачу. Изменение логики логирования не затрагивает обработку заказов.
2. Принцип открытости/закрытости (OCP)
Суть: Классы должны быть открыты для расширения, но закрыты для модификации. То есть вы должны иметь возможность добавлять новое поведение без изменения существующего кода.
Нарушение OCP в C#
Классический пример — калькулятор с условиями:
public class AreaCalculator
{
public double CalculateArea(object shape)
{
if (shape is Rectangle rect)
return rect.Width * rect.Height;
else if (shape is Circle circle)
return Math.PI * circle.Radius * circle.Radius;
// Добавление новой фигуры требует изменения этого метода!
throw new NotSupportedException();
}
}
Проблема: При добавлении треугольника или квадрата придется модифицировать метод CalculateArea.
Правильная реализация OCP
Используем полиморфизм и абстракцию:
public abstract class Shape
{
public abstract double CalculateArea();
}
public class Rectangle : Shape
{
public double Width { get; set; }
public double Height { get; set; }
public override double CalculateArea() => Width * Height;
}
public class Circle : Shape
{
public double Radius { get; set; }
public override double CalculateArea() => Math.PI * Radius * Radius;
}
// Клиентский код не меняется при добавлении новых фигур
public class AreaCalculator
{
public double CalculateArea(Shape shape) => shape.CalculateArea();
}
Теперь для добавления новой фигуры достаточно создать новый класс, наследующий от Shape, без изменения существующего кода.
3. Принцип подстановки Лисков (LSP)
Суть: Объекты базового класса должны заменяться объектами производных классов без нарушения корректности программы. Проще говоря, наследник не должен ломать логику родителя.
Нарушение LSP в C#
Печально известный пример с квадратом и прямоугольником:
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area() => Width * Height;
}
public class Square : Rectangle
{
private int _side;
public override int Width
{
set { _side = value; }
}
public override int Height
{
set { _side = value; }
}
}
// Клиентский код:
void Test(Rectangle rect)
{
rect.Width = 5;
rect.Height = 10;
Debug.Assert(rect.Area() == 50); // Для Square будет 100!
}
Проблема: Квадрат нарушает поведение прямоугольника — установка ширины меняет высоту.
Правильная реализация LSP
Лучше использовать общий интерфейс или абстрактный класс:
public interface IShape
{
int Area();
}
public class Rectangle : IShape
{
public int Width { get; set; }
public int Height { get; set; }
public int Area() => Width * Height;
}
public class Square : IShape
{
public int Side { get; set; }
public int Area() => Side * Side;
}
Теперь каждый класс реализует интерфейс без нарушения ожиданий клиентского кода.
4. Принцип разделения интерфейсов (ISP)
Суть: Клиенты не должны зависеть от методов, которые они не используют. Вместо одного "толстого" интерфейса лучше создать несколько специализированных.
Нарушение ISP в C#