🔒 Розділ 13 · Питання #10

Що таке захисна копія (defensive copy)

Головне призначення захисної копії — гарантувати цілісність і незмінність об'єкта незалежно від дій викликаючого коду. Патерн реалізується за «правилом двох точок»:


🟢 Junior Level

30-секундна відповідь

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

Головне призначення захисної копії — гарантувати цілісність і незмінність об’єкта незалежно від дій викликаючого коду. Патерн реалізується за «правилом двох точок»:

  1. Точка входу (Конструктор): створюється копія переданих параметрів, щоб запобігти модифікації внутрішнього стану через вихідні посилання викликаючої сторони.
  2. Точка виходу (Гетер): повертається копія внутрішнього об’єкта (або незмінне представлення), щоб клієнт не міг зруйнувати інваріанти класу через отримане посилання.

Аналогія з реального життя

Уявіть нотаріально завірену копію паспорта:

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

Наочний приклад

import java.util.Date;

public final class Appointment {
    private final String title;
    private final Date time; // java.util.Date мутабельний!

    public Appointment(String title, Date time) {
        this.title = title;
        // ТОЧКА 1: Захисна копія на вході
        this.time = new Date(time.getTime());
    }

    public String getTitle() {
        return title;
    }

    public Date getTime() {
        // ТОЧКА 2: Захисна копія на виході
        return new Date(time.getTime());
    }
}

🟡 Middle Level

1. Поверхнева (Shallow) vs Глибока (Deep) захисна копія

При створенні захисних копій колекцій критично важливо враховувати тип елементів:

  • Shallow Copy (Поверхнева): створюється новий контейнер, але посилання всередині вказують на ті самі екземпляри. Достатня, якщо самі елементи незмінні (String, Integer, UUID):
    // Достатньо, оскільки String незмінний
    this.tags = List.copyOf(tags);
    
  • Deep Copy (Глибока): рекурсивно копіюється як сам контейнер, так і кожен вкладений змінний об’єкт. Обов’язкова, якщо елементи мутабельні:
    // Глибоке копіювання мутабельних елементів через конструктор копіювання:
    this.items = items.stream()
        .map(item -> new OrderItem(item.getSku(), item.getPrice()))
        .toList();
    

2. Захисна копія vs Незмінна обгортка (Wrapper)

Часта плутанина на співбесідах: Collections.unmodifiableList() vs List.copyOf():

  • Collections.unmodifiableList(original) — це обгортка (View) з $O(1)$ за часом і пам’яттю. Вона забороняє виклик add()/remove() через себе, але не захищає від мутацій оригінального списку. Якщо зовнішній код змінить original, зміни миттєво відобразяться в обгортці!
  • List.copyOf(original) (Java 10+) — це повноцінна захисна копія зі складністю $O(N)$. Вона фізично відсікає зв’язок з оригіналом і повертає незмінну колекцію платформи.
List<String> original = new ArrayList<>(List.of("A", "B"));

List<String> view = Collections.unmodifiableList(original);
List<String> copy = List.copyOf(original);

original.add("C");

System.out.println(view); // [A, B, C] — ОБГОРТКА ЗМІНИЛАСЯ!
System.out.println(copy); // [A, B] — ЗАХИСНА КОПІЯ ЗБЕРЕГЛА СТАН!

3. Коли захисна копія НЕ потрібна

  1. Незмінні типи: примітиви, String, BigDecimal, класи пакета java.time (LocalDate, Instant).
  2. Закритий довірений код (Private/Package-Private API): коли клас і викликаючий код розташовані всередині одного пакета й гарантують відсутність мутацій заради екстремальної продуктивності.

🔴 Senior Level

Захист від TOCTOU-атак (Time-of-Check to Time-of-Use)

У багатопотоковому середовищі валідація аргументів без попереднього копіювання відкриває критичну вразливість:

// ❌ ВРАЗЛИВА РЕАЛІЗАЦІЯ:
public TimeInterval(Date start, Date end) {
    if (start.compareTo(end) > 0) { // Time of Check
        throw new IllegalArgumentException();
    }
    // ВІКНО ВРАЗЛИВОСТІ: Інший потік може викликати start.setTime(Long.MAX_VALUE) прямо тут!
    this.start = new Date(start.getTime()); // Time of Use
    this.end = new Date(end.getTime());
}
// ✅ ПРАВИЛЬНА РЕАЛІЗАЦІЯ: Копія ДО валідації!
public TimeInterval(Date start, Date end) {
    // 1. Спочатку копіюємо у внутрішні/локальні змінні:
    Date localStart = new Date(start.getTime());
    Date localEnd = new Date(end.getTime());

    // 2. Валідуємо ізольовану копію:
    if (localStart.compareTo(localEnd) > 0) {
        throw new IllegalArgumentException();
    }

    this.start = localStart;
    this.end = localEnd;
}

Гарантії JMM §17.5 при створенні захисних копій

Якщо захисна копія створюється всередині конструктора і зберігається у final-поле:

  1. Усі об’єкти, алоковані в процесі створення захисної копії (наприклад, внутрішні вузли List.copyOf або новий масив byte[]), вважаються частиною ініціалізації об’єкта.
  2. Дія Freeze Action наприкінці конструктора встановлює бар’єр StoreStore, гарантуючи, що поля захисної копії скинуті в пам’ять до публікації посилання на основний об’єкт.
  3. Читаючі потоки гарантовано бачать консистентний стан захисної копії без додаткових блокувань.

Усунення алокацій JIT-компілятором (Escape Analysis)

Senior-розробник повинен розуміти витрати $O(N)$ копіювання в гарячих шляхах:

  • При виклику гетера, що повертає new Date(time.getTime()) або array.clone(), JVM створює новий об’єкт в Eden.
  • Проте якщо викликаючий код використовує результат локально:
    long epoch = appointment.getTime().getTime();
    
  • Високорівневий JIT-компілятор C2 виконує Escape Analysis (Аналіз витоку): виявивши, що повернена захисна копія не залишає стек поточного методу, C2 здійснює Scalar Replacement (Скалярне заміщення), повністю виключаючи алокацію об’єкта Date в купі!

4 Tricky Questions

1. У якому сценарії повернення Collections.unmodifiableList(this.list) із гетера є на 100% безпечним і не вимагає виділення пам’яті на копіювання?

Відповідь: Цей підхід є на 100% безпечним, якщо внутрішній список this.list був надійно ізольований у конструкторі (через захисне копіювання new ArrayList<>(input)), посилання на нього збережено у private final поле, жоден метод класу ніколи його не модифікує, а обгортка Collections.unmodifiableList(this.list) створена один раз у конструкторі і збережена у друге поле private final List<T> unmodifiableView;. У такому разі гетер просто повертає посилання на попередньо створений unmodifiableView за $O(1)$ без алокацій і без ризику зміни даних.


2. Чи захищає List.copyOf(input) у конструкторі від шкідливої реалізації List, переданої зловмисником?

Відповідь: Так, метод List.copyOf() спроєктований спеціально для захисту від атак через недовірені реалізації інтерфейсу List:

  1. List.copyOf() перевіряє клас аргументу: якщо це не внутрішній довірений ImmutableCollections, метод запитує масив елементів через стандартний ітератор або метод toArray().
  2. Внутрішній масив елементів негайно упаковується в закритий незмінний системний клас (List12, ListN), що забороняє додавання null та будь-яких модифікацій.
  3. Будь-які перевизначені шкідливі методи переданого зовнішнього списку відсікаються, забезпечуючи повну безпеку інваріантів.

3. Чому для полів-масивів (byte[], int[]) виклик array.clone() є обов’язковим, навіть якщо поле оголошено як private final?

Відповідь: Модифікатор final забороняє лише переприсвоєння посилання на сам масив. Сам масив у Java є мутабельним об’єктом фіксованої довжини: будь-який клієнт, що має посилання на масив, може виконати array[0] = 999;. Оскільки в масивів немає гетерів і сетерів, а доступ до елементів здійснюється напряму за індексом, єдиним способом захистити масив є виклик .clone() або Arrays.copyOf() як при прийомі масиву в конструкторі, так і при поверненні з гетера.


4. Чи може C2 JIT-компілятор усунути витрати на захисну копію, яку повертає гетер? За якої умови?

Відповідь: Так, через механізм Escape Analysis та Scalar Replacement: Якщо метод-гетер інлайниться (inlined) у викликаючий контекст, і скомпільований код бачить, що повернена копія (наприклад, масив або об’єкт Date) не передається в зовнішні методи, не зберігається в статичні/екземплярні поля і не використовується іншими потоками, C2 компілятор:

  1. Повністю скасовує фізичну алокацію нового об’єкта в купі (Heap).
  2. Замінює поля об’єкта локальними регістрами процесора або стековими змінними.
  3. Читає дані напряму з пам’яті оригінального об’єкта, зводячи оверхед захисної копії практично до нуля.

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

Зведена таблиця підходів до Defensive Copy

| Тип об’єкта | У конструкторі (Вхід) | У гетері (Вихід) | Складність | | :— | :— | :— | :— | | Колекція (Immutable елементи) | List.copyOf(input) | Пряме повернення this.list | $O(N)$ вхід / $O(1)$ вихід | | Колекція (Mutable елементи) | stream().map(Item::new).toList() | Повернення списку клонованих копій | $O(N)$ глибоке копіювання | | Масиви (byte[]) | input.clone() | this.array.clone() | $O(N)$ вхід / $O(N)$ вихід | | Застарілий Date | new Date(input.getTime()) | new Date(this.date.getTime()) | $O(1)$ алокація | | Сучасний Instant | Пряме присвоєння (незмінний) | Пряме повернення | $O(1)$ без алокацій |

Червоні прапорці на співбесіді (Чого говорити не можна)

  • ❌ «Collections.unmodifiableList() створює захисну копію списку» — це обгортка, при зміні оригіналу дані в ній змінюються.
  • ❌ «Перевірку аргументів на null і валідність потрібно робити ДО створення захисної копії» — відкриває вразливість TOCTOU.
  • ❌ «Для незмінних типів на кшталт String та LocalDate теж потрібно робити defensive copy» — безглузда витрата ресурсів, незмінні типи копіювати не потрібно.
  • ❌ «Якщо поле оголошено як final byte[], його елементи не можна змінити» — елементи масиву можна вільно перезаписувати за індексом.

Пов’язані запитання