Хабрахабр

[Перевод] Разбираем лямбда-выражения в Java

image

Мы открыли его для себя совсем недавно, но уже по достоинству оценили его возможности. От переводчика: LambdaMetafactory, пожалуй, один из самых недооценённых механизмов Java 8. 0 фреймворка CUBA улучшена производительность за счет отказа от рефлективных вызовов в пользу генерации лямбда выражений. В версии 7. Мы считаем, что знание принципов работы LambdaFactory может быть полезно во многих Java приложениях, и спешим поделиться с вами этим переводом. Одно из применений этого механизма в нашем фреймворке — привязка обработчиков событий приложения по аннотациям, часто встречающаяся задача, аналог EventListener из Spring.

Целевая аудитория статьи — senior Java разработчики, исследователи и разработчики инструментария. В этой статье мы покажем несколько малоизвестных хитростей при работе с лямбда-выражениями в Java 8 и ограничения этих выражений. Будет использоваться только публичный Java API без com.sun.* и других внутренних классов, поэтому код переносим между разными реализациями JVM.

Короткое предисловие

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

Например, у нас есть следующий код:

void printElements(List<String> 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<String> strings) { Consumer<String> lambda = invokedynamic# bootstrapLambda, #lambda_forEach strings.forEach(lambda);
}

Инструкция invokedynamic может быть примерно представлена как вот такой Java код:

private static CallSite cs;
void printElements(List<String> strings) { Consumer<String> lambda; //begin invokedynamic if (cs == null) cs = bootstrapLambda(MethodHandles.lookup(), "lambda_forEach", MethodType.methodType(void.class, String.class)); lambda = (Consumer<String>)cs.getTarget().invokeExact(); //end invokedynamic strings.forEach(lambda);
}

Этот метод возвращает реализацию функционального интерфейса, используя invokeExact. Как видно, LambdaMetafactory применяется для создания CallSite который предоставляет фабричный метод, возвращающий обработчик целевого метода,. Если в лямбда-выражении есть захваченные переменные, то invokeExact принимает эти переменные как фактические параметры.

К созданному классу могут быть добавлены дополнительные поля, если лямбда-выражение захватывает внешние переменные. В Oracle JRE 8 metafactory динамически генерирует Java класс, используя ObjectWeb Asm, который и создает класс-реализацию функционального интерфейса. Этот похоже на анонимные классы Java, но есть следующие отличия:

  • Анонимный класс генерируется компилятором Java.
  • Класс для реализации лямбда-выражения создается JVM во время выполнения.

Реализация metafactory зависит от вендора JVM и от версии

В основном, она применяется при выполнении динамических языков в среде JVM. Конечно же, инструкция invokedynamic используется не только для лямбда-выражений в Java. Движок Nashorn для исполнения JavaScript, который встроен в Java, интенсивно использует эту инструкцию.

Следующий
раздел этой статьи исходит из предположения, что вы отлично понимаете как работают методы metafactory и что такое MethodHandle Далее мы сфокусируемся на классе LambdaMetafactory и его возможностях.

Трюки с лямбда-выражениями

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

Проверяемые исключения и лямбды

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

Например, нужно преобразовать список строк в список URL как здесь: А что, если вам нужно использовать код с проверяемыми исключениями внутри лямбда-выражений в сочетании с Java Streams?

Arrays.asList("http://localhost/", "https://github.com").stream() .map(URL::new) .collect(Collectors.toList())

В конструкторе URL(String) объявлено проверяемое исключение, таким образом, он не может быть использован напрямую в виде ссылки на метод в классе Functiion.

Вы скажете: "Нет, возможно, если использовать вот такую хитрость":

public static <T> T uncheckCall(Callable<T> callable) catch (Exception e) { return sneakyThrow(e); }
}
private static <E extends Throwable, T> T sneakyThrow0(Throwable t) throws E { throw (E)t; }
public static <T> T sneakyThrow(Throwable e) { return Util.<RuntimeException, T>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> V callUnchecked(Callable<V> callable){ return callable.call();
}

Но мы можем убрать эту секцию, используя динамически сконструированное лямбда-выражение. Этот код не скомпилируется, потому что у метода Callable.call объявлены проверяемые исключения в секции throws.

Сначала нам нужно объявить функциональный интерфейс, в котором нет секции throws
но который сможет делегировать вызов к Callable.call:

@FunctionalInterface
interface SilentInvoker { MethodType SIGNATURE = MethodType.methodType(Object.class, Callable.class);//сигнатура метода INVOKE <V> V invoke(final Callable<V> callable);
}

Как было сказано ранее, секция throws игнорируется на уровне байткода, таким образом, метод SilentInvoker.invoke сможет вызвать метод Callable.call без объявления исключений: Второй шаг — создать реализацию этого интерфейса, используя LambdaMetafactory и делегировать вызов метода 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> V callUnchecked(final Callable<V> 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());

Более того, вызов этого метода может быть заинлайнен при помощи мономорфного инлайн кэширования, потому что это только один класс во всей JVM, который реализует интерфейс SilentOnvoker Этот код скомпилируется без проблем, потому что в callUnchecked нет объявления проверяемых исключений.

Если реализация 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

Более того, он может быть довольно полезен, если ваш код сильно полагается на рефлексию для Getters и Setters в JavaBeans. Этот раздел будет полезен тем, кто пишет сериализацию/десериализацию для различных форматов данных, таких как JSON, Thrift и т.д.

Setter, объявленный в JavaBean — метод с именем setXXX, с одним параметром и возвращающий void. Getter, объявленный в JavaBean — это метод с именем getXXX без параметров и возвращаемым типом данных, отличным от void. Эти две нотации могут быть представленв как функциональные интерфейсы:

  • Getter может быть представлен классом Function, в котором аргумент — значение this.
  • Setter может быть представлен классом BiConsumer, в котором первый аргумент — this, а второй — значение, которое передается в Setter.

И неважно, что оба интерфейса — generics. Теперь мы создадим два метода, которые смогут преобразовать любой getter или setter в эти
функциональные интерфейсы. Автоматическое приведение возвращаемого типа и аргументов может быть сделано при помощи LambdaMetafactory. После стирания типов
реальный тип данных будет Object. В дополнение, библиотека Guava поможет с кэшированием лямбда-выражений для одинаковых getters и setters.

Класс Method из Reflection API представляет реальный getter или setter и используется в качестве ключа.
Значение кэша — динамически сконструированный функциональный интерфейс для определенного getter'а или setter'а. Первый шаг: необходимо создать кэш для getters и setters.

private static final Cache<Method, Function> GETTERS = CacheBuilder.newBuilder().weakValues().build();
private static final Cache<Method, BiConsumer> 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()); }
}

Примите во внимание, что у методов экземпляров класса, всегда есть скрытый первый аргумент, используемый для передачи this в этот метод. Информация о методе, полученная из экземпляра класса Method с использованием Java Reflection API может быть легко преобразована в MethodHandle. Например, реальная сигнатура метода Integer.intValue() выглядит как int intValue(Integer this). У статических методов такого параметра нет. Эта хитрость используется в нашей имплементации функциональных оберток для getters и setters.

А теперь — время тестировать код:

final Date d = new Date();
final BiConsumer<Date, Long> timeSetter = reflectSetter(MethodHandles.lookup(), Date.class.getDeclaredMethod("setTime", long.class));
timeSetter.accept(d, 42L); //the same as d.setTime(42L);
final Function<Date, Long> 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.

Ограничения и баги

Все эти ограничения можно воспроизвести в OpenJDK и Oracle JDK с javac версии 1. В этом разделе мы рассмотрим некоторые баги и ограничения, связанные с лямбда-выражениями в компиляторе Java и JVM. 0_131 для Windows и Linux. 8.

Создание лямбда-выражений из обработчиков методов

Чтобы это сделать, нужно определить обработчик — класс MethodHandle, который указывает на реализацию единственного метода, который определен в функциональном интерфейсе. Как вы знаете, лямбда-выражение можно сконструировать динамически, используя LambdaMetaFactory. Давайте взглянем на этот простой пример:

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<String> getter = (Supplier<String>) site.getTarget().invokeExact(obj);
System.out.println(getter.get());

Этот код эквивалентен:

final TestClass obj = new TestClass();
obj.setValue("Hello, world!");
final Supplier<String> 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<String> getter = MethodHandleProxies .asInterfaceInstance(Supplier.class, lookup.findGetter(TestClass.class, "value", String.class) .bindTo(obj));

Этот подход использует Java Reflection и работает очень медленно. Нужно отметить, что MethodHandleProxies — не очень хороший способ для динамического создания лямбда-выражений, потому что этот класс просто оборачивает MethodHandle в прокси-класс и делегирует вызов InvocationHandler.invoke методу MethodHandle.invokeWithArguments.

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

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

Вот они:

  • 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<V, E extends Exception> extends Callable<V>{ @Override V call() throws E;
}
final ExtendedCallable<URL, MalformedURLException> urlFactory = () -> new URL("http://localhost"); urlFactory.call();

Но он не компилируется. Этот код должен скомпилироваться, потому что конструктор класса URL выбрасывает MalformedURLException. Выдается следующее сообщение об ошибке:

Error:(46, 73) java: call() in <anonymous Test$CODEgt; cannot implement call() in ExtendedCallable
overridden method does not throw java.lang.Exception

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


final ExtendedCallable<URL, MalformedURLException> urlFactory = new ExtendedCallable<URL, MalformedURLException>() { @Override public URL call() throws MalformedURLException { return new URL("http://localhost"); } };
urlFactory.call();

Из этого следует:

Вывод типов для generic исключений не работет корректно в сочетании с лямбда-выражениями

Ограничения типов параметризации

Z>.
Такой способ определения generic параметров редко используется, но определенным образом влияет на лямбда-выражения в Java из-за некоторых ограничений: Можно сконструировать generic объект с несколькими ограничениями типов, используя знак &: <T extends A & B & C & ...

  • Каждое ограничение типа, кроме первого, должно быть интерфейсом.
  • Чистая версия класса с таким 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 <T extends Number & IntSupplier> OptionalInt findMinValue(final Collection<T> values){ return values.stream().mapToInt(UtilsTest::getInt).min();
}

Этот пример демонстрирует некорректный вывод типов в компиляторе и среде исполнения.

Обработка нескольких ограничений типов generic параметров в сочетании с использованием лямбда-выражений во время компиляции и выполнения — неконсистентна

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть