Objects Should Be Immutable

The following text is a partial translation of the original English article, performed by ChatGPT (gpt-3.5-turbo) and this Jekyll plugin:

В объектно-ориентированном программировании объект считается неизменяемым, если его состояние не может быть изменено после создания. В Java хорошим примером неизменяемого объекта является String. После создания его состояние не может быть изменено. Мы можем запросить создание новых строк, но собственное состояние не изменится.

Однако в JDK не так много неизменяемых классов. Возьмем, к примеру, класс Date. Его состояние можно изменить с помощью метода setTime().

Я не знаю, почему разработчики JDK решили сделать эти два очень похожих класса по-разному. Однако я считаю, что дизайн изменяемого класса Date имеет много недостатков, в то время как неизменяемый класс String гораздо более соответствует объектно-ориентированной парадигме.

Кроме того, я считаю, что в идеальном объектно-ориентированном мире все классы должны быть неизменяемыми. К сожалению, иногда это технически невозможно из-за ограничений в JVM. Тем не менее, мы всегда должны стремиться к лучшему.

Это неполный список аргументов в пользу неизменяемости:

  • неизменяемые объекты проще создавать, тестировать и использовать

  • на самом деле неизменяемые объекты всегда безопасны для многопоточности

  • они помогают избежать временной связности

  • их использование не вызывает побочных эффектов (нет защитных копий)

  • проблема изменяемости идентичности избегается

  • они всегда имеют атомарность сбоя

  • они намного проще кэшировать

  • они предотвращают пустые ссылки, которые являются нежелательными

Давайте обсудим самые важные аргументы по одному.

Thread Safety

Первый и наиболее очевидный аргумент заключается в том, что неизменяемые объекты безопасны для работы с потоками. Это означает, что несколько потоков могут одновременно получать доступ к одному и тому же объекту, не сталкиваясь с другим потоком.

Если ни один из методов объекта не может изменять его состояние, не важно, сколько их и как часто они вызываются параллельно, они будут работать в своем собственном пространстве памяти в стеке.

Goetz и соавторы подробнее объяснили преимущества неизменяемых объектов в своей очень известной книге Java Concurrency in Practice (очень рекомендуется).

Avoiding Temporal Coupling

Вот пример временной связности (код выполняет два последовательных HTTP POST-запроса, где второй содержит тело HTTP).

Request request = new Request("http://localhost");
request.method("POST");
String first = request.fetch();
request.body("text=hello");
String second = request.fetch();

Этот код работает. Однако, необходимо помнить, что первый запрос должен быть настроен перед возможностью выполнения второго. Если мы решим удалить первый запрос из скрипта, мы также удалим вторую и третью строки, и не получим никаких ошибок от компилятора.

Request request = new Request("http://localhost");
// request.method("POST");
// String first = request.fetch();
request.body("text=hello");
String second = request.fetch();

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

Мы должны помнить, что второй запрос всегда должен оставаться вместе и выполняться после первого.

Если бы класс Request был неизменяемым, первый фрагмент кода вообще не работал бы и был бы переписан следующим образом:

final Request request = new Request("");
String first = request.method("POST").fetch();
String second = request.method("POST").body("text=hello").fetch();

Теперь эти два запроса не связаны друг с другом. Мы можем безопасно удалить первый запрос, и второй запрос все равно будет работать правильно. Вы можете указать на то, что есть дублирование кода. Да, мы должны избавиться от него и переписать код:

final Request request = new Request("");
final Request post = request.method("POST");
String first = post.fetch();
String second = post.body("text=hello").fetch();

Видите, рефакторинг ничего не сломал, и у нас все еще отсутствует временная связанность. Первый запрос может быть безопасно удален из кода, не затрагивая второй.

Я надеюсь, что этот пример показывает, что код, работающий с неизменяемыми объектами, более читаем и поддерживаем, потому что он не имеет временной связи.

Avoiding Side Effects

Давайте попробуем использовать наш класс Request в новом методе (теперь он изменяемый):

public String post(Request request) {
  request.method("POST");
  return request.fetch();
}

Давайте попробуем сделать два запроса: первый с помощью метода GET, а второй с помощью метода POST.

Request request = new Request("http://localhost");
request.method("GET");
String first = this.post(request);
String second = request.fetch();

Метод post() имеет “побочный эффект” - он вносит изменения в изменяемый объект request. В данном случае такие изменения не ожидаются. Мы ожидаем, что он выполнит POST-запрос и вернет его тело. Мы не хотим читать его документацию, чтобы узнать, что за кулисами он также изменяет передаваемый ему аргумент request.

Не нужно говорить, что такие побочные эффекты приводят к ошибкам и проблемам с поддержкой. Было бы намного лучше работать с неизменяемым Request.

public String post(Request request) {
  return request.method("POST").fetch();
}

В данном случае у нас может не быть никаких побочных эффектов. Никто не может изменить наш объект request, независимо от того, где он используется и насколько глубоко передается через стек вызовов методов.

Request request = new Request("http://localhost").method("GET");
String first = this.post(request);
String second = request.fetch();

Этот код абсолютно безопасен и не имеет побочных эффектов.

Avoiding Identity Mutability

Очень часто мы хотим, чтобы объекты были идентичными, если их внутреннее состояние одинаково. Класс Date является хорошим примером:

Date first = new Date(1L);
Date second = new Date(1L);
assert first.equals(second); // true

Есть два разных объекта; однако они равны друг другу, потому что их инкапсулированные состояния одинаковы. Это достигается благодаря специальной перегрузке методов equals() и hashCode().

Последствием использования такого удобного подхода с изменяемыми объектами является то, что каждый раз, когда мы изменяем состояние объекта, меняется его идентичность.

Date first = new Date(1L);
Date second = new Date(1L);
first.setTime(2L);
assert first.equals(second); // false

Это может выглядеть естественно, пока вы не начнете использовать изменяемые объекты в качестве ключей в картах.

Map<Date, String> map = new HashMap<>();
Date date = new Date();
map.put(date, "hello, world!");
date.setTime(12345L);
assert map.containsKey(date); // false

При изменении состояния объекта date мы не ожидаем, что он изменит свою идентичность. Мы не ожидаем, что потеряем запись в карте просто потому, что изменилось состояние ее ключа. Однако именно это происходит в приведенном выше примере.

Когда мы добавляем объект в карту, его hashCode() возвращает одно значение. Это значение используется HashMap для помещения записи во внутреннюю хэш-таблицу. Когда мы вызываем containsKey(), хэш-код объекта отличается (потому что он основан на его внутреннем состоянии), и HashMap не может найти его во внутренней хэш-таблице.

Это очень раздражающая и сложная в отладке побочный эффект изменяемых объектов. Неизменяемые объекты полностью избегают его.

Failure Atomicity

Вот простой пример:

public class Stack {
  private int size;
  private String[] items;
  public void push(String item) {
    size++;
    if (size > items.length) {
      throw new RuntimeException("stack overflow");
    }
    items[size] = item;
  }
}

Очевидно, что объект класса Stack останется в неправильном состоянии, если он вызывает исключение времени выполнения при переполнении. Его свойство size будет увеличено, в то время как items не получит новый элемент.

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

Для получения дополнительной информации по этой теме, прочтите Effective Java Джошуа Блоха.

Arguments Against Immutability

Существует несколько аргументов против неизменяемости.

  1. «Неизменяемость не предназначена для корпоративных систем». Очень часто слышу, как люди говорят, что неизменяемость - это модная функция, абсолютно непрактичная в реальных корпоративных системах. В качестве контраргумента могу привести примеры приложений, которые содержат только неизменяемые объекты Java: jcabi-http, jcabi-xml, jcabi-github, jcabi-s3, jcabi-dynamo, jcabi-w3c, jcabi-jdbc, jcabi-simpledb, jcabi-ssh. Все вышеперечисленные библиотеки Java работают исключительно с неизменяемыми классами/объектами. netbout.com и stateful.co - это веб-приложения, которые работают исключительно с неизменяемыми объектами.

  2. «Обновление существующего объекта дешевле, чем создание нового». Oracle считает, что «Влияние создания объекта часто преувеличивается и может быть уравновешено некоторыми преимуществами неизменяемых объектов, таких как снижение накладных расходов, связанных с сборкой мусора, и устранение необходимости защищать изменяемые объекты от повреждений». Я согласен.

Если у вас есть еще какие-либо аргументы, пожалуйста, напишите их ниже, и я постараюсь прокомментировать.

P.S. Проверьте takes.org, Java веб-фреймворк, который полностью состоит из неизменяемых объектов.

Translated by ChatGPT gpt-3.5-turbo/35 on 2023-09-09 at 05:54

sixnines availability badge   GitHub stars