🧩 Розділ 2 · Питання #9

Що таке патерн Prototype

Ксерокопіювання документів або виготовлення дубліката ключа за зліпком. Якщо потрібно створити новий документ із десятками складних таблиць та налаштувань, набагато швидше зроби...


🟢 Junior Level

Prototype (Прототип) — це породжувальний патерн проєктування, який дозволяє створювати нові об’єкти шляхом копіювання (клонування) вже існуючого екземпляра (прототипу) замість конструювання з нуля через оператор new.


Побутова аналогія

Ксерокопіювання документів або виготовлення дубліката ключа за зліпком. Якщо потрібно створити новий документ із десятками складних таблиць та налаштувань, набагато швидше зробити копію вже налаштованого зразка (шаблону) і змінити в ній пару полів (наприклад, ПІБ клієнта), ніж заново заповнювати всі дані з чистого аркуша.


Найпростіша реалізація: Конструктор копіювання (Copy Constructor)

У мові Java загальноприйнятим і найбільш безпечним способом реалізації патерна Prototype є конструктор копіювання:

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

public class UserProfile {
    private String username;
    private String role;
    private List<String> permissions;

    // Звичайний конструктор
    public UserProfile(String username, String role, List<String> permissions) {
        this.username = username;
        this.role = role;
        this.permissions = new ArrayList<>(permissions);
    }

    // Конструктор копіювання (патерн Prototype)
    public UserProfile(UserProfile prototype) {
        this.username = prototype.username;
        this.role = prototype.role;
        // Важливо: створюємо новий список для захисту від побічних ефектів (глибока копія)
        this.permissions = new ArrayList<>(prototype.permissions);
    }

    public static UserProfile copyOf(UserProfile other) {
        return new UserProfile(other);
    }
}

// Використання:
UserProfile adminTemplate = new UserProfile("admin_base", "ADMIN", List.of("READ", "WRITE", "DELETE"));
UserProfile newAdmin = UserProfile.copyOf(adminTemplate);

Коли застосовувати:

  1. Створення нового об’єкта вимагає важких обчислень, читання файлової системи або повільних мережевих запитів до бази даних.
  2. Системі потрібно створювати безліч схожих об’єктів, що відрізняються лише кількома полями.
  3. Потрібно ізолювати клієнтський код від конкретних класів створюваних об’єктів (клієнт знає лише абстрактний інтерфейс із методом копіювання або clone()).

🟡 Middle Level

Чому Cloneable та Object.clone() у Java вважаються невдалим дизайном?

У стандартній бібліотеці Java присутній інтерфейс java.lang.Cloneable, проте визнані експерти (зокрема Джошуа Блох) вважають його серйозною архітектурною помилкою:

// ❌ Проблемний стандартний підхід Java
public class BrokenUser implements Cloneable {
    private String name;
    private List<String> roles;

    @Override
    public BrokenUser clone() {
        try {
            return (BrokenUser) super.clone(); // ⚠️ Поверхнева копія за замовчуванням!
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

Фатальні недоліки Cloneable:

  1. Відсутність методу в інтерфейсі: Cloneable — маркерний інтерфейс без жодного методу. Метод clone() оголошений у класі Object як protected. Реалізація інтерфейсу Cloneable не зобов’язує компілятор перевіряти наявність публічного методу clone().
  2. Поверхневе копіювання (Shallow Copy): super.clone() виконує побітове копіювання полів пам’яті. Посилальні типи (списки, масиви, вкладені об’єкти) копіюються за посиланням:
    BrokenUser copy = original.clone();
    copy.getRoles().add("ROOT"); // ❌ Мутує список і в оригінальному об'єкті original!
    
  3. Несумісність із final-полями: Метод clone() не може переприсвоїти значення полям із модифікатором final, оскільки вони можуть ініціалізуватися лише в конструкторі.
  4. Обхід конструктора: super.clone() створює об’єкт у купі безпосередньо через native-виклик JVM, повністю оминаючи конструктори класу, що ламає інваріанти ініціалізації.
  5. Checked Exception: Метод змушує обробляти або прокидати архаїчний виняток CloneNotSupportedException.

Поверхневе копіювання (Shallow Copy) проти Глибокого (Deep Copy)

Вихідний об'єкт:
┌─────────────────┐
│ UserProfile     │
│  username: "Ann"│
│  address ───────┼────────┐
└─────────────────┘        │
                           ▼
Поверхнева копія:      ┌───────────────┐
┌─────────────────┐    │ Address       │  (Обидва об'єкти посилаються
│ UserProfile     │    │  city: "Kyiv" │   на ОДИН екземпляр у купі!)
│  username: "Bob"│    └───────────────┘
│  address ───────┼────────▲
└─────────────────┘        │
Глибока копія (Deep Copy):
┌─────────────────┐         ┌───────────────┐
│ UserProfile (1) │ ──────> │ Address (1)   │
└─────────────────┘         └───────────────┘

┌─────────────────┐         ┌───────────────┐
│ UserProfile (2) │ ──────> │ Address (2)   │ (Повністю незалежні екземпляри)
└─────────────────┘         └───────────────┘

Способи реалізації глибокого копіювання:

  1. Ручне створення через конструктори копіювання (найшвидший і найбільш безпечний спосіб).
  2. Серіалізація через Jackson / Fastjson (JSON):
    public static <T> T deepCopyJson(T object, Class<T> clazz) {
        String json = objectMapper.writeValueAsString(object);
        return objectMapper.readValue(json, clazz);
    }
    
  3. Java Serialization (ByteArrayOutputStream / ObjectOutputStream): повільно, вимагає інтерфейсу Serializable, створює суттєві вразливості безпеки.

Патерн Prototype у Spring Framework: @Scope("prototype")

У Spring Boot скоуп prototype створює новий екземпляр біна під час кожного запиту з контексту:

import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ReportGenerator {
    // Створюється наново під час кожного виклику getBean()
}

Проблема «Prototype всередині Singleton»:

Якщо впровадити Prototype-бін у Singleton-сервіс через @Autowired, Prototype-бін впровадиться рівно один раз під час підняття контексту:

@Service // Singleton
public class OrderService {
    @Autowired
    private ReportGenerator generator; // ❌ Бін буде одним і тим самим для всіх наступних замовлень!
}

Коректні рішення:

  1. Впровадження ObjectProvider<T>:
    import org.springframework.beans.factory.ObjectProvider;
    import org.springframework.stereotype.Service;
    
    @Service
    public class OrderService {
        private final ObjectProvider<ReportGenerator> generatorProvider;
    
        public OrderService(ObjectProvider<ReportGenerator> provider) {
            this.generatorProvider = provider;
        }
    
        public void process() {
            ReportGenerator gen = generatorProvider.getObject(); // Щоразу новий екземпляр!
        }
    }
    
  2. Методне впровадження @Lookup: Spring динамічно перевизначає метод за допомогою генерації CGLIB-проксі.

🔴 Senior Level

Внутрішня будова Object.clone() у HotSpot JVM

На рівні JVM метод Object.clone() є native-методом, реалізованим мовою C++:

  1. Метод виконує перевірку: якщо клас не реалізує маркерний інтерфейс Cloneable, негайно викидається виняток CloneNotSupportedException.
  2. JVM виділяє неперервний блок пам’яті в купі (Heap), який за розміром точно відповідає розміру оригінального об’єкта.
  3. Відбувається пряме побайтове копіювання структури заголовка об’єкта (Mark Word + Klass Word) та його полів даних (memcpy).
  4. Конструктор <init> не викликається взагалі. Це означає, що будь-які механізми аудиту, лічильники створених екземплярів, підписки на події або генерація унікальних UUID, закладені в конструкторі, не спрацюють.

Життєвий цикл бінів зі scope prototype у Spring

Критична відмінність Prototype-біна від Singleton у Spring:

  • Spring ініціалізує Prototype: виконуються впровадження залежностей, методи @PostConstruct та InitializingBean.afterPropertiesSet(), проганяються всі BeanPostProcessor.
  • Spring НЕ знищує Prototype: після створення та ініціалізації контейнер повністю передає управління біном клієнтському коду і забуває про нього.
  • Хуки @PreDestroy та DisposableBean.destroy() для prototype-бінів НІКОЛИ не викликаються контейнером.
  • Якщо prototype-бін захоплює системні ресурси (файлові дескриптори, мережеві сокети, з’єднання з БД), їхнє коректне закриття зобов’язаний виконувати клієнтський код, інакше виникне витік ресурсів.

Порівняльний аналіз способів клонування

Метод клонування Відносна швидкість Виклик конструктора Підтримка final Безпека типів
Copy Constructor 🟢 1x (найвища, ~15–20 ns) ✅ Так ✅ Так ✅ Compile-time
Object.clone() 🟡 ~1.5x (швидко, але небезпечно) ❌ Ні (native memcpy) ❌ Проблеми ⚠️ Вимагає приведення типу
JSON (Jackson Afterburner) 🔴 ~20x–30x повільніше ✅ Так ⚠️ Потребує гетерів ⚠️ Runtime parsing
Java Serialization 🔴 ~40x–50x повільніше ❌ Ні ⚠️ Складно ❌ Вразливості OOS

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«Патерн Prototype (Прототип) вирішує завдання створення нових об’єктів шляхом клонування наявного прототипу, що особливо корисно при дорогій ініціалізації або складній конфігурації. У Java стандартний інтерфейс Cloneable визнаний невдалим проєктним рішенням через обхід виклику конструктора, поверхневе копіювання за замовчуванням та несумісність із final-полями. Еталонним способом реалізації Prototype у Java є конструктор копіювання (Copy Constructor) або статичний фабричний метод copyOf(). У Spring Framework патерн представлений областю видимості @Scope("prototype"), при цьому критично пам’ятати, що хук @PreDestroy для таких бінів контейнером ніколи не викликається».


3–4 каверзних запитання з відповідями

  1. Чому super.clone() ламає інкапсуляцію та інваріанти класів із final-полями?
    • Відповідь: super.clone() виділяє пам’ять і побайтово копіює поля на рівні JVM без виклику конструктора. Якщо у класу є final-поле з мутабельним об’єктом (наприклад, final List<String> list), то в клонованому об’єкті це поле посилатиметься на той самий екземпляр списку. Переприсвоїти посилання на новий список неможливо, оскільки компілятор Java забороняє зміну final-полів поза конструктором.
  2. Чи викликається @PreDestroy для бінів зі scope prototype у Spring?
    • Відповідь: Ні, Spring передає prototype-бін клієнту і більше не відстежує його життєвий цикл. Збиранням сміття та звільненням ресурсів має опікуватися клієнтський код (наприклад, через виклик методу очищення або кастомний BeanPostProcessor).
  3. Як правильно впровадити Prototype-бін у Singleton-сервіс?
    • Відповідь: Пряме впровадження через @Autowired призведе до того, що Prototype створиться лише один раз під час створення синглтона. Коректні рішення: використовувати ObjectProvider<T>.getObject(), методну ін’єкцію @Lookup, або впроваджувати фабрику Provider<T> чи ApplicationContext.
  4. У чому різниця між поверхневим (shallow) та глибоким (deep) клонуванням?
    • Відповідь: Під час поверхневого клонування копіюються лише примітивні типи та посилання на об’єкти (посилальні поля оригіналу та копії вказують на одну й ту саму область пам’яті в купі). Під час глибокого клонування рекурсивно створюються дублікати всіх вкладених об’єктів, гарантуючи повну незалежність копії від оригіналу.

Типові помилки та Red Flags

  • 🚩 Використання implements Cloneable у новому коді замість конструкторів копіювання.
  • 🚩 Поверхневе копіювання колекцій, що призводить до прихованих паралельних мутацій даних у різних потоках.
  • 🚩 Очікування, що Spring закриє ресурси prototype-біна через анотацію @PreDestroy.
  • 🚩 Прямий @Autowired prototype-біна в singleton-сервіс із припущенням, що для кожного HTTP-запиту створюватиметься новий екземпляр.

Пов’язані теми