Haulmont corporate blog
Programming
Java
December 2018 11

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

Original author: Evgeniy Kirichenko, Roman Sakno
Translation

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<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);
}

Как видно, 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> T uncheckCall(Callable<T> callable) {
    try { return callable.call(); }
    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);
}

Второй шаг — создать реализацию этого интерфейса, используя 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> 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());

Этот код скомпилируется без проблем, потому что в callUnchecked нет объявления проверяемых исключений. Более того, вызов этого метода может быть заинлайнен при помощи мономорфного инлайн кэширования, потому что это только один класс во всей 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<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());
        }
}

Информация о методе, полученная из экземпляра класса Method с использованием Java Reflection API может быть легко преобразована в MethodHandle. Примите во внимание, что у методов экземпляров класса, всегда есть скрытый первый аргумент, используемый для передачи this в этот метод. У статических методов такого параметра нет. Например, реальная сигнатура метода 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.


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


В этом разделе мы рассмотрим некоторые баги и ограничения, связанные с лямбда-выражениями в компиляторе 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<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));

Нужно отметить, что 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<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 исключений не работет корректно в сочетании с лямбда-выражениями




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


Можно сконструировать generic объект с несколькими ограничениями типов, используя знак &: <T extends A & B & C & ... Z>.
Такой способ определения 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 <T extends Number & IntSupplier> OptionalInt findMinValue(final Collection<T> values){
        return values.stream().mapToInt(UtilsTest::getInt).min();
}

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




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




+25
18.2k 181
Comments 34
Top of the day