Найти в Дзене
Software development

Принципы SOLID, о которых должен знать каждый разработчик

Оглавление

Вот как расшифровывается акроним SOLID:

  • S: Single Responsibility Principle (Принцип единственной ответственности).
  • O: Open-Closed Principle (Принцип открытости-закрытости).
  • L: Liskov Substitution Principle (Принцип подстановки Барбары Лисков).
  • I: Interface Segregation Principle (Принцип разделения интерфейса).
  • D: Dependency Inversion Principle (Принцип инверсии зависимостей).

Рассмотри подробнее каждый из принципов.

Single Responsibility Principle (Принцип единственной ответственности)

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

Пример:

class Animal {
   public Animal(String name){ }
   public String getAnimalName() { }
   void saveAnimal(a: Animal) { }
}

В соответствии с принципом единственной ответственности класс должен решать лишь какую-то одну задачу. Он же решает две, занимаясь работой с хранилищем данных в методе saveAnimal и манипулируя свойствами объекта в конструкторе и в методе getAnimalName.

Для того чтобы привести вышеприведённый код в соответствие с принципом единственной ответственности, создадим ещё один класс, единственной задачей которого является работа с хранилищем, в частности — сохранение в нём объектов класса Animal:

class Animal {
   public Animal(String name) { }
   String getAnimalName() { }
}
class AnimalDB {
   Animal getAnimal( ) { }
   void saveAnimal(Animal a) { }
}

Open-Closed Principle (Принцип открытости-закрытости)

Программные сущности (классы, модули, функции) должны быть открыты для расширения, но не для модификации.

Пример:

Допустим у нас есть класс:

public class Logger {
public void Log(String text) {
// сохранить лог в файле
}
}

public class MessageSender {
private Logger logger;
public MessageSender() {
this.logger = new Logger();
}
public void SendMessage(String message) {
// отправка сообщения
logger.Log("Сообщение ");
}
}

И тоже самое происходит в других классах, которые используют Logger. Такая конструкция вполне жизнеспособна до тех, пока мы не решим записывать лог MessageSender в базу данных. Для этого нам надо создать класс, который будет записывать все логи не в текстовый файл, а в базу данных:

public class DbLogger {
public void Log(String text) {
// сохранить лог в БД
}
}

Теперь мы должны изменить класс MessageSender из-за изменившегося бизнес-требования:

public class MessageSender {
private DbLogger logger;
public MessageSender() {
this.logger =
new DbLogger();
}
public void SendMessage(String message) {
// отправка сообщения
logger.Log("Сообщение ");
}
}

Но ведь по принципу единственности ответственности не MessageSender отвечает за логирование, почему изменения дошли и до него? Потому что нарушен наш принцип открытости/закрытости. MessageSender не закрыт для модификации. Нам пришлось его изменить, чтобы поменять способ хранения его логов.

В данном случае защитить MessageSender поможет выделение абстракции. Пусть MessageSender зависит от интерфейса Logger:

public interface Logger {
void Log(String text);
}

public class fileLogger implements Logger {
@Override
public void Log(String text) {
// сохранить лог в файле
}
}
public class DbLogger
implements Logger {
@Override
public void Log(String text) {
// сохранить лог в БД
}
}

public class MessageSender {
private Logger logger; //
интерфейс
public MessageSender(
Logger logger) {
this.logger = logger;
}
public void SendMessage(String message) {
// отправка сообщения
logger.Log("Сообщение ");
}
}

Теперь при создании экземпляра MessageSender мы можем передать ему любую реализацию интерфейса Logger.

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

Изначальное определение данного принципа, которое было дано Барбарой Лисков в 1988 году, выглядело следующим образом:

"Если для каждого объекта o1 типа S существует объект o2 типа T, такой, что для любой программы P, определенной в терминах T, поведение P не изменяется при замене o2 на o1, то S является подтипом T."

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

В общем случае данный принцип можно сформулировать так:

Должна быть возможность вместо базового типа подставить любой его подтип.

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

Моделируя систему, мы описываем поведение её компонентов, отношения их друг с другом, а не иерархию. Иерархия — удобный инструмент для моделирования, но иногда она приводит к неправильному описанию поведения.

Классический пример

Представим, что есть класс Rectangle, который описывает прямоугольник:

public class Rectangle {
private double width;
private double height;
Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
public void setWidth(double width) {
this.width = width;
}
public void setHeight(double height) {
this.height = height;
}
public double getAreaOf(){
return this.width * this.height;
}
}

Квадрат — тоже прямоугольник, мы можем использовать наследование, чтобы описать его:

public Square extends Rectangle {
private double width;
private double height;
Rectangle(double size) {
super(size, size)
}
public void setWidth(double width) {
this.width = width;
this.height = width;
}
public void setHeight(double height) {
this.width = width;
this.height = width;
}
}

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

Предположим, при написании класса Rectangle мы написали простой тест:

public class RectangleTest {
@Test
public void areaTest() {
Rectangle rectangle = new Rectangle();
rectangle.setHeight(5);
rectangle.setWidth(7);
double result = rectangle.perimeter();
assertEquals(35, result);
}
}

Очевидно, что при введении класса Square мы должны написать тесты и для него. Так как класс Square находится в соотношении ISA с классом Rectangle, будет логично предположить, что тесты на Rectangle должны быть справедливы и для Square. А чтобы не дублировать код, мы напишем тесты следующим образом:

public class RectangleTest {
@Test
public void areaTest() {
Rectangle rectangle = initRectangle();
rectangle.setHeight(5);
rectangle.setWidth(7);
int result = rectangle.perimeter();
assertEquals(35, result);
}

protected Rectangle initRectangle() {
return new Rectangle();
}
}

public class SquareTest extends RectangleTest {
@Override
protected Rectangle initRectangle() {
return new Square();
}
}

Очевидно, что тест SquareTest.areaTest провалится, так как результат вызова функции perimeter будет не 24, как написано в тесте, а 28. И тут мы должны задать себе очень важный вопрос: правильно ли написан тест, в котором предполагается, что при изменении длины прямоугольника его ширина не изменяется? Очевидно — да. Наш тест наглядно демонстрирует код, который корректно работает с объектом класса Rectangle, но ломается при работе с объектами класса Square. То есть не для каждого объекта типа Square существует объект типа Rectangle такой, что определённая в терминах Rectangle программа (в данном случае тест) не меняется, если вместо объекта типа Rectangle подставить объект типа Square. Следовательно Square — не подтип Rectangle, следовательно LSP нарушается.

Квадрат — это, конечно, прямоугольник, но вот объект класса Square — это определенно не объект класса Rectangle. Дело в том, что поведение объекта класса Square не согласовано с поведением объекта класса Rectangle. Ведь квадрат ведет себя иначе, чем прямоугольник.

Принцип подстановки Лисков наглядно показывает, что в ООП отношение ISA относится именно к поведению. Причем не к внутреннему, а к внешнему поведению, от которого зависят клиенты. Мы всегда должны помнить об этом, когда говорим про моделирование объектов реального мира в парадигме ООП.

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

Ну а что тогда делать?

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

Например, и у квадрата, и у прямоугольника есть площадь и, вероятно, в некоторых частях программы нам нужны её значения независимо от того, с какой фигурой мы работаем. В этом случае легко вынести этот метод в интерфейс Shape и прописать его имплементации в классах Square и Rectangle.

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

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

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

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

Рассмотрим интерфейс Shape:

interface Shape {
drawCircle();
drawSquare();
drawRectangle();
}

В результате все классы, реализующие этот интерфейс, должны реализовать все методы:

class Circle implements Shape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}
class Square implements Shape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}
class Rectangle implements Shape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}

А если нам понадобится добавить еще один метод в интерфейс?

Выход из ситуации:

interface Shape {
draw();
}
interface Circle {
drawCircle();
}
interface Square {
drawSquare();
}
interface Rectangle {
drawRectangle();
}
interface Triangle {
drawTriangle();
}
class CircleImpl implements Circle {
drawCircle() {
//...
}
}
class SquareImpl implements Square {
drawSquare() {
//...
}
}
class RectangleImpl implements Rectangle {
drawRectangle() {
//...
}
}
class CustomShapeImpl implements Shape {
draw() {
//...
}
}

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

Объектом зависимости должна быть абстракция, а не что-то конкретное.

  1. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
  2. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Классическое применение этого принципа — Spring framework. В рамках Spring framework все модули выполнены в виде отдельных компонентов, которые могут работать вместе. Они настолько автономны, что могут быть быть с такой же легкостью задействованы в других программных модулях помимо Spring framework.

Итоги

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