Post

SOLID: SRP — Принцип единственной ответственности

SOLID: SRP — Принцип единственной ответственности

Принцип единственной ответственности. Он гласит:

“Каждый программный компонент должен иметь одну и только одну ответственность”

Проще говоря, если класс или функция умеют делать слишком многое — это повод задуматься. Чем меньше и четче зона ответственности компонента, тем легче его поддерживать, тестировать и изменять.

Примеры SRP из реальной жизни и ПО

Рассмотрим некоторые примеры:

  • Командная строка Linux: Утилиты вроде cat, grep, awk, sort делают одну задачу и делают ее хорошо. Они не пытаются делать все сразу, зато отлично комбинируются.
  • Микросервисы: Каждый микросервис отвечает за одну бизнес-функцию (например, регистрация пользователя или отправка email). Это масштабированный пример SRP на уровне архитектуры.
  • REST API эндпоинты: В хороших REST API каждый эндпоинт выполняет одну конкретную операцию: получить список товаров, создать заказ, обновить профиль пользователя.
  • Класс JButton в UI: Кнопка в графическом интерфейсе пользователя обычно отвечает только за генерацию события при нажатии. Реакция на событие реализуется отдельными обработчиками (слушателями). Она не должна, например, самостоятельно сохранять данные в БД или выполнять бизнес-логику.
  • Принцип единой ответственности в бизнесе: Кассир продает товар, бухгалтер ведет учет, уборщица убирает помещение. Никто не пытается делать все сразу.

И так далее.

Примеры нарушения SRP: мультитулзы “N в одном”

К примеру, такой нож, который умеет: резать, открывать бутылки, работать как отвертка или штопор.

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

Аналогичный пример – сложная бытовая техника. Представьте сложную бытовую технику “все в одном” — например, стиральную машинку с сушкой и встроенной кофеваркой. Если ломается сушилка или кофеварка, вся система может потребовать сложного ремонта, поломка одного компонента нарушает работу других.

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

Понятия когезии и связанности

Понятия когезии и связанности были предложены в конце 1960-х — начале 1970-х Larry Constantine и Edward Yourdon как основные характеристики качества модульного проектирования (Structured Design). Эти идеи стали фундаментом для современных принципов проектирования, включая SOLID.

  • Когезия (cohesion) — это мера того, насколько хорошо части компонента связаны общей целью. Высокая когезия говорит о том, что все элементы компонента работают вместе для выполнения одной задачи. Если когезия низкая, части компонента выполняют разные, слабо связанные функции.
  • Связанность (coupling) — показывает, насколько один программный компонент зависит от других. Чем выше связанность, тем сложнее менять или тестировать компоненты по отдельности, т.е. изменение одного компонента требует изменений в других. Низкая связанность делает компоненты более независимыми и гибкими, позволяет менять их независимо друг от друга.

⚠️ Высокая когезия + слабая связанность = хороший дизайн.

Рассмотрим примеры.

Примеры из жизни

Пример 1. Кухня

Когезия: На хорошо организованной кухне посуда хранится в одном шкафу, специи — в другом, продукты — в холодильнике. Все элементы каждой группы объединены общей функцией, когезия высокая. Если же все свалено на одну полку — когезия низкая.

Связанность: Шкафы и холодильник не зависят друг от друга - это низкая связанность. Можно заменить холодильник, не меняя шкафы.

Пример 2. Книжная библиотека

Когезия: В библиотеке книги отсортированны по темам или жанрам — это высокая когезия. Если книги перемешаны без логики, когезия низкая.

Связанность: Отделы библиотеки работают независимо и изменение каталога одного отдела не влияет на другие, это низкая связанность.

Пример 3. Автомобиль

Когезия: Система торможения отвечает только за торможение — высокая когезия. Если бы в тормозную систему встроили кондиционер или аудио — когезия была бы низкой.

Связанность: Система торможения работает независимо от, например, мультимедийной системы - это низкая связанность. Если тормоза модернизируют, менять аудиосистему не нужно - взаимозависимости нет.

⚠️ Важно: когда мы говорим о когезии и связанности, речь идет не только о классах или функциях, но и о системе в целом. Эти характеристики применимы ко всем уровням проектирования — от методов и классов до модулей и даже микросервисов.

Примеры на Java

Ниже приведены примеры, где мы достигаем SRP с помощью повышения когезии и снижения связанности в коде на Java.

Повышение когезии (Circle)

Рассмотрим класс Circle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Circle {
  
  private double radius;
  
      public double calculateArea() {
          return Math.PI * radius * radius;
      }
  
      public double calculateCircumference() {
          return 2 * Math.PI * radius;
      }
  
      public void render() {
          // код для отрисовки круга на UI
      }
  
      public void resize(double factor) {
          radius *= factor;
      }
}

Методы calculateArea() и calculateCircumference() объединены общей функцией, т.к. вычисляют какое-то геометрическое значение. Когезия между этими двумя методами (в системе этих двух методов) высокая.

Методы render() и resize() также обладают высокой когезией (хотя и меньшей по сравнению с методами расчета), так как оба связаны с рендерингом и управлением отображением в UI.

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

Итак, геометрические расчеты будут выполняться в классе CircleGeometry:

1
2
3
4
5
6
7
8
9
10
11
12
public class CircleGeometry {

      private double radius;
      
      public double calculateArea() { 
          return Math.PI * radius * radius; 
      }
      
      public double calculateCircumference() { 
          return 2 * Math.PI * radius; 
      }
}

Рендеринг будет выполнять класс CircleRenderer:

1
2
3
4
5
public class CircleRenderer {
      public void render(CircleGeometry geometry) {
          // код для отрисовки круга на UI
      }
}

Размер окружности будет изменять класс CircleResizer:

1
2
3
4
5
public class CircleResizer {
      public void resize(CircleGeometry geometry, double factor) {
          // изменение размера круга
      }
}

Теперь:

  • CircleGeometry отвечает только за расчеты.
  • CircleRenderer отвечает только за визуализацию.
  • CircleResizer отвечает только за изменение размера.

Когезия в каждом классе высокая. Связанность между классами минимальна.

При этом когезия может повышаться и далее, если есть методы. Например – вычисление длинн, площадей, углов и т.д.

⚠️ Отсюда правило – мы всегда должны повышать уровень когезии в компоненте. Повышение когезии приводит к достижению SRP.

Снижение связанности (Customer)

Рассмотрим другой пример.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Customer {

    private String customerId;
    private String name;

    public void save() {
        try (Connection connection = DriverManager.getConnection(
                "jdbc:mysql://localhost:3306/mydb", "root", "password")) {
            Statement stmt = connection.createStatement();
            stmt.execute("INSERT INTO customers (id, name) VALUES ('" 
                    + customerId + "', '" + name + "')");
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

В текущей реализации класс Customer, являющийся по сути моделью, жестко связан со слоем базы данных через метод save(). Это создает проблему: если система переедет, например, с MySQL на NoSQL, придется переписывать сам класс Customer.

Модель заказчика должна описывать только данные — такие как идентификатор, курс или группа. Она не должна включать низкоуровневую логику сохранения в базе данных.

В данной реализации Customer выполняет сразу две задачи: хранит данные заказчика и умеет сохранять себя в БД.

Поэтому также разделим этот класс, чтобы получить несколько слабо связанных систем (классов).

В классе Customer оставляем только данные. Ответственность за хранение убираем:

1
2
3
4
5
6
7
public class Customer {

    private String customerId;
    private String name;

    // getters & setters
}

После рефакторинга появляется CustomerRepository, который отделяет работу с БД от данных заказчика:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CustomerRepository {

    public void save(Customer customer) {
        try (Connection connection = DriverManager.getConnection(
                "jdbc:mysql://localhost:3306/mydb", "root", "password")) {
            Statement stmt = connection.createStatement();
            stmt.execute("INSERT INTO customers (id, name) VALUES ('" 
                    + customer.getCustomerId() + "', '" + customer.getName() + "')");
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

Теперь:

  • Customer отвечает только за хранение данных заказчика.
  • CustomerRepository отвечает только за работу с базой данных.

Связанность между слоями снижена. Customer больше не зависит от способа хранения данных. Если завтра система переедет с MySQL на MongoDB или файл, код Customer останется неизменным.

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

⚠️ Отсюда также правило – мы всегда должны понижать уровень связанности между компонентами. Снижение связанности способствует соблюдению SRP.

Таким образом, принцип единственной ответственности (SRP) может достигаться двумя путями:

  • За счет повышения когезии внутри компонентов,
  • за счет снижения связанности между ними.

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

Кроме этих, существует еще один подход.

Минимизация причин для изменений (Customer)

Принцип SRP можно переформулировать:

“Каждый компонент должен иметь одну и только одну причину для изменения”

Это расширяет понимание SRP: важно не только разделять ответственности, но и минимизировать количество причин, по которым компонент может потребовать изменений.

Например, вернёмся к нашему классу Customer. В исходной реализации у него сразу несколько причин для изменения:

  1. Изменение формата идентификатора заказчика,
  2. Изменение формата наименования заказчика,
  3. Изменение способа сохранения данных (например, переход с MySQL на NoSQL).

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

После рефакторинга класс Customer отвечает только за изменения профиля заказчика (например, изменение имени или идентификатора). Поэтому у него только одна причина для изменения - изменения профиля. Класс CustomerRepository отвечает только за изменения, связанные с хранением данных и тоже имеет только одну причину для изменения - изменение способа хранения данных.

⚠️ Отсюда правило — мы всегда должны стремиться к тому, чтобы у каждого компонента была только одна причина для изменения. Это способствует соблюдению SRP.

Статьи серии

This post is licensed under CC BY 4.0 by the author.