W3docs

Неизменяемые классы в Java

Создание неизменяемых классов в Java: final-поля, защитные копии и отсутствие сеттеров.

Неизменяемый класс — это класс, экземпляры которого нельзя изменить после создания. String, Integer, LocalDate, BigDecimal, UUID — стандартная библиотека Java полна ими, и это не случайно. Неизменяемые объекты безопасно разделять между потоками, использовать в качестве ключей HashMap, кэшировать и легко понимать: увидев объект однажды, вы знаете его состояние на всю оставшуюся жизнь.

Сделать класс неизменяемым — это не добавить одно ключевое слово, а следовать нескольким правилам одновременно. Упустите одно — и получите класс, который выглядит неизменяемым, но таковым не является.

Пять правил

Чтобы сделать класс по-настоящему неизменяемым:

  1. Объявите класс final (или используйте только закрытые конструкторы). Иначе подкласс может нарушить контракт.
  2. Сделайте каждое поле private final. final запрещает переназначение после создания объекта; private не позволяет вызывающему коду обращаться к полям напрямую.
  3. Не добавляйте сеттеры. Любой метод изменения (add, set, clear, reset) недопустим.
  4. Делайте защитные копии изменяемых входных данных в конструкторе. Если вызывающий код передаёт Date или List, скопируйте их — иначе их можно изменить снаружи, и ваш «неизменяемый» объект изменится вместе с ними.
  5. Делайте защитные копии изменяемых возвращаемых значений в геттерах — по той же причине, только в обратном направлении.

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

Внимание

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);
  }
}

Когда записи подходят, это кратчайший путь к корректному неизменяемому классу.

Разобранный пример

java— editable, runs on the server

Что дальше

Неизменяемые классы — это контроль над изменением. Последняя глава части 6 посвящена контролю над количеством — классу, у которого существует только один экземпляр. Переходите к паттерну одиночки в Java.

Практика

Практика
Зачем нужна защитная копия в конструкторе неизменяемого класса, хранящего изменяемый аргумент, например `List` или массив?
Зачем нужна защитная копия в конструкторе неизменяемого класса, хранящего изменяемый аргумент, например `List` или массив?
Was this page helpful?