RU | EN | DE

Что такое «неизменяемый объект» и зачем он нужен

Неизменяемый объект — это объект, чьё состояние невозможно изменить после построения (конструкции).
Плюсы:

  • Потокобезопасность по умолчанию (без синхронизации).
  • Простые инварианты: если объект валиден в конструкторе — он валиден всегда.
  • Безопасное шаринг/кэширование (можно свободно передавать между потоками, класть в кэши).
  • Удобно как ключи в Map/элементы в Set (стабильный hashCode).
  • Проще в тестировании и reasoning.

Правила конструирования immutable-класса

  1. Сделать класс final (или все поля private final + запретить наследование «логически»).
  2. Все поля — private final.
  3. Инициализация только в конструкторе (после — никаких сеттеров).
  4. Не «утекать this» из конструктора (не передавать ссылку на себя наружу до завершения инициализации).
  5. Дефенсивное копирование (defensive copy) для:
    • входящих изменяемых аргументов (массивы, коллекции, Date, любые mutable-типы);
    • возвращаемых значений (геттеры не должны возвращать «живые» внутренние ссылки).
  6. Глубокая неизменяемость: если поле — коллекция/массив/объект, позаботься о глубокой защите (см. ниже).

Базовый пример (правильный)

import java.util.List;
import java.util.Objects;
 
public final class Money {
    private final String currency;      // неизменяемый тип (String — immutable)
    private final long minorUnits;      // копейки/центы
    private final List<Integer> tags;   // ПРИМ.: коллекция — mutable по природе
 
    public Money(String currency, long minorUnits, List<Integer> tags) {
        this.currency   = Objects.requireNonNull(currency);
        this.minorUnits = minorUnits;
        // defensive copy + неизменяемая обёртка
        this.tags = List.copyOf(tags);  // Java 10+: по сути unmodifiable snapshot
    }
 
    public String currency()   { return currency; }
    public long   minorUnits() { return minorUnits; }
 
    public List<Integer> tags() {
        // можно вернуть прямо tags, т.к. copyOf сделал неизменяемый снимок
        return tags;
    }
 
    // функциональный стиль "с изменением": создать НОВЫЙ объект с другим значением
    public Money withMinorUnits(long newMinorUnits) {
        return new Money(this.currency, newMinorUnits, this.tags);
    }
 
    @Override public boolean equals(Object o) { /* стандартно по двум полям + tags */ }
    @Override public int hashCode()          { /* консистентен с equals */ }
    @Override public String toString()       { /* удобно для логов */ }
}

Пояснения:

  • List.copyOf(...) возвращает неизменяемый снимок текущего содержимого (и бросит NullPointerException при null-элементах).
  • Если нужна глубокая неизменяемость (элементы коллекции сами mutable) — копируй элементы (см. «глубина неизменяемости»).

Работа с массивами и коллекциями

Массивы

Массивы изменяемы. Значит:

  • В конструкторе: this.arr = Arrays.copyOf(arr, arr.length);
  • В геттере: return Arrays.copyOf(arr, arr.length);

Коллекции

  • Для поверхностной неизменяемости: List.copyOf, Set.copyOf, Map.copyOf (Java 10+) или Collections.unmodifiableXxx(...).
  • unmodifiableList только запрещает мутацию через wrapper, но если держишь ссылку на исходную mutable-коллекцию — изменения видны. Поэтому:
    • либо создавай новую коллекцию и оборачивай её,
    • либо используй copyOf (делает snapshot).
  • Для глубокой неизменяемости: копируй и элементы (если они mutable).

Частые ошибки (интервью любят)

  1. Возврат живой ссылки на внутреннюю коллекцию/массив:
    «immutable» класс, который отдаёт getList() и тот можно изменить — это не immutable.
  2. Нет defensive copy в конструкторе:
    передали список, сохранили ссылку как есть → внешняя сторона изменит список после конструктора — класс «сломался».
  3. Mutable-типы внутри immutable-оболочки:
    хранить java.util.Date (mutable) без копии → изменения через ссылку. Использовать java.time (immutable).
  4. Наследование от immutable-класса:
    потомок может добавить изменяемое состояние/методы — разломает гарантию. Итог: делай базовый класс final (или конструктор private + фабрики).
  5. Нарушение equals/hashCode при «мутации»:
    если объект как ключ в HashMap, его hashCode должен оставаться стабильным. Поэтому — immutability.

Неизменяемость и record (Java 16+)

record — синтаксический сахар для «данных». Пример:

public record Point(int x, int y) {}

Особенности:

  • Все компоненты final, геттеры генерятся, equals/hashCode/toString уже готовы.
  • Но: неизменяемость поверхностная. Если поле — коллекция/массив/любой mutable-тип, нужно defensive copy в компактном конструкторе:
public record Team(String name, List<String> members) {
    public Team {
        name = Objects.requireNonNull(name);
        members = List.copyOf(members); // делаем snapshot
    }
}

Неизменяемость и Lombok

  • @Value → делает класс final, поля private final, геттеры, equals/hashCode.
  • Всё равно самостоятельно обеспечивай defensive copy для массивов/коллекций/Date.
@lombok.Value
public class UserDto {
    String name;
    List<String> roles; // в конструкторе сам сделай List.copyOf(...)
}

«Функциональные апдейты»: with*-методы

В immutable-модели изменения делают через создание нового объекта:

public Money withCurrency(String newCurrency) {
    if (this.currency.equals(newCurrency)) return this; // микро-оптимизация
    return new Money(newCurrency, this.minorUnits, this.tags);
}

Это удобно и безопасно для многопоточности.

Сериализация immutable-классов

  • Нормально сериализуются/десериализуются (Java/JSON/Protobuf).
  • Если нужен синглтон — добавь readResolve().
  • При JSON-десериализации (Jackson) пользуйся:
    • полноаргументным конструктором или
    • @JsonCreator + @JsonProperty.

Почему String — immutable в Java

  1. Безопасность
    Строки часто используются как идентификаторы, имена классов, ключи ресурсов, параметры ClassLoader’а, SQL-строки и т.п. Если бы кто-то мог изменить строку «на месте», это открывало бы массив уязвимостей (подмена пути, имени класса, SQL-инъекции на уровне объекта и т.д.).
  2. String Pool (интернирование)
    Литералы строк хранятся в пуле: одинаковые литералы указывают на один объект ради экономии памяти и ускорения сравнения == между литералами. Пул возможен только если строки неизменяемы (иначе изменение «Алисы» меняло бы все ссылки на неё).
  3. Кэширование hashCode
    String.hashCode() вычисляется один раз и кэшируется в поле. Если бы строку можно было менять, кэш стал бы неверен → нарушение контрактов Map/Set.
  4. Потокобезопасность и шаринг
    Одинаковая строка может свободно разделяться между потоками без синхронизации.
  5. Оптимизации JVM/JIT
    Неизменяемые объекты легче оптимизировать (escape-analysis, постоянный инлайн, константные свёртки).

Итог: неизменяемость — фундамент String ради безопасности, производительности и предсказуемости.

Мини-примеры: правильно/неправильно

❌ Неправильно (утечка внутреннего массива)

public final class Bad {
    private final int[] data;
    public Bad(int[] data) { this.data = data; }       // нет копии!
    public int[] getData() { return data; }            // отдали живую ссылку!
}

✅ Правильно

public final class Good {
    private final int[] data;
    public Good(int[] data) { this.data = data.clone(); }
    public int[] data()     { return data.clone(); }
}

✅ Правильно для дат: использовать java.time

public final class Booking {
    private final java.time.Instant from;
    private final java.time.Instant to;
    public Booking(Instant from, Instant to) {
        this.from = from; this.to = to; // Instant — immutable
    }
}

Глубина неизменяемости (deep immutability)

Если поле — список объектов Address, а Address — mutable, то:

  • Сделай immutable Address (предпочтительно), или
  • В конструкторе делай глубокое копирование элементов и оборачивай список в List.copyOf.
public record Address(String city, String street) {} // immutable
 
public final class Person {
    private final List<Address> addresses;
    public Person(List<Address> addresses) {
        this.addresses = List.copyOf(addresses); // элементы уже immutable
    }
    public List<Address> addresses() { return addresses; }
}

Best practices (короткий чек-лист)

  • Делай final класс + все поля private final.
  • Без сеттеров. «Изменения» — через with*-методы, создающие новый объект.
  • Defensive copy для массивов/коллекций/mutable-типов и на входе, и на выходе.
  • Используй java.time вместо Date/Calendar.
  • Для коллекций — List.copyOf/Map.copyOf (или Collections.unmodifiableXxx + предварительная копия).
  • Не допускай escape this в конструкторе (никаких колбэков/регистрации слушателей там).
  • Храни инварианты в одном месте — в конструкторе/фабрике, проверяй requireNonNull, диапазоны и т.д.
  • Для производительности можно кэшировать производные значения (например, hashCode) — это безопасно, если объект immutable.