Ответ 1
TL;DR:
- В
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;>;>;
Исходя из этого, 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 ваших клиентов еще долго после ее исправления, если предположить, что она исправлена совсем.