Как стать автором
Обновить
743.93
OTUS
Цифровые навыки от ведущих экспертов

Java 15 глазами программиста Scala

Время на прочтение10 мин
Количество просмотров6.6K
Автор оригинала: Adam Warski

В преддверии курса "Scala-разработчик" приглашаем на открытый вебинар "Функциональное программирование в Scala".

А пока делимся традиционным полезным переводом.


Время летит. Не успеваешь моргнуть глазом, а уже вышел очередной релиз Java. В соответствии с графиком (по релизу каждые полгода) комьюнити получило в свое распоряжение Java 15, судьба которой — стать фундаментом для Java 17 LTS (выйдет через год).

В Java постоянно вносятся улучшения, многие из которых были реализованы под влиянием других языков JVM и функционального программирования. Сюда можно отнести такие возможности, как лямбды, ограниченный вывод типов локальных переменных (тип var) и switch-выражения. Scala — особенно богатый источник идей, благодаря инновационному сочетанию в ней объектно-ориентированного и функционального программирования.

Предлагаю рассмотреть, как представленные в новом релизе Java 15 (пока в статусе превью финальной версии) возможности соотносятся с конструкциями в Scala. Мы сосредоточимся на особенностях языка, пропустим улучшения в JVM и доработку стандартной библиотеки. Кроме того, нужно отметить, что некоторые из описанных компонентов уже были доступны в более ранних версиях Java (в виде предварительной или бета-версии).

Настоящая Ява
Настоящая Ява

Записи

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

При создании класса данных мы обычно указываем:

  • Поля, содержащие данные private final

  • Конструктор, устанавливающий для полей заданные значения

  • Методы-аксессоры для доступа к данным

  • equalshashCode и 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)

Что же общего между записями и case-классами?

  • Методы equalshashCode и toString генерируются автоматически (если они в явном виде не переопределяются).

  • Поля данных неизменны и общедоступны: в виде полей private final+ публичных методов-аксессоров без параметров в Java, в виде публичных val в Scala.

  • Доступен конструктор со всеми полями данных.

  • В теле записи/case-класса можно определять методы.

  • Записи могут использовать объекты interface, а case-классы — trait (по сути это более мощные эквиваленты Java-объектов interface в Scala).

  • Все записи расширяют java.lang.Record, а все case-классы реализуют scala.Product.

Однако есть и некоторые заметные отличия:

  • Записи не могут иметь дополнительного состояния: полей экземпляра с модификатором private или public. Это означает, что записи не могут иметь вычисленного внутреннего состояния; все, что доступно, является частью основной сигнатуры записи. В Scala case-классы могут иметь поля экземпляров с модификатором private или public, как и любой другой класс.

  • Записи не могут расширять классы, так как они уже неявно расширяют . В Scala case-классы могут расширять любой класс, поскольку неявно они только реализуют трейт (за одним исключением: case-класс не может расширять другой case-класс).

  • Записи всегда окончательны (), то есть не могут расширяться, в то время как case-классы могут (хотя это полезно лишь в ограниченном числе случаев).

  • Возможности конструкторов записей весьма ограничены, так как они не могут иметь вычисленное состояние. В основном их удается использовать только для валидации, например:

record Person(String name, int age) {
  Person {
    if (age < 0)
      throw new IllegalArgumentException("Too young");
  }
}

В Scala конструкторы не имеют ограничений.

Обновление от 9 октября. Как заметили Лоик Дескотт (Loïc Descotte) и Ярек Ратайский (Jarek Ratajski), у записей нет важной особенности case-классов Scala: метода copy. Он позволяет создать копию экземпляра (мы не можем изменять поля из-за их неизменяемости), при этом для некоторых полей могут быть установлены новые значения. Copy — это действительно одна из самых полезных функций Scala, и настолько привычная, что о ней легко забыть!

Подводя итоги, в Scala case действительно выполняет роль модификатора для class: почти все, что разрешено в обычном классе, также разрешено и в case-классе; этот модификатор генерирует для нас некоторые методы и поля. В Java записи — отдельные «сущности», которые компилируются в класс, но имеют свои собственные ограничения и синтаксис (подобно enum ).

Запечатанные классы

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

Например, ниже создается интерфейс Animal с ограниченным набором реализаций:

public sealed interface Animal permits Cat, Dog, Elephant {...}

Реализация определяется в обычном порядке:

public class Cat implements Animal { ... }
public class Dog implements Animal { ... }
public class Elephant implements Animal { ... }

Реализации можно явно перечислить после ключевого слова permits, либо их может вывести компилятор, если они размещены в одном и том же исходном файле. Однако возможности подобного вывода, вероятно, будут ограничены, так как каждый публичный класс в Java должен объявляться в отдельном файле верхнего уровня.

В Scala используется то же самое ключевое слово (sealed), да и сам механизм весьма похож. Однако ключевое слово permits не применяется. Реализации всегда выводятся, и все они должны находиться в том же исходном файле, что и базовый трейт или класс. Это менее гибкий подход, чем в Java, но классы Scala, как правило, короче, и в одном и том же исходном файле может быть определено несколько публичных классов (часто именуемых по базовому трейту или классу):

sealed trait Animal
class Cat extends Animal { ... }
class Dog extends Animal { ... }
class Elephant extends Animal { ... }

(учтите, что в Scala public является модификатором доступа по умолчанию, поэтому в данном коде он опускается, в то время как в Java по умолчанию используется package-private).

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

Как насчет расширения реализаций запечатанного типа? В Java у нас три возможности:

  • реализация может быть final, что исключает создание подклассов;

  • она может быть сама sealed, что предполагает перечисление возможных реализаций с использованием permits;

  • или же non-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, код, использующий запечатанный тип, все равно может делать предположения о возможных реализациях по причине подтипирования.

В Scala мы имеем аналогичный уровень контроля; реализация может быть:

  •  final

  •  sealed (все реализации также должны находиться в одном исходном файле);

  • без модификатора, класс открыт к расширению.

Последняя опция (по умолчанию) соответствует non-sealed:

sealed trait Animal
final class Cat extends Animal { ... }
sealed class Dog extends Animal { ... }
class Elephant extends Animal { ... }

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

Небольшой, но, вероятно, крайне полезной возможностью, также доступной в качестве «второго превью», является сопоставление с образцом для instanceof:

if (myValue instanceof String s) {
    // s is in scope and is a String
} else {
    // s is not in scope
}

Новый синтаксис не только избавляет нас от добавления приведения типа var s = (String) myValue в ветку if, но и исключает вероятность глупых ошибок, когда условие if и приведение типов рассинхронизируются (например, мы выполняем приведение в неправильной ветке).

Это в чем-то похоже на flow-typing в TypeScript, однако здесь нам нужно ввести новое имя (в примере это s) для приводимого значения.

Эквивалент в Scala задействует механизм сопоставления с образцом:

myValue match {
  case s: String => // s is in scope and is a String
  case _ =>         // s is not in scope
}

Синтаксис сильно отличается, но подход имеет общие черты.

В Java также доступна более короткая версия, полезная при написании сложных условий, например:

if (myValue instanceof String s && s.length() > 42) { ... }

В Scala мы бы записали это следующим образом:

myValue match {
  case s: String if s.length() > 42 => ...
}

Один из случаев, когда синтаксис Java лаконичнее, — когда мы хотим сохранить результат условия в виде значения:

var isLongString = myValue instanceof String s && s.length() > 42

Сопоставления с образцами в Scala, как правило, записываются в несколько строк (для удобочитаемости), поэтому код будет выглядеть так:

val isLongString = myValue match {
  case s: String if s.length() > 42 => true
  case _ => false
}

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

На что еще способен механизм сопоставления с образцами в Java? Как было указано в примечаниях к релизу, сопоставление с образцами и запечатанные классы естественным образом дополняют друг друга. При выполнении сопоставления с образцом для значения типа sealed компилятор может статически проверить, что мы охватили все возможные случаи. Scala выполняет такую исчерпывающую проверку для match-выражений по значению трейта или класса типа sealed.

Текстовые блоки

Реализация строковых переменных, состоящих из нескольких строк, часто является неизбежным злом, с которым нам приходится мириться. До сих пор в Java нам приходилось прибегать к конкатенации нескольких строковых переменных путем явного указания символов новой строки \n. Теперь этого не требуется! Текстовые блоки вышли из фазы «превью» и доступны как стандартная возможность.

Текстовые блоки разделяются тройными кавычками """, например:

var response = """
   <html>
     <body>Internal server error</body>
   </html>"""

После открывающей комбинации """ должна следовать новая строка, с которой и начинается тело самой строковой переменной. Текстовые блоки интересны особенностью обработки пробелов. В приведенном выше примере создается строка со следующим содержимым:

<html>
  <body>Internal server error</body>
</html>

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

В Scala также существуют текстовые блоки, использующие тот же самый разделитель в виде тройных кавычек, однако не требующие перехода на новую строку после открытия кавычек:

val response = """<html>
     <body>Internal server error</body>
   </html>"""

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

val response = """<html>
    |  <body>Internal server error</body>
    |</html>""".stripMargin)

(По умолчанию в качестве разделителя используется |, но можно вместо него использовать другой символ и передать его в stripMargin.)

Механизм, реализованный в Scala, предлагает более широкие возможности, однако реализация Java более удобна и лучше подходит для типовых ситуаций.

Scala также может выполнять интерполяцию строк. В Java этого не хватает, приходится использовать String::formatted. В Scala нам всего лишь нужно добавить перед строкой s:

val message = "Internal server error"
val response = s"""<html>
    |  <body>$message</body>
    |</html>""".stripMargin)

Выводы

Что Scala, что Java — языки не новые. Они должны развиваться с учетом существующих кодовых баз и «духа» платформы. Тем не менее у Scala была возможность опираться на опыт Java и других языков. Это позволило избежать ловушек, которые попались на пути Java.

Новые возможности, представленные в последних версиях Java, — это долгожданные дополнения, значительно облегчающие жизнь Java-программистов. Теперь код может быть лаконичнее и легче для чтения, так как «суть» проблемы можно сделать более заметной.

Однако такие расширения зачастую носят непоследовательный характер, так как каждое из них решает отдельную задачу, а не предоставляет общий способ структурирования кода. Например, switch-выражения, представленные в Java 12, позволяют обрабатывать switch в качестве выражения (результат, например, можно присвоить переменной). В Scala всё является выражением, поэтому нет необходимости в специальном синтаксисе или поддержке.

То же самое относится и к сопоставлению с образцом — это скорее общий механизм, опять же демонстрирующий новый тип выражений, а не специальный синтаксис для приведения типов. Или записи — Scala расширяет существующий концепт class, а Java вводит новый тип структуры.

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

Пока мы ждем Java 16 и последующих версий (более интересные функции находятся в стадии разработки), Scala может много чего предложить помимо тех возможностей, что мы рассмотрели здесь! Если вы хотите узнать больше, посетите нашу страничку по введению в Scala или подпишитесь на Scala Times.


Зарегистрироваться на открытый урок и записаться на курс можно здесь.

Теги:
Хабы:
+8
Комментарии6

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS