Автор Дэйв Николетт

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

Ну и что?

Думаю, первый вопрос: «И что?» Кого вообще волнует неизменность?

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

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

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

Некоторые из ключевых преимуществ неизменяемых объектов:

  • Безопасность потоков
  • Атомарность отказа
  • Отсутствие скрытых побочных эффектов
  • Защита от ошибок нулевой ссылки
  • Легкость кеширования
  • Предотвращение идентичности мутации
  • Избежание временной привязки между методами
  • Поддержка ссылочной прозрачности
  • Защита от создания логически недействительных объектов
  • Защита от непреднамеренного повреждения существующих объектов

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

Почему это проблема для Java?

Неизменяемость обычно является хорошей идеей для большинства категорий программного обеспечения. Это не ограничивается только Java или объектно-ориентированным дизайном. Как и любая другая в целом хорошая идея, это не правило или догма. Программное обеспечение должно делать все необходимое, чтобы решить возникшую проблему. Есть много случаев, когда наличие изменяемых сущностей - это нормально. Тем не менее, в целом, неизменяемость помогает нам строить пригодный для жизни код. Итак, зачем выбирать Java?

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

Почему Java кажется более восприимчивой к этой проблеме, чем другие языки? В конце концов, есть много информации о ценности неизменяемости и множество рекомендаций о том, как проектировать неизменяемые объекты в Java. Официальные руководства Oracle по Java включают урок неизменности. Как разработчики изучают Java, не работая с этими руководствами? Они бесплатные и прямо изо рта лошади. Очевидная отправная точка для изучения языка.

Многие разработчики также поделились своими знаниями о неизменяемых объектах в Java. Например, у Дэвида О’Мира есть хорошее объяснение JavaRanch, которое основано непосредственно на базовом туроциале Java (без указания авторства; tsk, tsk). На мой взгляд, его пошаговое руководство и примеры более понятны, чем в руководстве по Java. Это только один пример. информации предостаточно.

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

Я предложу следующие возможные причины:

  • Большинство программистов на Java считают, что необходимо или требуется писать методы получения и установки для всех полей в классе. Они также, кажется, верят, что любой метод, имя которого начинается с get или set, не может содержать никакой логики, кроме извлечения или изменения значения поля. Так они изначально учатся писать код на Java и никогда не подвергают сомнению. По сути, это вряд ли лучше, чем объявить каждую область публичной. Эта практика может быть возвратом к соглашению о Java Beans, неправильно примененным к классам, которые не предназначены для использования в качестве Java Beans.
  • Ранние реализации контейнеров внедрения зависимостей, которые использовались с платформами веб-приложений на основе Java, требовали, чтобы классы определялись с конструкторами без аргументов. Программисты на Java привыкли писать конструкторы без аргументов или позволять конструктору без аргументов по умолчанию оставаться на месте, даже в тех случаях, когда проект решения не включает таких требований.
  • Многие программисты преувеличивают стоимость создания объекта. Они жертвуют удобочитаемостью, надежностью, простотой и другими желательными характеристиками пригодного для жилья кода, чтобы избежать создания экземпляров новых объектов.

Конвенция Java Beans

Согласно спецификации, целью API JavaBeans является определение модели программных компонентов для Java, чтобы сторонние независимые поставщики программного обеспечения могли создавать и поставлять компоненты Java, которые могут быть объединены в приложения конечными пользователями. Такой подход к созданию приложений был сформулирован очень рано в истории языка Java; текущая версия документа спецификации Java Beans датирована 8 августа 1997 г. и применяется к Java 1.1.

Соглашение о Java Beans было принято компаниями, которые производят средства построения визуальных приложений с графическим пользовательским интерфейсом. Эти инструменты позволяют разработчикам создавать экран, перетаскивая виджеты GUI (компоненты AWT) в визуальное рабочее пространство. Компоненты AWT реализованы как Java Beans. Это позволяет инструментам использовать API отражения Java для обнаружения любых методов, имена которых начинаются с set, так что разработчики могут настраивать каждый виджет, добавляя текст, значки, действия и т. Д. В течение нескольких лет это был популярный способ создания автономных приложений и Java-апплетов на основе Swing. Концепция оказалась успешной в этой области.

Но у Sun Microsystems было более широкое видение Java Beans, чем просто виджеты GUI. Из спецификации: «Некоторые компоненты JavaBean будут больше похожи на обычные приложения, которые затем можно будет объединить в составные документы. Таким образом, компонент электронной таблицы может быть встроен в веб-страницу ». Фактически, я видел (и писал) решения, в которых компоненты Swing были объединены в элементы, подобные электронным таблицам, в приложении с графическим интерфейсом пользователя. В целом, такой подход никогда не применялся при разработке общих приложений.

Феномен Java Bean получил широкое распространение. Sun Microsystems расширила идею за пределы внешнего интерфейса и применила ее к приложениям среднего и внутреннего уровня в форме Enterprise Java Beans (EJB). Как и многие идеи в нашей области работы, в то время EJB-компоненты, должно быть, казались хорошей идеей.

Использование Java Beans сократилось по мере появления более простых и / или более надежных альтернатив. Ключевыми целями дизайна соглашения Java Beans были (согласно спецификации):

  • Детализация компонентов
  • Портативность
  • Единый высококачественный API
  • Простота

Сегодня все эти цели обычно достигаются другими способами. Детализация компонентов и простота поддерживаются общепринятыми принципами проектирования программного обеспечения, такими как SOLID и GRASP. Цели переносимости и унифицированного высококачественного API поддерживаются принципами проектирования API, сформировавшимися с момента появления соглашения Java Beans, включая Руководящие принципы разработки API Google, Руководство по API Swift.org , Руководство по REST API и Руководство по разработке приложений с двенадцатью факторами от Heroku.

Java Swing - это среда, основанная на событиях. Java Beans предназначены для работы в этой структуре. Бин имеет три аспекта:

  • Характеристики
  • Методы
  • События

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

Если вы пишете код, отличный от этого, то соглашение Java Beans, вероятно, не имеет значения.

Когда фасоль - это не фасоль

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

Вот пример класса Java со всеми его полями, объявленными общедоступными:

public class Shoe { public ShoeStyle style; public double size; public ShowWidth width; public int heel; public Color[] availableColors; public String photoFilename; }

Мы предполагаем существование типов ShoeStyle и ShoeWidth, которые, скорее всего, были бы перечислениями, если бы это было реальное приложение.

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

Вот как это может написать типичный программист на Java:

public class Shoe { private ShoeStyle style; private double size; private ShoeWidth width; private int heel; private List availableColors; private String photoFilename; public double getSize() { return size; } public void setSize(double size) { this.size = size; } etc. }

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

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

Чуть более объектно-ориентированный подход может быть:

public class Shoe { private ShoeStyle style; private double size; private ShoeWidth width; private int heel; private List availableColors; private String photoFilename; public Shoe(ShoeStyle style, double size, ShoeWidth width, int heel, List availableColors, photoFilename) { if (style == null || width == null || availableColors == null || size < 1 || size > 15 || heel < 1 || heel > 12) { throw new IllegalArgumentException(); } this.style = style; this.size = double; this.heel = heel; this.width = width; this.availableColors = availableColors; this.photoFilename = photoFilename; } public double size() { return size; } public boolean comesIn(Color color) { return availableColors.contains(color); } public Shoe addColor(Color color) { List newAvailableColors = availableColors; newAvailableColors.add(color); return new Shoe(style, size, width, heel, newAvailableColors, photoFilename); } etc. }

В этой версии есть конструктор, который гарантирует, что логически недопустимый экземпляр не будет создан. Это предотвращает создание объекта Shoe, если не заполнены все обязательные поля (он допускает пустую ссылку для photoFilename, указывающую, что это необязательное значение).

У него есть аксессоры (показан метод size ()), но они не называются «getThis ()» и «getThat ()». В этом нет смысла, поскольку это не Java Bean. Названия других методов предполагают их обычное использование.

Может ли код быть красивее? Конечно. Например, эти магические числа не такие уж и большие. Их можно заменить константами типа int, такими как MIN_SHOE_SIZE и т. Д., Или перечислениями или классами, которые знают свои собственные верхние и нижние границы и гарантируют, что могут быть созданы только допустимые объекты. Но это всего лишь быстрый и грязный пример; мы не хотим увлекаться здесь.

Чтобы узнать, доступна ли обувь красного цвета, не нужно вызывать «shoe.getAvailableColors ()» и копаться в списке. Вы просто напишите: «shoe.comesIn (Color.RED)». Вам не нужно знать, что класс поддерживает доступные цвета в виде списка, и вам не нужно это знать. Клиентский код становится более слабосвязанным и более выражающим намерения:

public void expressExtremeShoeColorBias(Shoe shoe) { if ( shoe.comesIn(Color.RED) ) { System.out.println("Let's go shopping!"); } else { System.out.println("Let's go barefoot!"); } }

Сеттеров нет. Чтобы добавить атрибут к обуви, мы создаем новый экземпляр Shoe с новым атрибутом (проиллюстрирован методом addColor ()).

Вы можете сделать больше; это предназначено для иллюстрации объекта, который не является Java Bean, и не обязательно должен выглядеть как Java Bean, и ему не выгодно пытаться выглядеть как Java Bean.

Вывод: чрезмерное использование геттеров и сеттеров, по-видимому, является пережитком эпохи Java Beans и EJB.

Внедрение зависимости

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

Обратной стороной было то, что можно было создать логически недопустимые объекты; то есть объекты, в которых отсутствовали значения, необходимые для использования объекта. В мире Java это привело к частым необъявленным посещениям нашего дорогого друга, NullPointerException. Время от времени заходили и другие друзья, такие как Таинственная прерывистая ошибка времени выполнения, которую я не могу воспроизвести, и старые добрые работы на моей машине, но не в тестовой среде, и его двоюродный брат, но модульные тесты пройдены.

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

Бойтесь создания движущихся объектов над головой

Среда выполнения Java использует ссылки для доступа к объектам в памяти. При чем здесь неизменность?

Предположим, нам нужно два букета по два цветка в каждом. В первом букете должны быть цветы с 6 и 9 лепестками. Во втором букете должны быть цветы с 4 и 8 лепестками. (Довольно романтично, а?) После запуска этого кода ...

public class Flower { private int petalCount; public setPetalCount(int petalCount) { this.petalCount = petalCount; } } public class Bouquet { private List flowers; public Bouquet() { flowers = new ArrayList(); } public void setFlowers(List flowers) { this.flowers = flowers; } } public class SomeClientClass { . . . public List makeBouquets() { // build first bouquet Flower flower1 = new Flower(); flower1.setPetalCount(6); Flower flower2 = new Flower(); flower2.setPetalCount(9); List flowers = new ArrayList(); flowers.add(flower1); flowers.add(flower2); Bouquet bouquet1 = new Bouquet(); bouquet1.setFlowers(flowers); // build second bouquet flower1.setPetalCount(4); flower2.setPetalCount(8); Bouquet bouquet2 = new Bouquet(); bouquet2.setFlowers(flowers); List bouquets = new ArrayList(); bouquets.add(bouquet1); bouquets.add(bouquet2); } }

… У обоих букетов есть цветы с 4 и 8 лепестками. Ссылки с именами flower1 и flower2 указывают на одни и те же экземпляры Flower. И букеты, и букеты 2 обращаются к одним и тем же ссылкам. Итак, когда мы изменяем цветок1 и цветок2, чтобы установить количество лепестков для букета 2, эти изменения также видны в букете 1.

Создание неглубокой копии объекта Bouquet не приводит к разным ссылкам на flower1 и flower2:

public class Bouquet { . . . public Bouquet clone() { Bouquet newBouquet = new Bouquet(); newBouquet.setFlowers(this.flowers); return newBouquet; } }

Ссылки flower1 и flower2 по-прежнему указывают на одни и те же объекты в памяти.

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

public class Bouquet { . . . public Bouquet clone() { Bouquet newBouquet = new Bouquet(); List newFlowers = new ArrayList(); for (Flower flower : this.flowers) { Flower newFlower = new Flower(); newFlower.setPetalCount(flower.getPetalCount()); newFlowers.add(newFlower); } newBouquet.setFlowers(newFlowers); return newBouquet; } }

Даже этот тривиальный пример выглядит так, как будто он потребует значительных вычислительных ресурсов для создания экземпляров новых объектов Flower. Представьте себе более реалистичный пример, содержащий множество нетривиальных объектов. Выглядит довольно устрашающе, а?

Выглядит страшно, но это не так.

Многие разработчики Java чрезмерно беспокоятся о влиянии создания экземпляров объекта на производительность. Проблема настолько распространена, что Учебник Java по неизменяемости прямо называет ее: Программисты часто не хотят использовать неизменяемые объекты, потому что они беспокоятся о стоимости создания нового объекта, а не об обновлении объекта на месте. Влияние создания объекта часто переоценивается и может быть компенсировано некоторыми эффективностями, связанными с неизменяемыми объектами. К ним относятся снижение накладных расходов из-за сборки мусора и устранение кода, необходимого для защиты изменяемых объектов от повреждения .

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

Если ваше приложение предъявляет действительно серьезные требования к производительности, то мой первый вопрос к вам будет: «Почему вы выбрали Java для этого приложения?» Java - отличный язык для программирования общих бизнес-приложений, и его характеристики производительности подходят для таких приложений. Java не разработан для действительно серьезных требований к производительности.

Почему я продолжаю писать фразу «по-настоящему сурово?» Потому что у большинства программистов на Java нереалистичное представление о том, что такое «высокая производительность». Высокопроизводительное программное обеспечение не предназначено для извлечения одной записи из внутреннего хранилища данных и отображения ее полей на экране. Высокопроизводительное программное обеспечение предназначено для таких вещей, как быстрый поиск в огромных хранилищах данных и наведение крылатых ракет в реальном времени. Если вы разрабатываете веб-приложение или микросервис, ориентированный на бизнес, у вас нет действительно серьезных требований к производительности.

Преодолей это. Струны интернированы. Псевдопримитивные типы, такие как int, управляются аналогичным образом. Компиляторы Java оптимизируют код лучше, чем вы можете вручную. JVM оптимизируют использование памяти и сборку мусора лучше, чем вы можете вручную. Не жертвуйте надежностью и простым дизайном, чтобы добиться повышения производительности, которое не имеет значения.

Вывод

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

Об авторе:

Дэйв Николетт был ИТ-специалистом с 1977 года. Он занимал различные технические и управленческие должности. Он работал главным образом консультантом с 1984 года, оставаясь одной ногой в техническом лагере, а другой в лагере менеджеров… Подробнее.

Первоначально опубликовано на www.leadingagile.com 5 марта 2018 г.