tldr:
- В
javac
есть ошибка, которая записывает неправильный метод включения для встроенных лямбда-классов. В результате переменные типа в фактическом включающем методе не могут быть разрешены этими внутренними классами.
- В реализации 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 ваших клиентов еще долго после ее исправления, при условии, что это вообще исправлено.