Java 16 — новые синтаксические возможности языка

от автора

В марте этого года Oracle выпускает 16-ю версию Java, а уже осенью выйдет 17-я версия — следующая версия с долгосрочной поддержкой (LTS). Вряд ли за пол года появятся какие-то существенные нововведения, а потому уже сейчас можно взглянуть на то, с чем мы будем работать в ближайшие несколько лет. С момента выхода 11-й версии — текущей LTS версии Java, компанией Oracle было внедрено большое количество новых функций — от новых синтаксических конструкций до новых алгоритмов сборки мусора. В данной статье рассмотрим новые синтаксические возможности языка, появившиеся в версиях 12 — 16.

Записи (Records). JEP 395.

Традиционные классы в Java довольно перегружены деталями, особенно если речь идет о POJO классах, являющихся простыми неизменяемыми (immutable) агрегатами данных. Такой класс, оформленный по правилам, содержит большое количество не очень ценного и повторяющегося кода, такого как конструкторы, методы чтения полей, методы equals(), hashCode() и toString(). Например, взгляните на класс Point, предназначенный для хранения координат на плоскости:

class Point {      private final int x;     private final int y;      Point(int x, int y) {         this.x = x;         this.y = y;     }      int x() { return x; }     int y() { return y; }      public boolean equals(Object o) {         if (!(o instanceof Point)) return false;         Point other = (Point) o;         return other.x == x && other.y = y;     }      public int hashCode() {         return Objects.hash(x, y);     }      public String toString() {         return String.format("Point[x=%d, y=%d]", x, y);     } }

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

Объявление записи состоит из имени, опциональных параметров типа, заголовка и тела класса. Заголовок состоит из компонентов класса, которые являются переменными, формирующими его состояние, например:

record Point(int x, int y) { }

Для записей многие стандартные вещи генерируются автоматически:

  • Для каждого компонента из заголовка генерируется финальное приватное поле и метод чтения. Обратите внимание, что методы чтения именуются не стандартным для Java способом. Например, для атрибута x из класса Point метод чтения называется x(), а не getX().

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

  • Методы equals() и hashCode(), которые гарантируют, что 2 записи «равны», если они одного типа и имеют одинаковые значения соответствующих полей.

  • Метод toString().

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

record Point(int x, int y) {    Point(int x, int y) {     if (x < 0 || x > 100 || y < 0 || y > 100) {       throw new IllegalArgumentException("Point coordinates must be between 0 and 100");     }     this.x = x;     this.y = y;   } }

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

record Point(int x, int y) {    Point {     if (x < 0 || x > 100 || y < 0 || y > 100) {       throw new IllegalArgumentException("Point coordinates must be between 0 and 100");     }   } }

На записи накладываются некоторые ограничения:

  • Записи не могут наследоваться от других классов. Родительским классом для записи всегда является java.lang.Record. Это связано с тем, что иначе они имели бы унаследованное состояние, помимо состояния описанного в заголовке.

  • Классы записей являются финальными и не могут быть абстрактными.

  • Поля записей являются финальными.

  • Нельзя добавлять поля и блоки инициализации экземпляра.

  • Разрешается переопределять генерируемые методы, но тип возвращаемого значения должен в точности совпадать с типом значения генерируемого метода.

  • Нельзя добавлять нативные методы.

В остальном записи являются обычными классами:

  • Записи могут быть верхнеуровневыми или вложенными, могут быть параметризованными.

  • Записи могут иметь статические методы, поля и инициализаторы, а также методы экземпляра.

  • Записи могут реализовывать интерфейсы.

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

  • Класс записи и компоненты его заголовка могут быть декорированы аннотациями. Аннотации компонентов затем переносятся на поля, методы и параметры конструктора в зависимости от типа аннотации. Аннотации типов на типах компонентов также переносятся в места использования этих типов.

  • Объекты записей можно сериализовать и десериализовать, однако процесс сериaлизации/десериализации нельзя настраивать writeObject(), readObject(), readObjectNoData(), writeExternal(), readExternal().

Статические члены внутренних классов

Как известно внутренние классы в Java не могут иметь статических членов. Это значило бы, что внутренний класс не мог бы иметь записей. Это ограничение было ослаблено, проверил на следующем примере:

public class Outer {      class Inner {          private String id;          private static String idPrefix = "Inner_";          Inner(String id) {             this.id = idPrefix + id;         }          static class StaticClass {         }          record Point(int x, int y) {         }     }      public static void main(String[] args) {         Inner inner = new Outer().new Inner("1");         System.out.println(inner.id);          Inner.StaticClass staticClass = new Inner.StaticClass();         System.out.println(staticClass);          Inner.Point point = new Inner.Point(1, 2);         System.out.println(point);     } }
java  --enable-preview --source 16 Outer.java  Inner_1 jdk16.Outer$Inner$StaticClass@6b67034 Point[x=1, y=2]

Текстовые блоки. JEP 378.

Традиционно, задавать в Java многострочный текст было не очень удобно:

String html = "<html>\n" +               "    <body>\n" +               "        <p>Hello, world</p>\n" +               "    </body>\n" +               "</html>\n";

Теперь это можно сделать так:

String html = """               <html>                   <body>                       <p>Hello, world</p>                   </body>               </html>               """;

Намного лаконичнее. Есть возможность разбивать длинные строки на несколько строк для удобства восприятия. Для этого используется escape-последовательность \<line-terminator>, например, такую строку:

String literal = "Lorem ipsum dolor sit amet, consectetur adipiscing " +                  "elit, sed do eiusmod tempor incididunt ut labore " +                  "et dolore magna aliqua.";

можно представить в виде:

String text = """               Lorem ipsum dolor sit amet, consectetur adipiscing \               elit, sed do eiusmod tempor incididunt ut labore \               et dolore magna aliqua.\               """;

Также появилась новая escape-последовательность \s, которая транслируется в единичный пробел (\u0020). Поскольку escape-последовательности транслируются после удаления пробелов в начале и конце строки, её можно использовать как барьер, чтобы помешать удалению пробелов. Например, в примере ниже последовательность \s используется, чтобы сделать каждую строку длиной ровно 6 символов:

String colors = """                 red  \s                 green\s                 blue \s                 """;

Паттерны для instanceof (Pattern Matching for instanceof). JEP 394.

Практически в каждой программе встречается код вида:

if (obj instanceof String) {     String s = (String) obj;     ... }

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

if (obj instanceof String s) {   ... }

Область видимости переменной s может быть как внутри блока if (как в примере выше), так и за его пределами, например:

if (!(obj instanceof String s)) {   throw new Exception(); } System.out.println(s);

Переменную паттерна можно использовать и в выражении оператора if:

if (obj instanceof String s && s.length() > 5) {     System.out.println(s); }

Однако такой пример приведет к ошибке компиляции:

if (obj instanceof String s || s.length() > 5) { // Error!     ... }

Переменные из паттерна могут затенять поля класса, следует быть внимательным при именовании и использовании переменных:

class Example1 {     String s;      void test1(Object o) {         if (o instanceof String s) {             System.out.println(s);      // Field s is shadowed             s = s + "\n";               // Assignment to pattern variable             ...         }         System.out.println(s);          // Refers to field s         ...     } }  class Example2 {     Point p;      void test2(Object o) {         if (o instanceof Point p) {             // p refers to the pattern variable             ...         } else {             // p refers to the field             ...         }     } }

Изолированные типы (Sealed Classes). JEP 397.

Изолированные классы и интерфейсы могут быть расширены и реализованы только теми классами и интерфейсами, которым это разрешено. Это позволяет передать компилятору знания о том, что существует ограниченная иерархия каких-либо классов. Для объявления изолированных типов используется модификатор sealed. Затем, после ключевых слов extends и implements идет ключевое слово permits, после которого перечисляются классы, которым разрешено расширять или реализовывать данный класс/интерфейс. Взглянем на пример:

package com.example.geometry;  public abstract sealed class Shape     permits Circle, Rectangle, Square { ... } ... class Circle    extends Shape { ... } ... class Rectangle extends Shape { ... } ... class Square    extends Shape { ... }

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

  • Модификатор final, если иерархия типов не должна расширяться далее.

  • Модификатор sealed, если иерархия типов может расширяться далее, но в ограниченном ключе.

  • Модификатор non-sealed, если эта часть иерархии может расширяться произвольным образом.

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

Shape rotate(Shape shape, double angle) {     if (shape instanceof Circle) return shape;     else if (shape instanceof Rectangle) return shape.rotate(angle);     else if (shape instanceof Square) return shape.rotate(angle);     // no else needed! }

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

public class Main {      static abstract sealed class Shape permits Rect, Circle {     }      static final class Rect extends Shape {     }      static final class Circle extends Shape {     }      public Shape getShape(Shape shape) {         if (shape instanceof Rect) return shape;         else if (shape instanceof Circle) return shape;     }      public static void main(String[] args) {         new Main().getShape(new Rect());     } }
javac -Xlint:preview --enable-preview --release 16 Main.java  Main.java:9: warning: [preview] sealed classes are a preview feature and may be removed in a future release.     static abstract sealed class Shape permits Rect, Circle {                     ^ Main.java:9: warning: [preview] sealed classes are a preview feature and may be removed in a future release.     static abstract sealed class Shape permits Rect, Circle {                     ^ Main.java:9: warning: [preview] sealed classes are a preview feature and may be removed in a future release.     static abstract sealed class Shape permits Rect, Circle {                                        ^ Main.java:21: error: missing return statement     }     ^ 1 error 3 warnings 

Switch выражения (Switch Expressions). JEP 361.

Использование оператора switch чревато ошибками из-за его сквозной семантики. Взгляните на пример:

switch (day) {     case MONDAY:     case FRIDAY:     case SUNDAY:         System.out.println(6);         break;     case TUESDAY:         System.out.println(7);         break;     case THURSDAY:     case SATURDAY:         System.out.println(8);         break;     case WEDNESDAY:         System.out.println(9);         break; }

Из-за большого количества ключевых слов break легко запутаться и пропустить его где-то.

Кроме того, очень часто оператор switch используется для эмуляции switch выражения, но это не удобно и тоже чревато ошибками:

int numLetters; switch (day) {     case MONDAY:     case FRIDAY:     case SUNDAY:         numLetters = 6;         break;     case TUESDAY:         numLetters = 7;         break;     case THURSDAY:     case SATURDAY:         numLetters = 8;         break;     case WEDNESDAY:         numLetters = 9;         break;     default:         throw new IllegalStateException("Wat: " + day); }

Для решения перечисленных проблем был введен новый способ записи условий в операторе switch в виде «case L ->» и сам оператор стал еще и выражением.

Если условие записано в виде «case L ->», то при его срабатывании выполняется только инструкция справа от него. Сквозная семантика в этом случае не работает. Пример такой записи:

static void howMany(int k) {     switch (k) {         case 1  -> System.out.println("one");         case 2  -> System.out.println("two");         default -> System.out.println("many");     } }

Теперь рассмотрим пример switch выражения:

static void howMany(int k) {     System.out.println(         switch (k) {             case  1 -> "one";             case  2 -> "two";             default -> "many";         }     ); }

Большинство выражений будут иметь единственную инструкцию справа от условия «case L ->». На случай, если понадобится целый блок, вводится ключевое слово yield для возврата значения из выражения:

int j = switch (day) {     case MONDAY  -> 0;     case TUESDAY -> 1;     default      -> {         int k = day.toString().length();         int result = f(k);         yield result;     } };

Условия в switch выражении должны быть исчерпывающими, то есть охватывать все возможные варианты. На практике это означает, что обязательно присутствие общего условия — default (в случае с простым оператором switch это не обязательно). Однако, в случае со switch выражениями на enum типах, которые покрывают все возможные константы, наличие общего условия необязательно. В таком случае, при добавлении новой константы в enum, компилятор выдаст ошибку, чего не случилось бы, будь общее условие задано.

Заключение

В данной статье мы рассмотрели новые синтаксические возможности Java 16: записи, текстовые блоки, паттерны для instanceof, изолированные типы и switch выражения. Стоит отметить, что изолированные типы все еще находятся на стадии preview, а потому в Java 17 могут и не войти.

Ссылки

ссылка на оригинал статьи https://habr.com/ru/post/538800/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *