☕ Java

Паттерны проектирования в Java: подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты строишь дом. Ты можешь каждый раз изобретать велосипед: придумывать, как соединять брёвна, как делать крышу, как проводить электричество. А можешь воспользоваться готовыми чертежами и решениями, которые уже проверили тысячи строителей до тебя. Паттерны проектирования — это и есть такие готовые «чертежи» для программистов.

В реальной жизни мы постоянно используем паттерны, даже не замечая этого. Например:

  • Очередь в кафе — это паттерн «Очередь» (Queue). Люди приходят, становятся в конец, и обслуживаются по порядку.
  • Пульт от телевизора — это паттерн «Команда». Ты нажимаешь кнопку, и телевизор выполняет действие, не зная, как именно устроена кнопка.
  • Строитель Lego — это паттерн «Строитель». Ты берёшь готовые детали и собираешь из них сложную конструкцию.

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

Важно: Паттерны — это не готовый код, а концепция. Ты можешь реализовать один и тот же паттерн по-разному, но структура останется той же.

Паттерны делятся на три большие группы:

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

Теория: как это работает изнутри

Давай разберёмся, что происходит «под капотом» у паттернов. Каждый паттерн — это набор классов и интерфейсов, которые взаимодействуют определённым образом. В Java это выглядит как обычные классы, но с чёткой структурой.

Основные элементы любого паттерна:

  • Контекст — ситуация, в которой применяется паттерн
  • Проблема — что именно мы хотим решить
  • Решение — как паттерн предлагает это сделать

Например, паттерн «Одиночка» (Singleton) решает проблему: «Как сделать так, чтобы у класса был только один экземпляр?». Решение: спрятать конструктор (сделать его приватным) и создать статический метод, который возвращает единственный объект.

Паттерн «Наблюдатель» (Observer) решает проблему: «Как одним объектам узнавать об изменениях в других объектах?». Решение: объект-издатель хранит список подписчиков и уведомляет их о событиях.

Важно понимать: паттерны не привязаны к конкретному языку. Но в Java есть свои особенности. Например, в Java нет функций как объектов первого класса (как в Python), поэтому для реализации некоторых паттернов приходится использовать интерфейсы и анонимные классы.

Синтаксис в Java: все формы и варианты

В Java паттерны реализуются с помощью стандартных средств языка: классов, интерфейсов, абстрактных классов, наследования и полиморфизма. Рассмотрим основные синтаксические конструкции, которые используются в паттернах.

Интерфейсы

Интерфейс — это контракт. Класс, который реализует интерфейс, обязан выполнить все его методы.

// Объявляем интерфейс
interface Observable {
    void update(String message);
}

// Класс реализует интерфейс
class User implements Observable {
    private String name;
    
    User(String name) {
        this.name = name;
    }
    
    @Override
    public void update(String message) {
        System.out.println(name + " получил: " + message);
    }
}

Абстрактные классы

Абстрактный класс — это класс, который нельзя создать напрямую. Он может содержать как абстрактные методы (без реализации), так и обычные.

abstract class Shape {
    protected String color;
    
    Shape(String color) {
        this.color = color;
    }
    
    // Абстрактный метод — нужно реализовать в наследнике
    abstract double getArea();
    
    // Обычный метод — уже готов
    void displayColor() {
        System.out.println("Цвет: " + color);
    }
}

class Circle extends Shape {
    private double radius;
    
    Circle(String color, double radius) {
        super(color);
        this.radius = radius;
    }
    
    @Override
    double getArea() {
        return Math.PI * radius * radius;
    }
}

Статические методы и поля

Статические члены принадлежат классу, а не объекту. Это ключевой элемент для паттерна «Одиночка».

class Database {
    private static Database instance;
    
    private Database() {
        // Приватный конструктор
    }
    
    public static Database getInstance() {
        if (instance == null) {
            instance = new Database();
        }
        return instance;
    }
}

Внутренние и анонимные классы

В Java можно создавать классы внутри других классов. Это удобно для паттернов, где нужны небольшие вспомогательные классы.

class EventManager {
    interface EventListener {
        void onEvent(String event);
    }
    
    private EventListener listener;
    
    void setListener(EventListener listener) {
        this.listener = listener;
    }
    
    void triggerEvent(String event) {
        if (listener != null) {
            listener.onEvent(event);
        }
    }
}

// Использование с анонимным классом
EventManager manager = new EventManager();
manager.setListener(new EventManager.EventListener() {
    @Override
    public void onEvent(String event) {
        System.out.println("Событие: " + event);
    }
});

Дженерики (Generics)

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

// Паттерн "Хранитель" (Memento) с дженериками
class Memento {
    private T state;
    
    Memento(T state) {
        this.state = state;
    }
    
    T getState() {
        return state;
    }
}

class Originator {
    private T state;
    
    void setState(T state) {
        this.state = state;
    }
    
    Memento save() {
        return new Memento<>(state);
    }
    
    void restore(Memento memento) {
        state = memento.getState();
    }
}

Примеры кода — от простого к сложному

Пример 1: Одиночка (Singleton) — простейший вариант

Гарантирует, что у класса есть только один экземпляр.

public class Logger {
    // Единственный экземпляр класса
    private static Logger instance;
    
    // Приватный конструктор — никто не сможет создать объект извне
    private Logger() {
        System.out.println("Логгер создан!");
    }
    
    // Статический метод для получения экземпляра
    public static Logger getInstance() {
        // Если экземпляр ещё не создан — создаём
        if (instance == null) {
            instance = new Logger();
        }
        return instance;
    }
    
    public void log(String message) {
        System.out.println("[LOG] " + message);
    }
}

// Использование:
public class Main {
    public static void main(String[] args) {
        Logger logger1 = Logger.getInstance();
        Logger logger2 = Logger.getInstance();
        
        logger1.log("Первое сообщение");
        logger2.log("Второе сообщение");
        
        // Проверяем, что это один и тот же объект
        System.out.println(logger1 == logger2); // true
    }
}

Пример 2: Фабричный метод (Factory Method)

Создаёт объекты, не указывая конкретный класс.

// Абстрактный класс для всех типов транспорта
abstract class Transport {
    abstract void deliver();
}

// Конкретные классы транспорта
class Car extends Transport {
    @Override
    void deliver() {
        System.out.println("Доставка на машине по дороге");
    }
}

class Ship extends Transport {
    @Override
    void deliver() {
        System.out.println("Доставка на корабле по воде");
    }
}

// Создатель — фабрика
abstract class Logistics {
    // Фабричный метод — создаёт транспорт
    abstract Transport createTransport();
    
    // Бизнес-логика, использующая транспорт
    void planDelivery() {
        Transport transport = createTransport();
        transport.deliver();
    }
}

// Конкретные фабрики
class RoadLogistics extends Logistics {
    @Override
    Transport createTransport() {
        return new Car();
    }
}

class SeaLogistics extends Logistics {
    @Override
    Transport createTransport() {
        return new Ship();
    }
}

// Использование:
public class Main {
    public static void main(String[] args) {
        Logistics logistics = new RoadLogistics();
        logistics.planDelivery(); // Доставка на машине по дороге
        
        logistics = new SeaLogistics();
        logistics.planDelivery(); // Доставка на корабле по воде
    }
}

Пример 3: Строитель (Builder)

Позволяет создавать сложные объекты пошагово.

// Класс, который мы будем строить
class Pizza {
    private String dough;
    private String sauce;
    private String topping;
    
    // Приватный конструктор — только через Builder
    private Pizza(Builder builder) {
        this.dough = builder.dough;
        this.sauce = builder.sauce;
        this.topping = builder.topping;
    }
    
    @Override
    public String toString() {
        return "Пицца: тесто=" + dough + ", соус=" + sauce + ", начинка=" + topping;
    }
    
    // Внутренний класс Builder
    static class Builder {
        private String dough;
        private String sauce;
        private String topping;
        
        Builder setDough(String dough) {
            this.dough = dough;
            return this; // Возвращаем Builder для цепочки вызовов
        }
        
        Builder setSauce(String sauce) {
            this.sauce = sauce;
            return this;
        }
        
        Builder setTopping(String topping) {
            this.topping = topping;
            return this;
        }
        
        Pizza build() {
            return new Pizza(this);
        }
    }
}

// Использование:
public class Main {
    public static void main(String[] args) {
        Pizza pizza = new Pizza.Builder()
            .setDough("тонкое")
            .setSauce("томатный")
            .setTopping("сыр")
            .build();
        
        System.out.println(pizza); // Пицца: тесто=тонкое, соус=томатный, начинка=сыр
    }
}

Пример 4: Адаптер (Adapter)

Позволяет объектам с несовместимыми интерфейсами работать вместе.

// Старый интерфейс — работает с дюймами
interface OldSystem {
    double getSizeInInches();
}

// Старый класс
class OldRectangle implements OldSystem {
    private double widthInInches;
    private double heightInInches;
    
    OldRectangle(double width, double height) {
        this.widthInInches = width;
        this.heightInInches = height;
    }
    
    @Override
    public double getSizeInInches() {
        return widthInInches * heightInInches;
    }
}

// Новый интерфейс — работает с сантиметрами
interface NewSystem {
    double getSizeInCentimeters();
}

// Адаптер — преобразует дюймы в сантиметры
class Adapter implements NewSystem {
    private OldSystem oldSystem;
    
    Adapter(OldSystem oldSystem) {
        this.oldSystem = oldSystem;
    }
    
    @Override
    public double getSizeInCentimeters() {
        // 1 дюйм = 2.54 см
        return oldSystem.getSizeInInches() * 2.54;
    }
}

// Использование:
public class Main {
    public static void main(String[] args) {
        OldSystem oldRect = new OldRectangle(10, 20);
        NewSystem adapter = new Adapter(oldRect);
        
        System.out.println("Площадь в см²: " + adapter.getSizeInCentimeters());
    }
}

Пример 5: Наблюдатель (Observer)

Оповещает множество объектов об изменениях.

import java.util.ArrayList;
import java.util.List;

// Интерфейс наблюдателя
interface Observer {
    void update(String news);
}

// Класс издателя новостей
class NewsPublisher {
    private List observers = new ArrayList<>();
    private String latestNews;
    
    // Подписаться на новости
    void subscribe(Observer observer) {
        observers.add(observer);
    }
    
    // Отписаться
    void unsubscribe(Observer observer) {
        observers.remove(observer);
    }
    
    // Выпустить новость
    void publishNews(String news) {
        this.latestNews = news;
        notifyObservers();
    }
    
    // Уведомить всех подписчиков
    private void notifyObservers() {
        for (Observer observer : observers) {
            observer.update(latestNews);
        }
    }
}

// Конкретный наблюдатель — подписчик
class Subscriber implements Observer {
    private String name;
    
    Subscriber(String name) {
        this.name = name;
    }
    
    @Override
    public void update(String news) {
        System.out.println(name + " получил новость: " + news);
    }
}

// Использование:
public class Main {
    public static void main(String[] args) {
        NewsPublisher publisher = new NewsPublisher();
        
        Subscriber alice = new Subscriber("Алиса");
        Subscriber bob = new Subscriber("Боб");
        
        publisher.subscribe(alice);
        publisher.subscribe(bob);
        
        publisher.publishNews("Java 21 вышел!");
        // Алиса получил новость: Java 21 вышел!
        // Боб получил новость: Java 21 вышел!
    }
}

Пример 6: Стратегия (Strategy)

Позволяет менять алгоритм поведения объекта на лету.

// Интерфейс стратегии оплаты
interface PaymentStrategy {
    void pay(int amount);
}

// Конкретные стратегии
class CreditCardPayment implements PaymentStrategy {
    private String cardNumber;
    
    CreditCardPayment(String cardNumber) {
        this.cardNumber = cardNumber;
    }
    
    @Override
    public void pay(int amount) {
        System.out.println("Оплачено " + amount + " руб. картой " + cardNumber);
    }
}

class PayPalPayment implements PaymentStrategy {
    private String email;
    
    PayPalPayment(String email) {
        this.email = email;
    }
    
    @Override
    public void pay(int amount) {
        System.out.println("Оплачено " + amount + " руб. через PayPal (" + email + ")");
    }
}

// Контекст — корзина покупок
class ShoppingCart {
    private PaymentStrategy paymentStrategy;
    
    // Устанавливаем способ оплаты
    void setPaymentStrategy(PaymentStrategy strategy) {
        this.paymentStrategy = strategy;
    }
    
    // Оплатить заказ
    void checkout(int amount) {
        if (paymentStrategy == null) {
            System.out.println("Выберите способ оплаты!");
            return;
        }
        paymentStrategy.pay(amount);
    }
}

// Использование:
public class Main {
    public static void main(String[] args) {
        ShoppingCart cart = new ShoppingCart();
        
        cart.setPaymentStrategy(new CreditCardPayment("1234-5678-9012-3456"));
        cart.checkout(1500); // Оплачено 1500 руб. картой 1234-5678-9012-3456
        
        cart.setPaymentStrategy(new PayPalPayment("user@example.com"));
        cart.checkout(2500); // Оплачено 2500 руб. через PayPal (user@example.com)
    }
}

Пример 7: Декоратор (Decorator)

Динамически добавляет новые функции объектам.

// Базовый интерфейс для напитков
interface Beverage {
    String getDescription();
    double getCost();
}

// Конкретный напиток
class Coffee implements Beverage {
    @Override
    public String getDescription() {
        return "Кофе";
    }
    
    @Override
    public double getCost() {
        return 100.0;
    }
}

// Абстрактный декоратор
abstract class BeverageDecorator implements Beverage {
    protected Beverage beverage;
    
    BeverageDecorator(Beverage beverage) {
        this.beverage = beverage;
    }
}

// Конкретные декораторы
class MilkDecorator extends BeverageDecorator {
    MilkDecorator(Beverage beverage) {
        super(beverage);
    }
    
    @Override
    public String getDescription() {
        return beverage.getDescription() + ", с молоком";
    }
    
    @Override
    public double getCost() {
        return beverage.getCost() + 20.0;
    }
}

class SugarDecorator extends BeverageDecorator {
    SugarDecorator(Beverage beverage) {
        super(beverage);
    }
    
    @Override
    public String getDescription() {
        return beverage.getDescription() + ", с сахаром";
    }
    
    @Override
    public double getCost() {
        return beverage.getCost() + 10.0;
    }
}

// Использование:
public class Main {
    public static void main(String[] args) {
        Beverage coffee = new Coffee();
        System.out.println(coffee.getDescription() + " = " + coffee.getCost() + " руб.");
        
        // Добавляем молоко
        coffee = new MilkDecorator(coffee);
        System.out.println(coffee.getDescription() + " = " + coffee.getCost() + " руб.");
        
        // Добавляем сахар
        coffee = new SugarDecorator(coffee);
        System.out.println(coffee.getDescription() + " = " + coffee.getCost() + " руб.");
    }
}

Сравнение с Python: чем похоже и чем отличается

Python и Java — оба объектно-ориентированные языки, но реализация паттернов в них отличается из-за разной философии.

Аспект Java Python
Интерфейсы Отдельный тип (interface) Неявные (duck typing) или через ABC
Приватные члены Строгие модификаторы (private, protected) Соглашение об именах (_ и __)
Статические методы static @staticmethod или @classmethod
Анонимные классы Есть (громоздкие) Лямбды и замыкания (компактные)
Дженерики Строгая типизация Динамическая типизация (не нужны)
Одиночка Приватный конструктор + статический метод Модуль (сам по себе синглтон) или __new__
Декоратор Классы-обёртки Встроенные декораторы (@decorator)

Пример паттерна «Стратегия» на Python для сравнения:

// Java — много кода, строгая типизация
interface Strategy {
    void execute();
}

class ConcreteStrategyA implements Strategy {
    @Override
    public void execute() {
        System.out.println("Стратегия A");
    }
}
# Python — меньше кода, динамическая типизация
class ConcreteStrategyA:
    def execute(self):
        print("Стратегия A")

# Или даже просто функция:
def strategy_a():
    print("Стратегия A")
Вывод: В Java паттерны требуют больше кода из-за строгой типизации и отсутствия функций как объектов первого класса. Но этот код более надёжен и легче поддерживается в больших проектах.

Типичные ошибки и подводные камни

1. Использование паттерна «на всякий случай»

Самая частая ошибка — применять паттерн, когда он не нужен. Паттерн должен решать конкретную проблему, а не усложнять код.

// ПЛОХО: Одиночка для класса, который может иметь много экземпляров
class User {
    private static User instance;
    private User() {}
    public static User getInstance() { ... }
    // Зачем? Пользователей может быть много!
}

// ХОРОШО: Одиночка для подключения к БД
class DatabaseConnection {
    private static DatabaseConnection instance;
    private DatabaseConnection() { /* подключение */ }
    public static DatabaseConnection getInstance() { ... }
    // Одно подключение на всю программу — логично
}

2. Нарушение принципа единственной ответственности

Класс должен делать только одно дело. Если паттерн заставляет класс делать слишком много — вы что-то делаете не так.

3. Проблемы с многопоточностью в Одиночке

Простая реализация Одиночки не работает в многопоточных программах.

// ПЛОХО: Не потокобезопасный Одиночка
class Singleton {
    private static Singleton instance;
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) { // Поток 1 и Поток 2 могут войти сюда одновременно
            instance = new Singleton(); // И создать два экземпляра!
        }
        return instance;
    }
}

// ХОРОШО: Потокобезопасный Одиночка
class Singleton {
    private static volatile Singleton instance;
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

4. Слишком глубокое наследование

Паттерны часто используют наследование. Но слишком глубокая иерархия усложняет код.

// ПЛОХО: 5 уровней наследования
class A extends B extends C extends D extends E { }

// ХОРОШО: Композиция вместо наследования
class A {
    private B b;
    private C c;
}

5. Игнорирование существующих решений в Java

Java уже имеет встроенные реализации некоторых паттернов. Не нужно изобретать велосипед.

Паттерн Встроенная реализация в Java
Наблюдатель java.util.Observer, java.util.Observable (устарели), но есть PropertyChangeListener
Итератор java.util.Iterator, java.util.Iterable
Стратегия java.util.Comparator
Команда java.lang.Runnable, java.util.concurrent.Callable

Лучшие практики и советы профессионалов

1. Изучай паттерны в контексте

Не учи паттерны абстрактно. Каждый раз, когда сталкиваешься с проблемой, подумай: «А какой паттерн здесь подойдёт?». Со временем это войдёт в привычку.

2. Начинай с простых решений

Не усложняй код паттернами, если можно обойтись простым if-else или switch. Паттерны — это инструмент для сложных случаев.

3. Используй принципы SOLID

Паттерны проектирования тесно связаны с принципами SOLID. Если ты соблюдаешь эти принципы, паттерны будут ложиться естественно.

  • S — Single Responsibility (единственная ответственность)
  • O — Open/Closed (открыт для расширения, закрыт для изменения)
  • L — Liskov Substitution (подстановка Барбары Лисков)
  • I — Interface Segregation (разделение интерфейсов)
  • D — Dependency Inversion (инверсия зависимостей)

4. Документируй использование паттернов

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

/**
 * Паттерн: Стратегия (Strategy)
 * 
 * Позволяет выбирать алгоритм сортировки на лету.
 * Новые алгоритмы можно добавлять без изменения класса Sorter.
 */
class Sorter {
    private SortingStrategy strategy;
    
    void setStrategy(SortingStrategy strategy) {
        this.strategy = strategy;
    }
    
    void sort(int[] data) {
        strategy.sort(data);
    }
}

5. Тестируй паттерны

Паттерны упрощают тестирование, потому что разделяют ответственность. Пиши unit-тесты для каждого компонента паттерна отдельно.

6. Не бойся рефакторинга

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

Вопросы для самопроверки

  1. Что такое паттерны проектирования?

    Ответ: Это готовые решения типовых проблем в программировании. Они не являются готовым кодом, а описывают общую структуру классов и их взаимодействие.

  2. На какие три группы делятся паттерны?

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

  3. Как реализовать паттерн «Одиночка» в Java?

    Ответ: Сделать конструктор приватным, создать статическое поле для хранения единственного экземпляра и статический метод для его получения. Для многопоточности использовать synchronized или volatile.

  4. В чём разница между паттернами «Фабричный метод» и «Абстрактная фабрика»?

    Ответ: Фабричный метод создаёт один тип объектов через наследование. Абстрактная фабрика создаёт семейства связанных объектов через композицию.

  5. Когда нужно использовать паттерн «Строитель»?

    Ответ: Когда объект имеет много параметров (особенно необязательных), и создание через конструктор становится неудобным. Builder позволяет создавать объект пошагово.

  6. Как паттерн «Адаптер» помогает в работе с устаревшим кодом?

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

  7. В чём преимущество паттерна «Стратегия» перед простым if-else?

    Ответ: Стратегия позволяет добавлять новые алгоритмы без изменения существующего кода (принцип Open/Closed). Код становится более гибким и тестируемым.

  8. Какая типичная ошибка при использовании паттерна «Наблюдатель»?

    Ответ: Забыть отписать наблюдателя, что приводит к утечке памяти. Всегда нужно предусмотреть метод unsubscribe.

  9. Чем отличается реализация паттернов в Java и Python?

    Ответ: В Java требуется больше кода из-за строгой типизации и отсутствия функций как объектов. Python позволяет реализовать те же паттерны более компактно благодаря динамической типизации и встроенным возможностям.

  10. Какой принцип SOLID нарушается, если класс делает слишком много?

    Ответ: Принцип единственной ответственности (Single Responsibility). Класс должен иметь только одну причину для изменения.

Итог и что изучить дальше

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

Мы рассмотрели основные паттерны: Одиночка, Фабричный метод, Строитель, Адаптер, Наблюдатель, Стратегия и Декоратор. Это только вершина айсберга. Всего существует 23 классических паттерна, описанных в книге «Банды четырёх» (Gang of Four).

Что изучить дальше:

  • Паттерн «Компоновщик» (Composite) — для работы с древовидными структурами
  • Паттерн «Состояние» (State) — для управления состояниями объекта
  • Паттерн «Цепочка обязанностей» (Chain of Responsibility) — для последовательной обработки запросов
  • Паттерн