В чому різниця між shallow copy та deep copy
Різниця між Shallow Copy (поверхневим копіюванням) та Deep Copy (глибоким копіюванням) полягає в глибині дублювання графа об'єктів у пам'яті:
🟢 Junior Level
30-секундна відповідь
Різниця між Shallow Copy (поверхневим копіюванням) та Deep Copy (глибоким копіюванням) полягає в глибині дублювання графа об’єктів у пам’яті:
- Shallow Copy (Поверхнева копія): створює новий об’єкт-контейнер, але копіює лише значення примітивних полів та посилання на вкладені об’єкти. Як наслідок, оригінальний об’єкт і його копія посилаються на одні й ті самі екземпляри в купі (Heap). Модифікація внутрішнього об’єкта через копію призведе до його зміни і в оригіналі!
- Deep Copy (Глибока копія): рекурсивно створює дублікати як самого контейнера, так і всіх вкладених об’єктів за всім ланцюжком залежностей. Оригінал і копія стають на 100% ізольованими: жодні зміни в одному об’єкті не можуть вплинути на інший.
Наочний приклад
class Address {
String city;
Address(String city) { this.city = city; }
}
class User {
String name;
Address address;
User(String name, Address address) {
this.name = name;
this.address = address;
}
}
public class CopyDemo {
public static void main(String[] args) {
Address addr = new Address("Berlin");
User user1 = new User("Alice", addr);
// 1. Поверхнева копія (Shallow Copy)
User shallowCopy = new User(user1.name, user1.address);
// Змінюємо місто через копію:
shallowCopy.address.city = "Munich";
// Постраждав оригінал!
System.out.println(user1.address.city); // "Munich" — дані зіпсовано!
// 2. Глибока копія (Deep Copy)
User deepCopy = new User(user1.name, new Address(user1.address.city));
deepCopy.address.city = "Hamburg";
System.out.println(user1.address.city); // "Munich" — оригінал повністю ізольований!
}
}
Схема в пам’яті (Heap)
Shallow Copy:
[user1] ──► address ──┐
▼
[shallowCopy] ──► address ──► [Address Object: "Berlin"] (Один спільний об'єкт!)
Deep Copy:
[user1] ──► address ──► [Address Object: "Berlin"]
[deepCopy] ──► address ──► [Address Object: "Hamburg"] (Два різні об'єкти в пам'яті!)
🟡 Middle Level
Способи реалізації Deep Copy в Java
1. Конструктори копіювання та фабричні методи (Рекомендований підхід)
Найнадійніший, найшвидший і типобезпечний спосіб у Java (рекомендований Джошуа Блохом):
public class Order {
private final String id;
private final List<OrderItem> items;
// Конструктор копіювання
public Order(Order other) {
this.id = other.id; // String незмінний, глибока копія не потрібна
this.items = other.items.stream()
.map(OrderItem::new) // Виклик конструктора копіювання кожного елемента
.toList();
}
}
2. Інтерфейс Cloneable та метод Object.clone()
За замовчуванням нативний метод Object.clone() виконує виключно поверхневе копіювання (побітове копіювання полів об’єкта). Для реалізації Deep Copy необхідно вручну перевизначити метод clone() і рекурсивно схилити кожне поле:
@Override
public User clone() {
try {
User copy = (User) super.clone(); // Копіює примітиви та name (String)
copy.address = this.address.clone(); // Ручне глибоке клонування
return copy;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
Мінуси: архітектурно недосконалий механізм (Effective Java, Стаття 13), не викликає конструктори, вимагає приведення типів та обробки перевірюваних винятків.
3. Серіалізація (Java Serialization або JSON/Kryo)
Об’єкт серіалізується в байти й десеріалізується назад у новий незалежний граф:
// Через стандартну серіалізацію:
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(original);
}
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
try (ObjectInputStream ois = new ObjectInputStream(bais)) {
User deepCopy = (User) ois.readObject();
}
Мінуси: колосальні накладні витрати процесора та пам’яті (у 50–100 разів повільніше за конструктори), вимагає реалізації інтерфейсу Serializable.
🔴 Senior Level
Проблема циклічних посилань у графі об’єктів (Graph Cycles)
Якщо об’єкт $A$ посилається на $B$, а $B$ посилається назад на $A$ ($A \leftrightarrow B$):
- Наївна рекурсивна реалізація Deep Copy через конструктори призведе до нескінченної рекурсії та аварійного завершення JVM із
StackOverflowError. - Промислові алгоритми глибокого копіювання (наприклад, у серіалізаторах Kryo, Jackson чи Apache Commons
SerializationUtils) використовують карту ідентичності:Map<Object, Object> visited = new IdentityHashMap<>(); - Перед створенням копії алгоритм перевіряє
visited.containsKey(original). Якщо об’єкт уже копіювався, повертається готове посилання з мапи, що розриває цикли та зберігає топологію вихідного графа.
Альтернатива: Структурне розділення (Structural Sharing)
Виконання повного $O(N)$ глибокого копіювання при кожній модифікації стану суттєво знижує пропускну здатність у високонавантажених сервісах (HFT, ігрові рушії, розподілені сховища). Рішення — персистентні структури даних (Persistent Data Structures):
- Використовуються збалансовані префіксні дерева (HAMT — Hash Array Mapped Trie, реалізовані у Vavr або Clojure).
- При «модифікації» створюється не повна копія, а копіюються лише вузли дерева вздовж шляху від кореня до змінюваного листка ($O(\log_{32} N)$).
- Усі інші гілки дерева (до 98% вузлів) безпечно розділяються між старою та новою версіями (Structural Sharing), гарантуючи незмінність без витрат на глибоке копіювання.
До додавання елемента: Після додавання нового вузла X:
[Root 1] [Root 2]
/ \ / \
[Node A] [Node B] [Node A'] [Node B] (СПІЛЬНИЙ!)
/ \ / \
[Leaf 1] [Leaf 2] [Leaf 1] [Node X]
4 Tricky Questions
1. Як обробити циклічні посилання при реалізації глибокого копіювання вручну?
Відповідь:
Для запобігання StackOverflowError метод глибокого копіювання повинен приймати контекст обходу — структуру IdentityHashMap<Object, Object> visited, яка зіставляє оригінальні об’єкти з їхніми вже створеними копіями:
- Перед інстанціюванням копії перевіряється:
if (visited.containsKey(obj)) return (T) visited.get(obj);. - До рекурсивного заповнення полів новостворена копія реєструється в мапі:
visited.put(obj, copy);. - Поля копії заповнюються рекурсивними викликами
deepCopy(field, visited). Якщо поле посилається назад на батьківський об’єкт, метод поверне вже зареєстровану копію, коректно замкнувши цикл посилань.
2. Чому метод Object.clone() виконує саме поверхневе побітове копіювання, і чому він обходить виклик конструкторів?
Відповідь:
Object.clone() — це нативний метод JVM (JVM_Clone), реалізований на C++.
Він працює на рівні низькорівневого копіювання блоків пам’яті (аналог системного виклику memcpy):
- Виділяється блок пам’яті такого ж розміру в купі, і в нього побайтово копіюється вміст усіх полів вихідного об’єкта (значення примітивів та 32/64-бітні адреси посилань на інші об’єкти).
- Конструктори класу взагалі не викликаються, оскільки пам’ять ініціалізується прямим копіюванням бітів. З цієї причини вкладені посилальні типи отримують точні копії вказівників (адрес пам’яті), що й обумовлює суто поверхневе копіювання (Shallow Copy).
3. У чому різниця у швидкодії між глибоким копіюванням через Java Serialization та конструктором копіювання?
Відповідь: Різниця у швидкодії досягає двох порядків (у 50–100 разів) на користь конструктора копіювання:
- Java Serialization: супроводжується колосальним навантаженням: інспекція метаданих класів через JVM reflection, запис службових заголовків потоку, створення проміжних байтових буферів (
ByteArrayOutputStream), перехоплення дескрипторів класів та постійна алокація об’єктів у купі. - Конструктор копіювання: являє собою прямий скомпільований Java-код. JIT-компілятор C2 оптимізує такі виклики, повністю інлайнить створення об’єктів та звертається до полів безпосередньо за фіксованими зміщеннями в заголовку об’єкта без жодної рефлексії та серіалізації.
4. Чи достатньо Shallow Copy для колекції, якщо її елементами є об’єкти java.lang.String або java.math.BigDecimal?
Відповідь:
Так, абсолютно достатньо!
Оскільки String, BigDecimal, UUID та примітивні обгортки (Integer, Long) є глибоко незмінними (Immutable), їхній внутрішній стан неможливо модифікувати жодним публічним методом API.
Тому спільне використання посилань на одні й ті самі екземпляри між оригіналом і копією є на 100% безпечним: ніхто не зможе змінити стан об’єкта за посиланням. Створення Deep Copy для таких об’єктів було б марною тратою оперативної пам’яті та процесорного часу.
🎯 Шпаргалка для інтерв’ю
Зведена таблиця порівняння
| Критерій | Shallow Copy (Поверхнева) | Deep Copy (Глибока) |
| :— | :— | :— |
| Що копіюється | Тільки посилання на вкладені об’єкти | Повні дублікати всіх об’єктів |
| Ізоляція оригіналу | ❌ Часткова (вкладені об’єкти спільні) | ✅ Повна ізоляція |
| Час виконання | $O(N)$ для контейнера | $O(V + E)$ рекурсивний обхід графа |
| Коли застосовувати | Елементи незмінні (String, примітиви) | Елементи змінювані (мутабельні) |
| Ризик StackOverflowError| Виключено | Можливий при циклічних посиланнях |
Червоні прапорці на співбесіді (Чого говорити не можна)
- ❌ «Метод
Object.clone()за замовчуванням виконує глибоке копіювання» — він виконує лише побітове поверхневе копіювання. - ❌ «Для колекцій рядків завжди потрібно робити Deep Copy» — рядки незмінні, Shallow Copy є абсолютно безпечною та оптимальною.
- ❌ «Серіалізація через JSON або Java IO — відмінний спосіб глибокого копіювання в продакшені» — це найповільніший спосіб із можливих.
- ❌ «Конструктор копіювання легко рекурсивно копіює будь-які структури» — за наявності циклічних посилань він обов’язково впаде з
StackOverflowErrorбез картиvisited.