Remove null checks before instanceof only for side-effect-free expressions - #972
Draft
martinfrancois wants to merge 4 commits into
Draft
Conversation
…ree expressions `expr != null && expr instanceof T` evaluates `expr` twice, while the `expr instanceof T` the recipe leaves behind evaluates it once. That is only equivalent when evaluating `expr` has no side effects. `SemanticallyEqual` proves that the two occurrences mean the same thing, not that evaluating them twice is the same as evaluating them once, so both the direct and the chained `&&` branch could silently drop a call: `next() != null && next() instanceof String` became `next() instanceof String`. Gate both branches on the existing package-private `SideEffects` helper, which already backs the purity guards in `RemoveDuplicateConditions`, `AllBranchesIdentical` and `SimplifyRedundantLogicalExpression`. Local variables, parameters and plain field access still simplify; method invocations, constructor calls, assignments and increments no longer do. Reviewers should note that the existing test `removeRedundantNullCheckWithMethodInvocation` asserted the old behaviour, so it becomes the no-change case `doNotChangeWhenMethodInvocation`.
This was referenced Aug 11, 2026
martinfrancois
marked this pull request as draft
August 16, 2026 01:10
instanceof only for side-effect-free expressions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Suggested review order: 31 of 52 (Score: 3)
Review first: #996
What's changed?
RemoveRedundantNullCheckBeforeInstanceofnow makes no change when the expression tested byinstanceofmay have side effects. It still removes the null check for variables, fields and other expressions that only read a value.The fix adds one condition to the
ifat the top of the helper methodisRedundantNullCheck, whose first conjunct is unchanged from main:visitBinarycallsisRedundantNullCheckfor both of the shapes it handles, the directexpr != null && expr instanceof Tand the chaineda && expr != null && expr instanceof T, so the single condition covers both.mayHaveSideEffectsisSideEffects.mayHaveSideEffects, a package-private helper added to main by #959 and reached here through a new static import; this change calls it and does not modify it. Four recipes on main already call it to avoid the same problem, dropping an expression whose evaluation may be observable:RemoveDuplicateConditions,AllBranchesIdentical,RemoveUnconditionalValueOverwriteandSimplifyRedundantLogicalExpression. Only the expression thatinstanceoftests is passed to it, not the null-checked one, which is enough: the recipe changes nothing unless those two are semantically equal, andmayHaveSideEffectsanswers the same for two semantically equal trees.What's your motivation?
Recipe:
org.openrewrite.staticanalysis.RemoveRedundantNullCheckBeforeInstanceof.Before
Actual after the recipe
Expected after the recipe
(unchanged)
expr != null && expr instanceof Tevaluatesexprtwice. Theexpr instanceof Tleft behind evaluates it once. Main usesSemanticallyEqual.areEqualalone to decide that the two occurrences are interchangeable, which proves that they mean the same thing, not that evaluating the expression twice is the same as evaluating it once.The output that main produces still compiles, but the method it has rewritten can return a different value. Compiled and run with Java 21,
direct()above returnsfalsefrom the input source andtruefrom the source main produces, because therenext()is called once instead of twice. The lost evaluation reaches other observable behaviour too: when the tested expression throws on its second invocation and not on its first, the caller no longer sees that exception at all, because after the rewrite the second invocation never happens. Reproduced on 2.40.0 and on 2.41.0-SNAPSHOT built from main5785534a. The recipe is listed incommon-static-analysis.yml, so it runs for everyone who appliesCommonStaticAnalysis.Anything in particular you'd like reviewers to focus on?
This change alters an expectation that used to hold. Main's
removeRedundantNullCheckWithMethodInvocationexpectedgetValue() != null && getValue() instanceof Stringto becomegetValue() instanceof String. That name is gone: the samebeforesource is now one of the three no-change scenarios indoNotChangeWhenNullCheckedExpressionIsMethodInvocation, with theafterblock removed.Limits worth knowing:
list.get(0). The Lossless Semantic Tree, OpenRewrite's representation of the parsed source, does not record whether a called method's body changes state, not even for a getter that only returns a field, so no invocation can be ruled harmless. The same applies to a constructor call (doNotChangeWhenConstructorCall) and to an assignment or increment inside the tested expression (doNotChangeWhenArrayIndexHasSideEffect).other.flagparses as a field access, so the null check is still removed there. Same as on main.volatilefield are two separate actions in the Java memory model (JLS 17.4.2) and can return different values, and the null check is still removed in that case. Also the same as on main.Have you considered any alternatives or workarounds?
One alternative is to mention in the recipe's
descriptionfield the new case in which the recipe declines to remove the null check. I left that text unchanged, because the existing description does not claim that the null check is always removed. If you would rather see that carve-out spelled out, it is a one-line edit.Any additional context
Pre-existing tests changed:
RemoveRedundantNullCheckBeforeInstanceofTest.java.removeRedundantNullCheckWithMethodInvocation(removed).This change adds 4 net test methods to
RemoveRedundantNullCheckBeforeInstanceofTest, taking the focused class from 19 to 23 executions. They cover 7 new scenarios. Without the code change in this pull request, these 4 methods fail:doNotChangeWhenNullCheckedExpressionIsMethodInvocation, covering direct, null-on-left, and chained callsdoNotChangeWhenConstructorCalldoNotChangeWhenArrayIndexHasSideEffectdoNotChangeWhenOnlyTheNullCheckedOperandHasSideEffectsremoveRedundantNullCheckInChainedConditionpasses either way and keeps the chained&&shape covered.This change was prepared with AI assistance (Claude Code). I reviewed the code, the tests and this description.
Checklist
./gradlew buildlocally, and committed any resulting changes torecipes.csv