Принципы SOLID в C#

112

SOLID - это 5 принципов объектно-ориентированного программирования, описывающих архитектуру программного обеспечения. Вы можете заметить, что все шаблоны проектирования (паттерны) основаны на этих принципах. Аббревиатура SOLID расшифровывается как:

S (The Single Responsibility Principle) - принцип единой ответственности (SRP).

O (The Open Closed Principle) - обозначает принцип открытости/закрытости (OCP).

L (The Liskov Substitution Principle) – принцип подстановки Лисков, описывающий возможности заменяемости экземпляров объектов (LSP).

I (The Interface Segregation Principle) - принцип разделения интерйесов (ISP).

D (The Dependency Inversion Principle) - принцип инверсии зависимостей (DIP).

Я считаю, что с наглядными примерами SOLID, статья будет более доступной и понятной.

SOLID

SRP – принцип единой ответственности

Этот принцип означает, что каждый класс или подобная структура в вашем коде должна отвечать только за одну цель. Все члены этого класса должны быть связаны одной целью. Наш класс не должен быть похож на швейцарский нож, в котором при изменении одного из членов нужно изменять весь инструментарий. Это не означает, что ваши классы должны содержать только один метод или свойство. Может быть много членов, если они относятся к единой ответственности.

Принцип единой ответственности дает нам хороший способ определения классов на этапе проектирования приложения и заставляет вас думать о всех способах изменения класса. Хорошее разделение обязанностей выполняется только тогда, когда имеется полная картина того, как приложение должно работать.

Давайте рассмотрим пример:

namespace SOLID
{
    public class Employee
    {
        public int ID { get; set; }
        public string FullName { get; set; }

        /// <summary>
        /// Данный метод добавляет в БД нового сотрудника
        /// </summary>
        /// <param name="em">Объект для вставки</param>
        /// <returns>Результат вставки новых данных</returns>
        public bool Add(Employee emp)
        {
            // Вставить данные сотрудника в таблицу БД
            return true;
        }

        /// <summary>
        /// Отчет по сотруднику
        /// </summary>
        public void GenerateReport(Employee em)
        {
            // Генерация отчета по деятельности сотрудника
        }
    }
}

В данном случае класс Employee не соответствует принципу SRP, т.к. несет две ответственности – добавление нового сотрудника в базу данных и создание отчета. Класс Employee не должен нести ответственность за отчетность, т.к. например, если через какое-то время вам скажут, что нужно предоставить отчет в формате Excel или изменить алгоритм создания отчета, вам потребуется отредактировать класс Employee.

Согласно SRP, один класс должен взять на себя ответственность, поэтому мы должны написать отдельный класс для генерации отчетов:

namespace SOLID
{
    public class Employee
    {
        public int ID { get; set; }
        public string FullName { get; set; }

        /// <summary>
        /// Данный метод добавляет в БД нового сотрудника
        /// </summary>
        /// <param name="em">Объект для вставки</param>
        /// <returns>Результат вставки новых данных</returns>
        public bool Add(Employee emp)
        {
            // Вставить данные сотрудника в таблицу БД
            return true;
        }
    }

    public class EmployeeReport
    {
        /// <summary>
        /// Отчет по сотруднику
        /// </summary>
        public void GenerateReport(Employee em)
        {
            // Генерация отчета по деятельности сотрудника
        }
    }
}

OCP - принцип открытости/закрытости

Главной концепцией данного принципа является то, что класс должен быть открыт для расширений, но закрыт от модификаций. Наш модуль должен быть разработан так, чтобы новая функциональность могла быть добавлена только при создании новых требований. «Закрыт для модификации» означает, что мы уже разработали класс, и он прошел модульное тестирование. Мы не должны менять его, пока не найдем ошибки. Как говорится, класс должен быть открытым только для расширений и в C# мы можем использовать для этого наследование.

Давайте рассмотрим наглядный пример:

public class EmployeeReport
{
    // <summary>
    /// Тип отчета
    /// </summary>
    public string TypeReport { get; set; }

    /// <summary>
    /// Отчет по сотруднику
    /// </summary>
    public void GenerateReport(Employee em)
    {
        if (TypeReport == "CSV")
        {
            // Генерация отчета в формате CSV
        }

        if (TypeReport == "PDF")
        {
            // Генерация отчета в формате PDF
        }
    }
}

Проблема в этом классе заключается в том, что если мы захотим внести новый тип отчета (например, для выгрузки в Excel), нам понадобится добавить новое условие if. Но согласно принципу OCP, наш класс должен быть закрыт от модификаций и открыт для расширений. Ниже показано, как это можно сделать:

public class IEmployeeReport
{
    /// <summary>
    /// Метод для создания отчета
    /// </summary>
    public virtual void GenerateReport(Employee em)
    {
        // Базовая реализация, которую нельзя модифицировать
    }
}

public class EmployeeCSVReport : IEmployeeReport
{
    public override void GenerateReport(Employee em)
    {
        // Генерация отчета в формате CSV
    }
}

public class EmployeePDFReport : IEmployeeReport
{
    public override void GenerateReport(Employee em)
    {
        // Генерация отчета в формате PDF
    }
}

Теперь, если вы захотите добавить новый тип отчета, просто создайте новый класс и унаследуйте его от IEmployeeReport. Таким образом, класс IEmployeeReport закрыт от модификаций, но доступен для расширений.

Принцип подстановки Лисков (LCP)

Данный принцип гласит, что «вы должны иметь возможность использовать любой производный класс вместо родительского класса и вести себя с ним таким же образом без внесения изменений». Этот принцип прост, но очень важен для понимания. Класс Child не должен нарушать определение типа родительского класса и его поведение.

В чем же смысл этого! Посмотрите на следующее изображение:

Наследование классов для примера LCP

Employee является родительским классом, а Senior и Junior дочерними классами, унаследованными от Employee. Теперь рассмотрим пример:

using System;

namespace SOLID
{

    public abstract class Employee
    {
        public virtual string GetWorkDetails(int id)
        {
            return "Base Work";
        }

        public virtual string GetEmployeeDetails(int id)
        {
            return "Base Employee";
        }
    }

    public class SeniorEmployee : Employee
    {
        public override string GetWorkDetails(int id)
        {
            return "Senior Work";
        }

        public override string GetEmployeeDetails(int id)
        {
            return "Senior Employee";
        }
    }

    public class JuniorEmployee : Employee
    {
        // Допустим, для Junior’a отсутствует информация
        public override string GetWorkDetails(int id)
        {
            throw new NotImplementedException();        }

        
        public override string GetEmployeeDetails(int id)
        {
            return "Junior Employee";

        }
    }
}

Возможно вам покажется что с данным кодом все в порядке. Однако, проанализируйте следующий код:

List<Employee> list = new List<Employee>();

list.Add(new JuniorEmployee());
list.Add(new SeniorEmployee());

foreach (Employee emp in list)
{
    emp.GetEmployeeDetails(985);
}

Теперь у нас есть проблема. Для JuniorEmployee невозможно вернуть информацию о работе, поэтому вы получите необработанное исключение, что нарушит принцип LSP. Для решения этой проблемы в C# необходимо просто разбить функционал на два интерфейса IWork и IEmployee:

public interface IEmployee
{
    string GetEmployeeDetails(int employeeId);
}

public interface IWork
{
    string GetWorkDetails(int employeeId);
}

public class SeniorEmployee : IWork, IEmployee
{
    public string GetWorkDetails(int employeeId)
    {
        return "Senior Work";
    }

    public string GetEmployeeDetails(int employeeId)
    {
        return "Senior Employee";
    }
}

public class JuniorEmployee : IEmployee
{
    public string GetEmployeeDetails(int employeeId)
    {
        return "Junior Employee";
    }
}

Теперь JuniorEmployee требует реализации только IEmployee, а не IWork. При таком подходе будет поддерживаться принцип LSP.

Принцип разделения интерфейсов (ISP)

Принцип разделения интерфейсов гласит, что клиенты не должны принудительно внедрять интерфейсы, которые они не используют. Что же это означает? Давайте предположим, что есть одна база данных для хранения данных всех типов сотрудников (то есть Junior и Senior). Какой будет лучший подход для нашего интерфейса?

public interface IEmployee
{
    bool AddDetailsEmployee();
}

Допустим все классы Employee наследуют этот интерфейс для сохранения данных. Теперь предположим, что компания однажды сказала вам, что они хотят читать данные только для сотрудников в должности senior. Что вы будете делать, просто добавьте один метод в этот интерфейс?

public interface IEmployee
{
    bool AddDetailsEmployee();
    bool ShowDetailsEmployee(int id);
}

Но теперь мы что-то ломаем. Мы вынуждаем объекты JuniorEmployee показывать свои данные из базы данных. Таким образом, решение заключается в том, чтобы передать эту ответственность другому интерфейсу:

public interface IOperationAdd
{
    bool AddDetailsEmployee ();
}

public interface IOperationGet
{
    bool ShowDetailsEmployee (int id);
}

И теперь, класс JuniorEmployee будет реализовывать только интерфейс IOperationAdd, а SeniorEmployee оба интерфейса. Таким образом обеспечивается разделение интерфейсов.

Принцип инверсии зависимостей (DIP)

Данный принцип гласит, что, во-первых, классы высокого уровня не должны зависеть от низкоуровневых классов. При этом оба должны зависеть от абстракций. Во-вторых, абстракции не должны зависеть от деталей, но детали должны зависеть от абстракций. Классы высокого уровня реализуют бизнес-правила или логику в системе (приложении). Низкоуровневые классы занимаются более подробными операциями, другими словами, они могут заниматься записью информации в базу данных или передачей сообщений в операционную систему или службы и т.п.

Говорят, что высокоуровневый класс, который имеет зависимость от классов низкого уровня или какого-либо другого класса и много знает о других классах, с которыми он взаимодействует, тесно связан. Когда класс явно знает о дизайне и реализации другого класса, возникает риск того, что изменения в одном классе нарушат другой класс.

Поэтому мы должны держать эти высокоуровневые и низкоуровневые классы слабо связанными, насколько мы можем. Чтобы сделать это, нам нужно сделать их зависимыми от абстракций, а не друг от друга.

Давайте рассмотрим пример. Предположим, что после сохранения некоторых деталей в базе данных существует одна система уведомлений:

public class Email
{
    public void Send()
    {
        // код для отправки email-письма
    }
}

// Уведомление
public class Notification
{
    private Email email;
    public Notification()
    {
        email = new Email();
    }

    public void EmailDistribution ()
    {
        email.Send();
    }
}

Теперь класс Notification полностью зависит от класса Email, потому что он отправляет только один тип уведомлений. А если мы захотим ввести какие-либо другие уведомления, например отправку? Тогда нам понадобится изменить всю систему уведомлений. В данном случае это система является тесно связанной. Что мы можем сделать, чтобы она была слабо связанной? Посмотрите на следующую реализацию:

public interface IMessenger
{
    void Send();
}

public class Email : IMessenger
{
    public void Send()
    {
        // код для отправки email-письма
    }
}

public class SMS : IMessenger
{
    public void Send()
    {
        // код для отправки SMS
    }
}

// Уведомление
public class Notification
{
    private IMessenger _messenger;
    public Notification()
    {
        _messenger = new Email();
    }

    public void DoNotify()
    {
        _messenger.Send();
    }
}

В данном случае класс Notification все еще зависит от класса Email, т.к. использует его объект в конструкторе. В данном случае мы можем использовать принцип внедрения зависимостей (dependency injection – DI), реализацию которого, в виде библиотеки Ninject, мы подробно описали в статье Внедрение зависимостей Ninject. Существует три базовых принципа внедрения зависимостей.

Внедрение зависимостей через конструктор (Constructor Injection)

public class Notification
{
    private IMessenger _messenger;
    public Notification(IMessenger mes)
    {
        _messenger = mes;
    }

    public void DoNotify()
    {
        _messenger.Send();
    }
}

Внедрение зависимостей через свойства (Property Injection)

public class Notification
{
    private IMessenger _messenger;
    public Notification()
    {

    }

    public IMessenger Messanger
    {
        set
        {
            _messenger = value;
        }
    }

    public void DoNotify()
    {
        _messenger.Send();
    }
}

Внедрение зависимостей через метод (Method Injection)

public class Notification
{
    public void DoNotify(IMessenger mes)
    {
        mes.Send();
    }
}
Пройди тесты
Лучший чат для C# программистов