Як правильно працювати з колекціями в незмінних класах
Колекції — найвразливіший та найскладніший аспект при проєктуванні незмінних класів. Колекція в Java є посилальним змінним об'єктом, тому ключового слова final біля поля недоста...
🟢 Junior Level
30-секундна відповідь
Колекції — найвразливіший та найскладніший аспект при проєктуванні незмінних класів. Колекція в Java є посилальним змінним об’єктом, тому ключового слова final біля поля недостатньо.
Щоб гарантувати 100% незмінність, необхідно дотримуватися 3 золотих правил:
- Ніколи не зберігати посилання на вхідну колекцію: у конструкторі завжди створювати захисну копію (Defensive Copy) через
List.copyOf(). - Ніколи не віддавати змінну колекцію назовні: якщо поле незмінне (
List.copyOf), повертати його напряму; якщо поле змінне — огортати вCollections.unmodifiableList(). - Забезпечувати глибокий захист: якщо елементи всередині колекції мутабельні, захистити сам список недостатньо — необхідно робити глибоке копіювання (Deep Copy) кожного елемента.
Наочний приклад: Уразливості та їх вирішення
// ❌ ВРАЗЛИВИЙ КЛАС:
public final class VulnerableGroup {
private final List<String> members; // final захищає лише посилання на список!
public VulnerableGroup(List<String> members) {
this.members = members; // ПОМИЛКА 1: Збережено пряме посилання!
}
public List<String> getMembers() {
return members; // ПОМИЛКА 2: Віддано посилання на змінний список!
}
}
// ✅ ПРАВИЛЬНИЙ НЕЗМІННИЙ КЛАС (Java 10+):
public final class ImmutableGroup {
private final List<String> members;
public ImmutableGroup(List<String> members) {
// Захисна копія: ізолює від змін оригіналу та забороняє модифікацію!
this.members = (members == null) ? List.of() : List.copyOf(members);
}
public List<String> getMembers() {
// Безпечно: List.copyOf вже гарантує незмінність
return members;
}
}
🟡 Middle Level
Еволюція методів захисту колекцій у версіях Java
- До Java 10 (Епоха Java 8):
// Потрібна була подвійна дія: скопіювати дані та огорнути в декоратор this.list = Collections.unmodifiableList(new ArrayList<>(inputList)); - У Java 10+ (Сучасний стандарт):
this.list = List.copyOf(inputList); // Одразу створює компактний незмінний список! - У Java 16+ (Stream API):
this.list = inputList.stream().map(Item::copy).toList(); // Незмінний список
Аналогічні методи застосовуються для множин та карт: Set.copyOf(set) та Map.copyOf(map).
Пастка Shallow Immutability (Поверхневого захисту)
List.copyOf() гарантує, що методи add(), remove() та clear() заблоковані. Проте якщо елементами списку є звичайні мутабельні об’єкти, їхні поля можна вільно змінити:
public record Department(String name, List<Employee> staff) {
public Department {
staff = List.copyOf(staff); // Захищений лише контейнер!
}
}
// Злам стану:
Department dept = getDepartment();
// dept.staff().add(...) кине виняток, але:
dept.staff().get(0).setSalary(1_000_000); // 💥 Стан відділу мутував!
Рішення: Глибоке копіювання (Deep Copy) елементів
Якщо елементи мутабельні, у конструкторі необхідно рекурсивно скопіювати кожен об’єкт:
public record Department(String name, List<Employee> staff) {
public Department {
staff = (staff == null) ? List.of() : staff.stream()
.map(Employee::new) // Конструктор копіювання кожного співробітника
.toList();
}
}
Якщо ж елементами є незмінні типи (String, Integer, record, LocalDate), глибоке копіювання надлишкове.
🔴 Senior Level
Records та колекції: Компактний конструктор
У записах (record) гетери генеруються компілятором автоматично й повертають пряме посилання на поле компонента.
Тому єдине місце для захисту колекції — компактний конструктор:
public record SecurityPolicy(String name, Set<String> permissions) {
public SecurityPolicy {
Objects.requireNonNull(name, "Name required");
// Захисна копія обов'язкова!
permissions = (permissions == null) ? Set.of() : Set.copyOf(permissions);
}
}
JMM §17.5 Final Field Freeze та колекції
Старший інженер повинен розуміти різницю між заморожуванням посилання та заморожуванням вмісту:
- Модифікатор
finalбіля поляprivate final List<String> listгарантує виконання дії Freeze Action за специфікацією JMM §17.5 лише для самого посиланняlist. - Якщо поле посилається на стандартний
new ArrayList<>(), то його внутрішній масивelementData[]та лічильникsizeне захищені семантикою final-полів! - Потік на іншому ядрі CPU може прочитати посилання на
ArrayList, але побачити неініціалізований внутрішній масивnullабо прочитатиsize = 0. - Висновок: використання
List.copyOf()критичне не лише для незмінності, але й для безпечної публікації (Safe Publication), оскільки внутрішні реалізаціїImmutableCollectionsтакож використовують строго фінальні поля для своїх елементів.
Оптимізація $O(1)$ у List.copyOf()
Розробники OpenJDK реалізували елегантну оптимізацію:
static <E> List<E> copyOf(Collection<? extends E> coll) {
if (coll instanceof ImmutableCollections.AbstractImmutableList) {
return (List<E>) coll; // Повернення this за O(1) без виділення пам'яті!
}
return (List<E>) List.of(coll.toArray());
}
Якщо в List.copyOf() передати колекцію, яку вже було створено через List.of() або List.copyOf(), JVM не дублює масив, а миттєво повертає вихідний екземпляр за $O(1)$, зберігаючи процесорний час та пам’ять.
4 Tricky Questions
1. Що станеться, якщо передати в List.copyOf(list) список, що містить null? Як правильно обробити такий сценарій?
Відповідь:
Метод List.copyOf() негайно кине NullPointerException, оскільки фабрики незмінних колекцій у Java 9+ є суворо нетерпимими до null (null-hostile).
Якщо за контрактом бізнес-логіки в колекції легітимно можуть бути присутніми елементи null:
- Використовувати зв’язку Java 8:
Collections.unmodifiableList(new ArrayList<>(input)), яка коректно зберігає та захищає елементиnull. - Або використати метод
Stream.toList()(Java 16+), який повертає незмінний список, але при цьому допускає наявність елементівnull:this.list = input == null ? List.of() : input.stream().toList();
2. Чи захистить колекцію SortedSet або TreeSet простий виклик Set.copyOf(sortedSet)? Які приховані проблеми тут виникають?
Відповідь:
Виклик Set.copyOf(sortedSet) захистить колекцію від модифікацій, але повністю зруйнує порядок сортування та контракт компаратора:
- Метод
Set.copyOf()повертає реалізаціюImmutableCollections.SetN, яка є невпорядкованою хеш-множиною з рандомізованим порядком обходу! - Усе сортування елементів буде безповоротно втрачено, а кастомний
Comparatorпроігноровано. - Як захистити правильно: для впорядкованих колекцій необхідно використовувати спеціалізований метод:
this.sortedSet = Collections.unmodifiableSortedSet(new TreeSet<>(sortedSet)); // або Collections.unmodifiableNavigableSet(new TreeSet<>(navigableSet));
3. Чому створення захисної копії через new ArrayList<>(input) з наступним Collections.unmodifiableList(...) у багатопотоковому коді піддається уразливості TOCTOU?
Відповідь:
Якщо вхідна колекція input передана з іншого потоку і в цей момент модифікується:
- Конструктор
new ArrayList<>(input)викликаєinput.toArray(). - Якщо інший потік паралельно викликає
input.remove(), розмір масиву та фактична кількість елементів розходяться. МетодArrays.copyOf()у надрахArrayListпадає зIndexOutOfBoundsExceptionабо записуєnullу комірки. - Якщо перед копіюванням виконувалася валідація (
if (input.size() > 10) throw ...), то між перевіркою та копіюванням інший потік може змінити розмір колекції (Time-of-Check to Time-of-Use — TOCTOU). - Правило: валідація колекції має виконуватися строго ПІСЛЯ створення локальної копії!
4. У якому випадку використання бібліотеки Vavr є кращим за стандартний List.copyOf() при проєктуванні незмінних класів?
Відповідь: Бібліотека Vavr є кращою, коли незмінний об’єкт часто «еволюціонує» (додаються або видаляються елементи):
- При використанні
List.copyOf()додавання одного елемента вимагає створення нового списку та повного $O(N)$ копіювання масиву елементів. Для списку з 10 000 елементів це дорого. - Персистентні колекції Vavr використовують Structural Sharing (розділення структури): додавання елемента в
io.vavr.collection.Listзаймає $O(1)$ за часом та пам’яттю (новий вузол-голова просто посилається на старий хвіст). - Якщо колекція велика і часто оновлюється, використання персистентних структур Vavr кардинально знижує навантаження на CPU та збирач сміття.
🎯 Шпаргалка для інтерв’ю
3 правила роботи з колекціями в незмінних класах
- Вхід (Конструктор): завжди робити захисну копію (
List.copyOf(input)). - Вихід (Гетер): повертати незмінну колекцію (якщо зроблено
List.copyOf, можна віддавати напряму). - Глибина: якщо елементи колекції мутабельні, робити глибоке копіювання (Deep Copy) кожного елемента.
Порівняння методів захисту колекцій
| Метод | Версія Java | Робить копію? | Дозволений null? | Зв’язок з оригіналом |
| :— | :— | :—: | :—: | :— |
| List.copyOf(list) | Java 10+ | ✅ Так ($O(N)$ або $O(1)$) | ❌ NPE | 🟢 Розірваний |
| Collections.unmodifiableList(list) | Java 1.2+ | ❌ Ні (обгортка) | ✅ Так | 🔴 Живий зв’язок! |
| Collections.unmodifiableList(new ArrayList<>(list)) | Java 1.2+ | ✅ Так ($O(N)$) | ✅ Так | 🟢 Розірваний |
| stream.toList() | Java 16+ | ✅ Так ($O(N)$) | ✅ Так | 🟢 Розірваний |
Червоні прапорці на співбесіді (Чого говорити не можна)
- ❌ «Якщо поле оголошене як
private final List<String> list, то колекція захищена від змін» — final захищає лише посилання, у список можна вільно додавати елементи черезlist.add(). - ❌ «Collections.unmodifiableList(original) захищає від змін вихідного списку» — це обгортка; зміна оригіналу відобразиться в обгортці.
- ❌ «List.copyOf() захищає мутабельні об’єкти всередині списку» — це поверхневий захист (Shallow Copy); поля об’єктів усередині списку можна змінити.