Version
NullAway 0.14.0, and current master (21f02dd). Error Prone 2.50.0, JDK 21.
Flags: -XepOpt:NullAway:CheckContracts=true -XepOpt:NullAway:JSpecifyMode=true -XDaddTypeAnnotationsToSymbol=true.
Summary
CheckContracts reports a violation for a return null that is reachable only when the contract's preconditions are false, whenever that return is reached by a control-flow merge whose incoming edges each contradict a different argument.
The merge is what matters, not the operator. if (a == null || b == null) reports, and if (a == null || a == null) — same operator, one argument — does not.
Reproducer
package foo;
import org.jetbrains.annotations.Contract;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@NullMarked
class Test {
@Contract("!null, !null -> !null")
static @Nullable Double or(@Nullable Double a, @Nullable Double b) {
// BUG: Diagnostic contains: Method or has @Contract(!null, !null -> !null), but this appears to be violated
if (a == null || b == null) { return null; }
return a + b;
}
@Contract("!null, !null -> !null")
static @Nullable Double and(@Nullable Double a, @Nullable Double b) {
if (a != null && b != null) { return a + b; }
// BUG: Diagnostic contains: Method and has @Contract(!null, !null -> !null), but this appears to be violated
return null;
}
@Contract("!null, !null -> !null")
static @Nullable Double ternary(@Nullable Double a, @Nullable Double b) {
// BUG: Diagnostic contains: Method ternary has @Contract(!null, !null -> !null), but this appears to be violated
return (a == null || b == null) ? null : a + b;
}
// no merge before the return: clean
@Contract("!null, !null -> !null")
static @Nullable Double twoIfs(@Nullable Double a, @Nullable Double b) {
if (a == null) { return null; }
if (b == null) { return null; }
return a + b;
}
// a merge, but both edges contradict the same argument: clean
@Contract("!null, _ -> !null")
static @Nullable Double sameVar(@Nullable Double a, @Nullable Double b) {
if (a == null || a == null) { return null; }
return a + 1;
}
}
All five bodies satisfy their contracts. and contains no disjunction at all, and sameVar contains one and is accepted, so neither || nor the number of arguments in the antecedent is the discriminator.
What does and does not report
Body, with @Contract("!null, !null -> !null") unless noted |
Reported |
if (a == null || b == null) return null; |
yes |
if (a == null | b == null) return null; |
yes |
if (a != null && b != null) return a + b; return null; |
yes |
return (a == null || b == null) ? null : a + b; |
yes |
if (a == null) return null; if (b == null) return null; |
no |
@Contract("!null -> !null"), if (a == null) return null; |
no |
@Contract("!null, _ -> !null"), if (a == null || a == null) return null; |
no |
What appears to be going on
ContractCheckHandler.visitReturn skips a return when hasBottomAccessPathForContractDataflow finds an access path mapped to BOTTOM in the store before the expression. Nullness.leastUpperBound documents BOTTOM as losing at a merge:
// Bottom loses.
if (this == BOTTOM) {
return other;
}
Seed the contract dataflow with a = NONNULL, b = NONNULL and take or. The return null has two incoming edges: a == null leaves a at BOTTOM, and a != null && b == null leaves b at BOTTOM. Each edge is individually infeasible, but each is infeasible for a different variable, so the merged store is a = NONNULL, b = NONNULL with no BOTTOM anywhere, and the return is analysed as reachable.
sameVar isolates this: both edges leave the same variable at BOTTOM, BOTTOM ⊔ BOTTOM = BOTTOM survives the merge, and the return is correctly skipped. twoIfs avoids the merge entirely — each return null sits on its own branch.
Expected
A return reachable only when the contract's antecedent is false should not be reported, however the control flow arrives there. Tracking infeasibility so it survives a merge would cover all four rows above.
Workaround
Write one if per argument.
Where we hit it
NumberUtil.add and RelMdPercentageOriginalRows.quotientForPercentage. 108 other contracts in the same codebase verify without complaint; these two are the ones whose return null sits after a merge. Found while replacing the Checker Framework with NullAway in Apache Calcite (apache/calcite#5213).
Version
NullAway 0.14.0, and current
master(21f02dd). Error Prone 2.50.0, JDK 21.Flags:
-XepOpt:NullAway:CheckContracts=true -XepOpt:NullAway:JSpecifyMode=true -XDaddTypeAnnotationsToSymbol=true.Summary
CheckContractsreports a violation for areturn nullthat is reachable only when the contract's preconditions are false, whenever thatreturnis reached by a control-flow merge whose incoming edges each contradict a different argument.The merge is what matters, not the operator.
if (a == null || b == null)reports, andif (a == null || a == null)— same operator, one argument — does not.Reproducer
All five bodies satisfy their contracts.
andcontains no disjunction at all, andsameVarcontains one and is accepted, so neither||nor the number of arguments in the antecedent is the discriminator.What does and does not report
@Contract("!null, !null -> !null")unless notedif (a == null || b == null) return null;if (a == null | b == null) return null;if (a != null && b != null) return a + b; return null;return (a == null || b == null) ? null : a + b;if (a == null) return null; if (b == null) return null;@Contract("!null -> !null"),if (a == null) return null;@Contract("!null, _ -> !null"),if (a == null || a == null) return null;What appears to be going on
ContractCheckHandler.visitReturnskips a return whenhasBottomAccessPathForContractDataflowfinds an access path mapped toBOTTOMin the store before the expression.Nullness.leastUpperBounddocumentsBOTTOMas losing at a merge:Seed the contract dataflow with
a = NONNULL, b = NONNULLand takeor. Thereturn nullhas two incoming edges:a == nullleavesaatBOTTOM, anda != null && b == nullleavesbatBOTTOM. Each edge is individually infeasible, but each is infeasible for a different variable, so the merged store isa = NONNULL, b = NONNULLwith noBOTTOManywhere, and the return is analysed as reachable.sameVarisolates this: both edges leave the same variable atBOTTOM,BOTTOM ⊔ BOTTOM = BOTTOMsurvives the merge, and the return is correctly skipped.twoIfsavoids the merge entirely — eachreturn nullsits on its own branch.Expected
A
returnreachable only when the contract's antecedent is false should not be reported, however the control flow arrives there. Tracking infeasibility so it survives a merge would cover all four rows above.Workaround
Write one
ifper argument.Where we hit it
NumberUtil.addandRelMdPercentageOriginalRows.quotientForPercentage. 108 other contracts in the same codebase verify without complaint; these two are the ones whosereturn nullsits after a merge. Found while replacing the Checker Framework with NullAway in Apache Calcite (apache/calcite#5213).