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

С их помощью я стала писать небольшие фрагменты кода с одной ответственностью в каждом и без лишних инструкций if-else. И теперь я счастливый обладатель слабо связанного кода, который к тому же мне удается сохранять простым, поддерживаемым, читаемым, тестируемым и легко расширяемым

SOLID  —  это принципы объектно-ориентированного программирования, сформулированные Робертом Сесилом Мартином, известным как Дядя Боб, в его работе “Design Principles and Design Patterns” (“Принципы и шаблоны проектирования”). Однако сам термин появился позднее благодаря Майклу Фэзерсу. 

Итак, знакомьтесь: 

  • Принцип единственной ответственности.
  • Принцип открытости/закрытости.
  • Принцип подстановки Лисков.
  • Принцип разделения интерфейса. 
  • Принцип инверсии зависимостей.

Давайте рассмотрим, в чем суть каждого из них.  

Принцип единственной ответственности (SRP)

У класса должна быть одна и только одна причина для изменения. 

Каждая функция или класс призваны выполнять только одну задачу. 

Позднее Дядя Боб напишет о том, что этот принцип касается и людей. И действительно, суть его становится намного понятнее, если вместо классов и функций провести аналогию с жизнью. Например, у каждого члена семьи есть свои обязанности, которые он выполняет лучше всего: кто-то отвечает за стирку, а другой готовит еду. 

Ниже представлен класс User, предназначенный для хранения данных пользователя и выполнения его действий, например Registration. Функция Registration выполняет несколько задач: обеспечивает соединение с базой данных (БД), сохраняет данные, отправляет подтверждение по e-mail и при необходимости записывает лог события. 

public class User
{
    public void Registration(DeliveryType delivertyType)
    {
        try
        {
            // сохраняем данные в БД 
            using (var conn = new SqlConnection("connectionString"))
            {
                using (var cmd = new SqlCommand("UserSave", conn) { CommandType = CommandType.StoredProcedure })
                {

                    conn.Open();
                    cmd.Parameters.AddWithValue("name", Name);
                    cmd.Parameters.AddWithValue("adress", Adress);
                    cmd.Parameters.AddWithValue("email", Email);
                    cmd.Parameters.AddWithValue("phone", Phone);
                    cmd.ExecuteNonQuery();
                }
            }

            //отправляем ссылку для подтверждения 
            string content = "please confirm your registration";
            if (delivertyType == DeliveryType.Email)
            {
                Console.WriteLine("send email: " + content);
            }
            else
            {
                Console.WriteLine("send sms: " + content);
            }

        }
        catch(Exception ex)
        {
            string ApplicationName = Assembly.GetExecutingAssembly().FullName;
            EventLog.WriteEntry(ApplicationName, ex.ToString(), EventLogEntryType.Error);
        }
    }
}

При таком способе написания кода каждое изменение в движке БД, сервере e-mail или логике логирования будет влиять на класс. Эти изменения не входят в число обязанностей User. Его задача  —  убедиться в том, что данные пользователя сохранены в БД. Он не должен следить за тем, как они сохраняются, какая строка отвечает за подключение или какой тип движка БД используется. Перед нами явный пример нарушения SRP. 

Для решения этой проблемы делегируем ответственность за действия БД классу DBUtil и добавим службы обмена сообщениями и логирования. После разделения ответственности класс User будет более читаемым, и изменения ему грозят только в случае модификации данных или логики регистрации. 

public class User
{
    public void Registration(DeliveryType delivertyType)
    {
        try
        {
            //сохраняем данные в БД 
            DBUtil.ExecuteNonQuery("UserSave", Name, Adress, Email, Phone);

            //отправляем ссылку для подтверждения
            string content = "please confirm your registration";
            MessegingService.Send(delivertyType, content);
        }
        catch (Exception ex)
        {
            Log.WriteLog(ex.ToString(), EventLogEntryType.Error);
        }

    }
}

Принцип открытости/закрытости (OCP)

Модуль должен быть открыт для расширения, но закрыт для модификации. 

Это значит, что мы должны избегать изменения существующего кода, добавляя новую функциональность посредством нового класса. 

В классе MessagingService с помощью инструкции if-else и обработки всей логики обмена сообщениями мы проверяем, какой требуется тип сообщения о доставке. В этом случае при добавлении новых типов доставки или изменении логики текущего типа нам придется просканировать и протестировать весь код.

public class MessagingService
{
    public static void Send(DeliveryType delivertyType,string content)
    {
        if (delivertyType == DeliveryType.Email)
        {
            Console.WriteLine("send email: " + content);
        }
        else
        {
            Console.WriteLine("send sms: " + content);
        }
    }
}

Во избежание нарушения OCP добавим интерфейс IMessagingService, т.е. класс для каждого типа сообщения о доставке, а также класс MessagingFactory для реализации фабричного шаблона проектирования.

public interface IMessagingService
{
    void Send(string content);
}

public class EmailService : IMessagingService
{
    public void Send(string content)
    {
        Console.WriteLine("send email: " + content);
    }
}

public class SMSService : IMessagingService
{
    public void Send(string content)
    {
        Console.WriteLine("send sms: " + content);
    }
}

public class MessagingFactory
{
    public IMessagingService InitializeMessage(DeliveryType deliveryType)
    {
        if (deliveryType == DeliveryType.Email)
        {
            return new EmailService();
        }
        else if(deliveryType==DeliveryType.Sms){ 
                return new SMSService();
        }

        throw new Exception("Unsuported delivery method");    
    }
}

Для отправки сообщения применяется класс Messaging factory, который возвращает соответствующий конкретный объект. 

MessegingFactory messagingFactory = new MessagingFactory();
IMessagingService messagingService = messagingFactory.InitializeMessege(deliveryType);
messagingService.Send(content);

Теперь добавление нового типа доставки никак не меняет наши службы. Единственный и известным нам участок кода, подверженный изменениям,  —  это messagingFactory. Соблюдая принцип OCP и избегая модификаций, мы сможем сохранить код стабильным, поддерживаемым, легко расширяемым, не ломая при этом текущий код. 

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

Этот принцип был введен Барбарой Лисков. 

Если П является подтипом Т, то объекты типа Т, присутствующие в программе, можно заменить объектами типа П без модификации установленных свойств этой программы. 

или так: 

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

Необходимо, чтобы каждый наследующий класс мог заменить родительский без нарушения кода. 

Суть LSP часто поясняется с помощью утиного теста: “Если нечто выглядит и крякает как утка, но при этом работает на батарейках, то, вероятно, проблема в неверной абстракции”. При попытке заменить настоящую утку (duck) ее моделью она явно не полетит. Так что модель класса duck не сможет реализовать класс duck.

Для воплощения LSP на практике класс-потомок должен соблюдать несколько правил, чтобы оставить в неизменном виде поведение и свойства своего родителя. Например, “правило сигнатуры” обязывает придерживаться сигнатуры метода родительского класса, сохраняя число аргументов и их типы. То же требование распространяется на возвращаемые типы и набор исключений. 

Следующий пример демонстрирует нарушение LSP в результате переопределения исключений метода и возвращения их набора, отличного от родительского класса. 

У нашей системы есть 2 типа пользователей: зарегистрированные и гости, оба из которых реализуют интерфейс IUser. Одно из действий зарегистрированных пользователей  —  UpdatePassword (обновление пароля). Класс Guest использует тот же интерфейс, а следовательно вынужден реализовывать функциональность UpdatePassword. В результате выбрасывается исключение, поскольку у гостей нет пароля для обновления. 

interface IUser
{
    void UpdateUserDetails(DeliveryType deliveryType);
    void UpdatePassword(string oldPassword, string newPassword);
}

public class GuestUser : IUser
{
    public void UpdateUserDetails(DeliveryType deliveryType)
    {
        Console.WriteLine("UpdateUserDetails Logic...");
    }

    public void UpdatePassword(string oldPassword, string newPassword)
    {
        throw new Exception("guest user does not have any password to update");
    }
}

Напишем 2 интерфейса для исправления этой ошибки: 

  • IUser с общей функциональностью для зарегистрированных пользователей и гостей; 
  • IRegisteredUser реализует IUser и расширяет функциональность зарегистрированного пользователя. 
interface IUser
{
    void UpdateUserDetails(DeliveryType deliveryType);
}

interface IRegisteredUser : IUser
{
    void UpdatePassword(string oldPassword, string newPassword);
}

public class GuestUser : IUser
{
    public void UpdateUserDetails(DeliveryType deliveryType)
    {
        Console.WriteLine("UpdateUserDetails Logic...");
    }
}

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

Лучше несколько отдельных клиентских интерфейсов, чем один общий интерфейс. 

Иначе говоря, “клиенты не должны зависеть от методов, которые они не используют”. Этот принцип очень близок по смыслу SRP. Однако отличие состоит в том, что здесь мы исходим из позиции клиента. Если он применяет одну функцию, то не следует привязывать его к интерфейсу, включающему больше методов, чем ему требуется. Вот что пишет по этому поводу Дядя Боб: 

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

Допустим, необходимо отправить уведомления всем пользователям. С этой целью создаем класс Notification с одной функцией Send, которая побуждает объект IUser получить e-mail пользователя с помощью свойства GetEmailNotification.

class Notification
{
    void Send(IUser subscriber)
    {
        Console.WriteLine("send notigication to " + subscriber.GetEmailNotification());
    }
}

Класс User реализует набор функций, несоответствующих классу Notification, что говорит о нарушении принципа ISP.

interface IUser
{
    void Registration(DeliveryType deliveryType);
    void UpdateUserDetails();
    string GetEmailNotification();
}

Для решения этой проблемы с помощью GetEmailNotification определяем новый интерфейс INotifiable, и в службе уведомлений вместо IUser получаем INotifiable

interface INotifiable
{
    string GetEmailNotification();
}

class Notification
{
    void Send(INotifiable subscriber)
    {
        Console.WriteLine("send notigication to " + subscriber.GetEmailNotification());
    }
}

Теперь класс GuestUser реализует интерфейсы IUser и INotifiable.

interface IUser
{
    void Registration(DeliveryType deliveryType);
    void UpdateUserDetails();

}

public class GuestUser : IUser, INotifiable
{

    public GuestUser()
    {
        Console.WriteLine("Constractor Logic...");
    }

    public void Registration(DeliveryType deliveryType)
    {
        Console.WriteLine("Registration Logic...");
    }

    public string GetEmailNotification()
    {
        Console.WriteLine("GetEmailNotification Logic...");
        return "User Email";
    }

    public void UpdateUserDetails()
    {
        Console.WriteLine("UpdateUserDetails Logic...");
    }
}

В этом случае класс Notification зависит не от большого интерфейса с кучей лишних функций, а от маленького и с одной. Более того, если в перспективе потребуется применить службу уведомлений для других типов, мы легко сможем это сделать без реализации всей функциональности IUser

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

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

Класс User реализует функцию Registration, которая в качестве аргумента получает класс SQLDBUtil для управления операциями с данными в БД. Очевидно, что любые изменения в ее движке повлияют на Registration. Например, задумай мы перейти от SQL к MongoDB, нам бы потребовалось соответствующим образом модифицировать и эту функцию. А с учетом потенциального наличия многочисленных классов, использующих SQLDBUtil, масштабы работы будут невероятно велики.

Рассмотрим пример с нарушением принципа DIP: 

public class User 
{

    public void Registration(SQLDBUtil db, DeliveryType deliveryType)
    {
        // Сохраняем данные в БД
        db.ExecuteNonQuery("User","Save", Name, Adress, Email, Phone);
    }
}

class SQLDBUtil
{
    const string dBConString = @"Server=localhost\SQLEXPRESS;Database=Test;Trusted_Connection=True;";

    public void ExecuteNonQuery(string entityName,string actionName, params object[] parameters)
    {
        Console.WriteLine("Run Non Query in Mongo DB");
    }

}

Чтобы не нарушать принцип, объявим новый интерфейс IDBUtil, который реализуется классом SQLDBUtil, изменяющим функцию Registration для получения IDBUtil.

interface IDBUtil
{
    void ExecuteNonQuery(string entityName, string actionName, params object[] parameters);
}

class MongoDBUtil : IDBUtil
{
    public void ExecuteNonQuery(string entityName, string actionName, params object[] parameters)
    {
        Console.WriteLine("Run Non Query in Mongo DB");
    }
}
    
class SQLDbUtil: IDBUtil
{
    const string dBConString = @"Server=localhost\SQLEXPRESS;Database=Test;Trusted_Connection=True;";

    public void ExecuteNonQuery(string entityName, string actionName, params object[] parameters)
    {
        Console.WriteLine("Run Non Query in SQL");
    }
}

Обратите внимание, как класс User вместо конкретного SQLDBUtil получает IDBUtil

public class User 
{
    public void Registration(IDBUtil db, DeliveryType deliveryType)
    {
        // Сохраняем данные в БД
        db.ExecuteNonQuery("User","Save", Name, Adress, Email, Phone);
   }
}

// Создаем экземпляр IDBUtil 
IDDBUtil db = new SQLDBUtil();
User.Registration(db,DeliveryType.sms);

В таком случае при переходе с движка SQL на Mongo нужно лишь изменить фрагмент кода, создающего не класс User, а экземпляр БД. 

IDDBUtil db = new MongoDbUtil();
User.Registration(db,DeliveryType.sms);

Теперь класс User зависит от абстракции IDBUtil, а не от нижнеуровнего класса SQLDBUtil.

Заключение 

Напомню, что SOLID являются принципами, а не правилами. Это инструмент, позволяющий сделать код читаемым, поддерживаемым и расширяемым. Используйте его разумно. 

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Liat Kompas: Coding the SOLID Principles

Предыдущая статья10 полезных инструментов для разработчика
Следующая статьяОсновные принципы темного UI-дизайна