Що таке захисна копія (defensive copy)
Головне призначення захисної копії — гарантувати цілісність і незмінність об'єкта незалежно від дій викликаючого коду. Патерн реалізується за «правилом двох точок»:
🟢 Junior Level
30-секундна відповідь
Захисна копія (Defensive Copy) — це ідіома програмування, при якій створюється незалежний дублікат мутабельного об’єкта для ізоляції внутрішнього стану класу від зовнішніх змін.
Головне призначення захисної копії — гарантувати цілісність і незмінність об’єкта незалежно від дій викликаючого коду. Патерн реалізується за «правилом двох точок»:
- Точка входу (Конструктор): створюється копія переданих параметрів, щоб запобігти модифікації внутрішнього стану через вихідні посилання викликаючої сторони.
- Точка виходу (Гетер): повертається копія внутрішнього об’єкта (або незмінне представлення), щоб клієнт не міг зруйнувати інваріанти класу через отримане посилання.
Аналогія з реального життя
Уявіть нотаріально завірену копію паспорта:
- Якщо клієнт просить ваш паспорт для оформлення договору, ви віддаєте ксерокопію.
- Якщо клієнт поставить на копії штамп або зробить позначку ручкою, ваш оригінальний паспорт у кишені залишиться цілим і неушкодженим.
Наочний приклад
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. Коли захисна копія НЕ потрібна
- Незмінні типи: примітиви,
String,BigDecimal, класи пакетаjava.time(LocalDate,Instant). - Закритий довірений код (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-поле:
- Усі об’єкти, алоковані в процесі створення захисної копії (наприклад, внутрішні вузли
List.copyOfабо новий масивbyte[]), вважаються частиною ініціалізації об’єкта. - Дія Freeze Action наприкінці конструктора встановлює бар’єр
StoreStore, гарантуючи, що поля захисної копії скинуті в пам’ять до публікації посилання на основний об’єкт. - Читаючі потоки гарантовано бачать консистентний стан захисної копії без додаткових блокувань.
Усунення алокацій 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:
List.copyOf()перевіряє клас аргументу: якщо це не внутрішній довіренийImmutableCollections, метод запитує масив елементів через стандартний ітератор або методtoArray().- Внутрішній масив елементів негайно упаковується в закритий незмінний системний клас (
List12,ListN), що забороняє додаванняnullта будь-яких модифікацій. - Будь-які перевизначені шкідливі методи переданого зовнішнього списку відсікаються, забезпечуючи повну безпеку інваріантів.
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 компілятор:
- Повністю скасовує фізичну алокацію нового об’єкта в купі (Heap).
- Замінює поля об’єкта локальними регістрами процесора або стековими змінними.
- Читає дані напряму з пам’яті оригінального об’єкта, зводячи оверхед захисної копії практично до нуля.
🎯 Шпаргалка для інтерв’ю
Зведена таблиця підходів до 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[], його елементи не можна змінити» — елементи масиву можна вільно перезаписувати за індексом.