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

image

От переводчика: 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 параметров в сочетании с использованием лямбда-выражений во время компиляции и выполнения — неконсистентна



© Habrahabr.ru