Skip to content

Commit 39d65f8

Browse files
FxMorinmsridhar
andauthored
Add PureExceptLambda annotation (#1325)
### Description This PR adds support for nullability preserving methods annotated with a new `PureExceptLambda` annotation. When a method is marked as `@PureExceptLambda`, it means that there's no way that it could effect the variables used within a lambda passed to it (no side-effects except those possibly performed by the lambda once invoked), and that the lambda will only be called within that method and not stored for later use. Allowing developers to implement their own lambda api's without needing explicit checks in NullAway. Here's an example of a simple adapter pattern with the issue: ```java @FunctionalInterface public interface LegacyListener { void onEvent(String payload); } public static class EventAdapter { @PureExceptLambda public void withLegacyListener(String payload, LegacyListener listener) { listener.onEvent(payload); } } public class Service { private @nullable Database db; // set elsewhere public void process(String input) { if (db == null) { // After this, `db` is known non-null in this branch throw new IllegalStateException("db not initialized"); } EventAdapter.withLegacyListener(input, payload -> db.execute(payload)); } } ``` Before this PR, NullAway would have warned that `db` is null inside the lambda returned from `EventSource.withListener`. However, now its seen as nullness preserving, it preserves the non-null state. <details> <summary>Details</summary> Please note that once you click "Create Pull Request" you will be asked to sign our [Uber Contributor License Agreement](https://cla-assistant.io/uber/NullAway) via [CLA assistant](https://cla-assistant.io/). Before pressing the "Create Pull Request" button, please provide the following: - [x] A description about what and why you are contributing, even if it's trivial. - [x] The issue number(s) or PR number(s) in the description if you are contributing in response to those. - [x] If applicable, unit tests. </details> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added @PureExceptLambda annotation to mark methods that preserve nullness properties when invoking lambda parameters, enabling improved null-safety analysis for lambda-based APIs. * **Tests** * Added test coverage validating nullness fact preservation in methods annotated with @PureExceptLambda. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Manu Sridharan <msridhar@gmail.com>
1 parent 82784a2 commit 39d65f8

File tree

3 files changed

+155
-0
lines changed

3 files changed

+155
-0
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.uber.nullaway.annotations;
2+
3+
import java.lang.annotation.ElementType;
4+
import java.lang.annotation.Retention;
5+
import java.lang.annotation.RetentionPolicy;
6+
import java.lang.annotation.Target;
7+
8+
/**
9+
* Indicates that a method or constructor preserves the nullness environment when invoking lambda
10+
* expressions passed as parameters.
11+
*
12+
* <p>You can only use this on methods which have a single lambda callback. Otherwise, the purity
13+
* cannot be guaranteed.
14+
*
15+
* <p>When a method or constructor is annotated with {@code @PureExceptLambda}, NullAway assumes
16+
* that any lambda arguments passed to it are invoked <em>synchronously</em> and that the invocation
17+
* does not modify the visible state or capture the lambda for later (asynchronous) execution. As a
18+
* result, NullAway treats the body of the lambda as being executed in the same nullness environment
19+
* as the code at the call site.
20+
*
21+
* <p>In other words, this annotation tells NullAway that:
22+
*
23+
* <ul>
24+
* <li>The annotated method or constructor does not perform side effects on fields or global state
25+
* visible at the call site.
26+
* <li>The single functional parameter (e.g., lambda or method reference) is invoked synchronously
27+
* within the method or constructor body and is not stored or invoked later.
28+
* <li>Nullability information from the surrounding context is preserved inside the lambda body.
29+
* </ul>
30+
*
31+
* <p>This allows NullAway to safely reason about nullness within lambdas passed to such methods as
32+
* if the lambda were executed inline at the call site.
33+
*
34+
* <p><b>Important:</b> NullAway does <em>not</em> verify that annotated methods actually satisfy
35+
* these requirements. The annotation represents a contract that developers must uphold manually.
36+
* Misuse of this annotation (e.g., annotating methods that perform side effects or invoke lambdas
37+
* asynchronously) can lead to unsound nullability analysis. Note that this annotation requires only
38+
* side-effect freedom; determinism (always producing the same result for the same inputs) is not
39+
* required.
40+
*
41+
* <p><b>Example:</b>
42+
*
43+
* <pre>{@code
44+
* @PureExceptLambda
45+
* public static <T> void runWith(T value, Consumer<T> action) {
46+
* action.accept(value);
47+
* }
48+
* }</pre>
49+
*/
50+
@Retention(RetentionPolicy.CLASS)
51+
@Target({ElementType.METHOD, ElementType.CONSTRUCTOR})
52+
public @interface PureExceptLambda {}

nullaway/src/main/java/com/uber/nullaway/handlers/SynchronousCallbackHandler.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import com.uber.nullaway.LibraryModels.MethodRef;
2020
import com.uber.nullaway.dataflow.AccessPath;
2121
import java.util.function.Predicate;
22+
import javax.lang.model.element.AnnotationMirror;
23+
import org.checkerframework.nullaway.javacutil.AnnotationUtils;
2224

2325
public class SynchronousCallbackHandler extends BaseNoOpHandler {
2426

@@ -67,6 +69,11 @@ public Predicate<AccessPath> getAccessPathPredicateForNestedMethod(
6769
// preserve access paths for all callbacks passed to stream methods
6870
return TRUE_AP_PREDICATE;
6971
}
72+
// If the callee is a method that preserves the nullability of lambdas passed as parameters.
73+
// (e.g., annotated with @PureExceptLambda).
74+
if (isMethodPureExceptLambda(symbol)) {
75+
return TRUE_AP_PREDICATE;
76+
}
7077
String invokedMethodName = symbol.getSimpleName().toString();
7178
if (METHOD_NAME_TO_SIG_AND_PARAM_INDEX.containsKey(invokedMethodName)) {
7279
ImmutableMap<MethodRef, Integer> entriesForMethodName =
@@ -91,4 +98,16 @@ public Predicate<AccessPath> getAccessPathPredicateForNestedMethod(
9198
}
9299
return FALSE_AP_PREDICATE;
93100
}
101+
102+
private boolean isMethodPureExceptLambda(Symbol.MethodSymbol methodSymbol) {
103+
for (AnnotationMirror annotation : methodSymbol.getAnnotationMirrors()) {
104+
String name = AnnotationUtils.annotationName(annotation);
105+
int lastDot = name.lastIndexOf('.');
106+
String simpleName = lastDot == -1 ? name : name.substring(lastDot + 1);
107+
if (simpleName.equals("PureExceptLambda")) {
108+
return true;
109+
}
110+
}
111+
return false;
112+
}
94113
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.uber.nullaway;
2+
3+
import java.util.Arrays;
4+
import org.junit.Test;
5+
6+
/**
7+
* Tests for preserving environment nullness facts for lambdas passed to nullness preserving methods
8+
* (methods annotated with @PureExceptLambda). Variables captured in such lambdas should not be
9+
* treated as automatically nullable; instead, the facts at the call site should be visible in the
10+
* lambda body.
11+
*/
12+
public class PureExceptLambdaTests extends NullAwayTestsBase {
13+
14+
@Test
15+
public void methodLambdaPreservesFacts() {
16+
makeTestHelperWithArgs(
17+
Arrays.asList(
18+
"-d",
19+
temporaryFolder.getRoot().getAbsolutePath(),
20+
"-XepOpt:NullAway:AnnotatedPackages=com.uber"))
21+
.addSourceLines(
22+
"com/example/library/PureLibrary.java",
23+
"package com.example.library;",
24+
"import com.uber.nullaway.annotations.PureExceptLambda;",
25+
"import java.util.function.Consumer;",
26+
"public class PureLibrary {",
27+
" @PureExceptLambda",
28+
" public static <T> void withConsumer(T t, Consumer<T> consumer) {",
29+
" consumer.accept(t);",
30+
" }",
31+
"}")
32+
.addSourceLines(
33+
"com/uber/Test.java",
34+
"package com.uber;",
35+
"import org.jspecify.annotations.Nullable;",
36+
"import com.example.library.PureLibrary;",
37+
"public class Test {",
38+
" private @Nullable Object f;",
39+
" public void test1() {",
40+
" if (this.f == null) {",
41+
" throw new IllegalArgumentException();",
42+
" }",
43+
" // f is known non-null after the check; that fact should be visible in the lambda",
44+
" PureLibrary.withConsumer(\"x\", v -> System.out.println(this.f.toString()));",
45+
" }",
46+
"}")
47+
.doTest();
48+
}
49+
50+
@Test
51+
public void methodLambdaDoesNotPreserveFacts() {
52+
makeTestHelperWithArgs(
53+
Arrays.asList(
54+
"-d",
55+
temporaryFolder.getRoot().getAbsolutePath(),
56+
"-XepOpt:NullAway:AnnotatedPackages=com.uber"))
57+
.addSourceLines(
58+
"com/example/library/OrdinaryLibrary.java",
59+
"package com.example.library;",
60+
"import java.util.function.Consumer;",
61+
"public class OrdinaryLibrary {",
62+
" public static <T> void withConsumer(T t, Consumer<T> consumer) {",
63+
" consumer.accept(t);",
64+
" }",
65+
"}")
66+
.addSourceLines(
67+
"com/uber/Test.java",
68+
"package com.uber;",
69+
"import org.jspecify.annotations.Nullable;",
70+
"import com.example.library.OrdinaryLibrary;",
71+
"public class Test {",
72+
" private @Nullable Object f;",
73+
" public void test1() {",
74+
" if (this.f == null) {",
75+
" throw new IllegalArgumentException();",
76+
" }",
77+
" // Without nullness preserving annotation, we should not propagate the non-null fact into the lambda",
78+
" // BUG: Diagnostic contains: dereferenced expression this.f is @Nullable",
79+
" OrdinaryLibrary.withConsumer(\"x\", v -> System.out.println(this.f.toString()));",
80+
" }",
81+
"}")
82+
.doTest();
83+
}
84+
}

0 commit comments

Comments
 (0)