Add library model for ObjectUtils.firstNonNull - #1705
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
WalkthroughAdds an Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
I'm not on the NullAway team, but: I'd like to think that JSpecify mode would at least eventually handle this "naturally" if we could have a library model of the form: @NullMarked
public static <T extends @Nullable Object> T firstNonNull(T... values)I don't know whether that would work now or anytime soon, but does it seem right in principle? |
|
@cpovirk I think what is wanted here is that for a call like this: Object x = ObjectUtils.firstNonNull(null, null, new Object());NullAway should learn that |
|
Oh, you're right, sorry. |
Precisely: in the expected use case, you try a bunch of things and default to something non-null. Normally the guaranteed non-null is at the end of the list (because having it any earlier makes everything past it dead code). What I've written will work fine in this case, or also in the case where everything is nullable in which case it will infer nullability of There is an edge case, however, when we know that one of N values is non null but not which one. But there's no way of really determining given Consider this class I wrote in my day job:
Sure, ultimately it's Should that default to nullable in the simpler case (without the exceptional return)? I think so; one can always suppress the warning. |
org.apache.commons.lang3.ObjectUtils.firstNonNull returns the first non-null value passed to it, or null when every value is null. That cannot be expressed with @contract, whose antecedent must contain one entry per call-site argument, because a varargs method has a different arity at each call site. Adds a library model kind for methods whose return is null only when all of their arguments are null. It is the dual of the existing nullImpliesNullParameters, and applies to all arguments of a call rather than to fixed parameter indices, which is what makes it usable for varargs methods. At the dataflow level a varargs call has already been desugared into an array creation, so the implementation inspects that node's initializers rather than the argument itself, which is a freshly-created and therefore non-null array. When an existing array is passed in the varargs position its contents are not visible, and the return is conservatively treated as nullable. Fixes uber#612
be24353 to
5f2829e
Compare
|
Hi @dbwiddis one thing I'm confused on here. I don't see that |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1705 +/- ##
============================================
- Coverage 87.83% 87.81% -0.02%
- Complexity 3184 3192 +8
============================================
Files 109 109
Lines 10815 10850 +35
Branches 2186 2199 +13
============================================
+ Hits 9499 9528 +29
- Misses 622 625 +3
- Partials 694 697 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Great question and this bears a bit of context on how I got to this PR. I've been picking through open issues looking for small ones to learn the codebase on. I used AI to help scan for tractable ones and this surfaced early, but I passed on it at first because it needed new code rather than reusing an existing pattern. After a few easier ones I came back to it as a challenge, knowing it was marked low priority and might not go anywhere. To your question: yes. However, the behavior itself isn't in question: commons-lang3's own javadoc says firstNonNull returns null when every value is null. This is documented nullability if not annotated. The catch is that a model applies to everyone at once, and here it creates new warnings on builds that are clean today. I actually encountered this very decision today when implementing JSpecify annotations on OSHI. One module implemented an unannotated external dependency's interface and I had to decide whether to introduce warnings where they did not previously exist in a widely adopted dependency. I decided not to "narrow" the contract in that case. I get it. So I'm actually not eager to merge this for this specific upstream method. I'm wondering if is there is value in keeping the new model type but not registering firstNonNull with it? Even if commons-lang3 annotated this method tomorrow, the most it could say is "the return is @nullable", which would make every call site warn. I was hoping to at least improve on that and identify a case where it could pass (if one of the arguments was demonstrably non null). I'll admit I focused on how to do this and never asked whether it should be done. Anyway, I've taken my best shot at the how; the whether is yours to call, and if the answer is that it isn't worth the churn, that's worth writing down on the issue so the next person doesn't retrace my steps. Either way this was a genuinely useful way to learn the codebase. |
|
Thanks so much for the context, @dbwiddis; really appreciate all the fixes and tests you've contributed thus far! Here are some thoughts on this PR. First, we're pretty careful these days about adding features that lead to new warnings outside of JSpecify mode, as that causes disruptions for many users. JSpecify mode is more explicitly "under development" and is the future of NullAway so I'm more ok with new warnings there. So, at the least, I'd prefer that whatever we do here not impact non-JSpecify-mode users. I'm still a bit unsure about the original motivation behind #612; @lazaroclapp do you remember by any chance? If I had to guess, I'd say maybe we ran into an NPE issue where a user passed all Speaking of priorities, I think our biggest priority right now is to get more JSpecify mode support debugged and shipped. A great deal of new support is currently gated under the I think ideally how we'd address this one is something like the following:
Right now the implementation mixes these two things, and I think it'd be good to separate them. Regarding 1, we have a way to designate a class as So, bottom line, I'd like to get this in, assuming we can change the implementation strategy a bit, and also make it impact only those with JSpecify mode enabled. It's probably not the highest priority at the moment, but if we want to just finish it up that's fine with me. |
Noted! JSpecify is what brought me here to begin with and I'm 100% on board with this and I actually did use the jspecify tag to filter when working on other contributions. I also see you actively working many of those so I've been digging through older issues to try to find the "less important" ones.... this particular issue was outside that umbrella but interesting from an educational lens.
Fair. I'm going to flip this PR into draft mode for now as I think the revised scope you mention is currently above my repo comprehension. Anyone else is free to take it on! |
Summary
org.apache.commons.lang3.ObjectUtils.firstNonNullreturns the first non-nullvalue passed to it, ornullwhen every value isnull. Today NullAway has no model for it, so — sinceObjectUtilsisunannotated — the return is optimistically treated as non-
nulland code like this is silently accepted:As #612 notes, the semantics are "
@Contract("!null -> !null")for each element of its varargs." Thatcan't be written as a
@Contracttoday:ContractUtils.getAntecedentrequires the antecedent to haveexactly one entry per call-site argument, and a varargs method has a different arity at each call site.
Approach
This adds a library model kind,
allParamsNullImpliesNullReturn, for methods whose return isnullonlywhen all of their arguments are
null. It is the dual of the existingnullImpliesNullParameters—that one is "any listed argument null ⇒ nullable return", this one is "every argument null ⇒ nullable
return" — and it keys on
MethodRefalone rather than on fixed parameter indices, which is what makes itapplicable to varargs. It is a
defaultmethod onLibraryModelsso existing implementors are unaffected.The one subtlety worth flagging for review is in
onDataflowVisitMethodInvocation. By the time thedataflow hook runs, the CFG has already desugared a varargs call:
node.getArguments()returns a singleArrayCreationNode, andAccessPathNullnessPropagation.visitArrayCreationcorrectly reports it asnon-
null, because a freshly created array is never null. Reading the arguments directly thereforeconcludes "some argument is non-null" at every call site. The implementation instead inspects that node's
initializers. This also means
firstNonNull(new String[] {"x", a})is handled correctly, which iscovered by a test rather than assumed.
When an existing array is passed in the varargs position, its contents are not visible at this level, so
those positions are skipped and the return is conservatively treated as nullable.
The model is consumed in two places: the dataflow hook above, and
onOverrideMayBeNullExpr, thenon-dataflow path answering whether an expression can be null at all. Without the second, the first is
overruled.
Known tradeoff
firstNonNullis often used the way SQL'sCOALESCEis, relying on an invariant the analysis cannot see —for example two fields where a constructor guarantees exactly one is non-
null. That guarantee is arelational fact between two access paths, and NullAway tracks nullness for each access path
independently, so such calls will now be reported.
Objects.requireNonNullat the point of use resolvesit (verified), as does a suppression. I've documented this on the new interface method so it's discoverable
next to the model definition.
This is the same shape of tradeoff as modeling
Map.removeas@Nullablein #1623, which shipped with arelease note about newly-reported warnings — but it is a real behavior change and I'd rather have it
called out explicitly than found in review. Happy to drop or gate the model if you'd rather not take it.
ObjectUtils.defaultIfNull(T, T)has identical semantics and would be a one-line addition to the same set,but it is an even purer instance of the
COALESCEidiom, so I left it out deliberately rather thandoubling the affected surface in one PR.
Testing
Two tests in
FrameworkTests.java, alongside the existing commons-lang3Validatetests.apacheObjectUtilsFirstNonNullcovers the semantics: all values nullable (reported), last value non-null(silent), first value non-null (silent — so the check isn't accidentally position-dependent), non-null known
only from dataflow via an enclosing null check (silent), no arguments at all (reported), result null-checked
before use (silent), result never dereferenced (silent), and a boxed result unboxed to
int(reported as anunboxing error).
apacheObjectUtilsFirstNonNullVarargsFormscovers the call shapes: an existing array passed in the varargsposition (reported, contents not visible), an array created at the call site with all-nullable elements
(reported) and with a non-null element (silent), and values that are themselves of array type so that
TisString[], both all-nullable (reported) and with one non-null (silent).objectUtilsFirstNonNullinJSpecifyLibraryModelsTests.javapins the same behavior down in JSpecifymode, since library modeling of varargs differs there (#1481, #1485). Behavior is identical in both modes.
methodRefentry makes both tests fail, so every assertion isload-bearing and the signature string is confirmed correct. That matters here because model lookup is
string equality against
MethodSymbol.toString()with no validation — a typo silently matches nothing../gradlew :nullaway:test— 920 tests, 0 failures../gradlew :test-library-models:test— passing; run because theLibraryModelsinterface changed.No CHANGELOG entry, as that file has no unreleased section; happy to add one wherever you'd prefer.
Fixes #612
AI usage disclosure
Summary by CodeRabbit
New Features
ObjectUtils.firstNonNull.Bug Fixes