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

Як правильно працювати з колекціями в незмінних класах

Колекції — найвразливіший та найскладніший аспект при проєктуванні незмінних класів. Колекція в Java є посилальним змінним об'єктом, тому ключового слова final біля поля недоста...


🟢 Junior Level

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

Колекції — найвразливіший та найскладніший аспект при проєктуванні незмінних класів. Колекція в Java є посилальним змінним об’єктом, тому ключового слова final біля поля недостатньо.

Щоб гарантувати 100% незмінність, необхідно дотримуватися 3 золотих правил:

  1. Ніколи не зберігати посилання на вхідну колекцію: у конструкторі завжди створювати захисну копію (Defensive Copy) через List.copyOf().
  2. Ніколи не віддавати змінну колекцію назовні: якщо поле незмінне (List.copyOf), повертати його напряму; якщо поле змінне — огортати в Collections.unmodifiableList().
  3. Забезпечувати глибокий захист: якщо елементи всередині колекції мутабельні, захистити сам список недостатньо — необхідно робити глибоке копіювання (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

  1. До Java 10 (Епоха Java 8):
    // Потрібна була подвійна дія: скопіювати дані та огорнути в декоратор
    this.list = Collections.unmodifiableList(new ArrayList<>(inputList));
    
  2. У Java 10+ (Сучасний стандарт):
    this.list = List.copyOf(inputList); // Одразу створює компактний незмінний список!
    
  3. У 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:

  1. Використовувати зв’язку Java 8: Collections.unmodifiableList(new ArrayList<>(input)), яка коректно зберігає та захищає елементи null.
  2. Або використати метод Stream.toList() (Java 16+), який повертає незмінний список, але при цьому допускає наявність елементів null:
    this.list = input == null ? List.of() : input.stream().toList();
    

2. Чи захистить колекцію SortedSet або TreeSet простий виклик Set.copyOf(sortedSet)? Які приховані проблеми тут виникають?

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

  1. Метод Set.copyOf() повертає реалізацію ImmutableCollections.SetN, яка є невпорядкованою хеш-множиною з рандомізованим порядком обходу!
  2. Усе сортування елементів буде безповоротно втрачено, а кастомний Comparator проігноровано.
  3. Як захистити правильно: для впорядкованих колекцій необхідно використовувати спеціалізований метод:
    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 правила роботи з колекціями в незмінних класах

  1. Вхід (Конструктор): завжди робити захисну копію (List.copyOf(input)).
  2. Вихід (Гетер): повертати незмінну колекцію (якщо зроблено List.copyOf, можна віддавати напряму).
  3. Глибина: якщо елементи колекції мутабельні, робити глибоке копіювання (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); поля об’єктів усередині списку можна змінити.

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