Принципы SOLID - ключи к чистому коду

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

Принципы SOLID были введены в 2000 году Робертом К. Мартином и с тех пор стали довольно популярными. Основной их целью является повышение качества разработки.

SOLID означает:

S — Single Responsibility Principle — принцип единственной ответственности.

O — Open-Closed Principle — принцип открытости/закрытости.

L — Liskov Substitution Principle — принцип подстановки Барбары Лисков.

I — Interface Segregation Principle — принцип разделения интерфейсов.

D — Dependency Inversion Principle — принцип инверсии зависимости.

Посмотрим на конкретных примерах, как работает каждый принцип.

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

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

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

Взглянем на пример:

public class NotificationService
{
public void NotifyUser(User user)
{
if (user.NotificationTool.HasFlag(UserNotification.Email))
{
SendMessageToEmail();
}
if (user.NotificationTool.HasFlag(UserNotification.Phone))
{
SendMessageToPhone();
}
}

public void SendMessageToEmail()
{

}

public void SendMessageToPhone()
{

}
}

Так что же не так с кодом? Как вы заметили, у нашей службы уведомлений есть две обязанности: она отправляет электронные письма и посылает текстовые сообщения на телефон. Со временем код вырастет и превратится в невообразимую путаницу, так что изменение может сломать какую-то часть системы, что заставит нас тратить время на обслуживание, а не на разработку.

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

Проведем небольшой рефакторинг и расставим все по местам.

public interface INotificationService
{
void Send();
}

public class EmailNotificationService : INotificationService
{
public void Send()
{

}
}

public class PhoneNotificationService : INotificationService
{
public void Send()
{

}
}

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

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

Объекты программного обеспечения (классы, модули, функции и т.д.) должны быть открыты для расширения, но закрыты для модификации.

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

Предположим, мы работаем над инструментом “холст”, в котором пользователь может рисовать многоугольники.

public class Figure
{
public void DrawRectangle()
{
DrawFigure(new[] { new Point() });
}

public void DrawCircle()
{
DrawFigure(new[] { new Point() });
}

public void DrawTriangle()
{
DrawFigure(new[] { new Point() });
}

public void Border(BorderStyle style)
{

}

private void DrawFigure(Point[] point)
{

}
}

Как видите из приведенного выше кода, у нас есть класс Figure (Фигура), отвечающий за рисование многоугольников. Кроме того, в нашем распоряжении несколько методов, используемых для рисования определенной фигуры, например, Triangle (Треугольник). Но что если я захочу добавить больше фигур? Изменение существующего кода может привести к каким-нибудь нарушениям. Кроме того, сам код выглядит ужасно. Итак, методы Rectangle (Прямоугольник), Triangle (Треугольник) и Circle (Круг) доступны публично в классе Figure. Я не могу добавить больше фигур, потому что в итоге у меня будет полный беспорядок. Более того, у Figure много обязанностей. Мы уже говорили, что у класса должна быть только одна причина для изменения. Поэтому, используя SRP и OCP, мы разделим обязанности.

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

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

public abstract class Figure
{

public void Border(BorderStyle style)
{

}

protected void DrawFigure(Point[] point)
{

}

public abstract void DrawFigure(Point[] point);
}

public class Rectangle : Figure
{
public override void DrawFigure(Point[] point)
{
throw new NotImplementedException();
}
}

public class Circle : Figure
{
public override void DrawFigure(Point[] point)
{
throw new NotImplementedException();
}
}

public class Triangle : Figure
{
public override void DrawFigure(Point[] point)
{
throw new NotImplementedException();
}
}

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

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

Пусть Φ(x) — свойство, доказуемое для объектов x типа T. Тогда Φ(y) должно быть истинным для объектов y типа S, где S — подтип T.

Предположим, у нас есть абстрактный класс Bird (Птица), объединяющий животных, которые могут ходить, летать и есть. И мы на основе Bird создали класс Penguin (Пингвин). Поэтому мы ожидаем, что Penguin будет летать, но, к нашему удивлению, метод Fly (Летать) не реализуется в классе Penguin, что приводит к неожиданному поведению.

Другой пример. Представьте, что вы работаете над открытым API, и у вас есть абстрактный класс и производные классы, а некоторые из производных классов не соответствуют абстрактному классу. Когда разработчик начнет использовать ваш API, его собьет с толку то, что некоторые производные классы работают некорректно.

Взглянем на пример с Bird:

public abstract class Bird
{
public abstract void Walk();
public abstract void Fly();
public abstract void Eat();
}

public class Sparrow : Bird
{
public override void Eat()
{
/* Implementation details are hidden */
}

public override void Fly()
{
/* Implementation details are hidden */
}

public override void Walk()
{
/* Implementation details are hidden */
}
}

public class Penguine : Bird
{
public override void Eat()
{
/* Implementation details are hidden */
}

public override void Fly()
{
throw new NotImplementedException("Oops.. sorry, I can't fly..");
}

public override void Walk()
{
/* Implementation details are hidden */
}
}

Как вы видели, в классе Penguin мы расширяем метод Fly, который не реализован, потому что пингвины не могут летать. Но пользователь ожидает, что метод Fly будет работать для всех классов, производных от Bird. Однако класс Penguin приводит пользователя к неожиданному поведению, потому что это не абстрактный класс.

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

public abstract class Bird
{
public abstract void Walk();
public abstract void Eat();
}

public abstract class FlyableBird : Bird
{
public abstract void Fly();
}

public class Sparrow : FlyableBird
{
public override void Eat()
{
/* Implementation details are hidden */
}

public override void Fly()
{
/* Implementation details are hidden */
}

public override void Walk()
{
/* Implementation details are hidden */
}
}

public class Penguine : Bird
{
public override void Eat()
{
/* Implementation details are hidden */
}

public override void Walk()
{
/* Implementation details are hidden */
}
}

Теперь классы Penguin (Пингвин) and Sparrow (Воробей) соответствуют абстрактным классам.

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

Клиенты не должны зависеть от интерфейсов, которые они не используют.

Когда мы разрабатываем программное обеспечение, код постоянно растет, время от времени добавляются новые требования. В результате некоторые интерфейсы продолжают расширяться и становятся запутанными. Поэтому всегда рекомендуется делать так, чтобы интерфейсы были как можно меньше.

Взглянем на пример:

public interface IDevice
{
void Calculate();
void Call();
}

public class Calculator : IDevice
{
public void Calculate()
{
throw new NotImplementedException();
}

public void Call()
{
throw new NotImplementedException();
}
}

public class Phone : IDevice
{
public void Calculate()
{
throw new NotImplementedException();
}

public void Call()
{
throw new NotImplementedException();
}
}

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

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

Посмотрим на пример:

public interface ICalculatorDevice
{
void Calculate();
}

public interface ICallDevice
{
void Call();
}

public class Calculator : ICalculatorDevice
{
public void Calculate()
{
/* Calculate */
}
}

public class Phone : ICallDevice, ICalculatorDevice
{
public void Calculate()
{
/* Calculate */
}

public void Call()
{
/* Call */
}
}

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

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

2. “Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций”.

Рассмотрим сценарий, в котором есть класс, на который ссылаются сотни классов. Если однажды вы решите изменить ссылку, то придется пройти все сто классов и поменять их один за другим. Таким образом, вы потратите много времени на изменение кода. Кроме того, вы должны протестировать каждую часть системы, чтобы убедиться, что ничего не сломали. Это трудоемкая задача, которой нам, разработчикам, всегда следует избегать.

Взглянем на этот пример:

public interface IDatabase
{
void Read();
void Write();
void Delete();
void Update();
}

public class Mongo : IDatabase
{
public void Delete()
{
throw new NotImplementedException();
}

public void Read()
{
throw new NotImplementedException();
}

public void Update()
{
throw new NotImplementedException();
}

public void Write()
{
throw new NotImplementedException();
}
}

public class Sql : IDatabase
{
public void Delete()
{
throw new NotImplementedException();
}

public void Read()
{
throw new NotImplementedException();
}

public void Update()
{
throw new NotImplementedException();
}

public void Write()
{
throw new NotImplementedException();
}
}

Как видите, наше приложение поддерживает две базы данных, SQL и Mongo, а также у нас есть UserRepository:

public class UserRepository
{
private readonly Mongo _mongo;

public UserRepository(Mongo mongo)
{
_mongo = mongo;
}

public void AddUser()
{
_mongo.Write();
}
}

Репозиторий имеет прямую ссылку на базу данных. Это плохо по нескольким причинам: во-первых, репозиторий ориентирован на того, кто выполняет операцию, а не на производимые операции; во-вторых, если мы хотим изменить Mongo с помощью SQL, то должны пройти через все репозитории, в которых есть ссылка на Mongo, и изменить их один за другим. Наконец, мы вносим сложность в репозитории. Так что, по сути, UserRepository тесно связан с Mongo.

Чтобы применить принцип DIP, мы должны изменить UserRepository:

public class UserRepository
{
private readonly IDatabase _database;

public UserRepository(IDatabase database)
{
_database = database;
}

public void AddUser()
{
_database.Write();
}
}

Теперь UserRepository зависит от абстракции высокого уровня. Он ориентирован только на операцию записи, а не на того, кто ее выполняет.

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

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи George Machaidze: What are the SOLID principles?

Предыдущая статьяТонкости представления нижнего всплывающего экрана в iOS 15
Следующая статьяПо маршруту SQLite - Pandas: 7 основных операций