Статьи

Объекты должны быть неизменными

В объектно-ориентированном программировании объект является неизменным, если его состояние не может быть изменено после его создания.

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

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

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

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

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

  • неизменяемые объекты проще создавать, тестировать и использовать
  • действительно неизменяемые объекты всегда поточно-ориентированы
  • они помогают избежать временной связи
  • их использование не имеет побочных эффектов (без защитных копий)
  • проблема изменчивости идентичности избегается
  • у них всегда есть отказ атомности
  • их гораздо проще кешировать
  • они предотвращают пустые ссылки, которые являются плохими

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

Поток безопасности

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

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

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

Избежание временного соединения

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

1
2
3
4
5
Request request = new Request("http://example.com");
request.method("POST");
String first = request.fetch();
request.body("text=hello");
String second = request.fetch();

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

1
2
3
4
5
Request request = new Request("http://example.com");
// request.method("POST");
// String first = request.fetch();
request.body("text=hello");
String second = request.fetch();

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

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

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

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

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

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

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

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

Избежание побочных эффектов

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

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

Попробуем сделать два запроса — первый с методом GET, а второй с POST:

1
2
3
4
Request request = new Request("http://example.com");
request.method("GET");
String first = this.post(request);
String second = request.fetch();

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

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

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

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

1
2
3
Request request = new Request("http://example.com").method("GET");
String first = this.post(request);
String second = request.fetch();

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

Избежание изменчивости идентичности

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

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

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

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

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

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

1
2
3
4
5
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 не может найти его во внутренней хеш-таблице.

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

Отказ атомарности

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

01
02
03
04
05
06
07
08
09
10
11
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, 2nd Edition » Джошуа Блоха.

Аргументы против неизменности

Есть ряд аргументов против неизменности.

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

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

Ссылка: Объекты должны быть неизменными от нашего партнера JCG Егора Бугаенко в блоге About Programming .