Лучший опыт

Программирование на Java. Глубокое погружение в ключевой функционал Java 21.

Я не всегда был фанатом Java, но в последние годы стал больше ценить этот язык и его экосистему, особенно после того, как решил использовать Java 21 для нового личного проекта. Мое знакомство с миром JVM началось с лаконичного Scala, в котором сочетаются концепции объектно-ориентированного и функционального программирования и делается акцент на безопасности типов. Но мне захотелось изучить новые интересные улучшения, а также текущее состоян
Программирование на Java. Глубокое погружение в ключевой функционал Java 21...

Я не всегда был фанатом Java, но в последние годы стал больше ценить этот язык и его экосистему, особенно после того, как решил использовать Java 21 для нового личного проекта. Мое знакомство с миром JVM началось с лаконичного Scala, в котором сочетаются концепции объектно-ориентированного и функционального программирования и делается акцент на безопасности типов.

Но мне захотелось изучить новые интересные улучшения, а также текущее состояние экосистемы, фреймворков и библиотек Java.

Среди множества других, более новых языков, ориентированных на платформу JVM, самые популярные  —  Scala и Kotlin. Это такие же языки программирования, как и Java  —  со статической и одновременно строгой типизацией.

Сравнение Java, Scala и Kotlin

Разработчикам, уже имеющим опыт в Java, синтаксис Kotlin кажется ближе, чем Scala. Главная его цель  —  стать более современным, лаконичным языком, полностью совместимым с Java и избавленным при этом от его недостатков. Сейчас Kotlin популярнее в сфере разработки Android.

Scala, с другой стороны, популярен главным образом в инженерии данных, Apache Spark например, и разработке серверных приложений. У Scala менее детализированный синтаксис  —  это такой Python на стероидах, особенно Scala3,  —  а система типов надежнее, благодаря этому во время компиляции устраняется больше ошибок.

Разработчики оценивают Java выше

Изучим распространенность и популярность Scala, Kotlin и Java по опросам Stack Overflow и соответствующим индексам TIOBE и Redmonk.

На этой диаграмме показаны результаты опросов Stack Overflow только по трем этим языкам:

Индекс языков Redmonk

Вот индекс Redmonk по данным Github и Stack Overflow на январь 2023 года, Java наверху, Scala и Kotlin тоже на относительно высоких позициях:

Источник

Индекс программирования TIOBE

Теперь обратимся к индексу TIOBE, учитывающему поисковые запросы 25 различных поисковых систем, Java и здесь в числе лучших, а вот Scala и Kotlin в десятку не вошли, заняв в 2023 году 15-е и 36-е места соответственно:

Источник

Java по-прежнему очень популярна, даже несмотря на сильную конкуренцию со стороны Kotlin и Scala. С годами кривая, естественно, ползет вниз: кусок пирога отхватили и другие языки программирования, в том числе из других экосистем, например Python, Go, Rust.

Приверженность обратной совместимости Java

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

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

Учет факторов значимости Java

Начиная с Java 9, цикл выпуска OpenJDK «по готовности» сменился на раз в полгода: март и сентябрь. Целью было смягчить последствия задержек выпуска новых версий из-за неготовности конкретных улучшений. Для этого появилось понятие функций предпросмотра. Если определенная функция находится полностью в рабочем состоянии, но продолжает претерпевать критические изменения, она появляется в следующей версии в предпросмотре.

Так обеспечивается обратная связь с сообществом разработчиков, в ходе которой формируется реализация, дорабатываемая в следующих регулярных выпусках. Раз в два года один из выпусков помечается Oracle и другими поставщиками JDK как долгосрочный LTS. Например, в Amazon Web Services имеется собственная сборка Corretto JDK с долгосрочной поддержкой.

Источник

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

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

Ключевое слово «var» в Java 10 и его влияние на детализацию

Многим разработчикам, которые уже владеют другими современными языками и изучают Java, не нравится в нем уровень детализации. Раньше тип переменной приходилось явно объявлять в левой части присваивания. Но в Java 10 появилось новое, специальное ключевое слово var, которое используется вместо самого типа. На этапе компиляции компилятором Java вставляется фактический тип, выведенный из выражения в правой части присваивания:

//Java 8
HashMap map = new HashMap();

DatabaseEngineColumnCacheImpl cache = new DatabaseEngineColumnCacheImpl();

Optional accessRole = user.getUserAccessRole();

//В Java 10 компилятор больше не нужно «убеждать» в том, каковы фактические типы
var map = new HashMap();

var cache = new DatabaseEngineColumnCacheImpl();

var accessRole = user.getUserAccessRole();

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

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

Преодоление проблем проверки типов при помощи «instanceof» и сопоставления с образцом

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

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

Остается только один вариант: реализовать вычисление периметра как некий вспомогательный метод, которым проверяется конкретный тип  —  Rectangle или Circle  —  и соответственным образом вычисляется периметр:

interface Shape {}

public class Rectangle implements Shape {
final double length;
final double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
}

public class Circle implements Shape {
final double radius;
public Circle(double radius) {
this.radius = radius;
}
}

public static double getPerimeter(Shape shape) {
if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
return 2 * r.length + 2 * r.width;
} else if (shape instanceof Circle) {
Circle c = (Circle) shape;
return 2 * c.radius * Math.PI;
} else {
throw new RuntimeException("Unknown shape");
}
}

Прежде чем выполнять операцию, в реализации метода getPerimeter придется еще осуществить нисходящее приведение, пусть уже и проверенного, типа и объявить новую переменную. А все потому, что shape для компилятора  —  это по-прежнему экземпляр Shape.

Сопоставление с образцом для instanceof позволяет объявить переменную проверенного нами типа для доступа к ней в блоке if-else. Вот тот же блок в Java 14:

public static double getPerimeter(Shape shape) {
if (shape instanceof Rectangle r) {
return 2 * r.length + 2 * r.width;
} else if (shape instanceof Circle c) {
return 2 * c.radius * Math.PI;
} else {
throw new RuntimeException("Unknown shape");
}
}

Так совершенствуясь, компилятор постепенно «становится чуть умнее». В последних версиях Java работа над сопоставлением с образцом для instanceof постоянно расширялась, в том числе над сопоставлением с образцом для классов record  —  следующий большой функционал, который мы рассмотрим.

От детализированности к простоте, классы данных формируются записями Java заново

Это важный функционал, по крайней мере для меня. Как легко в Scala моделировать с помощью классов данных! Java отлично справляется с моделированием предметной области, но до появления записей определение контейнеров данных или так называемых POJO было очень детализированным. И, чтобы не писать повторяющийся код вручную, а генерировать его, в экосистему привлекались различные библиотеки вроде Lombok.

Рассмотрим следующий пример

public class Person {
public final String id;
public final String name;
public final Integer age;

public Person(String id, String name, Integer age) {
this.id = id;
this.name = name;
this.age = age;
}
}

var p1 = new Person("a1b", "Frank", 30)
var p2 = new Person("a1b", "Frank", 30)

p1.equals(p2) //это не так, да?

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

Потому что в Java объекты являются ссылками на память, и без явного указания компилятору, как приравнивать объекты, стратегия equals() по умолчанию  —  сравнение адресов памяти. Именно этим и занимается оператор ==. Что же нужно сделать, чтобы объект Person был сопоставимым с другим экземпляром того же типа?

public class Person {
public final String id;
public final String name;
public final Integer age;

public Person(String id, String name, Integer age) {
this.id = id;
this.name = name;
this.age = age;
}

@Override
public boolean equals(Object o) {
if (o == this) return true;
if (o == null || !(o instanceof Person)) return false;

Person p = (Person) o;
return Objects.equals(p.id, this.id) && Objects.equals(p.age, this.age) && Objects.equals(p.name, this.name);
}

@Override
public int hashCode() {
int hash = 7;
hash = 31 * hash + Objects.hashCode(id);
hash = 31 * hash + Objects.hashCode(name);
hash = 31 * hash + age;
return hash;
}
}

Оказывается, немало. Чтобы определить правила сравнения объектов, нужно переопределить equals и hashCode. Эти методы писать не нужно: они генерируются библиотеками вроде Lombok. Но теперь в Java имеется способ делать это нативно, с помощью записей.

Начиная с Java 17  —  а в предпросмотре с Java 14  —  классы определяются ключевым словом record. Улучшим им предыдущий пример:

record Person(String id, String name, Integer age) {};

var p1 = new Person("1ab", "Frank", 30);
var p2 = new Person("1ab", "Frank", 30);

p1.equals(p2); //теперь верно!

p1 == p2; //не верно, что и ожидаемо, ведь это по-прежнему два разных экземпляра

Изучение «with» для записей Java

Вот и все. Здесь компилятором Java генерируется байт-код с учетом всех параметров, определяемых для реализации методов equals и hashCode. В классах record также имеются методы toString и геттеры, но не сеттеры. Эти классы неизменяемы: после создания экземпляра элементы объекта только считываются, их значения не изменяются.

Благодаря неизменяемости сокращаются баги в параллельных программах, упрощается анализ кода. Сейчас создавать обновленный экземпляр не очень удобно: приходится копировать все аргументы из одной записи в другую. Надеюсь, в будущем это усовершенствуется благодаря новому функционалу «With для записей», описанному в одном из черновиков частей проекта Amber самим Брайаном Гетцем, архитектором языка Java. В итоге новый экземпляр объекта записи будет создаваться изменением одного или нескольких его элементов. Например, так:

var p3 = p2 with { name = "Joe" };

Для этого уже имеются библиотеки, загляните в проект record-builder на Github. Кроме устранения ограничения на изменение объекта записи, им генерируются построители, очень полезные для классов данных посложнее.

В этом примере демонстрируется не все, что «умеют» записи Java. Они способны определять пользовательские конструкторы, а также обычные методы для работы с базовыми данными. Более того, поскольку записи изначально задумывались неизменяемыми, их сериализация проще и безопаснее сериализации экземпляров обычного класса. А еще записи деконструируются в значения, что применятся в следующем функционале  —  сопоставлении с образцом для записей.

Сопоставление с образцом для записей

Сопоставление с образцом для записей доработали в Java 21. Благодаря сопоставлению с образцом и записям, как части проекта Amber, появилась новая парадигма программирования  —  дата-ориентированное программирование. Акцент здесь делается на моделировании задач как неизменяемых структур данных и выполнении вычислений с использованием неизменяемых же функций общего назначения. Что-то знакомое  —  это же знаменитая концепция функционального программирования. Функционал этой парадигмы ДОП призван не заменять ООП, а элегантнее решать определенные задачи. Если ООП отлично справляется с определением и регулированием границ кода  —  при программировании в большом, то ДОП полезнее для моделирования и работы с данными  —  при программировании в малом.

Перейдем к сути. Что такое «сопоставление с образцом»? Это противоположное конструктору класса: им и предоставляемыми им данными мы конструируем объект, а сопоставлением с образцом деконструируем или извлекаем использованные при его конструировании данные. Посмотрим, как это выглядит на практике.

Пример сопоставления с образцом

В следующем примере мы моделируем различные типы транзакций, используя классы записей record. Цель  —  написать метод, которым получается список транзакций и вычисляется баланс счета. Если тип транзакции  —  Purchase, увеличиваем баланс, если Payment  —  уменьшаем, а если PaymentReturned  —  снова увеличиваем:

public interface Transaction {
String id();
}

record Purchase(String id, Integer purchaseAmount) implements Transaction {}

record Payment(String id, Integer paymentAmount) implements Transaction {}

record PaymentReturned(String id, Integer paymentAmount, String reason) implements Transaction {}

List transactions = List.of(
new Purchase("1", 1000),
new Purchase("2", 500),
new Purchase("3", 700),
new Payment("1", 1500),
new PaymentReturned("1", 1500, "NSF")
);

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

public static Integer calculateAccountBalance(List transactions) {
var accountBalance = 0;
for (Transaction t : transactions) {
if (t instanceof Purchase p) {
accountBalance += p.purchaseAmount();

} else if (t instanceof Payment p) {
accountBalance -= p.paymentAmount();

} else if (t instanceof PaymentReturned p) {
accountBalance += p.paymentAmount();
} else {
throw new RuntimeException("Unknown transaction type");
}
}

return accountBalance;
}

Неплохая реализация, достаточно удобная для восприятия. Но, если типов транзакций для обработки будет больше, она окажется длинноватой. Совершенствуется эта реализация применением сопоставления с образцом для записей:

public static Integer calculateAccountBalance(List transactions) {
var accountBalance = 0;

for (Transaction t: transactions) {
switch(t) {
case Purchase p -> accountBalance += p.purchaseAmount();
case Payment(var id, var amt) -> accountBalance -= amt;
case PaymentReturned(var id, var amt, var reason) -> accountBalance += amt;
default -> throw new RuntimeException("Unknown transaction type");
}
}

return accountBalance;
}

Так немного чище и менее детализированнно. Обратите внимание на ключевое слово switch  —  в Java оно уже давно. В Java 17 им стали поддерживаться выражения со знакомым синтаксисом лямбд, а в Java 19 и 21  —  сопоставления с образцом в записях.

Применяя сопоставление с образцом, мы либо ссылаемся на экземпляр типа, как в первом case, либо деконструируем тип на его составляющие, как во втором и третьем. Используя сопоставление с образцом для switch, мы также сопоставляем образец на основе булева выражения при помощи нового ключевого слова when. Так, можно несколько раз сопоставлять один и тот же тип с различными предикатами и для каждого case выполнять разную логику:

switch(t) {
case PaymentReturned p when p.reason.equals("NSF") -> ...
case PaymentReturned p -> ...
}

Чтобы окончательно убедиться в полезности сопоставления с образцом, введем новый тип транзакций Credit  —  для моделирования транзакций, обратных purchase. При добавлении нового типа записей, которым реализуется Transaction, код по-прежнему компилируется в обеих реализациях. Но во время выполнения обнаруживается проблема: логика сталкивается с типом, который она «не умеет» обрабатывать, и выбрасывается исключение.

Сопоставление с образцом для записей еще удобнее с другим функционалом Java 17 из JEP409  —  запечатанными классами и интерфейсами. Обозначив интерфейс как sealed, то есть запечатанный, мы сообщаем компилятору о наличии конечного числа реализаций для Transaction, поэтому компилятором проверяется, что все case при сопоставлении с образцом обработаны. Классы реализации помещаются в один файл с запечатанным интерфейсом, например внутри интерфейса или с использованием в интерфейсе ключевого слова permits для указания иерархии закрытых типов. Подробнее  —  в JEP.

Теперь, если пропустить обработку одного из case, код просто не скомпилируется. Однако, чтобы гарантировать это, нужно удалить case по умолчанию, которым обычно отлавливается любой отсутствующий образец:

public sealed interface Transaction {
String id();

// здесь определяется интерфейс расширения записей
}


public static Integer calculateAccountBalance(List transactions) {
var accountBalance = 0;

for (Transaction t: transactions) {
switch(t) {
case Purchase p -> accountBalance += p.purchaseAmount();
case Payment(var id, var amt) -> accountBalance -= amt;
case PaymentReturned(var id, var amt, var reason) -> accountBalance += amt;
}
}

return accountBalance;
}

Теперь работа компилятора прерывается таким приветственным сообщением об ошибке: Compilation failure: The switch statement does not cover all possible values («Сбой компиляции: оператором switch не охватываются все возможные значения»).

Для исходной реализации с блоками if-else ключевое слово sealed в интерфейсе бесполезно, а вот сопоставление с образцом здесь кстати. Хотя это еще не все, что «умеют» запечатанные интерфейсы/классы, в приведенном выше примере показано, как здорово сочетается различный языковой функционал и повышается надежность кода.

Обозначенная проблема решается также шаблоном проектирования «Посетитель». Однако его применение чревато значительным увеличением сложности и объема кода. После того как в Java 21 появились запечатанные интерфейсы и сопоставление с образцом, шаблон проектирования «Посетитель» фактически устарел.

Влияние Java 21 на ландшафт разработки

Java 21  —  полнофункциональный выпуск на основе фич Java 17 и новее. Поставщики выбрали его следующим выпуском с долгосрочной поддержкой. Это отличная новость, особенно для крупных компаний, в которых обычно разрешается использовать только такие версии.

За рамками этой статьи осталось много интересного. Виртуальные потоки, известные как project Loom, вероятно,  —  самый ожидаемый функционал, допиленный в Java 21. Но эта обширная тема достойна отдельной статьи. Подробнее о недавнем выпуске JDK 21  —  на сайте OpenJDK.

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

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