Post

Принципы SOLID

Часто встечающиюся вопросы о Java

Принципы SOLID

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. Документируйте решение. Например, комментарий:
    1
    2
    
     // Нарушаем DIP сознательно: PayPalGateway стабилен и не будет заменяться.  
      private PayPalGateway gateway = new PayPalGateway();  
    
  3. Пишите тесты. Чтобы при изменении кода последствия были предсказуемы.

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).
  • Упрощение тестирования — мокируются только используемые методы.

Пример из реального мира

  1. Интерфейсы в Java Collections
    • List, Set, Queue — отдельные интерфейсы, а не один «божественный» Collection с сотней методов.
    • Классы (ArrayList, HashSet) реализуют только нужные интерфейсы.
  2. Интерфейсы в Spring
    • CrudRepository разделён на:
    • PagingAndSortingRepository,
    • JpaRepository и т.д. - Такой подход позволяет не перегружать интерфейсы лишними методами.

Когда нарушать ISP?

Иногда можно сознательно объединить интерфейсы, если:

  • Методы всегда используются вместе (например, Readable + Writable → ReadWritable).
  • Интерфейс — часть стабильного API, который не будет меняться.

Но в 95% случаев лучше следовать ISP.


Вывод

  • ISP помогает избежать:
    • «Пустых» методов в классах.
    • Сложных зависимостей.
  • Как применять:
    • Дробите большие интерфейсы на маленькие.
    • Классы должны реализовывать только то, что они реально используют.

Главное правило:

«Интерфейс должен решать одну задачу, а не пытаться покрыть все возможные случаи».


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