Перейти к основному содержимому

Введение в проект «Янтарь»

· 14 мин. чтения

1. Что такое проект «Янтарь»

Проект Amber — это текущая инициатива разработчиков Java и OpenJDK, направленная на внесение небольших, но важных изменений в JDK, чтобы сделать процесс разработки более приятным . Это продолжается с 2017 года и уже внесло некоторые изменения в Java 10 и 11, а другие запланированы для включения в Java 12 и еще больше в будущих выпусках.

Все эти обновления упакованы в виде JEP — схемы JDK Enhancement Proposal.

2. Доставленные обновления

На сегодняшний день Project Amber успешно внес некоторые изменения в выпущенные в настоящее время версии JDK — JEP-286 и JEP-323 .

2.1. Определение типа локальной переменной

Java 7 представила Diamond Operator как способ упростить работу с дженериками . Эта функция означает, что нам больше не нужно записывать общую информацию несколько раз в одном и том же операторе, когда мы определяем переменные:

List<String> strings = new ArrayList<String>(); // Java 6
List<String> strings = new ArrayList<>(); // Java 7

Java 10 включает завершенную работу над JEP-286, что позволяет нашему коду Java определять локальные переменные без необходимости дублировать информацию о типе везде, где она уже доступна компилятору . Это упоминается в более широком сообществе как ключевое слово var и обеспечивает аналогичную функциональность для Java, которая доступна во многих других языках.

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

var strings = new ArrayList<String>();

В приведенном выше примере определено, что переменные strings имеют тип ArrayList<String>() , но без необходимости дублировать информацию в той же строке.

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

Слово var — это особый случай, поскольку это не зарезервированное слово. Вместо этого это имя специального типа. Это означает, что это слово можно использовать для других частей кода, включая имена переменных. Настоятельно не рекомендуется этого делать во избежание путаницы.

Мы можем использовать вывод локального типа только тогда, когда мы предоставляем фактический тип как часть объявления . Он намеренно разработан таким образом, чтобы не работать, когда значение явно равно null, когда значение вообще не указано или когда предоставленное значение не может определить точный тип — например, определение Lambda:

var unknownType; // No value provided to infer type from
var nullType = null; // Explicit value provided but it's null
var lambdaType = () -> System.out.println("Lambda"); // Lambda without defining the interface

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

Optional<String> name = Optional.empty();
var nullName = name.orElse(null);

В этом случае nullName выведет тип String , потому что это тип возвращаемого значения name.orElse() .

Определенные таким образом переменные могут иметь любые другие модификаторы точно так же, как и любая другая переменная — например, транзитивные, синхронизированные и конечные .

2.2. Определение типа локальной переменной для лямбда-выражений

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

В Java 10 мы можем определить функции Lambda одним из двух способов — либо явно объявив типы, либо полностью опустив их:

names.stream()
.filter(String name -> name.length() > 5)
.map(name -> name.toUpperCase());

Здесь вторая строка имеет явное объявление типа — String — тогда как третья строка полностью его опускает, и компилятор определяет правильный тип. Чего мы не можем сделать, так это использовать здесь тип var .

Java 11 позволяет это сделать , поэтому вместо этого мы можем написать:

names.stream()
.filter(var name -> name.length() > 5)
.map(var name -> name.toUpperCase());

Это согласуется с использованием типа var в другом месте нашего кода .

Лямбды всегда ограничивали нас использованием полных имен типов либо для каждого параметра, либо ни для одного из них. Это не изменилось, и использование var должно быть либо для каждого параметра, либо ни для одного из них :

numbers.stream()
.reduce(0, (var a, var b) -> a + b); // Valid

numbers.stream()
.reduce(0, (var a, b) -> a + b); // Invalid

numbers.stream()
.reduce(0, (var a, int b) -> a + b); // Invalid

Здесь первый пример вполне корректен, потому что оба лямбда-параметра используют var . Однако второй и третий недопустимы, потому что только один параметр использует var , хотя в третьем случае у нас также есть явное имя типа.

3. Грядущие обновления

В дополнение к обновлениям, которые уже доступны в выпущенных JDK, предстоящий выпуск JDK 12 включает одно обновление — JEP-325.

3.1. Переключение выражений

JEP-325 поддерживает упрощение работы операторов switch и позволяет использовать их в качестве выражений , чтобы еще больше упростить код, который их использует.

В настоящее время оператор switch работает так же, как в таких языках, как C или C++. Эти изменения делают его более похожим на оператор when в Kotlin или на оператор match в Scala .

С этими изменениями синтаксис для определения оператора switch похож на синтаксис lambdas с использованием символа -> . Это находится между совпадением case и кодом, который должен быть выполнен:

switch (month) {
case FEBRUARY -> System.out.println(28);
case APRIL -> System.out.println(30);
case JUNE -> System.out.println(30);
case SEPTEMBER -> System.out.println(30);
case NOVEMBER -> System.out.println(30);
default -> System.out.println(31);
}

Обратите внимание, что ключевое слово break не требуется, и более того, мы не можем использовать его здесь . Автоматически подразумевается, что каждое совпадение уникально, и отказ невозможен. Вместо этого мы можем продолжать использовать старый стиль, когда он нам понадобится.

Правая сторона стрелки должна быть либо выражением, либо блоком, либо оператором throws . Все остальное - ошибка. Это также решает проблему определения переменных внутри операторов switch — это может произойти только внутри блока, что означает, что они автоматически привязаны к этому блоку:

switch (month) {
case FEBRUARY -> {
int days = 28;
}
case APRIL -> {
int days = 30;
}
....
}

В операторе switch в старом стиле это было бы ошибкой из-за повторяющейся переменной days . Требование использовать блок позволяет избежать этого.

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

switch (month) {
case FEBRUARY -> System.out.println(28);
case APRIL, JUNE, SEPTEMBER, NOVEMBER -> System.out.println(30);
default -> System.out.println(31);
}

Пока что все это возможно с текущим способом работы операторов switch и делает его более аккуратным. Однако в этом обновлении также появилась возможность использовать оператор switch в качестве выражения . Это значительное изменение для Java, но оно согласуется с тем, сколько других языков, включая другие языки JVM, начинают работать.

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

final var days = switch (month) {
case FEBRUARY -> 28;
case APRIL, JUNE, SEPTEMBER, NOVEMBER -> 30;
default -> 31;
}

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

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

4. Предстоящие изменения

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

4.1. Необработанные строковые литералы

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

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

В JEP-326 представлен новый тип строкового литерала, который называется Raw String Literals . Они заключаются в обратные кавычки вместо двойных кавычек и могут содержать любые символы внутри них.

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

Например:

// File system path
"C:\\Dev\\file.txt"
`C:\Dev\file.txt`

// Regex
"\\d+\\.\\d\\d"
`\d+\.\d\d`

// Multi-Line
"Hello\nWorld"
`Hello
World`

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

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

``This string allows a single "`" because it's wrapped in two backticks``

Это позволяет нам вводить строки именно такими, какие они есть, вместо того, чтобы когда-либо нуждаться в специальных последовательностях для работы определенных символов.

4.2. Лямбда остатки

В JEP-302 внесены небольшие улучшения в работу лямбда-выражений.

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

В Java 8 было введено изменение, поэтому использование символа подчеркивания в качестве имени является предупреждением. Затем в Java 9 это превратилось в ошибку, что не позволило нам вообще их использовать. Это предстоящее изменение позволяет использовать лямбда-параметры без каких-либо конфликтов. Это позволит, например, следующий код:

jdbcTemplate.queryForObject("SELECT * FROM users WHERE user_id = 1", (rs, _) -> parseUser(rs))

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

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

String key = computeSomeKey();
map.computeIfAbsent(key, key2 -> key2.length());

Нет никакой реальной необходимости, кроме компилятора, почему key и key2 не могут иметь общее имя . Лямбде никогда не нужно ссылаться на переменную key , и принуждение нас к этому делает код более уродливым.

Вместо этого это усовершенствование позволяет нам записать его более очевидным и простым способом:

String key = computeSomeKey();
map.computeIfAbsent(key, key -> key.length());

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

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

m(Predicate<String> ps) { ... }
m(Function<String, String> fss) { ... }

Оба этих метода принимают лямбда-выражение с одним параметром String и возвращаемым типом, отличным от void. Для разработчика очевидно, что они разные — один возвращает String , а другой — boolean , но компилятор будет интерпретировать их как неоднозначные .

Этот JEP может устранить этот недостаток и разрешить явную обработку этой перегрузки.

4.3. Сопоставление с образцом

JEP-305 вносит улучшения в работу с оператором instanceof и автоматическим приведением типов.

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

if (obj instanceof String) {
String s = (String) obj;
// use s
}

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

Это усовершенствование вносит аналогичную корректировку в оператор instanceof , как это было сделано ранее в try-with-resources в Java 7 . С этим изменением сравнение, приведение и объявление переменной вместо этого становятся одним оператором:

if (obj instanceof String s) {
// use s
}

Это дает нам единый оператор без дублирования и риска появления ошибок , и при этом выполняет то же, что и выше.

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

if (obj instanceof String s) {
// can use s here
} else {
// can't use s here
}

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

String s = "Hello";
if (obj instanceof String s) {
// s refers to obj
} else {
// s refers to the variable defined before the if statement
}

Это также работает в том же предложении if , точно так же, как мы полагаемся на нулевые проверки:

if (obj instanceof String s && s.length() > 5) {
// s is a String of greater than 5 characters
}

В настоящее время это планируется только для операторов if , но в будущих работах, вероятно, будет расширена работа с выражениями switch .

4.4. Тела кратких методов

JEP Draft 8209434 — это предложение по поддержке упрощенных определений методов, аналогично тому, как работают определения лямбда.

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

ToIntFunction<String> lenFn = (String s) -> { return s.length(); };
ToIntFunction<String> lenFn = (String s) -> s.length();
ToIntFunction<String> lenFn = String::length;

Однако, когда дело доходит до написания реальных тел методов класса, в настоящее время мы должны писать их полностью .

Это предложение состоит в том, чтобы поддерживать формы ссылок на выражения и методы для этих методов , в тех случаях, когда они применимы. Это поможет сделать некоторые методы намного проще, чем они есть сейчас.

Например, методу-получателю не требуется полное тело метода, но его можно заменить одним выражением:

String getName() -> name;

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

int length(String s) = String::length

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

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

5. Расширенные перечисления

Ранее планировалось, что JEP-301 станет частью проекта Amber. Это внесло бы некоторые улучшения в перечисления, явно позволив отдельным элементам перечисления иметь различную информацию об универсальном типе .

Например, это позволит:

enum Primitive<X> {
INT<Integer>(Integer.class, 0) {
int mod(int x, int y) { return x % y; }
int add(int x, int y) { return x + y; }
},
FLOAT<Float>(Float.class, 0f) {
long add(long x, long y) { return x + y; }
}, ... ;

final Class<X> boxClass;
final X defaultValue;

Primitive(Class<X> boxClass, X defaultValue) {
this.boxClass = boxClass;
this.defaultValue = defaultValue;
}
}

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

Таким образом, это усовершенствование в настоящее время приостановлено до тех пор, пока эти детали не будут проработаны .

6. Резюме

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