[Перевод] Разбираем лямбда-выражения в Java
От переводчика: LambdaMetafactory, пожалуй, один из самых недооценённых механизмов Java 8. Мы открыли его для себя совсем недавно, но уже по достоинству оценили его возможности. В версии 7.0 фреймворка CUBA улучшена производительность за счет отказа от рефлективных вызовов в пользу генерации лямбда выражений. Одно из применений этого механизма в нашем фреймворке — привязка обработчиков событий приложения по аннотациям, часто встречающаяся задача, аналог EventListener из Spring. Мы считаем, что знание принципов работы LambdaFactory может быть полезно во многих Java приложениях, и спешим поделиться с вами этим переводом.
В этой статье мы покажем несколько малоизвестных хитростей при работе с лямбда-выражениями в Java 8 и ограничения этих выражений. Целевая аудитория статьи — senior Java разработчики, исследователи и разработчики инструментария. Будет использоваться только публичный Java API без com.sun.*
и других внутренних классов, поэтому код переносим между разными реализациями JVM.
Короткое предисловие
Лямбда-выражения появились в Java 8 как способ имплементации анонимных методов и,
в некоторых случаях, как альтернатива анонимным классам. На уровне байткода лямбда-выражение заменяется инструкцией invokedynamic
. Эта инструкция используется для создания реализации функционального интерфейса и его единственный метод делегирует вызов фактическому методу, который содержит код, определенный в теле лямбда-выражения.
Например, у нас есть следующий код:
void printElements(List strings){
strings.forEach(item -> System.out.println("Item = %s", item));
}
Этот код будет преобразован компилятором Java во что-то похожее на:
private static void lambda_forEach(String item) { //сгенерировано Java компилятором
System.out.println("Item = %s", item);
}
private static CallSite bootstrapLambda(Lookup lookup, String name, MethodType type) { //
//lookup = предоставляется VM
//name = "lambda_forEach", предоставляется VM
//type = String -> void
MethodHandle lambdaImplementation = lookup.findStatic(lookup.lookupClass(), name, type);
return LambdaMetafactory.metafactory(lookup,
"accept",
MethodType.methodType(Consumer.class), //сигнатура фабрики лямбда-выражений
MethodType.methodType(void.class, Object.class), //сигнатура метода Consumer.accept после стирания типов
lambdaImplementation, //ссылка на метод с кодом лямбда-выражения
type);
}
void printElements(List strings) {
Consumer lambda = invokedynamic# bootstrapLambda, #lambda_forEach
strings.forEach(lambda);
}
Инструкция invokedynamic
может быть примерно представлена как вот такой Java код:
private static CallSite cs;
void printElements(List strings) {
Consumer lambda;
//begin invokedynamic
if (cs == null)
cs = bootstrapLambda(MethodHandles.lookup(),
"lambda_forEach",
MethodType.methodType(void.class, String.class));
lambda = (Consumer)cs.getTarget().invokeExact();
//end invokedynamic
strings.forEach(lambda);
}
Как видно, LambdaMetafactory
применяется для создания CallSite который предоставляет фабричный метод, возвращающий обработчик целевого метода,. Этот метод возвращает реализацию функционального интерфейса, используя invokeExact
. Если в лямбда-выражении есть захваченные переменные, то invokeExact
принимает эти переменные как фактические параметры.
В Oracle JRE 8 metafactory динамически генерирует Java класс, используя ObjectWeb Asm, который и создает класс-реализацию функционального интерфейса. К созданному классу могут быть добавлены дополнительные поля, если лямбда-выражение захватывает внешние переменные. Этот похоже на анонимные классы Java, но есть следующие отличия:
- Анонимный класс генерируется компилятором Java.
- Класс для реализации лямбда-выражения создается JVM во время выполнения.
Реализация metafactory зависит от вендора JVM и от версии
Конечно же, инструкция invokedynamic
используется не только для лямбда-выражений в Java. В основном, она применяется при выполнении динамических языков в среде JVM. Движок Nashorn для исполнения JavaScript, который встроен в Java, интенсивно использует эту инструкцию.
Далее мы сфокусируемся на классе LambdaMetafactory
и его возможностях. Следующий
раздел этой статьи исходит из предположения, что вы отлично понимаете как работают методы metafactory и что такое MethodHandle
Трюки с лямбда-выражениями
В этом разделе мы покажем, как строить динамические конструкции из лямбд для использования в ежедневных задачах.
Проверяемые исключения и лямбды
Не секрет, что все функциональные интерфейсы, которые есть в Java, не поддерживают проверяемые исключения. Преимущества проверяемых исключений перед обычными — это очень давний (и до сих пор горячий) спор.
А что, если вам нужно использовать код с проверяемыми исключениями внутри лямбда-выражений в сочетании с Java Streams? Например, нужно преобразовать список строк в список URL как здесь:
Arrays.asList("http://localhost/", "https://github.com")
.stream()
.map(URL::new)
.collect(Collectors.toList())
В конструкторе URL (String) объявлено проверяемое исключение, таким образом, он не может быть использован напрямую в виде ссылки на метод в классе Functiion.
Вы скажете: «Нет, возможно, если использовать вот такую хитрость»:
public static T uncheckCall(Callable callable) {
try { return callable.call(); }
catch (Exception e) { return sneakyThrow(e); }
}
private static T sneakyThrow0(Throwable t) throws E { throw (E)t; }
public static T sneakyThrow(Throwable e) {
return Util.sneakyThrow0(e);
}
// Пример использования
//return s.filter(a -> uncheckCall(a::isActive))
// .map(Account::getNumber)
// .collect(toSet());
Это грязный хак. И вот почему:
- Используется блок try-catch.
- Исключение выбрасывается ещё раз.
- Грязное использование стирания типов в Java.
Проблема может быть решена более «легальным» способом, с использованием знания следующих фактов:
- Проверяемые исключения распознаются только на уровне Java компилятора.
- Секция
throws
— это всего лишь метаданные для метода без семантического значения на уровне JVM. - Проверяемые и обычные исключения неразличимы на уровне байткода в JVM.
Решение — обернуть метод Callable.call
в метод без секции throws
:
static V callUnchecked(Callable callable){
return callable.call();
}
Этот код не скомпилируется, потому что у метода Callable.call
объявлены проверяемые исключения в секции throws
. Но мы можем убрать эту секцию, используя динамически сконструированное лямбда-выражение.
Сначала нам нужно объявить функциональный интерфейс, в котором нет секции throws
но который сможет делегировать вызов к Callable.call
:
@FunctionalInterface
interface SilentInvoker {
MethodType SIGNATURE = MethodType.methodType(Object.class, Callable.class);//сигнатура метода INVOKE
V invoke(final Callable callable);
}
Второй шаг — создать реализацию этого интерфейса, используя LambdaMetafactory
и делегировать вызов метода SilentInvoker.invoke
методу Callable.call
. Как было сказано ранее, секция throws
игнорируется на уровне байткода, таким образом, метод SilentInvoker.invoke
сможет вызвать метод Callable.call
без объявления исключений:
private static final SilentInvoker SILENT_INVOKER;
final MethodHandles.Lookup lookup = MethodHandles.lookup();
final CallSite site = LambdaMetafactory.metafactory(lookup,
"invoke",
MethodType.methodType(SilentInvoker.class),
SilentInvoker.SIGNATURE,
lookup.findVirtual(Callable.class, "call", MethodType.methodType(Object.class)),
SilentInvoker.SIGNATURE);
SILENT_INVOKER = (SilentInvoker) site.getTarget().invokeExact();
Третье — напишем вспомогательный метод, который вызывает Callable.call
без объявления исключений:
public static V callUnchecked(final Callable callable) /*no throws*/ {
return SILENT_INVOKER.invoke(callable);
}
Теперь можно переписать stream без всяких проблем с проверяемыми исключениями:
Arrays.asList("http://localhost/", "https://dzone.com")
.stream()
.map(url -> callUnchecked(() -> new URL(url)))
.collect(Collectors.toList());
Этот код скомпилируется без проблем, потому что в callUnchecked
нет объявленния проверяемых исключений. Более того, вызов этого метода может быть заинлайнен при помощи [мономорфного инлайн кэширования]https://en.wikipedia.org/wiki/Inline_caching#Monomorphic_inline_caching), потому что это только один класс во всей JVM, который реализует интерфейс SilentOnvoker
Если реализация Callable.call
выкинет исключение во время выполнения, то оно будет перехвачено вызывающей функцией без всяких проблем:
try{
callUnchecked(() -> new URL("Invalid URL"));
} catch (final Exception e){
System.out.println(e);
}
Несмотря на возможности этого метода, нужно всегда помнить про следующую рекомендацию:
Скрывайте проверяемые исключения при помощи callUnchecked только если уверены, что вызываемый код
не выкинет никаких исключений
Следующий пример показывает пример такого подхода:
callUnchecked(() -> new URL("https://dzone.com")); //этот URL всегда правильный и конструктор никогда не выкинет MalformedURLException
Полная реализация этого метода находится здесь, это часть проекта с открытым кодом SNAMP.
Работаем с Getters и Setters
Этот раздел будет полезен тем, кто пишет сериализацию/десериализацию для различных форматов данных, таких как JSON, Thrift и т.д. Более того, он может быть довольно полезен, если ваш код сильно полагается на рефлексию для Getters и Setters в JavaBeans.
Getter, объявленный в JavaBean — это метод с именем getXXX
без параметров и возвращаемым типом данных, отличным от void
. Setter, объявленный в JavaBean — метод с именем setXXX
, с одним параметром и возвращающий void
. Эти две нотации могут быть представленв как функциональные интерфейсы:
- Getter может быть представлен классом Function, в котором аргумент — значение
this
. - Setter может быть представлен классом BiConsumer, в котором первый аргумент —
this
, а второй — значение, которое передается в Setter.
Теперь мы создадим два метода, которые смогут преобразовать любой getter или setter в эти
функциональные интерфейсы. И неважно, что оба интерфейса — generics. После стирания типов
реальный тип данных будет Object
. Автоматическое приведение возвращаемого типа и аргументов может быть сделано при помощи LambdaMetafactory
. В дополнение, библиотека Guava поможет с кэшированием лямбда-выражений для одинаковых getters и setters.
Первый шаг: необходимо создать кэш для getters и setters. Класс Method
из Reflection API представляет реальный getter или setter и используется в качестве ключа.
Значение кэша — динамически сконструированный функциональный интерфейс для определенного getter’а или setter’а.
private static final Cache GETTERS = CacheBuilder.newBuilder().weakValues().build();
private static final Cache SETTERS = CacheBuilder.newBuilder().weakValues().build();
Во-вторых, создадим фабричные методы, которые создают экземпляр функционального интерфейса на основе ссылок на getter или setter.
private static Function createGetter(final MethodHandles.Lookup lookup,
final MethodHandle getter) throws Exception{
final CallSite site = LambdaMetafactory.metafactory(lookup, "apply",
MethodType.methodType(Function.class),
MethodType.methodType(Object.class, Object.class), //signature of method Function.apply after type erasure
getter,
getter.type()); //actual signature of getter
try {
return (Function) site.getTarget().invokeExact();
} catch (final Exception e) {
throw e;
} catch (final Throwable e) {
throw new Error(e);
}
}
private static BiConsumer createSetter(final MethodHandles.Lookup lookup,
final MethodHandle setter) throws Exception {
final CallSite site = LambdaMetafactory.metafactory(lookup,
"accept",
MethodType.methodType(BiConsumer.class),
MethodType.methodType(void.class, Object.class, Object.class), //signature of method BiConsumer.accept after type erasure
setter,
setter.type()); //actual signature of setter
try {
return (BiConsumer) site.getTarget().invokeExact();
} catch (final Exception e) {
throw e;
} catch (final Throwable e) {
throw new Error(e);
}
}
Автоматическое приведение типов между аргументами типа Object
в функциональных интерфейсах (после стирания типов) и реальными типами аргументов и возвращамого значения достигается при помощи разницы между samMethodType
и instantiatedMethodType
(третий и пятый аргументы метода metafactory, соответственно). Тип созданного экземпляра метода — это и есть специализация метода, который предоставляет реализацию лямбда-выражения.
В-третьих, создадим фасад для этих фабрик с поддержкой кэширования:
public static Function reflectGetter(final MethodHandles.Lookup lookup, final Method getter) throws ReflectiveOperationException {
try {
return GETTERS.get(getter, () -> createGetter(lookup, lookup.unreflect(getter)));
} catch (final ExecutionException e) {
throw new ReflectiveOperationException(e.getCause());
}
}
public static BiConsumer reflectSetter(final MethodHandles.Lookup lookup, final Method setter) throws ReflectiveOperationException {
try {
return SETTERS.get(setter, () -> createSetter(lookup, lookup.unreflect(setter)));
} catch (final ExecutionException e) {
throw new ReflectiveOperationException(e.getCause());
}
}
Информация о методе, полученная из экземпляра класса Method
с использованием Java Reflection API может быть легко преобразована в MethodHandle
. Примите во внимание, что у методов экземпляров класса, всегда есть скрытый первый аргумент, используемый для передачи this
в этот метод. У статических методов такого параметра нет. Например, реальная сигнатура метода Integer.intValue()
выглядит как int intValue(Integer this)
. Эта хитрость используется в нашей имплементации функциональных оберток для getters и setters.
А теперь — время тестировать код:
final Date d = new Date();
final BiConsumer timeSetter = reflectSetter(MethodHandles.lookup(), Date.class.getDeclaredMethod("setTime", long.class));
timeSetter.accept(d, 42L); //the same as d.setTime(42L);
final Function timeGetter = reflectGetter(MethodHandles.lookup(), Date.class.getDeclaredMethod("getTime"));
System.out.println(timeGetter.apply(d)); //the same as d.getTime()
//output is 42
Этот подход с закэшированными getters и setters можно эффективно использовать в библиотеках для сериализации/десериализации (таких, как Jackson), которые используют getters и setters во время сериализации и десериализации.
Вызовы функциональных интерфейсов с динамически сгенерированными реализациями с использованием LambdaMetaFactory
значительно быстрее, чем вызовы через Java Reflection API
Полную версию кода можно найти здесь, это часть библиотеки SNAMP.
Ограничения и баги
В этом разделе мы рассмотрим некоторые баги и ограничения, связанные с лямбда-выражениями в компиляторе Java и JVM. Все эти ограничения можно воспроизвести в OpenJDK и Oracle JDK с javac
версии 1.8.0_131 для Windows и Linux.
Создание лямбда-выражений их обработчиков методов
Как вы знаетет, лямбда-выражение можно сконструировать динамически, используя LambdaMetaFactory
. Чтобы это сделать, нужно определить обработчик — класс MethodHandle
, который указывает на реализацию единственного метода, который определен в функциональном интерфейсе. Давайте взглянем на этот простой пример:
final class TestClass {
String value = "";
public String getValue() {
return value;
}
public void setValue(final String value) {
this.value = value;
}
}
final TestClass obj = new TestClass();
obj.setValue("Hello, world!");
final MethodHandles.Lookup lookup = MethodHandles.lookup();
final CallSite site = LambdaMetafactory.metafactory(lookup,
"get",
MethodType.methodType(Supplier.class, TestClass.class),
MethodType.methodType(Object.class),
lookup.findVirtual(TestClass.class, "getValue", MethodType.methodType(String.class)),
MethodType.methodType(String.class));
final Supplier getter = (Supplier) site.getTarget().invokeExact(obj);
System.out.println(getter.get());
Этот код эквивалентен:
final TestClass obj = new TestClass();
obj.setValue("Hello, world!");
final Supplier elementGetter = () -> obj.getValue();
System.out.println(elementGetter.get());
Но что, если мы заменим обработчик метода, который указывает на getValue
на обработчик, который представляет getter поля:
final CallSite site = LambdaMetafactory.metafactory(lookup,
"get",
MethodType.methodType(Supplier.class, TestClass.class),
MethodType.methodType(Object.class),
lookup.findGetter(TestClass.class, "value", String.class), //field getter instead of method handle to getValue
MethodType.methodType(String.class));
Этот код должен, ожидаемо, работать, потому что findGetter
возвращает обработчик, который указывает на getter поля и у него правильная сигнатура. Но, если вы запустите этот код, то увидите следующее исключение:
java.lang.invoke.LambdaConversionException: Unsupported MethodHandle kind: getField
Что интересно, getter для поля работает нормально, если будем использовать MethodHandleProxies:
final Supplier getter = MethodHandleProxies
.asInterfaceInstance(Supplier.class, lookup.findGetter(TestClass.class, "value", String.class)
.bindTo(obj));
Нужно отметить, что MethodHandleProxies
— не очень хороший способ для динамического создания лямбда-выражений, потому что этот класс просто оборачивает MethodHandle
в прокси-класс и делегирует вызов InvocationHandler.invoke методу MethodHandle.invokeWithArguments. Этот подход использует Java Reflection и работает очень медленно.
Как было показано ранее, не все обработчики методов могут быть использованы для создания лямбда-выражений во время выполнения кода.
Только несколько типов обработчиков методов могут быть использованы для динамического создания лямбда-выражений
Вот они:
- REF_invokeInterface: может быть создан при помощи Lookup.findVirtual для методов интерфейсов
- REF_invokeVirtual: может быть создан с помощью Lookup.findVirtual для виртуальных методов класса
- REF_invokeStatic: создается при помощи Lookup.findStatic для статических методов
- REF_newInvokeSpecial: может быть создан при помощи Lookup.findConstructor для конструкторов
- REF_invokeSpecial: может быть создан с помощью Lookup.findSpecial
для приватных методов и раннего связывания с виртуальными методами класса
Остальные типы обработчиков вызовут ошибку LambdaConversionException
.
Generic исключения
Этот баг связан с компилятором Java и возможностью объявлять generic исключения в секции throws
. Следующий пример кода демонстрирует это поведение:
interface ExtendedCallable extends Callable{
@Override
V call() throws E;
}
final ExtendedCallable urlFactory = () -> new URL("http://localhost");
urlFactory.call();
Этот код должен скомпилироваться, потому что конструктор класса URL
выбрасывает MalformedURLException
. Но он не компилируется. Выдается следующее сообщение об ошибке:
Error:(46, 73) java: call() in
Но, если мы заменим лямбда-выражение анонимным классом, то код скомпилируется:
final ExtendedCallable urlFactory = new ExtendedCallable() {
@Override
public URL call() throws MalformedURLException {
return new URL("http://localhost");
}
};
urlFactory.call();
Из этого следует:
Вывод типов для generic исключений не работет корректно в сочетании с лямбда-выражениями
Ограничения типов параметризации
Можно сконструировать generic объект с несколькими ограничениями типов, используя знак &
:
.
Такой способ определения generic параметров редко используется, но определенным образом влияет на лямбда-выражения в Java из-за некоторых ограничений:
- Каждое ограничение типа, кроме первого, должно быть интерфейсом.
- Чистая версия класса с таким generic учитывает только первое ограничение типа из списка.
Второе ограничение приводит к разному поведению кода во время компиляции и во время выполнения, когда происходит связываение с лямбда-выражения. Эту разницу можно продемонстрировать, используя следующий код:
final class MutableInteger extends Number implements IntSupplier, IntConsumer { //mutable container of int value
private int value;
public MutableInteger(final int v) {
value = v;
}
@Override
public int intValue() {
return value;
}
@Override
public long longValue() {
return value;
}
@Override
public float floatValue() {
return value;
}
@Override
public double doubleValue() {
return value;
}
@Override
public int getAsInt() {
return intValue();
}
@Override
public void accept(final int value) {
this.value = value;
}
}
static < T extends Number & IntSupplier > OptionalInt findMinValue(final Collection < T > values) {
return values.stream().mapToInt(IntSupplier::getAsInt).min();
}
final List < MutableInteger > values = Arrays.asList(new MutableInteger(10), new MutableInteger(20));
final int mv = findMinValue(values).orElse(Integer.MIN_VALUE);
System.out.println(mv);
Этот код абсолютно корректный и успешно компилируется. Класс MutableInteger
удовлетворяет ограничениям обобщенного типа T:
MutableInteger
наследуется отNumber
.MutableInteger
реализуетIntSupplier
.
Но код упадет с исключением во время выполнения:java.lang.BootstrapMethodError: call site initialization exception at java.lang.invoke.CallSite.makeSite(CallSite.java:341) at java.lang.invoke.MethodHandleNatives.linkCallSiteImpl(MethodHandleNatives.java:307) at java.lang.invoke.MethodHandleNatives.linkCallSite(MethodHandleNatives.java:297) at Test.minValue(Test.java:77) Caused by: java.lang.invoke.LambdaConversionException: Invalid receiver type class java.lang.Number; not a subtype of implementation type interface java.util.function.IntSupplier at java.lang.invoke.AbstractValidatingLambdaMetafactory.validateMetafactoryArgs(AbstractValidatingLambdaMetafactory.java:233) at java.lang.invoke.LambdaMetafactory.metafactory(LambdaMetafactory.java:303) at java.lang.invoke.CallSite.makeSite(CallSite.java:302)
Так получается, потому что конвейер JavaStream захватывает только чистый тип, который, в нашем случае — класс
Number
и он не реализует интерфейсIntSupplier
. Эту проблему можно исправить явным объявлением типа параметра в отдельном методе, используемом в качестве ссылки на метод:private static int getInt(final IntSupplier i){ return i.getAsInt(); } private static
OptionalInt findMinValue(final Collection values){ return values.stream().mapToInt(UtilsTest::getInt).min(); } Этот пример демонстрирует некорректный вывод типов в компиляторе и среде исполнения.
Обработка нескольких оганичений типов generic параметров в сочетании с использованием лямбда-выражений во время компиляции и выполнения — неконсистентна