Remove null checks before literal equality only for side-effect-free expressions - #973
Draft
martinfrancois wants to merge 3 commits into
Draft
Conversation
…t-free expressions The recipe rewrote `x != null && "literal".equals(x)` to `"literal".equals(x)` whenever `SemanticallyEqual` matched the two occurrences of `x`. Semantic equality proves the two trees mean the same thing, not that evaluating the expression once is equivalent to evaluating it twice, so `next() != null && "ok".equals(next())` lost an invocation, both on its own and inside a chained `&&`. Gate the removal on `SideEffects.mayHaveSideEffects`, the helper `RemoveRedundantNullCheckBeforeInstanceof` already uses for the same pattern, so invocations, assignments and `values[i++]` keep their null check. A read of a field attributed as volatile is checked separately: it has no side effect of its own, so `SideEffects` does not report it, but it is a synchronization action that must not be elided. For review: the existing `removeRedundantNullCheckWithMethodInvocation` test pinned the invocation rewrite as desired behavior. It is replaced by `doNotChangeWhenNullCheckedExpressionIsMethodInvocation`, covering the direct and the chained form. One case stays unfixed, matching the instanceof sibling: a Groovy implicit property read backed by a getter parses as an identifier, so a shape-based check cannot see the dispatch and the null check is still removed there. See openrewrite#953
This was referenced Aug 11, 2026
martinfrancois
marked this pull request as draft
August 16, 2026 01:10
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: 35 of 52 (Score: 2.5)
Review first: openrewrite/rewrite-migrate-java#1191
What's changed?
RemoveRedundantNullCheckBeforeLiteralEqualsrewritesexpr != null && "literal".equals(expr)into"literal".equals(expr). With this change it makes no change at all when evaluatingexprmay have side effects.isRedundantNullCheck, which decides whether a!= nulltest may be dropped, now returnsfalse, meaning "keep the null check, change nothing", when either of two new checks fires on the argument ofequals. Checking that one node is enough: the recipe removes nothing unlessSemanticallyEqual.areEqualreports it to be the same expression as the null-checked one.SideEffects.mayHaveSideEffects, the package-private helper that pull request Do not delete expressions that may have side effects #959 added to this package; this change does not modify it.readsVolatileField, a new private method in this recipe's visitor. A volatile read is not a side effect, soSideEffectsdoes not report it, but it is a synchronization action: JLS 17.4.2 lists a read of a volatile variable among them, so dropping one of two reads can change what another thread sees.visitBinarymatches a direct shape and a chained shape,enabled && expr != null && "literal".equals(expr), and callsisRedundantNullCheckon both, so the guard covers both.What's your motivation?
Recipe:
org.openrewrite.staticanalysis.RemoveRedundantNullCheckBeforeLiteralEquals.Before
Actual after the recipe
Expected after the recipe
(unchanged)
The input evaluates
exprtwice, the output evaluates it once.SemanticallyEqual.areEqualproves that the two occurrences ofexprmean the same thing, not that evaluating it once is the same as evaluating it twice.The output that main produces still compiles, but it can produce a different value at runtime. With a
next()that returns"no"on its first call and"ok"on its second, the input returnstrue: the null check consumes the first call andequalscompares"ok"with the"ok"of the second. The output returnsfalse, becauseequalsnow sees the first call. The same applies tovalues[i++], to an assignment such as(s = t)and to a volatile read.A recipe must not stop evaluating an expression that may have side effects. Issue #953 describes this class of problem, and pull request #959 (merged as commit 70da659) applied that rule to four other recipes and moved the side-effect check that
SimplifyRedundantLogicalExpressionalready carried into the new sharedSideEffectshelper. Neither the issue nor that pull request mentions this recipe. Reproduced on v2.40.0 and on 2.41.0-SNAPSHOT from main 5785534.Anything in particular you'd like reviewers to focus on?
One existing test is deleted, because this change reverses an expectation that held until now:
removeRedundantNullCheckWithMethodInvocationasserted thatgetValue() != null && "expected".equals(getValue())becomes"expected".equals(getValue()). The newdoNotChangeWhenNullCheckedExpressionIsMethodInvocationcovers the same shape and asserts that it is now left unchanged.Three limits, all with this change applied:
list.get(0):SideEffectscannot tell a read-only getter from one that changes state, so it reports every method call as possibly effectful.SideEffectssees no call and the null check in front of it is still removed. Groovy is partly fixed, not fully fixed.readsVolatileFieldasks every identifier in the argument ofequalswhether itsJavaType.Variablefield type carriesFlag.Volatile. An LST parsed without type attribution has no such field type, so the check misses that case.An array access such as
values[0]and a cast such as(String) oare still simplified: neither evaluates anything that may have side effects.Have you considered any alternatives or workarounds?
One alternative is to put the volatile check in
SideEffectsinstead of in this recipe. Four other recipes callSideEffects.mayHaveSideEffects:AllBranchesIdentical,RemoveDuplicateConditions,RemoveUnconditionalValueOverwriteandSimplifyRedundantLogicalExpression. Moving the check into the shared helper would therefore change their runtime behaviour too, not only this recipe's.The opposite option is to drop the volatile check entirely, leaving this change the same shape as the fixes in #959: a call to
mayHaveSideEffectsand nothing else.Either option is about ten lines: the private
readsVolatileFieldmethod and its call insideisRedundantNullCheck, plus the testdoNotChangeWhenNullCheckedFieldIsVolatile. That test would then belong with the shared helper, which has no test class of its own today, or be deleted along with the check.Any additional context
Pre-existing tests changed:
RemoveRedundantNullCheckBeforeLiteralEqualsTest.java.removeRedundantNullCheckWithMethodInvocation(removed).This change adds 8 tests to
RemoveRedundantNullCheckBeforeLiteralEqualsTestand deletes the test named above, taking that class from 16 tests to 23. Without the code change in this pull request, these 4 new tests fail:doNotChangeWhenArrayIndexHasSideEffectdoNotChangeWhenNullCheckedExpressionIsAssignmentdoNotChangeWhenNullCheckedExpressionIsMethodInvocationdoNotChangeWhenNullCheckedFieldIsVolatileThe other 4 new tests assert that expressions without side effects are still simplified.
It also adds a new Groovy test class of the same name, in
org.openrewrite.staticanalysis.groovy, with 2 tests. Without the code change in this pull request, 1 of them fails:doNotChangeWhenNullCheckedExpressionIsMethodInvocation. The other,removeRedundantNullCheck, asserts that the null check in front of a Groovy parameter is still removed.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
./gradlew buildlocally, and committed any resulting changes torecipes.csv