Skip to content

UseLambdaForFunctionalInterface: keep the receiver of implicit getClass() - #980

Draft
martinfrancois wants to merge 3 commits into
openrewrite:mainfrom
martinfrancois:fix/use-lambda-implicit-getclass-receiver
Draft

UseLambdaForFunctionalInterface: keep the receiver of implicit getClass()#980
martinfrancois wants to merge 3 commits into
openrewrite:mainfrom
martinfrancois:fix/use-lambda-implicit-getclass-receiver

Conversation

@martinfrancois

@martinfrancois martinfrancois commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Suggested review order: 24 of 52 (Score: 5)
Review first: #979

What's changed?

UseLambdaForFunctionalInterface no longer converts an anonymous class to a lambda when the body of that anonymous class calls getClass() on the anonymous instance itself. Three written forms land on that instance, and all three now block the conversion:

  • an unqualified getClass(), meaning a call written with no receiver at all;
  • super.getClass() with super written on its own and no class name qualifying it, called a bare super here, in contrast with the outer-qualified Test.super.getClass() under the limitations below;
  • the method reference super::getClass, again with a bare super.

A call in one of those three forms named getClass whose LST node carries no method type, because the source lacks complete type attribution, blocks the conversion too: the recipe cannot prove what it resolves to, and blocking is the fail-safe direction on missing type information (dontUseLambdaWhenGetClassCannotBeAttributed).

Any refusal by this new check records the text "calls getClass() on the anonymous instance" in the AnonymousFunctionalInterfaceImplementations data table, which already exists on main along with the recording of a reason for every refusal, so this change adds one reason text and no new plumbing. dataTableRecordsImplicitGetClass asserts that such a refusal produces exactly one row, with convertible false and that reason text.

Before

Inside class Test.

Supplier<Class<?>> supplier() {
    return new Supplier<Class<?>>() {
        @Override
        public Class<?> get() {
            return getClass();
        }
    };
}

Actual after the recipe

Using the recipe on current main.

Supplier<Class<?>> supplier() {
    return () -> getClass();
}

Expected after the recipe

(unchanged)

The recipe leaves the input above exactly as written.

The new check is a private method usesImplicitGetClass, called from conversionBlocker, the existing private method that runs the individual reasons for refusing a conversion and returns the first that applies, directly after the existing usesThis check.

What's your motivation?

Recipe: org.openrewrite.staticanalysis.UseLambdaForFunctionalInterface.

An anonymous class has its own this. A lambda does not, and uses the enclosing this instead. An instance method call written with no receiver takes this as its target reference (JLS 15.12.4.1), and a super::getClass method reference is evaluated against that same this (JLS 15.13.3). The recipe already refuses to convert when the body mentions this, but an unqualified getClass() has no this token for that check to find.

The converted code then returns a different value at run time. In the input above, the anonymous class is declared inside Test, so javac compiles it to the binary class Test$1 and the getClass() call returns the Class object for Test$1. After the conversion main performs, the same call returns the Class object for Test. The compiler reports nothing, so the changed class reaches logging output, cache keys and getClass().getResource(...) lookups unnoticed.

When the anonymous class is created inside a static method and its body calls super.getClass(), the output produced by main does not compile at all: the body becomes () -> super.getClass(), which javac rejects with non-static variable super cannot be referenced from a static context. dontUseLambdaWhenSuperGetClass covers exactly that shape. Reproduced on 2.40.0 and on 2.41.0-SNAPSHOT built from main.

Confirmed real-world execution

The released recipe changes an anonymous Runnable into a lambda. Its unqualified getClass() call then returns the enclosing OverlayQuickTile class instead of the anonymous implementation class, silently changing the application log tag.

Anything in particular you'd like reviewers to focus on?

No existing test changed its expectation: the test file has 251 added lines and no deleted lines.

Where the check stops, then two limitations:

  • usesImplicitGetClass walks the anonymous class body but does not descend into a class declaration written inside it, local or member, nor into a nested anonymous class body: code there has a this of its own, so a getClass() there keeps its receiver and must not block the outer conversion (useLambdaWhenOnlyANestedAnonymousClassCallsGetClass). It does visit the arguments of a nested anonymous class creation and its enclosing instance expression, the qualifier written before .new in outer.new Inner() { ... }, since both are evaluated in the outer scope (dontUseLambdaWhenEnclosingExpressionOfQualifiedNewCallsGetClass), and it descends into a lambda written in the body, which has no this of its own, so an unqualified getClass() there still lands on the anonymous instance (dontUseLambdaWhenImplicitGetClassIsNested).
  • Limitation. An anonymous class whose body only calls the outer-qualified Test.super.getClass() is still converted, because that call names the enclosing instance both before and after the conversion. A related effect on binary names that this change does not address either: converting an anonymous class that has further anonymous classes nested inside it makes javac number the remaining ones differently on the next compile, so what used to be Test$1$1 becomes Test$1. The recipe renames nothing itself, and this happens on main for every such conversion.
  • Limitation. An anonymous class whose body calls an unqualified toString(), hashCode() or equals(...) is still converted, and the converted code can still return a different result at run time, for the same reason getClass() does. Separately, an anonymous class created in a static method whose body makes a bare super. call to a method other than getClass() is still converted into code that does not compile. Neither case is changed here; both behave as on main.

Have you considered any alternatives or workarounds?

One alternative is to block every unqualified call to a java.lang.Object method, not only getClass(). I kept it to getClass() because java.lang.Object.getClass() is final and cannot be overridden, so the change in the returned value is certain, while toString(), hashCode() and equals(...) are often overridden on purpose and a wider check would refuse conversions that are correct today. Widening it is two lines inside usesImplicitGetClass, plus tests for the wider behaviour that I have not written and so cannot size: the check today compares the call against a MethodMatcher built for the signature java.lang.Object getClass(), and the wider version would instead test that method.getMethodType().getDeclaringType() is java.lang.Object, whatever the method name is. Say so in review and I will change it here.

Any additional context

Pre-existing tests changed: None.

This change adds 9 tests to UseLambdaForFunctionalInterfaceTest. Without the code change in this pull request, 8 fail. They cover these cases:

  • an unqualified getClass() whose converted code returns a different class, such as dontUseLambdaWhenImplicitGetClass
  • a bare super whose converted code does not compile, including the method-reference case
  • the boundary between the outer anonymous class and a nested anonymous class, such as useLambdaWhenOnlyANestedAnonymousClassCallsGetClass
  • a getClass call without type attribution, where leaving the source unchanged is the safe result

The ninth test passes either way and shows that a correct conversion is not blocked.

This change was prepared with AI assistance (Claude Code). I reviewed the code, the tests and this description.

I ran the formatter with the repository's .editorconfig. It also wanted to re-indent lines that this change does not touch, so I left those alone and kept the diff limited to this change.

Checklist

…ss()

An anonymous class has its own `this`; a lambda uses the enclosing
lexical `this`. The recipe's `usesThis` guard only recognises an
explicit `this` identifier, so an unqualified `getClass()` in the
functional method slipped past it and the conversion silently changed
the returned runtime class from the anonymous implementation to the
enclosing class.

Add a `usesImplicitGetClass` guard that keeps the anonymous class when
the functional method resolves `java.lang.Object getClass()` with no
receiver or with a bare `super` receiver, either as an invocation or
as a `super::getClass` member reference. It does not descend into
nested class or nested anonymous class bodies, which declare their own
`this`, but it does visit the enclosing expression and the arguments
of a qualified `new`, which are evaluated in the outer scope. A
skipped site is reported in the data table as "calls `getClass()` on
the anonymous instance".

Worth weighing on review: a `getClass` call with no type attribution
now blocks conversion, because its receiver cannot be proven; an
outer-qualified `Test.super.getClass()` keeps its receiver either way
and is deliberately left convertible; and `Test.this.getClass()` was
already blocked by `usesThis`. No existing test expectation changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants