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

Контекст

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

Что 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 самого щедрого типа, Object. Я не могу вспомнить ни одной причины, по которой разработчики языка решили сделать это.

Обновление 3

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

Обновление 4

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

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

Ответы [ 3 ]

0 голосов
/ 10 декабря 2018

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

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

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

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

javap -private YourClass

Это должно привести к листингу, содержащему что-то вроде:

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

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

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 здесь. reflect API и эта часть языка лямбда + дженерики мне не по вкусу.

0 голосов
/ 11 декабря 2018

tldr:

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

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

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

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

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

LTypeToken<[TF;>;

Имеет смысл, верно? Теперь давайте посмотрим, как API-интерфейсы java.lang.reflect могут извлекать общую информацию о супертипах из этих сигнатур. Если мы вглядимся в 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(), который расширяется до:

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 генерируется при первом обращении к этой инструкции.

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

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 не содержит W, поэтому анализатор не может ее разрешить. Из-за того, как написан код, эта неразрешенная переменная типа приводит к тому, что null помещается в параметры типа универсального супертипа.

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

Выводы

(i) Ошибка в javac. Спецификация виртуальной машины Java §4.7.7 («Атрибут EnclosingMethod»):

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

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

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

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

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

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

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

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

Обходные

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

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 ваших клиентов еще долго после ее исправления, при условии, что это вообще исправлено.

0 голосов
/ 09 декабря 2018

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

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

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