Неизменяемые классы в Java
Создание неизменяемых классов в Java: final-поля, защитные копии и отсутствие сеттеров.
Неизменяемый класс — это класс, экземпляры которого нельзя изменить после создания. String, Integer, LocalDate, BigDecimal, UUID — стандартная библиотека Java полна ими, и это не случайно. Неизменяемые объекты безопасно разделять между потоками, использовать в качестве ключей HashMap, кэшировать и легко понимать: увидев объект однажды, вы знаете его состояние на всю оставшуюся жизнь.
Сделать класс неизменяемым — это не добавить одно ключевое слово, а следовать нескольким правилам одновременно. Упустите одно — и получите класс, который выглядит неизменяемым, но таковым не является.
Пять правил
Чтобы сделать класс по-настоящему неизменяемым:
- Объявите класс
final(или используйте только закрытые конструкторы). Иначе подкласс может нарушить контракт. - Сделайте каждое поле
private final.finalзапрещает переназначение после создания объекта;privateне позволяет вызывающему коду обращаться к полям напрямую. - Не добавляйте сеттеры. Любой метод изменения (
add,set,clear,reset) недопустим. - Делайте защитные копии изменяемых входных данных в конструкторе. Если вызывающий код передаёт
DateилиList, скопируйте их — иначе их можно изменить снаружи, и ваш «неизменяемый» объект изменится вместе с ними. - Делайте защитные копии изменяемых возвращаемых значений в геттерах — по той же причине, только в обратном направлении.
Класс, соблюдающий все пять правил, является глубоко неизменяемым. Нарушение хотя бы одного из них разрушает гарантию.
final сам по себе — это не неизменяемость. final-поле нельзя переназначить, но если оно указывает на изменяемый объект — List, массив, Date — этот объект всё равно может меняться. final List<String> tags означает, что вы не можете заменить список на другой, но не то, что содержимое списка заморожено. Правила 4 и 5 существуют именно для того, чтобы закрыть эту брешь. Подробнее о том, что final обещает и не обещает, читайте в ключевое слово final в Java.
Минимальный пример
Если все поля класса являются примитивами или уже неизменяемыми типами, правила сводятся почти к нулю:
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() { return x; }
public int y() { return y; }
}int — примитив, поэтому защитное копирование не нужно. Класс помечен как final, поля — private final, сеттеров нет. Готово.
Изменяемые поля требуют защитных копий
Сложности начинаются, когда поле само по себе изменяемо — массив, Date, ArrayList. Если вы сохраняете ссылку вызывающего кода напрямую, он сохраняет доступ к ней и может изменить ваши внутренние данные:
// Broken: the array is shared
public final class Trajectory {
private final double[] points;
public Trajectory(double[] points) { this.points = points; }
public double[] points() { return points; }
}
double[] arr = {1.0, 2.0, 3.0};
Trajectory t = new Trajectory(arr);
arr[0] = 999; // mutates the "immutable" object!
System.out.println(t.points()[0]); // 999Решение — копировать при входе и при выходе:
public final class Trajectory {
private final double[] points;
public Trajectory(double[] points) {
this.points = points.clone(); // copy in
}
public double[] points() {
return points.clone(); // copy out
}
}Для коллекций эквивалентом является List.copyOf(other) (возвращает немодифицируемый список, основанный на копии):
public final class Recipe {
private final String name;
private final List<String> steps;
public Recipe(String name, List<String> steps) {
this.name = name;
this.steps = List.copyOf(steps); // copy + unmodifiable view
}
public List<String> steps() { return steps; } // already unmodifiable
}Обратите внимание на асимметрию с примером массива: clone() массива создаёт изменяемую копию, поэтому её нужно снова копировать при выходе. List.copyOf создаёт немодифицируемый список, поэтому геттер может возвращать его напрямую — любой вызывающий, попытавшийся изменить его, получит UnsupportedOperationException. По возможности предпочитайте неизменяемые типы коллекций: они устраняют целый класс ошибок с забытым копированием при выходе.
«Модификации» возвращают новые экземпляры
Неизменяемый класс всё равно может поддерживать изменение — возвращая новый экземпляр:
public final class Money {
private final long cents;
public Money plus(Money other) { return new Money(cents + other.cents); }
public Money times(int factor) { return new Money(cents * factor); }
// constructor + accessors omitted
}По соглашению метод называют with..., если он создаёт копию с одним изменённым полем: point.withX(5), user.withEmail("..."). API даты и времени Java последовательно использует этот паттерн — LocalDate.plusDays(7), LocalDate.withYear(2026).
Почему это важно
Неизменяемые объекты дают вам:
- Потокобезопасность бесплатно. Никаких блокировок,
volatile, сюрпризов с видимостью — нечего синхронизировать, потому что состояние не меняется. - Безопасное разделение и кэширование. Два вызывающих, держащих один и тот же
Money(2000, "USD"), не могут мешать друг другу. - Надёжные ключи хэш-таблицы. Поскольку поля, используемые в
hashCode, не меняются, «корзина» объекта никогда не устаревает. Изменяемый ключ, чей хэш меняется после помещения вHashMap, фактически теряется — см. equals и hashCode в Java. - Простота понимания. Увидев неизменяемый объект, вы знаете, как он будет вести себя до конца своей жизни. Никаких «где это было изменено?» расследований.
Расплата — выделение новых экземпляров при каждой «модификации». Для небольших часто используемых объектов (String, Integer) это редко является проблемой; JVM очень хорошо справляется с короткоживущими выделениями памяти. Для действительно затратных случаев существуют специальные техники (строковые строители, персистентные структуры данных) — но прибегайте к ним только тогда, когда профилирование выявило реальную проблему.
Записи берут на себя большую часть работы
Запись неявно является final, имеет private final-поля, генерирует аксессоры без сеттеров и бесплатно даёт equals/hashCode/toString:
public record Point(int x, int y) {}Это глубоко неизменяемый класс — при условии, что компоненты сами по себе неизменяемы. Для записей, содержащих изменяемый компонент (List, массив), всё равно нужен компактный конструктор с защитным копированием:
public record Recipe(String name, List<String> steps) {
public Recipe {
steps = List.copyOf(steps);
}
}Когда записи подходят, это кратчайший путь к корректному неизменяемому классу.
Разобранный пример
Что дальше
Неизменяемые классы — это контроль над изменением. Последняя глава части 6 посвящена контролю над количеством — классу, у которого существует только один экземпляр. Переходите к паттерну одиночки в Java.