Лучшие практики иммутабельности в Java
Почему иммутабельность — хороший выбор по умолчанию в Java и паттерны для безопасного создания иммутабельных типов.
Иммутабельный объект — это объект, состояние которого не может измениться после создания. Одно это свойство устраняет целый класс ошибок: нет неожиданных мутаций из другого потока, нет сюрпризов с псевдонимами, когда две переменные разделяют состояние, и нет необходимости делать защитные копии при каждом чтении. В Java иммутабельность не даётся автоматически — её нужно выстраивать намеренно. В этой главе рассматриваются паттерны, которые делают тип по-настоящему иммутабельным, и привычки, которые позволяют сохранять это свойство.
Почему иммутабельность — хороший выбор по умолчанию
Изменяемое общее состояние — корень большинства ошибок конкурентности и удивительно большого числа однопоточных тоже. Когда объект не может измениться, его можно свободно передавать, кешировать и рассуждать о нём, не отслеживая, кто ещё держит на него ссылку.
| Свойство | Изменяемый объект | Иммутабельный объект |
|---|---|---|
| Потокобезопасность | Требует блокировок или осторожности | Изначально потокобезопасен |
| Безопасность передачи | Нет — вызывающий код может мутировать | Да — можно раздавать один экземпляр |
| Безопасность как ключ map | Рискованно — hashCode может меняться | Да — идентичность стабильна |
| Кеширование | Нужна инвалидация при изменении | Кешировать вечно |
| Рассуждение | Нужно отслеживать каждую запись | Значение фиксировано при создании |
Цена — выделение памяти: изменение одного поля требует создания нового объекта. В большинстве случаев эта цена пренебрежимо мала, а безопасность того стоит. Прибегайте к изменяемости только тогда, когда профилирование доказывает необходимость.
Пять правил иммутабельного класса
Класс является иммутабельным, если выполняются все следующие условия. Нарушив одно из них, вы позволите вызывающему коду добраться до состояния и изменить его.
- Класс объявлен
final(или все конструкторы закрыты), чтобы его нельзя было расширить с изменяемым поведением. - Все поля —
private final. - Нет сеттеров — и никаких других методов, изменяющих поле.
- Изменяемые поля защитно копируются на входе, чтобы ссылка вызывающего кода не могла использоваться для мутации состояния.
- Геттеры никогда не возвращают изменяемый внутренний объект напрямую — возвращайте копию или немодифицируемое представление.
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.
Что следует вынести из запуска:
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.