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
. В исходной реализации у него сразу несколько причин для изменения:
- Изменение формата идентификатора заказчика,
- Изменение формата наименования заказчика,
- Изменение способа сохранения данных (например, переход с MySQL на NoSQL).
Чем больше причин для изменений, тем выше вероятность ошибок при изменении, сложнее тестирование и дороже сопровождение.
После рефакторинга класс Customer
отвечает только за изменения профиля заказчика (например, изменение имени или идентификатора). Поэтому у него только одна причина для изменения - изменения профиля. Класс CustomerRepository
отвечает только за изменения, связанные с хранением данных и тоже имеет только одну причину для изменения - изменение способа хранения данных.
⚠️ Отсюда правило — мы всегда должны стремиться к тому, чтобы у каждого компонента была только одна причина для изменения. Это способствует соблюдению SRP.
Статьи серии
- SOLID: серия материалов о принципах проектирования
- OCP — Open/Closed Principle
- LSP — Liskov Substitution Principle (в разработке)
- ISP — Interface Segregation Principle (в разработке)
- DIP — Dependency Inversion Principle (в разработке)