Принципы SOLID
Часто встечающиюся вопросы о Java
SOLID — 5 принципов ООП, делающих код гибким и поддерживаемым.
① Принцип единственной ответственности (SRP)
Суть: Класс должен решать только одну задачу.
Плохо:
1
2
3
4
class User {
void saveToDatabase() { ... } // Работа с БД
void sendEmail() { ... } // Отправка почты
}
Хорошо:
1
2
3
4
5
6
7
class User { ... } // Хранение данных
class UserRepository { // Работа с БД
void save(User user) { ... }
}
class EmailService { // Отправка почты
void sendEmail(User user) { ... }
}
② Принцип открытости/закрытости (OCP)
Суть: Классы должны быть открыты для расширения, но закрыты для изменений.
Плохо:
1
2
3
4
5
6
class Discount {
double applyDiscount(String type) {
if (type.equals("VIP")) return 0.2;
else return 0.1; // При добавлении новой скидки нужно менять код
}
}
Хорошо:
1
2
3
4
5
6
interface Discount {
double apply();
}
class VipDiscount implements Discount { ... }
class RegularDiscount implements Discount { ... }
③ Принцип подстановки Лисков (LSP)
Суть: Подклассы должны заменять родительские классы без изменения поведения.
Нарушение LSP:
1
2
3
4
5
6
7
8
9
10
11
class Rectangle {
int width, height;
void setWidth(int w) { width = w; }
void setHeight(int h) { height = h; }
}
class Square extends Rectangle { // Квадрат != Прямоугольник!
void setWidth(int w) {
width = height = w; // Нарушает логику Rectangle
}
}
Решение: Не наследовать Square от Rectangle.
④ Принцип разделения интерфейсов (ISP)
Суть: Много специализированных интерфейсов лучше одного общего.
Плохо:
1
2
3
4
interface Worker {
void work();
void eat(); // Не все работники могут есть (например, роботы)
}
Хорошо:
1
2
interface Workable { void work(); }
interface Eatable { void eat(); }
⑤ Принцип инверсии зависимостей (DIP)
Суть: Зависимости должны строиться на абстракциях, а не на конкретных классах.
Плохо:
1
2
3
4
5
6
7
8
class LightBulb {
void turnOn() { ... }
}
class Switch {
private LightBulb bulb; // Жёсткая зависимость
void operate() { bulb.turnOn(); }
}
Хорошо:
1
2
3
4
5
6
7
8
9
interface Switchable { void turnOn(); }
class LightBulb implements Switchable { ... }
class Fan implements Switchable { ... }
class Switch {
private Switchable device; // Зависимость от абстракции
void operate() { device.turnOn(); }
}
Итог
- 4 принципа ООП — база для проектирования классов.
- SOLID — делает код гибким и масштабируемым.
- Главное:
- Инкапсуляция защищает данные.
- Наследование и полиморфизм уменьшают дублирование.
- Абстракция упрощает сложные системы.
- SOLID предотвращает “спагетти-код”.
Эти принципы — основа Java и многих современных фреймворков (Spring, Hibernate).
Пример нарушения принципа подстановки Лисков (LSP)
Принцип Лисков (LSP) гласит: «Подклассы должны быть заменяемы своими базовыми классами без изменения корректности программы».
Нарушение LSP возникает, когда подкласс меняет поведение родительского класса так, что это приводит к ошибкам при подстановке.
Классический пример нарушения: «Прямоугольник и Квадрат»
1. Нарушающая реализация
Допустим, у нас есть класс Rectangle (прямоугольник), а Square (квадрат) наследуется от него. Квадрат — это частный случай прямоугольника, но их поведение при изменении сторон разное:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int getArea() {
return width * height;
}
}
class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width; // Нарушение LSP: меняет и высоту!
}
@Override
public void setHeight(int height) {
this.height = height;
this.width = height; // Нарушение LSP: меняет и ширину!
}
}
2. Почему это нарушение LSP?
Если код работает с Rectangle, он ожидает, что ширина и высота меняются независимо. Но Square ломает эту логику:
1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
Rectangle rect = new Square(); // Подстановка Square вместо Rectangle
rect.setWidth(5);
rect.setHeight(10);
// Ожидаем площадь = 5 * 10 = 50, но получаем 10 * 10 = 100!
System.out.println(rect.getArea()); // 100 (ошибка)
}
}
Проблема:
Класс Square нельзя использовать вместо Rectangle, так как он изменяет поведение методов setWidth и setHeight.
Ещё пример: «Птица и Пингвин»
1. Нарушающая реализация
1
2
3
4
5
6
7
8
9
10
11
12
class Bird {
public void fly() {
System.out.println("Flying...");
}
}
class Penguin extends Bird { // Пингвин — птица, но не летает!
@Override
public void fly() {
throw new UnsupportedOperationException("Penguins can't fly!"); // Нарушение LSP
}
}
2. Почему это нарушение LSP?
Код, работающий с Bird, ожидает, что все птицы умеют летать:
1
2
3
4
5
6
7
8
9
10
public class Main {
public static void makeBirdFly(Bird bird) {
bird.fly(); // Упадёт для Penguin!
}
public static void main(String[] args) {
makeBirdFly(new Bird()); // OK
makeBirdFly(new Penguin()); // Ошибка!
}
}
Проблема: Penguin не может быть заменой Bird, так как ломает контракт метода fly().
Как исправить нарушение LSP?
1. Для прямоугольника и квадрата
- Не наследовать Square от Rectangle.
- Использовать композицию или интерфейсы: ```java interface Shape { int getArea(); }
class Rectangle implements Shape { // Реализация как выше }
class Square implements Shape { private int size;
1
2
3
4
5
6
7
8
public void setSize(int size) {
this.size = size;
}
@Override
public int getArea() {
return size * size;
} } ``` **2. Для птиц и пингвинов**
Разделить логику на интерфейсы:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface Bird {
void eat();
}
interface FlyingBird extends Bird {
void fly();
}
class Sparrow implements FlyingBird { // Летает
public void fly() { /* ... */ }
public void eat() { /* ... */ }
}
class Penguin implements Bird { // Не летает
public void eat() { /* ... */ }
}
Вывод
- LSP нарушается, если подкласс:
- Меняет поведение базового класса (как Square с setWidth).
- Не поддерживает все методы родителя (как Penguin с fly).
- Решение:
- Избегать наследования, если подкласс не может выполнить контракт родителя.
- Использовать интерфейсы и композицию.
Главное:
«Если кажется, что подкласс — это частный случай родителя, но их поведение противоречит друг другу, наследование — плохой выбор».
Зачем нужны принципы Single Responsibility (SRP) и Open/Closed (OCP)?
Зачем нужен?
- Упрощает понимание кода. Класс, отвечающий за одно действие, легче читать и тестировать.
- Уменьшает влияние изменений. Если изменится одна функциональность, не придётся править код в других местах.
- Повышает переиспользуемость. Классы с одной задачей проще комбинировать.
Пример нарушения SRP
Плохо: класс User
отвечает и за хранение данных, и за работу с базой данных, и за отправку email.
1
2
3
4
5
6
7
8
class User {
private String name;
private String email;
// Хранение данных + логика БД + отправка email = 3 ответственности!
public void saveToDatabase() { /* ... */ }
public void sendEmail(String message) { /* ... */ }
}
Как исправить?
Разделить ответственности:
1
2
3
4
5
6
7
8
9
10
11
12
13
class User {
private String name;
private String email;
// Только данные, без логики.
}
class UserRepository {
public void save(User user) { /* Работа с БД */ }
}
class EmailService {
public void sendEmail(User user, String message) { /* Отправка email */ }
}
Итог:
User
— хранит данные.UserRepository
— работает с БД.EmailService
— отправляет письма.
Теперь изменения в логике отправки email не затронут класс User.
Принцип открытости/закрытости (Open/Closed Principle, OCP)
Зачем нужен?
- Защищает от ошибок. Не нужно менять уже работающий код при добавлении новой функциональности.
- Упрощает масштабирование. Новые фичи добавляются через расширение (наследование, интерфейсы), а не правки.
- Снижает риски. Изменения в старом коде могут сломать существующую логику.
Пример нарушения OCP
Плохо: класс AreaCalculator требует правок при добавлении новой фигуры.
1
2
3
4
5
6
7
8
9
10
class AreaCalculator {
public double calculate(Object shape) {
if (shape instanceof Circle) {
return ((Circle) shape).radius * ((Circle) shape).radius * Math.PI;
} else if (shape instanceof Square) {
return ((Square) shape).side * ((Square) shape).side;
}
// Добавим новый if для Triangle? Придётся менять класс!
}
}
Как исправить?
Использовать абстракции (интерфейсы или абстрактные классы):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface Shape {
double area();
}
class Circle implements Shape {
private double radius;
public double area() { return radius * radius * Math.PI; }
}
class Square implements Shape {
private double side;
public double area() { return side * side; }
}
class AreaCalculator {
public double calculate(Shape shape) {
return shape.area(); // Не требует изменений для новых фигур!
}
}
Теперь для добавления Triangle достаточно реализовать интерфейс Shape:
1
2
3
4
class Triangle implements Shape {
private double base, height;
public double area() { return 0.5 * base * height; }
}
Итог:
- AreaCalculator не нужно изменять — он закрыт для модификаций.
- Новая фигура (Triangle) добавляется без правок существующего кода — система открыта для расширений.
Когда можно нарушить Dependency Inversion осознанно?
Простые проекты или прототипы
Когда:
- Вы пишете небольшой скрипт, утилиту или MVP (минимально жизнеспособный продукт).
- Нет планов масштабировать код в будущем.
Почему:
- Внедрение абстракций (интерфейсов) увеличивает сложность без реальной пользы.
- Нарушение DIP ускоряет разработку.
Пример:
1
2
3
4
5
6
7
8
// Нарушение DIP: прямой вызов конкретного класса
class PaymentProcessor {
private PayPalGateway gateway = new PayPalGateway(); // Зависимость от конкретного класса
public void processPayment(double amount) {
gateway.charge(amount);
}
}
Альтернатива по DIP:
1
2
3
4
5
6
7
8
9
10
11
interface PaymentGateway {
void charge(double amount);
}
class PaymentProcessor {
private PaymentGateway gateway; // Зависимость от абстракции
public PaymentProcessor(PaymentGateway gateway) {
this.gateway = gateway;
}
}
Решение: В прототипе первый вариант допустим, если переделка займёт больше времени, чем жизнь кода.
Высокопроизводительные системы
Когда:
- Критична производительность (например, high-frequency trading, game development).
- Внедрение зависимостей через интерфейсы добавляет накладные расходы (виртуальные вызовы методов).
Почему:
- Прямые вызовы методов работают быстрее, чем вызовы через интерфейсы.
- В жёстких real-time условиях даже микрооптимизации важны.
Пример:
1
2
3
4
5
6
7
8
// Нарушение DIP: прямое использование класса
class PhysicsEngine {
private FastCollisionDetector detector = new FastCollisionDetector(); // Нет абстракции!
public void update() {
detector.checkCollisions(); // Прямой вызов для скорости
}
}
Альтернатива по DIP:
1
2
3
4
5
6
7
interface CollisionDetector {
void checkCollisions();
}
class PhysicsEngine {
private CollisionDetector detector; // Медленнее из-за виртуального вызова
}
Решение: Если производительность важнее гибкости, нарушение DIP оправдано.
Стабильные зависимости
Когда:
- Зависимость — это стабильная библиотека или системный класс (например, String, Math).
- Нет риска, что реализация изменится или потребуется замена.
Почему:
- Абстрагирование от неизменяемых компонентов — избыточно.
Пример:
1
2
3
4
5
6
7
8
// Нарушение DIP: использование стандартного класса без интерфейса
class Logger {
private FileWriter fileWriter = new FileWriter("logs.txt"); // Стабильная зависимость
public void log(String message) {
fileWriter.write(message);
}
}
Альтернатива по DIP:
1
2
3
4
5
6
7
interface IWriter {
void write(String text);
}
class Logger {
private IWriter writer; // Избыточная абстракция для FileWriter
}
Решение: Если FileWriter никогда не будет заменён, интерфейс не нужен.
Тесты и моки
Когда:
-Класс используется только в тестах как заглушка (mock).
- Нет смысла создавать интерфейс для одноразового использования.
Почему:
- Интерфейсы ради одного теста — overengineering.
Пример:
1
2
3
4
5
6
7
8
9
10
// Нарушение DIP: мок без интерфейса
class MockPaymentGateway {
public void charge(double amount) {
// Пустая заглушка для теста
}
}
class OrderServiceTest {
private OrderService service = new OrderService(new MockPaymentGateway()); // Нарушение DIP
}
Альтернатива по DIP:
1
2
3
4
5
interface IPaymentGateway {
void charge(double amount);
}
class MockPaymentGateway implements IPaymentGateway { ... } // "Правильный" мок
Решение: Для одноразовых тестов можно нарушить DIP, если это экономит время.
Фреймворки и библиотеки
Когда:
- Вы работаете с библиотекой, которая не поддерживает DI (например, утилитные классы Arrays, Collections).
- Или фреймворк уже навязывает свою зависимость (например, Android Context).
Почему:
- Абстрагирование от системных вещей усложняет код без выигрыша.
Пример:
1
2
3
4
5
6
// Нарушение DIP: использование System.currentTimeMillis()
class Cache {
public boolean isExpired(long timestamp) {
return System.currentTimeMillis() > timestamp; // Прямая зависимость
}
}
Альтернатива по DIP:
1
2
3
4
5
6
7
interface TimeProvider {
long currentTime();
}
class Cache {
private TimeProvider timeProvider; // Абстракция для времени (избыточно?)
}
Решение: Для стандартных вещей вроде System нарушение DIP допустимо.
Как нарушать DIP правильно?
- Изолируйте нарушение. Пусть оно остаётся в одном месте, а не расползается по коду.
- Документируйте решение. Например, комментарий:
1 2
// Нарушаем DIP сознательно: PayPalGateway стабилен и не будет заменяться. private PayPalGateway gateway = new PayPalGateway();
- Пишите тесты. Чтобы при изменении кода последствия были предсказуемы.
Interface Segregation (ISP), суть и пример
Суть:
«Клиенты не должны зависеть от методов, которые они не используют».
Другими словами:
- Интерфейсы должны быть узкоспециализированными, а не «толстыми».
- Лучше много маленьких интерфейсов, чем один большой «божественный» интерфейс.
Проблема «толстого» интерфейса
Представьте интерфейс Worker, который заставляет все классы реализовывать ненужные методы:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Плохо: "божественный" интерфейс
interface Worker {
void work();
void eat();
void sleep();
void code(); // А если работник не программист?
}
class OfficeWorker implements Worker {
public void work() { /* ... */ }
public void eat() { /* ... */ }
public void sleep() { /* ... */ }
public void code() { throw new UnsupportedOperationException(); } // Не нужен!
}
class Programmer implements Worker {
public void work() { /* ... */ }
public void eat() { /* ... */ }
public void sleep() { /* ... */ }
public void code() { /* ... */ } // Нужен только здесь
}
Проблемы:
- Классы вынуждены реализовывать ненужные методы (например, OfficeWorker.code()).
- Изменение интерфейса (добавление нового метода) затрагивает все классы, даже те, которым это не нужно.
Решение: Разделение интерфейсов
Разобьём Worker на маленькие интерфейсы:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Хорошо: разделённые интерфейсы
interface Workable {
void work();
}
interface Eatable {
void eat();
}
interface Sleepable {
void sleep();
}
interface Codable {
void code();
}
// Теперь классы реализуют только нужное
class OfficeWorker implements Workable, Eatable, Sleepable {
public void work() { /* ... */ }
public void eat() { /* ... */ }
public void sleep() { /* ... */ }
}
class Programmer implements Workable, Eatable, Sleepable, Codable {
public void work() { /* ... */ }
public void eat() { /* ... */ }
public void sleep() { /* ... */ }
public void code() { /* ... */ } // Только для программиста
}
class Robot implements Workable { // Роботу не нужно есть или спать
public void work() { /* ... */ }
}
Преимущества:
- Нет «пустых» методов — классы реализуют только то, что нужно.
- Гибкость — можно комбинировать интерфейсы (например, Robot только Workable).
- Упрощение тестирования — мокируются только используемые методы.
Пример из реального мира
- Интерфейсы в Java Collections
- List, Set, Queue — отдельные интерфейсы, а не один «божественный» Collection с сотней методов.
- Классы (ArrayList, HashSet) реализуют только нужные интерфейсы.
- Интерфейсы в Spring
- CrudRepository разделён на:
- PagingAndSortingRepository,
- JpaRepository и т.д. - Такой подход позволяет не перегружать интерфейсы лишними методами.
Когда нарушать ISP?
Иногда можно сознательно объединить интерфейсы, если:
- Методы всегда используются вместе (например, Readable + Writable → ReadWritable).
- Интерфейс — часть стабильного API, который не будет меняться.
Но в 95% случаев лучше следовать ISP.
Вывод
- ISP помогает избежать:
- «Пустых» методов в классах.
- Сложных зависимостей.
- Как применять:
- Дробите большие интерфейсы на маленькие.
- Классы должны реализовывать только то, что они реально используют.
Главное правило:
«Интерфейс должен решать одну задачу, а не пытаться покрыть все возможные случаи».