Различное общее поведение при использовании лямбды вместо явного анонимного внутреннего класса

Контекст

Я работаю над проектом, который сильно зависит от универсальных типов. Одним из его ключевых компонентов является так называемый TypeToken, который предоставляет способ представления универсальных типов во время выполнения и применения к ним некоторых служебных функций. Чтобы избежать Java Type Erasure, я использую нотацию в фигурных скобках ({}) для создания автоматически сгенерированного подкласса, так как это делает тип пригодным для повторного использования.

Что в основном делает TypeToken

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

public class TypeToken<T> {

    private final Type type;
    private final Class<T> rawType;

    private final int hashCode;


    /* ==== Constructor ==== */

    @SuppressWarnings("unchecked")
    protected TypeToken() {
        ParameterizedType paramType = (ParameterizedType) this.getClass().getGenericSuperclass();
        this.type = paramType.getActualTypeArguments()[0];

        // ...
    } 

Когда это работает

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

TypeToken<List<String>> token = new TypeToken<List<String>>() {};
TypeToken<List<? extends CharSequence>> token = new TypeToken<List<? extends CharSequence>>() {};

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

<T> void test() {
    TypeToken<T[]> token = new TypeToken<T[]>() {};
}

В этом случае type является GenericArrayType содержащим TypeVariable качестве типа компонента. Это прекрасно.

Странная ситуация при использовании лямбд

Однако, когда вы инициализируете TypeToken внутри лямбда-выражения, все начинает меняться. (Переменная типа взята из test функции выше)

Supplier<TypeToken<T[]>> sup = () -> new TypeToken<T[]>() {};

В этом случае type по-прежнему является GenericArrayType, но в качестве типа компонента он содержит значение null.

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

Supplier<TypeToken<T[]>> sup = new Supplier<TypeToken<T[]>>() {
        @Override
        public TypeToken<T[]> get() {
            return new TypeToken<T[]>() {};
        }
    };

В этом случае тип компонента снова содержит правильное значение (TypeVariable)

Результирующие вопросы

  1. Что происходит с TypeVariable в лямбда-примере? Почему вывод типа не учитывает универсальный тип?
  2. В чем разница между явно объявленным и неявно объявленным примером? Является ли вывод типа единственной разницей?
  3. Как я могу это исправить, не используя явную декларацию? Это становится особенно важным в модульном тестировании, поскольку я хочу проверить, вызывает ли конструктор исключения или нет.

Чтобы пояснить это немного: это не проблема, которая "актуальна" для программы, поскольку я вообще НЕ разрешаю неразрешимые типы, но это все еще интересное явление, которое я хотел бы понять.

Мое исследование

Обновление 1

Тем временем я провел некоторые исследования на эту тему. В Спецификации языка Java §15.12.2.2 я нашел выражение, которое может иметь к этому какое-то отношение - "относящееся к применимости", упомянув "неявно типизированное лямбда-выражение" в качестве исключения. Очевидно, это неверная глава, но выражение используется в других местах, включая главу о выводе типа.

Но, честно говоря: я еще не понял, что все эти операторы любят := или Fi0 что делает его действительно трудным для понимания в деталях. Я был бы рад, если бы кто-то мог прояснить это немного, и если это могло бы быть объяснением странного поведения.

Обновление 2

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

Обновление 3

Я только что перепроверил тот же код с последней версией Java (я использовал 8u191 раньше). К моему сожалению, это ничего не изменило, хотя вывод типа Java был улучшен...

Обновление 4

Я запросил запись в официальной Java Bug Database/Tracker несколько дней назад, и она только что была принята. Поскольку разработчики, просмотревшие мой отчет, присвоили этой ошибке приоритет P4, может пройти некоторое время, пока она не будет исправлена. Вы можете найти отчет здесь.

Огромный привет Тому Хоутину - тот факт, что он может быть существенной ошибкой в самой Java SE. Тем не менее, отчет Майка Штробеля, вероятно, будет более подробным, чем мой, из-за его впечатляющих знаний. Однако, когда я написал отчет, ответ Стробеля еще не был доступен.

Ответ 1

TL;DR:

  1. В javac есть ошибка, которая записывает неправильный метод включения для встроенных лямбда-классов. В результате переменные типа в фактическом включающем методе не могут быть разрешены этими внутренними классами.
  2. В реализации API java.lang.reflect возможно два набора ошибок:
    • Некоторые методы задокументированы как генерирующие исключения, когда встречаются несуществующие типы, но они этого не делают. Вместо этого они позволяют пустым ссылкам распространяться.
    • Различные переопределения Type::toString() настоящее время генерируют или распространяют NullPointerException когда тип не может быть разрешен.

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

Как правило, когда вы пишете класс, который имеет один или несколько общих супертипов, компилятор Java испускает атрибут Signature содержащий полностью параметризованные общие сигнатуры супертипа (ов) класса. Я писал об этом раньше, но короткое объяснение таково: без них было бы невозможно использовать универсальные типы в качестве универсальных типов, если бы у вас не было исходного кода. Из-за стирания типа информация о переменных типа теряется во время компиляции. Если эта информация не будет включена в качестве дополнительных метаданных, ни IDE, ни ваш компилятор не будут знать, что тип является универсальным, и вы не сможете использовать его как таковой. Также компилятор не может выдавать необходимые проверки во время выполнения для обеспечения безопасности типов.

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

TypeToken<?> token = new TypeToken<List<? extends CharSequence>>() {};

... содержит эту Signature:

LTypeToken<Ljava/util/List<+Ljava/lang/CharSequence;>;>;

Исходя из этого, API-интерфейсы java.lang.reflection могут анализировать общую информацию о супертипе вашего (анонимного) класса.

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

static <F> void test() {
    TypeToken sup = new TypeToken<F[]>() {};
}

Здесь мы получаем следующую подпись:

LTypeToken<[TF;>;

Имеет смысл, верно? Теперь давайте посмотрим, как API-интерфейсы java.lang.reflect могут извлекать общую информацию о супертипах из этих подписей. Если мы Class::getGenericSuperclass() в Class::getGenericSuperclass(), мы увидим, что первое, что он делает, это вызывает getGenericInfo(). Если мы не вызывали этот метод раньше, экземпляр ClassRepository получает:

private ClassRepository getGenericInfo() {
    ClassRepository genericInfo = this.genericInfo;
    if (genericInfo == null) {
        String signature = getGenericSignature0();
        if (signature == null) {
            genericInfo = ClassRepository.NONE;
        } else {
            // !!!  RELEVANT LINE HERE:  !!!
            genericInfo = ClassRepository.make(signature, getFactory());
        }
        this.genericInfo = genericInfo;
    }
    return (genericInfo != ClassRepository.NONE) ? genericInfo : null;
}

getFactory() элементом здесь является вызов getFactory(), который расширяется до:

CoreReflectionFactory.make(this, ClassScope.make(this))

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

public TypeVariable<?> lookup(String name) {
    TypeVariable<?>[] tas = getRecvr().getTypeParameters();
    for (TypeVariable<?> tv : tas) {
        if (tv.getName().equals(name)) {return tv;}
    }
    return getEnclosingScope().lookup(name);
}

И, наконец, ключ ко всему (от ClassScope):

protected Scope computeEnclosingScope() {
    Class<?> receiver = getRecvr();

    Method m = receiver.getEnclosingMethod();
    if (m != null)
        // Receiver is a local or anonymous class enclosed in a method.
        return MethodScope.make(m);

    // ...
}

Если переменная типа (например, F) не найдена в самом классе (например, анонимный TypeToken<F[]>), то следующим шагом будет поиск включающего метода. Если мы посмотрим на разобранный анонимный класс, мы увидим этот атрибут:

EnclosingMethod: LambdaTest.test()V

Наличие этого атрибута означает, что computeEnclosingScope будет создавать MethodScope для универсального метода static <F> void test(). Поскольку test объявляет переменную типа W, мы находим ее при поиске в области видимости.

Итак, почему это не работает внутри лямбды?

Чтобы ответить на это, мы должны понять, как лямбды компилируются. Тело лямбды перемещается в синтетический статический метод. В тот момент, когда мы объявляем нашу лямбду, генерируется invokedynamic инструкция, которая заставляет TypeToken реализации TypeToken генерироваться при первом TypeToken к этой инструкции.

В этом примере статический метод, сгенерированный для лямбда-тела, будет выглядеть примерно так (если декомпилируется):

private static /* synthetic */ Object lambda$test$0() {
    return new LambdaTest$1();
}

... где LambdaTest$1 - ваш анонимный класс. Давайте разберем это и осмотрим наши атрибуты:

Signature: LTypeToken<TW;>;
EnclosingMethod: LambdaTest.lambda$test$0()Ljava/lang/Object;

Как и в случае, когда мы создали экземпляр анонимного типа вне лямбды, сигнатура содержит переменную типа W Но EnclosingMethod относится к синтетическому методу.

Синтетический метод lambda$test$0() не объявляет переменную типа W Более того, lambda$test$0() не заключен в test(), поэтому объявление W внутри него не видно. У вашего анонимного класса есть супертип, содержащий переменную типа, о которой ваш класс не знает, поскольку он находится вне области видимости.

Когда мы вызываем getGenericSuperclass(), иерархия LambdaTest$1 действия для LambdaTest$1 не содержит W, поэтому анализатор не может ее разрешить. Из-за того, как написан код, эта неразрешенная переменная типа приводит к тому, что значение null помещается в параметры типа универсального супертипа.

Обратите внимание, что если бы ваша лямбда создала экземпляр типа, который не ссылался ни на какие переменные типа (например, TypeToken<String>), то вы не столкнулись бы с этой проблемой.

Выводы

(i) В javac есть ошибка. Java Virtual Machine Specification §4.7.7 ( " EnclosingMethod Атрибут") гласит:

Компилятор Java отвечает за то, чтобы метод, идентифицируемый с помощью method_index, действительно был ближайшим лексически включающим методом класса, который содержит этот атрибут EnclosingMethod. (акцент мой)

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

Возможно, также является ошибкой то, что синтаксический анализатор/преобразователь сигнатур молча допускает распространение аргумента null типа в ParameterizedType (который, как указывает @tom-hawtin-tackline, имеет вспомогательные эффекты, такие как toString() вызывающий NPE).

Мой отчет об ошибке для проблемы EnclosingMethod теперь онлайн.

(ii) В java.lang.reflect и его вспомогательных API, возможно, имеется несколько ошибок.

Метод ParameterizedType::getActualTypeArguments() задокументирован как TypeNotPresentException когда "любой из фактических аргументов типа ссылается на несуществующее объявление типа". Это описание, вероятно, охватывает случай, когда переменная типа не находится в области видимости. GenericArrayType::getGenericComponentType() должен GenericArrayType::getGenericComponentType() подобное исключение, когда "базовый тип массива ссылается на несуществующее объявление типа". В настоящее время ни при каких обстоятельствах ни один из них не создает TypeNotPresentException.

Я также утверждаю, что различные переопределения Type::toString должны просто заполнять каноническое имя любых неразрешенных типов, а не выбрасывать NPE или любое другое исключение.

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

Обходные?

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

class Test<X> {
    void test() {
        Supplier<TypeToken<X>> s1 = () -> new TypeToken<X>() {};
        Supplier<TypeToken<String>> s2 = () -> new TypeToken<String>() {};
        Supplier<TypeToken<List<String>>> s3 = () -> new TypeToken<List<String>>() {};
    }
}

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

Ответ 2

В качестве обходного пути вы можете переместить создание TypeToken из лямбды в отдельный метод и по-прежнему использовать лямбду вместо полностью объявленного класса:

static<T> TypeToken<T[]> createTypeToken() {
    return new TypeToken<T[]>() {};
}

Supplier<TypeToken<T[]>> sup = () -> createTypeToken();

Ответ 3

Я не нашел соответствующей части спецификации, но здесь частичный ответ.

Там, безусловно, ошибка с типом компонента, null. Для ясности, это TypeToken.type выше TypeToken.type приведенный к GenericArrayType (GenericArrayType !) С getGenericComponentType методом getGenericComponentType. Документы API не содержат явного упоминания того, является ли возвращенное значение null действительным или нет. Однако метод toString NullPointerException, поэтому определенно возникает ошибка (по крайней мере, в случайной версии Java, которую я использую).

У меня нет учетной записи bugs.java.com, поэтому я не могу сообщить об этом. Кто-то должен.

Давайте посмотрим на сгенерированные файлы классов.

javap -private YourClass

Это должно создать список, содержащий что-то вроде:

static <T> void test();
private static TypeToken lambda$test$0();

Обратите внимание, что наш явный метод test имеет параметр типа, а синтетический лямбда-метод - нет. Вы можете ожидать что-то вроде:

static <T> void test();
private static <T> TypeToken<T[]> lambda$test$0(); /*** DOES NOT HAPPEN ***/
             // ^ name copied from 'test'
                          // ^^^ 'Object[]' would not make sense

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

Вывод: здесь есть как минимум одна незарегистрированная ошибка JDK. API reflect и эта лямбда + обобщенная часть языка мне не по вкусу.

Ответ 4

Если вы все еще боретесь с реализацией лямбда-функций, я бы не стал использовать лямбда-выражения и заставлять ваши файлы работать с использованием обычных функций. Я бы реализовал лямбды в другом файле дампа, чтобы почувствовать их. Лямбда, как вы показали, может быть назначена переменной, однако тип лямбда-функции должен быть из класса или интерфейса, который содержит функцию. Я видел это, когда кто-то создает небольшой интерфейс с функцией и где они объявляют свои лямбды, они объявляют это типом интерфейса. из вашего кода: Supplier> sup = это, вероятно, недопустимый тип для лямбда-выражения, возможно, даже "неявно типизированное лямбда-выражение", см. примеры онлайн-кодирования