W3docs

Лучшие практики иммутабельности в Java

Почему иммутабельность — хороший выбор по умолчанию в Java и паттерны для безопасного создания иммутабельных типов.

Иммутабельный объект — это объект, состояние которого не может измениться после создания. Одно это свойство устраняет целый класс ошибок: нет неожиданных мутаций из другого потока, нет сюрпризов с псевдонимами, когда две переменные разделяют состояние, и нет необходимости делать защитные копии при каждом чтении. В Java иммутабельность не даётся автоматически — её нужно выстраивать намеренно. В этой главе рассматриваются паттерны, которые делают тип по-настоящему иммутабельным, и привычки, которые позволяют сохранять это свойство.

Почему иммутабельность — хороший выбор по умолчанию

Изменяемое общее состояние — корень большинства ошибок конкурентности и удивительно большого числа однопоточных тоже. Когда объект не может измениться, его можно свободно передавать, кешировать и рассуждать о нём, не отслеживая, кто ещё держит на него ссылку.

СвойствоИзменяемый объектИммутабельный объект
ПотокобезопасностьТребует блокировок или осторожностиИзначально потокобезопасен
Безопасность передачиНет — вызывающий код может мутироватьДа — можно раздавать один экземпляр
Безопасность как ключ mapРискованно — hashCode может менятьсяДа — идентичность стабильна
КешированиеНужна инвалидация при измененииКешировать вечно
РассуждениеНужно отслеживать каждую записьЗначение фиксировано при создании

Цена — выделение памяти: изменение одного поля требует создания нового объекта. В большинстве случаев эта цена пренебрежимо мала, а безопасность того стоит. Прибегайте к изменяемости только тогда, когда профилирование доказывает необходимость.

Пять правил иммутабельного класса

Класс является иммутабельным, если выполняются все следующие условия. Нарушив одно из них, вы позволите вызывающему коду добраться до состояния и изменить его.

  1. Класс объявлен final (или все конструкторы закрыты), чтобы его нельзя было расширить с изменяемым поведением.
  2. Все поля — private final.
  3. Нет сеттеров — и никаких других методов, изменяющих поле.
  4. Изменяемые поля защитно копируются на входе, чтобы ссылка вызывающего кода не могла использоваться для мутации состояния.
  5. Геттеры никогда не возвращают изменяемый внутренний объект напрямую — возвращайте копию или немодифицируемое представление.
public final class Money {
    private final long cents;
    private final String currency;

    public Money(long cents, String currency) {
        this.cents = cents;
        this.currency = currency;
    }

    public long cents() { return cents; }
    public String currency() { return currency; }

    // "Change" returns a new object instead of mutating this one.
    public Money plus(Money other) {
        return new Money(this.cents + other.cents, currency);
    }
}

Защитные копии для изменяемых полей

Примитивы и String уже иммутабельны, поэтому хранить их безопасно. Опасность представляют изменяемые поля — массивы, коллекции, даты. Если вы напрямую сохраняете ссылку вызывающего кода, тот сохраняет дескриптор к вашим внутренним данным.

public final class Schedule {
    private final List<String> slots;

    public Schedule(List<String> slots) {
        // Copy IN: the caller can't mutate our list later.
        this.slots = List.copyOf(slots);
    }

    public List<String> slots() {
        // copyOf already returns an unmodifiable list, so this is safe to hand out.
        return slots;
    }
}

List.copyOf, Set.copyOf и Map.copyOf (Java 10+) выполняют оба действия сразу: копируют данные и возвращают немодифицируемое представление. Для массивов используйте array.clone() на входе и clone() снова на выходе, поскольку массивы всегда изменяемы и не имеют обёртки только для чтения.

Records: иммутабельность по конструкции

record (Java 16+) — наиболее лаконичный способ объявить иммутабельный носитель данных. Компилятор генерирует поля private final, канонический конструктор, методы доступа и основанные на значении equals/hashCode/toString.

public record Point(int x, int y) {
    // Compact constructor for validation and defensive copying.
    public Point {
        if (x < 0 || y < 0) {
            throw new IllegalArgumentException("coordinates must be non-negative");
        }
    }
}

Records прекрасно покрывают типичные случаи, но они не являются магическим щитом: если компонент record — изменяемый тип (например, List), вы всё равно должны защитно скопировать его в компактном конструкторе, поскольку сгенерированный метод доступа возвращает сохранённую ссылку как есть.

Создание изменённых копий: паттерн «wither»

Поскольку иммутабельный объект нельзя изменить, вы создаёте модифицированную копию. Соглашение — это метод withX, который возвращает новый экземпляр с одним изменённым полем и остальными перенесёнными.

public final class User {
    private final String name;
    private final String email;

    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public User withEmail(String newEmail) {
        return new User(this.name, newEmail); // new object, original untouched
    }
}

Это сохраняет исходный объект безопасным для совместного использования, позволяя вызывающему коду создавать варианты. Та же модель используется внутри JDK — LocalDate.plusDays, String.replace и BigDecimal.add — все они возвращают новые экземпляры, не изменяя получателя.

Полный запускаемый пример

Программа ниже создаёт небольшой иммутабельный Account, затем пробует все трюки, которые вызывающий код может использовать для его мутации — передаёт список и мутирует оригинал, мутирует возвращённый список и переименовывает. Она доказывает, что каждая защита работает, а затем показывает, почему иммутабельные значения являются безопасными ключами map.

java— editable, runs on the server

Что следует вынести из запуска:

  • Roles after mutating source: [read, write] доказывает, что защитная копия сработала — добавление admin в оригинальный список так и не попало в Account.
  • UnsupportedOperationException при вызове acc.roles().add("hacker") показывает, что геттер вернул немодифицируемое представление, поэтому вызывающий код не может мутировать внутренние данные через него.
  • Original name still: Ada рядом с New object name: Grace доказывает, что withName создал копию и оставил оригинал нетронутым.
  • Different instance: true подтверждает, что wither вернул подлинно новый объект, а не ту же ссылку.
  • Records equal by value: true и Key still found: origin-ish показывают, что иммутабельные значения сравниваются и хешируются по содержимому, делая их надёжными ключами HashMap.

Практика

Практика
Когда иммутабельный класс хранит изменяемое поле, например List, почему в конструкторе необходимо делать защитную копию?
Когда иммутабельный класс хранит изменяемое поле, например List, почему в конструкторе необходимо делать защитную копию?
Was this page helpful?