Введение: зачем нужны SOLID принципы в C#?
Каждый разработчик C# рано или поздно сталкивается с проблемой «грязного кода»: классы, которые делают всё подряд, нечитаемые зависимости и невозможность внести изменения без риска сломать весь проект. SOLID — это аббревиатура пяти фундаментальных принципов объектно-ориентированного проектирования, сформулированных Робертом Мартином. Они помогают создавать гибкий, поддерживаемый и тестируемый код на C# (.NET).
В этой статье мы разберём каждый принцип SOLID с теоретической основой и практическими примерами на C#. Вы узнаете, как применять их в реальных проектах, избегая типичных ошибок.
1. Single Responsibility Principle (SRP) — принцип единственной ответственности
Теория
Класс должен иметь только одну причину для изменения. Иными словами, каждый класс отвечает только за одну часть функциональности программы. Нарушение SRP приводит к тому, что изменение одной операции (например, сохранение в БД) затрагивает логику расчётов или отображения.
Практика на C#
Рассмотрим типичный пример нарушения SRP — класс, который одновременно вычисляет зарплату и сохраняет сотрудника в базу:
// ❌ Нарушение SRP: класс делает слишком много
public class Employee
{
public string Name { get; set; }
public decimal Salary { get; set; }
public decimal CalculateBonus()
{
return Salary * 0.1m;
}
public void SaveToDatabase()
{
// Логика сохранения в БД
Console.WriteLine("Сотрудник сохранён в БД");
}
}
Исправим ситуацию, разделив ответственности:
// ✅ SRP: каждый класс отвечает за свою задачу
public class Employee
{
public string Name { get; set; }
public decimal Salary { get; set; }
}
public class BonusCalculator
{
public decimal Calculate(Employee employee)
{
return employee.Salary * 0.1m;
}
}
public class EmployeeRepository
{
public void Save(Employee employee)
{
Console.WriteLine("Сотрудник сохранён в БД");
}
}
2. Open-Closed Principle (OCP) — принцип открытости/закрытости
Теория
Программные сущности (классы, модули, функции) должны быть открыты для расширения, но закрыты для модификации. Это значит, что вы можете добавлять новую функциональность, не изменяя существующий код.
Практика на C#
Допустим, нам нужно рассчитывать площадь разных фигур. Нарушение OCP — использование if-else или switch для каждого нового типа фигуры:
// ❌ Нарушение OCP: при добавлении новой фигуры нужно менять код
public class AreaCalculator
{
public double Calculate(object shape)
{
if (shape is Circle circle)
return Math.PI * circle.Radius * circle.Radius;
else if (shape is Rectangle rect)
return rect.Width * rect.Height;
// При добавлении Triangle придётся дописывать ещё один if
return 0;
}
}
Решение — использовать абстракцию и полиморфизм:
// ✅ OCP: расширяем через наследование, не трогая существующий код
public abstract class Shape
{
public abstract double GetArea();
}
public class Circle : Shape
{
public double Radius { get; set; }
public override double GetArea() => Math.PI * Radius * Radius;
}
public class Rectangle : Shape
{
public double Width { get; set; }
public double Height { get; set; }
public override double GetArea() => Width * Height;
}
public class AreaCalculator
{
public double Calculate(Shape shape)
{
return shape.GetArea(); // Работает для любой фигуры
}
}
3. Liskov Substitution Principle (LSP) — принцип подстановки Барбары Лисков
Теория
Объекты базового класса должны быть заменимы объектами его подклассов без нарушения работы программы. Простыми словами: если у вас есть метод, работающий с базовым классом, он должен корректно работать с любым производным классом.
Практика на C#
Классическое нарушение LSP — наследование Square от Rectangle. Квадрат не должен быть прямоугольником с точки зрения поведения:
// ❌ Нарушение LSP: квадрат не может быть полноценным прямоугольником
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
}
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 rect = new Square();
rect.Width = 5;
rect.Height = 10;
// Ожидаемая площадь: 50, реальная: 100 — нарушение!
Правильное решение — не наследовать квадрат от прямоугольника, а создать общий интерфейс или абстрактный класс:
// ✅ LSP: разделяем на независимые классы
public interface IShape
{
int GetArea();
}
public class Rectangle : IShape
{
public int Width { get; set; }
public int Height { get; set; }
public int GetArea() => Width * Height;
}
public class Square : IShape
{
public int Side { get; set; }
public int GetArea() => Side * Side;
}
4. Interface Segregation Principle (ISP) — принцип разделения интерфейсов
Теория
Клиенты не должны зависеть от методов, которые они не используют. Лучше иметь несколько специализированных интерфейсов, чем один «толстый».
Практика на C#
Пример нарушения — интерфейс IWorker, который заставляет всех работников реализовывать методы, которые им не нужны:
// ❌ Нарушение ISP: интерфейс слишком «толстый»
public interface IWorker
{
void Work();
void Eat();
void Sleep();
}
public class HumanWorker : IWorker
{
public void Work() { /* работает */ }
public void Eat() { /* ест */ }
public void Sleep() { /* спит */ }
}
public class RobotWorker : IWorker
{
public void Work() { /* работает */ }
public void Eat() { throw new NotImplementedException(); } // Робот не ест!
public void Sleep() { throw new NotImplementedException(); } // Робот не спит!
}
Исправляем через разделение интерфейсов:
// ✅ ISP: узкие интерфейсы для разных ролей
public interface IWorkable
{
void Work();
}
public interface IFeedable
{
void Eat();
}
public interface ISleepable
{
void