Java 15 глазами Scala-программиста
Время летит и уже скоро нас ждет очередной релиз Java. Согласно полугодовому расписанию релизов, сейчас время для Java 15, который прокладывает путь к грядущей Java 17 LTS (через год).
В Java наблюдается постоянный поток улучшений, на многие из которых повлияли другие JVM-языки и идеи функционального программирования. Сюда входят такие фичи, как лямбды, ограниченный локальный вывод типов или switch-выражения. Scala — особенно богатый источник идей, благодаря инновационному сочетанию объектно-ориентированного и функционального программирования.
Давайте посмотрим, как фичи, доступные в Java 15, соотносятся с конструкциями, известными в Scala. Мы сосредоточимся на языковых фичах, пропуская улучшения JVM или очистку стандартной библиотеки. Также обратите внимание, что некоторые из описанных компонентов уже доступны в более ранних версиях Java.
Records
Начнем с рекордов (records), которые доступны в качестве превью предстоящей финальной версии. Объем кода, который был необходим для создания простого класса данных, был легкой мишенью, когда речь шла о многословности Java.
При создании класса данных мы обычно пишем: • закрытые финальные поля, содержащие данные; • конструктор, устанавливающий поля для заданных значений; • аксессоры для получения данных; • equals, hashCode и toString.
class Person { private final String name; private final int age; Person(String name, int age) { this.name = name; this.age = get; } String name() { return name; } int age() { return age; } public boolean equals(Object o) { if (!(o instanceof Person)) return false; Person other = (Person) o; return other.name == name && other.age = age; } public int hashCode() { return Objects.hash(name, age); } public String toString() { return String.format("Person[name=%s, age=%d]", name, age); } }
Существовали обходные пути, начиная от автоматического создания кода с помощью IDE до использования аннотаций и манипулирования байт-кодом во время выполнения (см. Проект Lombok). Но это всегда выглядело как воркэраунд, а не как правильный способ решить эту проблему. Что ж, больше нет!
С помощью рекордов приведенное выше сокращается до:
record Person(String name, int age) { }
Уменьшение кода в 21 раз! Байт-код, с которым оба они компилируются, будет похож. Экземпляры рекордов могут быть созданы так же, как и класс:
var john = new Person("john", 76);
В Scala есть очень похожая функциональность — case-классы. Приведенный выше пример будет записан как:
case class Person(name: String, age: Int)
В чем сходство между рекордами и кейс-классами?
- методы equals, hashCode и toString генерируются автоматически (если явно не переопределены);
- поля данных неизменяемы и общедоступны: private final поля + открытые методы доступа без параметров в Java и public val в Scala;
- доступен конструктор со всеми полями данных;
- методы могут быть определены в теле рекорда/кейс класса;
- рекорды могут реализовывать интерфейсы, а кейс-классы могут реализовывать трейты (trait) (которые являются более мощным эквивалентом интерфейса Java в Scala);
- все рекорды расширяют java.lang.Record, а все кейс классы реализуют scala.Product.
Однако есть и некоторые заметные отличия:
- Рекорды не могут иметь дополнительного состояния: private или public полей. Это означает, что рекорды не могут иметь вычисленное внутреннее состояние; все, что доступно, является частью основной сигнатуры рекорда. В Scala кейс-классы могут иметь private или public поля экземпляров, как и любой другой класс.
- Рекорды не могут расширять классы, поскольку они уже неявно расширяют java.lang.Record. В Scala кейс-классы могут расширять любой другой класс, поскольку они только неявно реализуют трейт (за одним исключением: кейс-класс не может расширять другой класс case).
- Рекорды всегда являются final (не могут быть расширены), в то время как кейс-классы могут (хотя это имеет ограниченную полезность).
- Конструкторы рекордов очень ограничены, поскольку они не могут иметь вычисленное состояние, их можно использовать в основном для проверки, например:
record Person(String name, int age) { Person { if (age < 0) throw new IllegalArgumentException("Too young"); } }
В Scala же конструкторы не ограничены.
Также важной особенностью кейс-классов Scala, которая отсутствует в рекордах, является метод копирования (copy). Он позволяет создать копию экземпляра (мы не можем изменять поля из-за неизменности) с некоторыми полями, установленными на новые значения. Копирование действительно является одной из самых полезных функций Scala и настолько широко распространено, что о нем легко забыть!
Подводя итог, можно сказать, что в Scala case действительно ведет себя как модификатор класса: почти все, что разрешено в обычном классе, также разрешено в кейс-классе; модификатор генерирует для нас несколько методов и полей. В Java, с другой стороны, рекорды представляют собой отдельный «тип вещей», который компилируется в класс, но имеет свои собственные ограничения и синтаксис (как и перечисления).
Sealed classes
Очень похожая функция, дебютировавшая в Java 15, — это поддержка sealed-классов и интерфейсов. Они позволяет ограничить возможные реализации класса или интерфейса. Затем любой код, использующий абстрактный класс или интерфейс, может безопасно сделать предположение о возможной форме значения. Довольно часто мы хотим сделать наш класс широко доступным, но не обязательно широко расширяемым.
Например, следующее определяет интерфейс Animal с закрытым набором реализаций:
public sealed interface Animal permits Cat, Dog, Elephant {...}
Затем реализация определяется как обычно:
public class Cat implements Animal { ... } public class Dog implements Animal { ... } public class Elephant implements Animal { ... }
Реализации могут быть либо явно перечислены после ключевого слова permit, либо они могут быть выведены компилятором, если все реализации находятся в одном исходном файле. Однако полезность стиля вывода, вероятно, ограничена, поскольку каждый публичный класс в Java должен быть объявлен в отдельном файле верхнего уровня.
Scala использует то же ключевое слово sealed и механизм очень похож. Однако ключевого слова permit нет. Предполагается, что реализации всегда должны находиться в том же исходном файле, что и базовый трейт/класс. Это менее гибко, чем Java, но классы Scala также имеют тенденцию быть короче, и несколько общедоступных классов могут быть определены в одном исходном файле (часто названном в честь базового трейта/класса):
sealed trait Animal class Cat extends Animal { ... } class Dog extends Animal { ... } class Elephant extends Animal { ... }
(обратите внимание, что в Scala public является модификатором доступа по умолчанию, поэтому он здесь опущен, в отличие от Java package-private).
И в Java, и в Scala модификатор sealed хорошо работает с рекордами/кейс-классами. Используя эту комбинацию, мы получаем реализацию алгебраических типов данных, одного из основных инструментов функционального программирования. Рекорд/кейс класс — это тип произведения, а sealed интерфейс/трейт — это тип суммы.
Что насчет расширения реализаций sealed-типов? В Java у нас есть три возможности: • реализация может быть final, что означает невозможность дальнейшего расширения; • он может быть объявлен sealed сам, снова перечисляя возможные реализации с использованием разрешений; • или он может быть non-sealed, что делает эту конкретную реализацию открытой для расширения.
Любая реализация sealed-типа должна содержать ровно один из упомянутых выше модификаторов; однако каждая реализация может содержать свой модификатор. Например:
public sealed interface Animal permits Cat, Dog, Elephant public final class Cat implements Animal { ... } public sealed class Dog permits Chihuahua, Pug implements Animal {} public non-sealed class Elephant implements Animal { ... }
Обратите внимание, что даже если реализация non-sealed, код, использующий sealed-тип, все равно может делать предположения о возможных реализациях из-за подтипов.
В Scala у нас похожий уровень контроля, реализации могут быть: • final; • sealed (опять же, все реализации должны быть в одном исходном файле); • без модификатора, что делает класс открытым для расширения.
Последний (и по умолчанию) вариант соответствует non-sealed:
sealed trait Animal final class Cat extends Animal { ... } sealed class Dog extends Animal { ... } class Elephant extends Animal { ... }
Материал является переводом первой части статьи "Java 15 through the eyes of a Scala programmer".