From 1718f4386a49cee954ccd1884cd2e190db983d0f Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Tue, 8 Sep 2026 08:38:27 -0700 Subject: [PATCH 01/43] Update sideeffectfree javadoc. --- .../dataflow/qual/SideEffectFree.java | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/checker-qual/src/main/java/org/checkerframework/dataflow/qual/SideEffectFree.java b/checker-qual/src/main/java/org/checkerframework/dataflow/qual/SideEffectFree.java index 4a91c2e2330a..5a7f57355ec2 100644 --- a/checker-qual/src/main/java/org/checkerframework/dataflow/qual/SideEffectFree.java +++ b/checker-qual/src/main/java/org/checkerframework/dataflow/qual/SideEffectFree.java @@ -24,6 +24,34 @@ * *

Also see {@link Pure}, which means both side-effect-free and {@link Deterministic}. * + *

Functional-interface parameters: A side-effect-free method may run code that its caller + * handed to it. If a parameter's type is a functional interface whose functional method (its single + * abstract method, or SAM) is not itself annotated with a purity annotation, then + * {@code @SideEffectFree} is also a requirement on the method's callers: the argument must denote a + * value whose functional method is side-effect-free. In return, the body of the method may call + * that parameter's functional method without thereby being impure. + * + * + *

@SideEffectFree
+ * int apply(Function<String, Integer> f, String s) {
+ *   return f.apply(s);  // permitted: the receiver is a formal parameter of this method
+ * }
+ * 
+ * + *

and, at calls to {@code apply}: + * + *

{@code
+ * apply(s -> s.length(), "hi");  // OK: the lambda body is side-effect-free
+ * apply(s -> count++, "hi");     // error: the lambda body has a side effect
+ * }
+ * + *

The requirement is imposed on every parameter whose type is a functional interface, whether or + * not the method's body ever calls it, so that a caller can read the requirement off the signature + * alone. The requirement is imposed only when the functional method is unannotated; when the + * functional method is itself annotated {@code @SideEffectFree} (or {@link SideEffectsOnly}), that + * annotation governs both the call in the body and the obligation on the argument, exactly as for + * any other method. + * *

Analysis: The Checker Framework performs a conservative analysis to verify a * {@code @SideEffectFree} annotation. The Checker Framework issues a warning if the method uses any * of the following Java constructs: @@ -32,18 +60,32 @@ *

  • Assignment to any expression, except for local variables and method parameters.
    * (Note that storing into an array element, such as {@code a[i] = x}, is not an assignment to * a variable and is therefore forbidden.) - *
  • A method invocation of a method that is not {@code @SideEffectFree}. + *
  • A method invocation of a method that is not {@code @SideEffectFree}. As an exception, a + * call to the functional method of one of the method's own formal parameters is permitted, as + * described above. The receiver must be the formal parameter itself; a local variable that + * has been assigned the parameter, or a field that holds it, does not qualify. Nor does a + * call to a {@code default} or {@code static} method of the functional interface. *
  • Construction of a new object where the constructor is not {@code @SideEffectFree}. * * - * These rules are conservative: any code that passes the checks is side-effect-free, but the + *

    Creating a lambda or an anonymous class is not itself a side effect, so a side-effect-free + * method may create an impure one. The body of a lambda is checked against the purity annotations + * on the functional method it implements, not against those on the method that creates it. + * + *

    At each call to a {@code @SideEffectFree} method or constructor, the Checker Framework also + * checks that every argument passed to a functional-interface parameter is side-effect-free. If the + * argument is a lambda, its body is checked directly and no annotation on it is needed. If the + * argument is a method reference, the referenced method must be {@code @SideEffectFree}. If the + * argument is a functional-interface parameter of the enclosing method, and that method is itself + * {@code @SideEffectFree}, then the enclosing method's own callers have already discharged the + * requirement and the argument is permitted. Otherwise, the functional method of the argument's + * declared type must be {@code @SideEffectFree}. + * + *

    These rules are conservative: any code that passes the checks is side-effect-free, but the * Checker Framework may issue false positive warnings, for code that uses one of the forbidden * constructs but is side-effect-free nonetheless. In particular, a method that caches its result * will be rejected. * - *

    In fact, the rules are so conservative that checking is currently disabled by default, but can - * be enabled via the {@code -AcheckPurityAnnotations} command-line option. - * *

    This annotation is inherited by subtypes, just as if it were meta-annotated with * {@code @InheritedAnnotation}. * From 84a53cd2b4706dc600731e8199d9c36d6919db0d Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Tue, 8 Sep 2026 10:11:10 -0700 Subject: [PATCH 02/43] Document purity of lambdas and functional-interface parameters Bring the Purity Checker chapter in line with the @SideEffectFree javadoc: creating a lambda or a local/anonymous class is not a side effect, a lambda's body is checked against the functional method's annotations, and @SideEffectFree on a method with a functional-interface parameter is also a requirement on that method's callers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XCfsNnTrse2emqZorUyFej --- docs/manual/purity-checker.tex | 94 ++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/docs/manual/purity-checker.tex b/docs/manual/purity-checker.tex index a334e5f8c365..66b7db65634b 100644 --- a/docs/manual/purity-checker.tex +++ b/docs/manual/purity-checker.tex @@ -43,6 +43,100 @@ \end{description} +\sectionAndLabel{Lambdas, method references, and functional interfaces}{purity-functional-interfaces} + +Creating a lambda, or declaring a local or anonymous class, is not itself a +side effect. A lambda expression creates an object; it does not run the code +in the lambda's body. A side-effect-free method may therefore create a lambda +whose body has side effects: + +\begin{Verbatim} + @SideEffectFree + Runnable makeRunnable() { + return () -> count++; // OK: creating the lambda has no side effect + } +\end{Verbatim} + +\noindent +The effects of the body occur where the functional method is invoked, and +each such invocation is checked on its own. + +The body of a lambda is checked against the purity annotations on the +functional interface method that the lambda implements, not against those on +the method that creates the lambda. For example: + +\begin{Verbatim} + @FunctionalInterface + interface PureSupplier { + @SideEffectFree String get(); + } + + PureSupplier s1 = () -> "hello"; // OK + PureSupplier s2 = () -> { count++; return ""; }; // error: the body has a side effect +\end{Verbatim} + + +\subsectionAndLabel{Functional-interface parameters}{purity-functional-interface-parameters} + +A side-effect-free method may run code that its caller handed to it. If a +parameter's type is a functional interface whose functional method (its +single abstract method) is not itself annotated with a purity annotation, +then \<@SideEffectFree> is also a requirement on the method's callers: the +argument must denote a value whose functional method is side-effect-free. +In return, the body of the method may call that parameter's functional +method without thereby being impure: + +\begin{Verbatim} + @SideEffectFree + int apply(Function f, String s) { + return f.apply(s); // permitted: the receiver is a formal parameter of this method + } +\end{Verbatim} + +\noindent +and, at calls to \: + +\begin{Verbatim} + apply(s -> s.length(), "hi"); // OK: the lambda body is side-effect-free + apply(s -> count++, "hi"); // error: the lambda body has a side effect +\end{Verbatim} + +The permission to call a method that is not \<@SideEffectFree> is narrow. +The receiver must be the formal parameter itself; a local variable that has +been assigned the parameter, or a field that holds it, does not qualify. +Nor does a call to a \ or \ method of the functional +interface. + +The requirement on callers is imposed on every parameter whose type is a +functional interface, whether or not the method's body ever calls it, so that +a caller can read the requirement off the signature alone. The requirement +is imposed only when the functional method is unannotated; when the +functional method is itself annotated \<@SideEffectFree>, that annotation +governs both the call in the body and the obligation on the argument, +exactly as for any other method. + +At each call to a \<@SideEffectFree> method or constructor, the Checker +Framework checks that every argument passed to a functional-interface +parameter is side-effect-free: + +\begin{itemize} +\item + If the argument is a lambda, its body is checked directly and no + annotation on it is needed. +\item + If the argument is a method reference, the referenced method must be + \<@SideEffectFree>. +\item + If the argument is a functional-interface parameter of the enclosing + method, and that method is itself \<@SideEffectFree>, then the enclosing + method's own callers have already discharged the requirement, so the + argument is permitted. +\item + Otherwise, the functional method of the argument's declared type must be + \<@SideEffectFree>. +\end{itemize} + + \sectionAndLabel{Purity annotations are trusted}{purity-trusted} By default, purity annotations are trusted. Purity annotations on called From fc38f8982b54641cdf791dd626dde9da1393d853 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Tue, 8 Sep 2026 10:26:08 -0700 Subject: [PATCH 03/43] Revert "Update sideeffectfree javadoc." This reverts commit 1718f4386a49cee954ccd1884cd2e190db983d0f. --- .../dataflow/qual/SideEffectFree.java | 52 ++----------------- 1 file changed, 5 insertions(+), 47 deletions(-) diff --git a/checker-qual/src/main/java/org/checkerframework/dataflow/qual/SideEffectFree.java b/checker-qual/src/main/java/org/checkerframework/dataflow/qual/SideEffectFree.java index 5a7f57355ec2..4a91c2e2330a 100644 --- a/checker-qual/src/main/java/org/checkerframework/dataflow/qual/SideEffectFree.java +++ b/checker-qual/src/main/java/org/checkerframework/dataflow/qual/SideEffectFree.java @@ -24,34 +24,6 @@ * *

    Also see {@link Pure}, which means both side-effect-free and {@link Deterministic}. * - *

    Functional-interface parameters: A side-effect-free method may run code that its caller - * handed to it. If a parameter's type is a functional interface whose functional method (its single - * abstract method, or SAM) is not itself annotated with a purity annotation, then - * {@code @SideEffectFree} is also a requirement on the method's callers: the argument must denote a - * value whose functional method is side-effect-free. In return, the body of the method may call - * that parameter's functional method without thereby being impure. - * - * - *

    @SideEffectFree
    - * int apply(Function<String, Integer> f, String s) {
    - *   return f.apply(s);  // permitted: the receiver is a formal parameter of this method
    - * }
    - * 
    - * - *

    and, at calls to {@code apply}: - * - *

    {@code
    - * apply(s -> s.length(), "hi");  // OK: the lambda body is side-effect-free
    - * apply(s -> count++, "hi");     // error: the lambda body has a side effect
    - * }
    - * - *

    The requirement is imposed on every parameter whose type is a functional interface, whether or - * not the method's body ever calls it, so that a caller can read the requirement off the signature - * alone. The requirement is imposed only when the functional method is unannotated; when the - * functional method is itself annotated {@code @SideEffectFree} (or {@link SideEffectsOnly}), that - * annotation governs both the call in the body and the obligation on the argument, exactly as for - * any other method. - * *

    Analysis: The Checker Framework performs a conservative analysis to verify a * {@code @SideEffectFree} annotation. The Checker Framework issues a warning if the method uses any * of the following Java constructs: @@ -60,32 +32,18 @@ *

  • Assignment to any expression, except for local variables and method parameters.
    * (Note that storing into an array element, such as {@code a[i] = x}, is not an assignment to * a variable and is therefore forbidden.) - *
  • A method invocation of a method that is not {@code @SideEffectFree}. As an exception, a - * call to the functional method of one of the method's own formal parameters is permitted, as - * described above. The receiver must be the formal parameter itself; a local variable that - * has been assigned the parameter, or a field that holds it, does not qualify. Nor does a - * call to a {@code default} or {@code static} method of the functional interface. + *
  • A method invocation of a method that is not {@code @SideEffectFree}. *
  • Construction of a new object where the constructor is not {@code @SideEffectFree}. * * - *

    Creating a lambda or an anonymous class is not itself a side effect, so a side-effect-free - * method may create an impure one. The body of a lambda is checked against the purity annotations - * on the functional method it implements, not against those on the method that creates it. - * - *

    At each call to a {@code @SideEffectFree} method or constructor, the Checker Framework also - * checks that every argument passed to a functional-interface parameter is side-effect-free. If the - * argument is a lambda, its body is checked directly and no annotation on it is needed. If the - * argument is a method reference, the referenced method must be {@code @SideEffectFree}. If the - * argument is a functional-interface parameter of the enclosing method, and that method is itself - * {@code @SideEffectFree}, then the enclosing method's own callers have already discharged the - * requirement and the argument is permitted. Otherwise, the functional method of the argument's - * declared type must be {@code @SideEffectFree}. - * - *

    These rules are conservative: any code that passes the checks is side-effect-free, but the + * These rules are conservative: any code that passes the checks is side-effect-free, but the * Checker Framework may issue false positive warnings, for code that uses one of the forbidden * constructs but is side-effect-free nonetheless. In particular, a method that caches its result * will be rejected. * + *

    In fact, the rules are so conservative that checking is currently disabled by default, but can + * be enabled via the {@code -AcheckPurityAnnotations} command-line option. + * *

    This annotation is inherited by subtypes, just as if it were meta-annotated with * {@code @InheritedAnnotation}. * From fb2d3deb7ec8a3dd0076e3a08ce39517086c61db Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 10 Sep 2026 08:50:49 -0700 Subject: [PATCH 04/43] Edits --- docs/manual/purity-checker.tex | 89 ++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 37 deletions(-) diff --git a/docs/manual/purity-checker.tex b/docs/manual/purity-checker.tex index 66b7db65634b..be0488facc51 100644 --- a/docs/manual/purity-checker.tex +++ b/docs/manual/purity-checker.tex @@ -1,20 +1,27 @@ \htmlhr \chapterAndLabel{Purity Checker}{purity-checker} -The Purity Checker identifies methods that have no side effects, that -return the same value each time they are called on the same argument, or both. - -Purity analysis aids type refinement (Section~\ref{type-refinement}). - -All checkers utilize purity annotations on called methods. -You do not need to run the Purity Checker directly. -However, you may run just the Purity Checker by -supplying the following command-line option to javac: +A \emph{side-effect-free} method is one that changes no object that exists +when it is called. +A \emph{deterministic} method returns the same value each time it is called +on the same arguments. +A \emph{pure} method is both side-effect-free and deterministic. +The Purity Checker can check and infer all three types of methods, +and ``purity'' refers to all concepts. + +All checkers utilize purity annotations to aid type refinement +(Section~\ref{type-refinement}). By default, purity annotations are +trusted, not checked. + +To check purity annotations, pass \<-AcheckPurityAnnotations> to any +checker. +To run \emph{only} the Purity Checker (this is uncommon), +supply the following command-line option to javac: \code{-processor org.checkerframework.framework.util.PurityChecker}. -The Checker Framework can infer purity annotations. -If you supply the command-line option \<-AsuggestPureMethods>, then the -Checker Framework will suggest methods that can be marked as +To infer purity annotations, +supply the command-line option \<-AsuggestPureMethods>. +The Checker Framework will suggest methods that can be marked as \<@SideEffectFree>, \<@Deterministic>, or \<@Pure>. In addition, such suggestions are output by \<-Ainfer> and when using whole-program inference. @@ -46,7 +53,7 @@ \sectionAndLabel{Lambdas, method references, and functional interfaces}{purity-functional-interfaces} Creating a lambda, or declaring a local or anonymous class, is not itself a -side effect. A lambda expression creates an object; it does not run the code +side effect. A lambda expression evaluates to an object but does not run the code in the lambda's body. A side-effect-free method may therefore create a lambda whose body has side effects: @@ -78,18 +85,26 @@ \subsectionAndLabel{Functional-interface parameters}{purity-functional-interface-parameters} -A side-effect-free method may run code that its caller handed to it. If a -parameter's type is a functional interface whose functional method (its -single abstract method) is not itself annotated with a purity annotation, -then \<@SideEffectFree> is also a requirement on the method's callers: the -argument must denote a value whose functional method is side-effect-free. -In return, the body of the method may call that parameter's functional -method without thereby being impure: +For a side-effect-free method, the default type is \<@SideEffectFree> for any +parameter that is a functional interface. +That is, the following two declarations are equivalent: + +\begin{Verbatim} + @SideEffectFree + int apply(Function f, String s) { ... } + + @SideEffectFree + int apply(@SideEffectFree Function f, String s) { ... } +\end{Verbatim} + + +A side-effect-free method may call arguments that are functional interface, +but only if the corresponding parameter is side-effect-free. \begin{Verbatim} @SideEffectFree - int apply(Function f, String s) { - return f.apply(s); // permitted: the receiver is a formal parameter of this method + int apply(Function f, String s) { // f is implicitly @SideEffectFree + return f.apply(s); // permitted: the called method f is @SideEffectFree } \end{Verbatim} @@ -101,11 +116,8 @@ apply(s -> count++, "hi"); // error: the lambda body has a side effect \end{Verbatim} -The permission to call a method that is not \<@SideEffectFree> is narrow. -The receiver must be the formal parameter itself; a local variable that has -been assigned the parameter, or a field that holds it, does not qualify. -Nor does a call to a \ or \ method of the functional -interface. +A \<@SideEffectFree> method \ may call \ only if \ is a +side-effect-free method reference or is a formal parameter. The requirement on callers is imposed on every parameter whose type is a functional interface, whether or not the method's body ever calls it, so that @@ -115,9 +127,11 @@ governs both the call in the body and the obligation on the argument, exactly as for any other method. -At each call to a \<@SideEffectFree> method or constructor, the Checker -Framework checks that every argument passed to a functional-interface -parameter is side-effect-free: +Suppose that \ is a \<@SideEffectFree> method or constructor with a +parameter \ of functional-interface type.[[TODO: I think this is true +whenever \ is itself \<@SideEffectFree>, whether or not \ is.]] +At each call to \, +the argument corresponding to \ must be side-effect-free: \begin{itemize} \item @@ -139,8 +153,8 @@ \sectionAndLabel{Purity annotations are trusted}{purity-trusted} -By default, purity annotations are trusted. Purity annotations on called -methods affect type-checking of client code. However, you can make a +By default, purity annotations are trusted by every checker built with the +Checker Framework, and the Purity Checker is not run. However, you can make a mistake % by writing \refqualclass{dataflow/qual}{SideEffectFree} on the @@ -155,9 +169,9 @@ by writing \refqualclass{dataflow/qual}{Deterministic} on the declaration of a method that is not deterministic. -To enable -checking of the annotations, supply the command-line option -\<-AcheckPurityAnnotations>. It is not enabled by default because of a high false +To run the Purity Checker together with another checker, supply the +command-line option \<-AcheckPurityAnnotations>. The Purity Checker is not +enabled by default because of a high false positive rate. In the future, after a new purity-checking analysis is implemented, the Checker Framework will default to checking purity annotations. @@ -243,5 +257,6 @@ \end{itemize} -%% LocalWords: AsuggestPureMethods AcheckPurityAnnotations -%% LocalWords: AsuppressWarnings AassumeSideEffectFree +%% LocalWords: AsuggestPureMethods AcheckPurityAnnotations Ainfer m2 fi +%% LocalWords: AsuppressWarnings AassumeSideEffectFree SideEffectsOnly +% LocalWords: TODO AassumeDeterministic AassumePure AassumePureGetters From d44bde9cdbfb4ec6a69d95778e5fa4c5bf147f0d Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Mon, 14 Sep 2026 11:01:25 -0700 Subject: [PATCH 05/43] Tweaks. --- docs/manual/purity-checker.tex | 81 ++++++++++++---------------------- 1 file changed, 28 insertions(+), 53 deletions(-) diff --git a/docs/manual/purity-checker.tex b/docs/manual/purity-checker.tex index be0488facc51..1f36fba2c775 100644 --- a/docs/manual/purity-checker.tex +++ b/docs/manual/purity-checker.tex @@ -83,72 +83,47 @@ \end{Verbatim} -\subsectionAndLabel{Functional-interface parameters}{purity-functional-interface-parameters} +\subsectionAndLabel{Functional-interface parameters of side-effect-free methods}{purity-functional-interface-parameters} -For a side-effect-free method, the default type is \<@SideEffectFree> for any -parameter that is a functional interface. -That is, the following two declarations are equivalent: +For a side-effect-free method, the functional method of any parameter that is +a functional interface is treated as side-effect-free. \begin{Verbatim} @SideEffectFree - int apply(Function f, String s) { ... } - - @SideEffectFree - int apply(@SideEffectFree Function f, String s) { ... } + int m(Function f, String s) { + return f.apply(s); // permitted: apply is treated as side-effect-free + } \end{Verbatim} - -A side-effect-free method may call arguments that are functional interface, -but only if the corresponding parameter is side-effect-free. +At calls to \, the functional method of the argument's declared type must +be \<@SideEffectFree>. \begin{Verbatim} - @SideEffectFree - int apply(Function f, String s) { // f is implicitly @SideEffectFree - return f.apply(s); // permitted: the called method f is @SideEffectFree - } + m(String::length, "hi"); // OK: String.length() is side-effect-free \end{Verbatim} -\noindent -and, at calls to \: +If the argument is a lambda, then the body of the lambda is checked directly +for side effects. \begin{Verbatim} - apply(s -> s.length(), "hi"); // OK: the lambda body is side-effect-free - apply(s -> count++, "hi"); // error: the lambda body has a side effect + m(s -> s.length(), "hi"); // OK: the lambda body is side-effect-free + m(s -> count++, "hi"); // error: the lambda body has a side effect \end{Verbatim} -A \<@SideEffectFree> method \ may call \ only if \ is a -side-effect-free method reference or is a formal parameter. - -The requirement on callers is imposed on every parameter whose type is a -functional interface, whether or not the method's body ever calls it, so that -a caller can read the requirement off the signature alone. The requirement -is imposed only when the functional method is unannotated; when the -functional method is itself annotated \<@SideEffectFree>, that annotation -governs both the call in the body and the obligation on the argument, -exactly as for any other method. - -Suppose that \ is a \<@SideEffectFree> method or constructor with a -parameter \ of functional-interface type.[[TODO: I think this is true -whenever \ is itself \<@SideEffectFree>, whether or not \ is.]] -At each call to \, -the argument corresponding to \ must be side-effect-free: - -\begin{itemize} -\item - If the argument is a lambda, its body is checked directly and no - annotation on it is needed. -\item - If the argument is a method reference, the referenced method must be - \<@SideEffectFree>. -\item - If the argument is a functional-interface parameter of the enclosing - method, and that method is itself \<@SideEffectFree>, then the enclosing - method's own callers have already discharged the requirement, so the - argument is permitted. -\item - Otherwise, the functional method of the argument's declared type must be - \<@SideEffectFree>. -\end{itemize} +%% TODO: we could implement a type system for functional interfaces with a +%% qualifier \<@SideEffectFreeFunctionalInterface>. Then the default for a +%% \<@SideEffectFree> method would be: +%For a side-effect-free method, the default type is +%\<@SideEffectFreeFunctionalInterface> for any parameter that is a functional +%interface. That is, the following two declarations are equivalent: +% +%\begin{Verbatim} +% @SideEffectFree +% int apply(Function f, String s) { ... } +% +% @SideEffectFree +% int apply(@SideEffectFreeFunctionalInterface Function f, String s) { ... } +%\end{Verbatim} \sectionAndLabel{Purity annotations are trusted}{purity-trusted} @@ -257,6 +232,6 @@ \end{itemize} -%% LocalWords: AsuggestPureMethods AcheckPurityAnnotations Ainfer m2 fi +%% LocalWords: AsuggestPureMethods AcheckPurityAnnotations Ainfer %% LocalWords: AsuppressWarnings AassumeSideEffectFree SideEffectsOnly % LocalWords: TODO AassumeDeterministic AassumePure AassumePureGetters From 9d18bab96cdc8f416f1678e684ef9f36199b52b9 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Wed, 16 Sep 2026 11:00:06 -0700 Subject: [PATCH 06/43] Check functional-interface arguments of side-effect-free methods A method annotated @SideEffectFree, @Deterministic, @Pure, or @SideEffectsOnly may call the functional method of its own effectively final functional-interface parameters. At each call to such a method, every argument passed to a functional-interface parameter must have the callee's purity: a lambda's body is checked directly, a method reference against the declaration of the method it refers to, and any other argument against the functional method of its declared type. An argument that is an effectively final functional-interface parameter of an enclosing method that makes the same promise needs no check, because the caller of that method already performed one. A requirement that the parameter's own functional method already makes is left to the checks that exist for it. New message key: purity.functional.argument. PurityChecker.checkPurity takes the method whose body is being checked, so that the assumption applies to a method body but not to a lambda body, which its own functional method constrains, nor to the arbitrary expression that CFAbstractTransfer passes. Three existing test files call an annotated JDK method with a functional-interface argument. Optional.map is @SideEffectFree and its declaration comments that "the mapper must not have side effects"; Collections.min is @Pure and List.sort is @SideEffectsOnly("this"), so each requires a side-effect-free Comparator. framework/tests/flow/PurityFunctionalArgumentJdk.java asserts those errors; the two all-systems files suppress them, because only 3 of the 37 test suites that compile that directory pass -AcheckPurityAnnotations. Co-Authored-By: Claude Opus 5 --- checker/tests/nullness/java8/Issue496.java | 4 + .../dataflow/util/PurityChecker.java | 163 ++++++++++++++- docs/CHANGELOG.md | 17 ++ .../common/basetype/BaseTypeVisitor.java | 188 +++++++++++++++--- .../common/basetype/messages.properties | 1 + .../framework/flow/CFAbstractTransfer.java | 4 + framework/tests/all-systems/Issue6874.java | 4 + framework/tests/all-systems/Issue6891.java | 5 +- .../tests/flow/PurityFunctionalArgument.java | 137 +++++++++++++ .../flow/PurityFunctionalArgumentJdk.java | 64 ++++++ .../flow/PurityFunctionalArgumentKinds.java | 100 ++++++++++ .../flow/PurityFunctionalConstructor.java | 39 ++++ .../tests/flow/PurityFunctionalOverride.java | 62 ++++++ .../tests/flow/PurityFunctionalParameter.java | 118 +++++++++++ 14 files changed, 881 insertions(+), 25 deletions(-) create mode 100644 framework/tests/flow/PurityFunctionalArgument.java create mode 100644 framework/tests/flow/PurityFunctionalArgumentJdk.java create mode 100644 framework/tests/flow/PurityFunctionalArgumentKinds.java create mode 100644 framework/tests/flow/PurityFunctionalConstructor.java create mode 100644 framework/tests/flow/PurityFunctionalOverride.java create mode 100644 framework/tests/flow/PurityFunctionalParameter.java diff --git a/checker/tests/nullness/java8/Issue496.java b/checker/tests/nullness/java8/Issue496.java index 071be672b14f..11fae06d0d8a 100644 --- a/checker/tests/nullness/java8/Issue496.java +++ b/checker/tests/nullness/java8/Issue496.java @@ -2,6 +2,7 @@ // https://github.com/typetools/checker-framework/issues/496 import java.util.Optional; +import org.checkerframework.dataflow.qual.SideEffectFree; public class Issue496 { @@ -9,6 +10,9 @@ public static class Entity { public final T value; public final Class cls; + // Optional.map is @SideEffectFree, so the mapper below must be too, and calling this + // constructor is side-effect-free only if it says so. + @SideEffectFree public Entity(T value, Class cls) { this.value = value; this.cls = cls; diff --git a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java index 3d0e6201bacc..676d2bc47f03 100644 --- a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java +++ b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java @@ -19,17 +19,24 @@ import java.util.ArrayList; import java.util.EnumSet; import java.util.List; +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.dataflow.qual.Deterministic; import org.checkerframework.dataflow.qual.Pure; import org.checkerframework.dataflow.qual.SideEffectFree; +import org.checkerframework.dataflow.qual.SideEffectsOnly; import org.checkerframework.javacutil.AnnotationProvider; import org.checkerframework.javacutil.ElementUtils; import org.checkerframework.javacutil.TreePathUtil; import org.checkerframework.javacutil.TreeUtils; +import org.checkerframework.javacutil.TypesUtils; import org.plumelib.util.IPair; /** @@ -56,6 +63,12 @@ private PurityChecker() { * * @param statement the statement to check * @param annoProvider the annotation provider + * @param enclosingMethod the method whose body {@code statement} is part of, or null if {@code + * statement} is not a method body. A call to the functional method of one of that method's + * functional-interface parameters has the method's own purity; see {@link + * #isFunctionalInterfaceParameter}. Pass null for a lambda body, which is constrained by the + * functional method that it implements rather than by the enclosing method. + * @param env the processing environment; used only if {@code enclosingMethod} is non-null * @param assumeSideEffectFree true if all methods should be assumed to be @SideEffectFree * @param assumeDeterministic true if all methods should be assumed to be @Deterministic * @param assumePureGetters true if all getter methods should be assumed to be @Pure @@ -65,16 +78,101 @@ private PurityChecker() { public static PurityResult checkPurity( TreePath statement, AnnotationProvider annoProvider, + @Nullable MethodTree enclosingMethod, + @Nullable ProcessingEnvironment env, boolean assumeSideEffectFree, boolean assumeDeterministic, boolean assumePureGetters) { PurityCheckerHelper helper = new PurityCheckerHelper( - annoProvider, assumeSideEffectFree, assumeDeterministic, assumePureGetters); + annoProvider, + enclosingMethod, + env, + assumeSideEffectFree, + assumeDeterministic, + assumePureGetters); helper.scan(statement, null); return helper.purityResult; } + /** + * Returns the kinds of purity that a method promises for the functional method of its + * functional-interface parameters. At a call to {@code method}, each argument passed to a + * functional-interface parameter is required to have those kinds, so within the body the + * parameters may be assumed to have them. + * + * @param annoProvider the annotation provider + * @param method a method or constructor, or null + * @return the purity kinds that {@code method} requires of its functional-interface arguments; + * empty if {@code method} is null or has no purity annotation + */ + public static EnumSet functionalParameterKinds( + AnnotationProvider annoProvider, @Nullable ExecutableElement method) { + if (method == null) { + return EnumSet.noneOf(PurityKind.class); + } + EnumSet result = EnumSet.copyOf(PurityUtils.getPurityKinds(annoProvider, method)); + if (annoProvider.getDeclAnnotation(method, SideEffectsOnly.class) != null) { + // A @SideEffectsOnly method may modify the listed expressions, but the code it is handed + // may not modify anything. + result.add(PurityKind.SIDE_EFFECT_FREE); + } + return result; + } + + /** + * Returns true if {@code expr} is an effectively final formal parameter of {@code method} whose + * type is a functional interface. + * + *

    The expression must be the parameter itself. A local variable that aliases it, a field that + * holds it, or the result of a call does not qualify: only the parameter is known to hold the + * value that the caller supplied. The parameter must be effectively final because a body that + * reassigns it no longer holds that value. + * + * @param expr an expression, or null + * @param method a method or constructor declaration, or null + * @param env the processing environment + * @return true if {@code expr} is an effectively final functional-interface parameter of {@code + * method} + */ + public static boolean isFunctionalInterfaceParameter( + @Nullable ExpressionTree expr, @Nullable MethodTree method, ProcessingEnvironment env) { + if (expr == null + || method == null + || !(TreeUtils.withoutParens(expr) instanceof IdentifierTree id)) { + return false; + } + Element element = TreeUtils.elementFromUse(id); + if (element == null + || element.getKind() != ElementKind.PARAMETER + || element.getEnclosingElement() != TreeUtils.elementFromDeclaration(method) + || !ElementUtils.isEffectivelyFinal(element)) { + return false; + } + TypeMirror type = functionalInterfaceType(element.asType(), env); + return type != null; + } + + /** + * Returns {@code type}, or its upper bound if it is a type variable, if that is a functional + * interface type; otherwise returns null. Use {@link TypesUtils#findFunction} on the result to + * obtain the functional method. + * + * @param type a type + * @param env the processing environment + * @return the functional interface type that {@code type} denotes, or null + */ + public static @Nullable TypeMirror functionalInterfaceType( + TypeMirror type, ProcessingEnvironment env) { + if (type.getKind() == TypeKind.TYPEVAR) { + type = TypesUtils.upperBound(type); + } + if (type.getKind() != TypeKind.DECLARED && type.getKind() != TypeKind.INTERSECTION) { + return null; + } + return TypesUtils.isFunctionalInterface(type, env) ? type : null; + } + /** * Result of the {@link PurityChecker}. Can be queried regarding whether a given tree was * side-effect-free, deterministic, or both; also gives reasons if the answer is "no". @@ -206,6 +304,19 @@ protected static class PurityCheckerHelper extends TreePathScanner { /** The annotation provider (typically an AnnotatedTypeFactory). */ protected final AnnotationProvider annoProvider; + /** The method whose body is being checked, or null if a method body is not being checked. */ + private final @Nullable MethodTree enclosingMethod; + + /** The processing environment; null if {@link #enclosingMethod} is null. */ + private final @Nullable ProcessingEnvironment env; + + /** + * The purity that {@link #enclosingMethod} promises for the functional method of its + * functional-interface parameters. Empty if there is no enclosing method or it has no purity + * annotation, in which case no call gets the assumption. + */ + private final EnumSet functionalParameterKinds; + /** * True if all methods should be assumed to be @SideEffectFree, for the purposes of * org.checkerframework.dataflow analysis. @@ -228,21 +339,67 @@ protected static class PurityCheckerHelper extends TreePathScanner { * Create a PurityCheckerHelper. * * @param annoProvider the annotation provider + * @param enclosingMethod the method whose body is being checked, or null + * @param env the processing environment; used only if {@code enclosingMethod} is non-null * @param assumeSideEffectFree true if all methods should be assumed to be @SideEffectFree * @param assumeDeterministic true if all methods should be assumed to be @Deterministic * @param assumePureGetters true if getter methods should be assumed to be @Pure */ public PurityCheckerHelper( AnnotationProvider annoProvider, + @Nullable MethodTree enclosingMethod, + @Nullable ProcessingEnvironment env, boolean assumeSideEffectFree, boolean assumeDeterministic, boolean assumePureGetters) { this.annoProvider = annoProvider; + this.enclosingMethod = enclosingMethod; + this.env = env; + this.functionalParameterKinds = + enclosingMethod == null + ? EnumSet.noneOf(PurityKind.class) + : functionalParameterKinds( + annoProvider, TreeUtils.elementFromDeclaration(enclosingMethod)); this.assumeSideEffectFree = assumeSideEffectFree; this.assumeDeterministic = assumeDeterministic; this.assumePureGetters = assumePureGetters; } + /** + * Returns true if {@code tree} invokes the functional method of an effectively final + * functional-interface parameter of the method being checked. + * + *

    Such a call has the purity that the method promises, because at every call to the method + * the argument was required to have it. + * + * @param tree a method invocation + * @param invoked the invoked method + * @return true if {@code tree} calls a functional-interface parameter of the enclosing method + */ + private boolean isCallOnFunctionalInterfaceParameter( + MethodInvocationTree tree, ExecutableElement invoked) { + if (functionalParameterKinds.isEmpty()) { + // There is no enclosing method, or it promises nothing. + return false; + } + ProcessingEnvironment env = this.env; + if (env == null) { + return false; + } + ExpressionTree receiver = TreeUtils.getReceiverTree(tree); + if (!isFunctionalInterfaceParameter(receiver, enclosingMethod, env)) { + return false; + } + Element receiverElement = TreeUtils.elementFromUse(receiver); + TypeMirror receiverType = functionalInterfaceType(receiverElement.asType(), env); + ExecutableElement functionalMethod = TypesUtils.findFunction(receiverType, env); + // Only the functional method gets the assumption; a default method such as + // `Function.andThen` does not. + return invoked == functionalMethod + || env.getElementUtils() + .overrides(invoked, functionalMethod, (TypeElement) invoked.getEnclosingElement()); + } + @Override public Void visitCatch(CatchTree tree, Void ignore) { purityResult.addNotDetReason(tree, "catch"); @@ -300,6 +457,10 @@ public Void visitClass(ClassTree tree, Void ignore) { public Void visitMethodInvocation(MethodInvocationTree tree, Void ignore) { ExecutableElement elt = TreeUtils.elementFromUse(tree); EnumSet eltPurityKinds = PurityUtils.getPurityKinds(annoProvider, elt); + if (isCallOnFunctionalInterfaceParameter(tree, elt)) { + eltPurityKinds = EnumSet.copyOf(eltPurityKinds); + eltPurityKinds.addAll(functionalParameterKinds); + } if (!eltPurityKinds.contains(PurityKind.SIDE_EFFECT_FREE) && !eltPurityKinds.contains(PurityKind.DETERMINISTIC)) { // The called method has no purity annotation, so the callee is not pure either. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b5d4ee08440c..529aa4692cb1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,8 +7,25 @@ ### User-visible changes +Under `-AcheckPurityAnnotations`, a method annotated `@SideEffectFree`, +`@Deterministic`, `@Pure`, or `@SideEffectsOnly` may call the functional method +of its own functional-interface parameters. At every call to such a method, +each argument passed to a functional-interface parameter is checked: a lambda's +body must have the callee's purity, a method reference's referenced method must +be declared with it, and any other argument must have it on the functional +method of its declared type. The new error message key is +`purity.functional.argument`. + +This affects existing code that calls an annotated JDK method with a +functional-interface parameter, such as `Optional.map`, `Collections.min`, or +`List.sort`. + ### Changes for type system implementers +`PurityChecker.checkPurity()` takes two new arguments: the method whose body is +being checked (or null, as for a lambda body or an arbitrary expression) and the +processing environment. + `JavaParserUtil`: moved `DEFAULT_LANGUAGE_LEVEL`, `parseCompilationUnit()`, `parseStubUnit()`, and `parseExpression()` into new class `StaticJavaParserUtil`. diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index d34a783dec2d..0c6a9c39a6cf 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1187,7 +1187,13 @@ protected void checkPurityAnnotations(MethodTree tree) { } else { r = PurityChecker.checkPurity( - body, atypeFactory, assumeSideEffectFree, assumeDeterministic, assumePureGetters); + body, + atypeFactory, + tree, + atypeFactory.getProcessingEnv(), + assumeSideEffectFree, + assumeDeterministic, + assumePureGetters); } if (!r.isPure(purityKinds)) { reportPurityErrors(r, purityKinds); @@ -1400,6 +1406,153 @@ protected void checkConstructorResult( } } + /** + * If the invoked method or constructor has a purity annotation, checks each argument that is + * passed to a functional-interface parameter: the code that the argument denotes must have the + * purity that the callee requires of it, because the callee's body is permitted to call it. + * + *

    How the argument is checked depends on its form. The body of a lambda is checked directly. A + * method reference is checked against the declaration of the method it refers to. An effectively + * final functional-interface parameter of the enclosing method needs no check, since the caller + * of that method already performed one. Any other argument is checked against the functional + * method of its declared type. + * + *

    This check is skipped for a requirement that the parameter's own functional method already + * makes, because {@link #checkLambdaPurity} and {@link + * BaseTypeVisitor.OverrideChecker#checkPurity} check that. + * + * @param callee the invoked method or constructor + * @param args the arguments to {@code callee} + */ + protected void checkFunctionalArguments( + ExecutableElement callee, List args) { + if (!checkPurityAnnotations || infer) { + return; + } + EnumSet calleeKinds = PurityChecker.functionalParameterKinds(atypeFactory, callee); + if (calleeKinds.isEmpty()) { + return; + } + ProcessingEnvironment env = atypeFactory.getProcessingEnv(); + List params = callee.getParameters(); + // For a varargs call, the trailing arguments have the component type of the last parameter, + // which is an array and therefore not a functional interface. + int numToCheck = Math.min(params.size(), args.size()); + for (int i = 0; i < numToCheck; i++) { + TypeMirror paramType = PurityChecker.functionalInterfaceType(params.get(i).asType(), env); + if (paramType == null) { + continue; + } + EnumSet required = EnumSet.copyOf(calleeKinds); + required.removeAll( + PurityUtils.getPurityKinds(atypeFactory, TypesUtils.findFunction(paramType, env))); + if (required.isEmpty()) { + continue; + } + checkFunctionalArgument(args.get(i), required, params.get(i), callee); + } + } + + /** + * Checks that one argument to a functional-interface parameter has the given purity. + * + * @param arg the argument + * @param required the purity that the argument's functional method must have + * @param param the parameter that {@code arg} is passed to + * @param callee the invoked method or constructor + */ + protected void checkFunctionalArgument( + ExpressionTree arg, + EnumSet required, + VariableElement param, + ExecutableElement callee) { + ProcessingEnvironment env = atypeFactory.getProcessingEnv(); + ExpressionTree argument = TreeUtils.withoutParens(arg); + + if (argument instanceof LambdaExpressionTree lambda) { + TreePath lambdaPath = new TreePath(getCurrentPath(), argument); + PurityResult r = + PurityChecker.checkPurity( + new TreePath(lambdaPath, lambda.getBody()), + atypeFactory, + null, + null, + assumeSideEffectFree, + assumeDeterministic, + assumePureGetters); + if (!r.isPure(required)) { + reportPurityErrors(r, required); + } + return; + } + + EnumSet argKinds; + if (argument instanceof MemberReferenceTree) { + argKinds = implementationPurityKinds((ExecutableElement) TreeUtils.elementFromUse(argument)); + } else { + TypeMirror argType = PurityChecker.functionalInterfaceType(TreeUtils.typeOf(argument), env); + if (argType == null) { + // The argument is the null literal, or its type is not a functional interface (which + // javac has already reported). There is no functional method to check. + return; + } + argKinds = implementationPurityKinds(TypesUtils.findFunction(argType, env)); + MethodTree enclosingMethod = TreePathUtil.enclosingMethod(getCurrentPath()); + if (PurityChecker.isFunctionalInterfaceParameter(argument, enclosingMethod, env)) { + argKinds.addAll( + PurityChecker.functionalParameterKinds( + atypeFactory, TreeUtils.elementFromDeclaration(enclosingMethod))); + } + } + + if (!argKinds.containsAll(required)) { + checker.reportError( + arg, + "purity.functional.argument", + param.getSimpleName(), + ElementUtils.getSimpleDescription(callee), + purityKindsToString(argKinds), + purityKindsToString(required)); + } + } + + /** + * Returns the purity of a method that implements a functional method: its declared purity, plus + * determinism if it returns no value. + * + * @param method a method or constructor + * @return the purity kinds of {@code method} + */ + private EnumSet implementationPurityKinds(ExecutableElement method) { + EnumSet result = EnumSet.copyOf(PurityUtils.getPurityKinds(atypeFactory, method)); + if (method.getKind() != ElementKind.CONSTRUCTOR + && method.getReturnType().getKind() == TypeKind.VOID) { + result.add(PurityKind.DETERMINISTIC); + } + return result; + } + + /** + * Formats purity kinds for a diagnostic message, as the annotations that a user writes rather + * than as the enum constant names that {@code EnumSet.toString} would produce. + * + * @param purityKinds a set of purity kinds + * @return the annotations corresponding to {@code purityKinds}, space-separated + */ + protected static String purityKindsToString(EnumSet purityKinds) { + if (purityKinds.isEmpty()) { + return "(no side effect annotation)"; + } + StringJoiner result = new StringJoiner(" "); + for (PurityKind purityKind : purityKinds) { + switch (purityKind) { + case SIDE_EFFECT_FREE -> result.add("@SideEffectFree"); + case DETERMINISTIC -> result.add("@Deterministic"); + } + } + return result.toString(); + } + /** * Reports errors found during purity checking. * @@ -2001,6 +2154,7 @@ public Void visitMethodInvocation(MethodInvocationTree tree, Void p) { AnnotatedTypes.adaptParameters(atypeFactory, invokedMethod, tree.getArguments(), tree); checkArguments(params, tree.getArguments(), methodName, method.getParameters()); checkVarargs(invokedMethod, tree); + checkFunctionalArguments(method, tree.getArguments()); if (ElementUtils.isMethod( invokedMethod.getElement(), vectorCopyInto, atypeFactory.getProcessingEnv())) { @@ -2345,6 +2499,7 @@ public Void visitNewClass(NewClassTree tree, Void p) { checkArguments(params, passedArguments, constructorName, constructor.getParameters()); checkVarargs(constructorType, tree); + checkFunctionalArguments(constructor, passedArguments); List paramBounds = CollectionsP.mapList(AnnotatedTypeVariable::getBounds, constructorType.getTypeVariables()); @@ -2437,7 +2592,15 @@ protected void checkLambdaPurity( TreePath body = new TreePath(getCurrentPath(), tree.getBody()); PurityResult r = PurityChecker.checkPurity( - body, atypeFactory, assumeSideEffectFree, assumeDeterministic, assumePureGetters); + body, + atypeFactory, + // A lambda body is constrained by the functional method that the lambda implements, + // not by the method that contains the lambda. + null, + null, + assumeSideEffectFree, + assumeDeterministic, + assumePureGetters); if (needToCheck && !r.isPure(purityKinds)) { reportPurityErrors(r, purityKinds); } @@ -4327,27 +4490,6 @@ private void checkPurity() { } } - /** - * Formats purity kinds for a diagnostic message, as the annotations that a user writes rather - * than as the enum constant names that {@code EnumSet.toString} would produce. - * - * @param purityKinds a set of purity kinds - * @return the annotations corresponding to {@code purityKinds}, space-separated - */ - private String purityKindsToString(EnumSet purityKinds) { - if (purityKinds.isEmpty()) { - return "(no side effect annotation)"; - } - StringJoiner result = new StringJoiner(" "); - for (PurityKind purityKind : purityKinds) { - switch (purityKind) { - case SIDE_EFFECT_FREE -> result.add("@SideEffectFree"); - case DETERMINISTIC -> result.add("@Deterministic"); - } - } - return result.toString(); - } - /** * Checks that overrides obey behavioral subtyping, that is, postconditions must be at least as * strong as the postcondition on the superclass, and preconditions must be at most as strong as diff --git a/framework/src/main/java/org/checkerframework/common/basetype/messages.properties b/framework/src/main/java/org/checkerframework/common/basetype/messages.properties index 681dc1eba032..5bc1a101c427 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/messages.properties +++ b/framework/src/main/java/org/checkerframework/common/basetype/messages.properties @@ -80,6 +80,7 @@ purity.not.sideeffectfree.call=call to side-effecting %s not allowed in side-eff purity.more.deterministic=the method %s could be declared as @Deterministic purity.more.pure=the method %s could be declared as @Pure purity.more.sideeffectfree=the method %s could be declared as @SideEffectFree +purity.functional.argument=incompatible purity of argument to parameter %s of %s%n found: %s%n required: %s purity.effectively.pure=a method declared both @SideEffectFree and @Deterministic can instead be declared @Pure purity.viewpoint.adaptation=@SideEffectsOnly(..., "%s") annotation (here, it refers to %s) on the declaration of %s is ignored because expression %s on the declaration of %s is interpreted as %s at this call site, which might have any effect diff --git a/framework/src/main/java/org/checkerframework/framework/flow/CFAbstractTransfer.java b/framework/src/main/java/org/checkerframework/framework/flow/CFAbstractTransfer.java index fa5c1bbc3cf3..308152a9350f 100644 --- a/framework/src/main/java/org/checkerframework/framework/flow/CFAbstractTransfer.java +++ b/framework/src/main/java/org/checkerframework/framework/flow/CFAbstractTransfer.java @@ -477,6 +477,10 @@ private boolean isExpressionOrStatementPure( PurityChecker.checkPurity( expressionOrStatement, aTypeFactory, + // This is an arbitrary expression, not a method body, so no method's parameters are + // known to hold side-effect-free values. + null, + null, isAssumeSideEffectFreeEnabled, isAssumeDeterministicEnabled, aTypeFactory.getChecker().hasOption("assumePureGetters")); diff --git a/framework/tests/all-systems/Issue6874.java b/framework/tests/all-systems/Issue6874.java index cc3c0d03ae66..0bbb858f7376 100644 --- a/framework/tests/all-systems/Issue6874.java +++ b/framework/tests/all-systems/Issue6874.java @@ -5,6 +5,10 @@ import java.util.Map; public class Issue6874 { + // Collections.min is @Pure, and Comparator.compare is not annotated, so passing `x` is a true + // positive under -AcheckPurityAnnotations. This test is not about purity; the expected error is + // asserted in framework/tests/flow/PurityFunctionalArgumentJdk.java. + @SuppressWarnings("purity") public static void test(Comparator x, Collection y, Collection z) { java.util.Collections.min((true) ? y : z, x); } diff --git a/framework/tests/all-systems/Issue6891.java b/framework/tests/all-systems/Issue6891.java index c2c7cbc8313f..80b647c655c0 100644 --- a/framework/tests/all-systems/Issue6891.java +++ b/framework/tests/all-systems/Issue6891.java @@ -8,7 +8,10 @@ public class Issue6891 { - @SuppressWarnings("optional") // true positives, but this test is not about Optional + // Optional.map is @SideEffectFree, whose declaration comments that "the mapper must not have + // side effects", so the mapper below is a true positive under -AcheckPurityAnnotations. The + // expected error is asserted in framework/tests/flow/PurityFunctionalArgumentJdk.java. + @SuppressWarnings({"optional", "purity"}) // true positives, but this test is not about them private Map func(Set entities) { return Optional.ofNullable(entities) diff --git a/framework/tests/flow/PurityFunctionalArgument.java b/framework/tests/flow/PurityFunctionalArgument.java new file mode 100644 index 000000000000..57eb6d98cfad --- /dev/null +++ b/framework/tests/flow/PurityFunctionalArgument.java @@ -0,0 +1,137 @@ +// At a call to a @SideEffectFree method, every argument passed to a functional-interface parameter +// must have a side-effect-free functional method: +// +// * a lambda: its body must be side-effect-free; +// * a method reference: the referenced method must be @SideEffectFree; +// * a functional-interface parameter of an enclosing @SideEffectFree method: the caller of that +// method has already discharged the obligation; +// * anything else: checked against the functional method of the argument's declared type. +// +// Each call site is in an unannotated method, so the only diagnostic a call site can produce is the +// one under test. The callees do not call their parameters; that is tested in +// PurityFunctionalParameter.java. + +import java.util.function.Function; +import org.checkerframework.dataflow.qual.SideEffectFree; + +public class PurityFunctionalArgument { + + int count = 0; + + /** A functional interface whose functional method is annotated. */ + @FunctionalInterface + interface PureFunction extends Function { + @Override + @SideEffectFree + R apply(T t); + } + + @SideEffectFree + static int callee(Function f, String s) { + return 0; + } + + static int unannotatedCallee(Function f, String s) { + return 0; + } + + @SideEffectFree + int pureLength(String s) { + return s.length(); + } + + int impureLength(String s) { + count++; + return s.length(); + } + + Function unannotatedVariable = s -> s.length(); + + PureFunction annotatedVariable = s -> s.length(); + + Function lengthFunction() { + return s -> s.length(); + } + + PureFunction pureLengthFunction() { + return s -> s.length(); + } + + // A lambda argument's body is checked directly. + + void lambdaArguments(String s) { + callee(t -> t.length(), s); + // :: error: [purity.not.sideeffectfree.assign.field] + callee(t -> count++, s); + // :: error: [purity.not.sideeffectfree.call] + callee(t -> impureLength(t), s); + callee( + t -> { + int local = 0; + local++; + return local; + }, + s); + } + + // The method that a method reference refers to must be @SideEffectFree. + + void methodReferenceArguments(String s) { + callee(this::pureLength, s); + // :: error: [purity.functional.argument] + callee(this::impureLength, s); + callee(String::length, s); + } + + // Any other argument is checked against the functional method of its declared type. + + void otherArguments(String s) { + // :: error: [purity.functional.argument] + callee(unannotatedVariable, s); + callee(annotatedVariable, s); + // :: error: [purity.functional.argument] + callee(lengthFunction(), s); + callee(pureLengthFunction(), s); + } + + // A functional-interface parameter may be passed onward from a @SideEffectFree method, but not + // from a method that makes no promise about what it was given. + + @SideEffectFree + int passesParameterOnward(Function f, String s) { + return callee(f, s); + } + + int unannotatedMethodPassesParameterOnward(Function f, String s) { + // :: error: [purity.functional.argument] + return callee(f, s); + } + + // An unannotated callee imposes no obligation on its arguments. + + void unannotatedCalleeTakesAnything(String s) { + unannotatedCallee(t -> count++, s); + unannotatedCallee(this::impureLength, s); + unannotatedCallee(unannotatedVariable, s); + } + + // An argument that is not of functional-interface type is unaffected. + + @SideEffectFree + static int ordinaryParameters(String s, int i) { + return i; + } + + void ordinaryArguments(String s) { + ordinaryParameters(s, count); + } + + // A method reference assigned to an annotated functional interface is checked against its + // functional method, wherever the assignment appears. + + void methodReferenceAgainstAnnotatedSam() { + PureFunction ok = this::pureLength; + // :: error: [purity.methodref] + PureFunction bad = this::impureLength; + } +} diff --git a/framework/tests/flow/PurityFunctionalArgumentJdk.java b/framework/tests/flow/PurityFunctionalArgumentJdk.java new file mode 100644 index 000000000000..873002bba4bf --- /dev/null +++ b/framework/tests/flow/PurityFunctionalArgumentJdk.java @@ -0,0 +1,64 @@ +// The requirement on a functional-interface argument applies to the annotated JDK's methods, which +// is where most calls to a side-effect-free method with a functional-interface parameter occur. +// +// The requirement holds at every call site, whatever the purity of the method that contains it. + +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import org.checkerframework.dataflow.qual.Pure; + +public class PurityFunctionalArgumentJdk { + + int count = 0; + + /** + * Collections.min is @Pure, so a comparator passed to it must be @Pure, not merely pure of side + * effects. + */ + @FunctionalInterface + interface PureComparator extends Comparator { + @Override + @Pure + int compare(T a, T b); + } + + // Optional.map is @SideEffectFree: "the mapper must not have side effects". + + void optionalMap(Optional o) { + o.map(s -> s.length()); + // :: error: [purity.not.sideeffectfree.assign.field] + o.map(s -> count++); + } + + // Collections.min is @Pure and List.sort is @SideEffectsOnly("this"), so each requires a + // side-effect-free comparator. Comparator.compare is not annotated, so a value of that type + // does not have one. + + void comparatorArguments(Collection c, List l, Comparator comparator) { + // :: error: [purity.functional.argument] + java.util.Collections.min(c, comparator); + // :: error: [purity.functional.argument] + l.sort(comparator); + } + + /** Declaring the purity in the comparator's own type is what makes such a value passable. */ + void pureComparatorArguments(Collection c, List l, PureComparator pure) { + java.util.Collections.min(c, pure); + l.sort(pure); + } + + /** A comparator written at the call site is checked directly, and this one is pure. */ + void comparatorWrittenHere(List l) { + l.sort((a, b) -> a.length() - b.length()); + // :: error: [purity.not.sideeffectfree.assign.field] + l.sort((a, b) -> count++); + } + + /** An unannotated JDK method imposes nothing: Stream.map is not annotated. */ + void unannotatedJdkMethod(Stream s) { + s.map(t -> count++); + } +} diff --git a/framework/tests/flow/PurityFunctionalArgumentKinds.java b/framework/tests/flow/PurityFunctionalArgumentKinds.java new file mode 100644 index 000000000000..5613b14e8087 --- /dev/null +++ b/framework/tests/flow/PurityFunctionalArgumentKinds.java @@ -0,0 +1,100 @@ +// The requirement on an argument passed to a functional-interface parameter matches the purity of +// the callee: a @Pure method's functional arguments must be @Pure, a @Deterministic method's must +// be @Deterministic, and a @SideEffectFree method's must be @SideEffectFree. +// +// Each call site is in an unannotated method, so the only diagnostic a call site can produce is the +// one under test. + +import java.util.function.Function; +import org.checkerframework.dataflow.qual.Deterministic; +import org.checkerframework.dataflow.qual.Pure; +import org.checkerframework.dataflow.qual.SideEffectFree; + +public class PurityFunctionalArgumentKinds { + + int count = 0; + + @Pure + static int pureCallee(Function f, String s) { + return 0; + } + + @Deterministic + static int deterministicCallee(Function f, String s) { + return 0; + } + + @SideEffectFree + static int sideEffectFreeCallee(Function f, String s) { + return 0; + } + + @Pure + int pureMethod(String s) { + return 1; + } + + @SideEffectFree + int sideEffectFreeOnly(String s) { + return count; + } + + @Deterministic + int deterministicOnly(String s) { + return 1; + } + + // A @Pure or @Deterministic method may call the functional method of its own + // functional-interface parameters. + + @Pure + int pureCallsParameter(Function f, String s) { + return f.apply(s); + } + + @Deterministic + int deterministicCallsParameter(Function f, String s) { + return f.apply(s); + } + + // A lambda argument's body is checked against the callee's purity. + + void lambdaArgumentsToPureCallee(String s) { + pureCallee(t -> 1, s); + // :: error: [purity.not.deterministic.not.sideeffectfree.assign.field] + pureCallee(t -> count++, s); + // :: error: [purity.not.deterministic.object.creation] + pureCallee(t -> new String("x").length(), s); + } + + /** A @SideEffectFree callee constrains side effects only. */ + void lambdaArgumentsToSideEffectFreeCallee(String s) { + sideEffectFreeCallee(t -> new String("x").length(), s); + // :: error: [purity.not.sideeffectfree.assign.field] + sideEffectFreeCallee(t -> count++, s); + } + + /** A @Deterministic callee constrains determinism only. */ + void lambdaArgumentsToDeterministicCallee(String s) { + deterministicCallee(t -> deterministicOnly(t), s); + // :: error: [purity.not.deterministic.object.creation] + deterministicCallee(t -> new String("x").length(), s); + // The same lambda is not side-effect-free, so it is rejected for a @SideEffectFree callee. + // :: error: [purity.not.sideeffectfree.call] + sideEffectFreeCallee(t -> deterministicOnly(t), s); + } + + // A method reference's annotations must cover the callee's. + + void methodReferenceArguments(String s) { + pureCallee(this::pureMethod, s); + // :: error: [purity.functional.argument] + pureCallee(this::sideEffectFreeOnly, s); + // :: error: [purity.functional.argument] + pureCallee(this::deterministicOnly, s); + sideEffectFreeCallee(this::sideEffectFreeOnly, s); + deterministicCallee(this::deterministicOnly, s); + // :: error: [purity.functional.argument] + deterministicCallee(this::sideEffectFreeOnly, s); + } +} diff --git a/framework/tests/flow/PurityFunctionalConstructor.java b/framework/tests/flow/PurityFunctionalConstructor.java new file mode 100644 index 000000000000..d962ac9ed409 --- /dev/null +++ b/framework/tests/flow/PurityFunctionalConstructor.java @@ -0,0 +1,39 @@ +// A constructor may be annotated @SideEffectFree, so the obligation on an argument passed to a +// functional-interface parameter is checked at a `new` expression too. +// +// Each call site is in an unannotated method, so the only diagnostic a call site can produce is the +// one under test. + +import java.util.function.Function; +import org.checkerframework.dataflow.qual.SideEffectFree; + +public class PurityFunctionalConstructor { + + int count = 0; + + static class Holder { + Function held; + + @SideEffectFree + Holder(Function f) { + this.held = f; + } + } + + static class UnannotatedHolder { + UnannotatedHolder(Function f) {} + } + + void constructorCallSites() { + new Holder(t -> t.length()); + // :: error: [purity.not.sideeffectfree.assign.field] + new Holder(t -> count++); + new UnannotatedHolder(t -> count++); + } + + /** A functional-interface parameter may be passed onward to a constructor. */ + @SideEffectFree + Holder passesParameterToConstructor(Function f) { + return new Holder(f); + } +} diff --git a/framework/tests/flow/PurityFunctionalOverride.java b/framework/tests/flow/PurityFunctionalOverride.java new file mode 100644 index 000000000000..ea4f0e65bb20 --- /dev/null +++ b/framework/tests/flow/PurityFunctionalOverride.java @@ -0,0 +1,62 @@ +// The obligation on the arguments of a @SideEffectFree method with a functional-interface +// parameter is inherited by an override, and a call site is checked against the declaration of its +// static target. +// +// Each call site is in an unannotated method, so the only diagnostic a call site can produce is the +// one under test. + +import java.util.function.Function; +import org.checkerframework.dataflow.qual.SideEffectFree; + +public class PurityFunctionalOverride { + + int count = 0; + + static class Super { + @SideEffectFree + int m(Function f, String s) { + return 0; + } + } + + static class Sub extends Super { + /** + * @SideEffectFree is inherited, so this body is checked as side-effect-free. + */ + @Override + int m(Function f, String s) { + return f.apply(s); + } + } + + static class SubRepeatsAnnotation extends Super { + @Override + @SideEffectFree + int m(Function f, String s) { + return 0; + } + } + + void callsThroughSupertype(Super receiver, String s) { + receiver.m(t -> t.length(), s); + // :: error: [purity.not.sideeffectfree.assign.field] + receiver.m(t -> count++, s); + } + + void callsThroughSubtype(Sub receiver, String s) { + receiver.m(t -> t.length(), s); + // :: error: [purity.not.sideeffectfree.assign.field] + receiver.m(t -> count++, s); + } + + /** An unrelated unannotated method imposes nothing on its arguments. */ + static class Unrelated { + int m(Function f, String s) { + return 0; + } + } + + void callsUnannotated(Unrelated receiver, String s) { + receiver.m(t -> count++, s); + } +} diff --git a/framework/tests/flow/PurityFunctionalParameter.java b/framework/tests/flow/PurityFunctionalParameter.java new file mode 100644 index 000000000000..789cc630bc8a --- /dev/null +++ b/framework/tests/flow/PurityFunctionalParameter.java @@ -0,0 +1,118 @@ +// A @SideEffectFree method may call the functional method of one of its own functional-interface +// parameters; that call is side-effect-free. +// +// The receiver must be the parameter itself. A local that aliases the parameter, a field, or the +// result of a call does not get the assumption, and neither does a method of the parameter's type +// other than the functional method. + +import java.util.function.Function; +import java.util.function.Supplier; +import org.checkerframework.dataflow.qual.SideEffectFree; + +public class PurityFunctionalParameter { + + int count = 0; + + @FunctionalInterface + interface PureFunc { + @SideEffectFree + String get(); + } + + Function field = s -> s.length(); + + PureFunc pureField = () -> ""; + + @SideEffectFree + Function lengthFunction() { + return s -> s.length(); + } + + // The receiver is one of this method's own parameters. + + @SideEffectFree + int callsParameter(Function f, String s) { + return f.apply(s); + } + + @SideEffectFree + int callsTwoParameters(Function f, Function g, String s) { + int a = f.apply(s); + int b = g.apply(s); + return a + b; + } + + @SideEffectFree + String callsSupplierParameter(Supplier supplier) { + return supplier.get(); + } + + /** A parameter whose type is a type variable bounded by a functional interface. */ + @SideEffectFree + > int callsTypeVariableParameter(T f, String s) { + return f.apply(s); + } + + // Only the parameter itself is a legal receiver. + + @SideEffectFree + int callsField(String s) { + // :: error: [purity.not.sideeffectfree.call] + return field.apply(s); + } + + @SideEffectFree + int callsLocalAlias(Function f, String s) { + Function g = f; + // :: error: [purity.not.sideeffectfree.call] + return g.apply(s); + } + + @SideEffectFree + int callsCallResult(String s) { + // :: error: [purity.not.sideeffectfree.call] + return lengthFunction().apply(s); + } + + // A functional interface whose functional method is annotated is side-effect-free wherever it is + // called, so these need no assumption about the receiver. + + @SideEffectFree + String annotatedSamField() { + return pureField.get(); + } + + @SideEffectFree + String annotatedSamLocalAlias(PureFunc p) { + PureFunc q = p; + return q.get(); + } + + @SideEffectFree + String annotatedSamTypeVariable(T t) { + return t.get(); + } + + // Only the functional method is assumed side-effect-free. + + @SideEffectFree + Function callsNonFunctionalMethod( + Function f, Function g) { + // :: error: [purity.not.sideeffectfree.call] + return f.andThen(g); + } + + /** The rest of the body is checked as usual. */ + @SideEffectFree + int otherEffectsAreReported(Function f, String s) { + // :: error: [purity.not.sideeffectfree.assign.field] + count++; + return f.apply(s); + } + + /** An unannotated method promises nothing, so nothing in its body is checked. */ + int unannotatedEnclosingMethod(Function f, String s) { + count++; + return f.apply(s); + } +} From 4ab023195072db4e1c2bd003293d9833593d0c19 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Wed, 16 Sep 2026 14:05:57 -0700 Subject: [PATCH 07/43] Fix holes and false positives in the functional-argument check A lambda's parameters have the enclosing method as their enclosing element, so testing that was not enough to identify a parameter of the method being checked; test membership in its parameter list. A lambda's parameter is checked at no call site, so passing one on was unsound. An argument's type may be a class, including an anonymous class, that implements the parameter's functional interface. Check the class's implementation of the functional method, which is the code that will run; before, no check was made at all. An anonymous class's constructor is synthetic and carries no annotation, so `new C(arg) {}` skipped the check that `new C(arg)` performs. Use TreeUtils.getSuperConstructor. A functional method that returns no value is deterministic, whatever implements it, so do not demand determinism of a lambda in that position while granting it to a method reference. An array constructor reference such as String[]::new creates an array, which modifies nothing, and cannot be annotated; treat it as side-effect-free. A lambda body may call the enclosing method's functional-interface parameters: the caller of that method was required to supply side-effect-free values, and that remains true whenever the lambda runs. Both lambda-body checks now pass the enclosing method, so that `o.map(s -> f.apply(s))` is accepted where `o.map(f)` is. Co-Authored-By: Claude Opus 5 --- .../dataflow/util/PurityChecker.java | 24 ++++- .../common/basetype/BaseTypeVisitor.java | 101 +++++++++++++++--- .../tests/flow/PurityFunctionalArgument.java | 85 +++++++++++++++ .../flow/PurityFunctionalArgumentKinds.java | 24 +++++ .../flow/PurityFunctionalConstructor.java | 7 ++ .../tests/flow/PurityFunctionalParameter.java | 13 +++ 6 files changed, 236 insertions(+), 18 deletions(-) diff --git a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java index 676d2bc47f03..adec2eebfd1a 100644 --- a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java +++ b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java @@ -14,6 +14,7 @@ import com.sun.source.tree.ThrowTree; import com.sun.source.tree.Tree; import com.sun.source.tree.UnaryTree; +import com.sun.source.tree.VariableTree; import com.sun.source.util.TreePath; import com.sun.source.util.TreePathScanner; import java.util.ArrayList; @@ -145,14 +146,35 @@ public static boolean isFunctionalInterfaceParameter( Element element = TreeUtils.elementFromUse(id); if (element == null || element.getKind() != ElementKind.PARAMETER - || element.getEnclosingElement() != TreeUtils.elementFromDeclaration(method) || !ElementUtils.isEffectivelyFinal(element)) { return false; } + // Test membership in the parameter list rather than the enclosing element, because javac + // gives a lambda's parameters the enclosing method as their enclosing element. A lambda's + // parameter is not checked at any call site. + if (!isParameterOf(element, method)) { + return false; + } TypeMirror type = functionalInterfaceType(element.asType(), env); return type != null; } + /** + * Returns true if {@code element} is one of {@code method}'s formal parameters. + * + * @param element an element + * @param method a method or constructor declaration + * @return true if {@code element} is a formal parameter of {@code method} + */ + private static boolean isParameterOf(Element element, MethodTree method) { + for (VariableTree parameter : method.getParameters()) { + if (TreeUtils.elementFromDeclaration(parameter) == element) { + return true; + } + } + return false; + } + /** * Returns {@code type}, or its upper bound if it is a type variable, if that is a functional * interface type; otherwise returns null. Use {@link TypesUtils#findFunction} on the result to diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index 0c6a9c39a6cf..f6c0e7d70c60 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -76,6 +76,7 @@ import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; import javax.lang.model.util.ElementFilter; +import javax.lang.model.util.Elements; import javax.tools.Diagnostic; import org.checkerframework.checker.compilermsgs.qual.CompilerMessageKey; import org.checkerframework.checker.interning.qual.FindDistinct; @@ -1443,13 +1444,17 @@ protected void checkFunctionalArguments( if (paramType == null) { continue; } + ExecutableElement paramFunction = TypesUtils.findFunction(paramType, env); EnumSet required = EnumSet.copyOf(calleeKinds); - required.removeAll( - PurityUtils.getPurityKinds(atypeFactory, TypesUtils.findFunction(paramType, env))); + required.removeAll(PurityUtils.getPurityKinds(atypeFactory, paramFunction)); + if (paramFunction.getReturnType().getKind() == TypeKind.VOID) { + // A functional method that returns no value is deterministic, whatever implements it. + required.remove(PurityKind.DETERMINISTIC); + } if (required.isEmpty()) { continue; } - checkFunctionalArgument(args.get(i), required, params.get(i), callee); + checkFunctionalArgument(args.get(i), required, paramFunction, params.get(i), callee); } } @@ -1458,12 +1463,14 @@ protected void checkFunctionalArguments( * * @param arg the argument * @param required the purity that the argument's functional method must have + * @param paramFunction the functional method of the parameter's type * @param param the parameter that {@code arg} is passed to * @param callee the invoked method or constructor */ protected void checkFunctionalArgument( ExpressionTree arg, EnumSet required, + ExecutableElement paramFunction, VariableElement param, ExecutableElement callee) { ProcessingEnvironment env = atypeFactory.getProcessingEnv(); @@ -1475,8 +1482,11 @@ protected void checkFunctionalArgument( PurityChecker.checkPurity( new TreePath(lambdaPath, lambda.getBody()), atypeFactory, - null, - null, + // The body may call the enclosing method's functional-interface parameters: they + // hold values that the caller of that method was required to check, whenever the + // lambda runs. + TreePathUtil.enclosingMethod(getCurrentPath()), + env, assumeSideEffectFree, assumeDeterministic, assumePureGetters); @@ -1487,16 +1497,24 @@ protected void checkFunctionalArgument( } EnumSet argKinds; - if (argument instanceof MemberReferenceTree) { - argKinds = implementationPurityKinds((ExecutableElement) TreeUtils.elementFromUse(argument)); + if (argument instanceof MemberReferenceTree memberReference) { + if (isArrayConstructorReference(memberReference)) { + // Creating an array modifies nothing that exists before the call. It is not + // deterministic, like any object creation. + argKinds = EnumSet.of(PurityKind.SIDE_EFFECT_FREE); + } else { + argKinds = + implementationPurityKinds((ExecutableElement) TreeUtils.elementFromUse(argument)); + } } else { - TypeMirror argType = PurityChecker.functionalInterfaceType(TreeUtils.typeOf(argument), env); - if (argType == null) { - // The argument is the null literal, or its type is not a functional interface (which - // javac has already reported). There is no functional method to check. + ExecutableElement argFunction = + functionalMethodOf(TreeUtils.typeOf(argument), paramFunction, env); + if (argFunction == null) { + // The argument is the null literal, or its type does not implement the parameter's + // functional method (which javac has already reported). There is nothing to check. return; } - argKinds = implementationPurityKinds(TypesUtils.findFunction(argType, env)); + argKinds = implementationPurityKinds(argFunction); MethodTree enclosingMethod = TreePathUtil.enclosingMethod(getCurrentPath()); if (PurityChecker.isFunctionalInterfaceParameter(argument, enclosingMethod, env)) { argKinds.addAll( @@ -1516,6 +1534,52 @@ protected void checkFunctionalArgument( } } + /** + * Returns the method that {@code type} supplies for the functional method {@code paramFunction}, + * or null if it has none. + * + *

    This is {@code paramFunction} itself when {@code type} is the functional interface, but an + * argument may also be of a class type, including an anonymous class, that implements the + * interface. The implementation is what will run, so its annotations are what matter. + * + * @param type the type of an argument + * @param paramFunction the functional method of the parameter's type + * @param env the processing environment + * @return the method of {@code type} that implements {@code paramFunction}, or null + */ + private @Nullable ExecutableElement functionalMethodOf( + TypeMirror type, ExecutableElement paramFunction, ProcessingEnvironment env) { + TypeMirror functionalType = PurityChecker.functionalInterfaceType(type, env); + if (functionalType != null) { + return TypesUtils.findFunction(functionalType, env); + } + if (type.getKind() == TypeKind.TYPEVAR) { + type = TypesUtils.upperBound(type); + } + TypeElement typeElement = TypesUtils.getTypeElement(type); + if (typeElement == null) { + return null; + } + Elements elements = env.getElementUtils(); + for (ExecutableElement method : ElementFilter.methodsIn(elements.getAllMembers(typeElement))) { + if (method.equals(paramFunction) || elements.overrides(method, paramFunction, typeElement)) { + return method; + } + } + return null; + } + + /** + * Returns true if the given method reference creates an array, as in {@code String[]::new}. + * + * @param tree a method reference + * @return true if {@code tree} refers to an array constructor + */ + private static boolean isArrayConstructorReference(MemberReferenceTree tree) { + return tree.getMode() == MemberReferenceTree.ReferenceMode.NEW + && TreeUtils.withoutParens(tree.getQualifierExpression()) instanceof ArrayTypeTree; + } + /** * Returns the purity of a method that implements a functional method: its declared purity, plus * determinism if it returns no value. @@ -2499,7 +2563,9 @@ public Void visitNewClass(NewClassTree tree, Void p) { checkArguments(params, passedArguments, constructorName, constructor.getParameters()); checkVarargs(constructorType, tree); - checkFunctionalArguments(constructor, passedArguments); + // For an anonymous class, the arguments are passed to the super constructor; the anonymous + // class's own constructor is synthetic and carries no annotation. + checkFunctionalArguments(TreeUtils.getSuperConstructor(tree), passedArguments); List paramBounds = CollectionsP.mapList(AnnotatedTypeVariable::getBounds, constructorType.getTypeVariables()); @@ -2594,10 +2660,11 @@ protected void checkLambdaPurity( PurityChecker.checkPurity( body, atypeFactory, - // A lambda body is constrained by the functional method that the lambda implements, - // not by the method that contains the lambda. - null, - null, + // The functional method that the lambda implements constrains the body, but the + // enclosing method's functional-interface parameters still hold values that its + // caller was required to check, whenever the lambda runs. + TreePathUtil.enclosingMethod(getCurrentPath()), + atypeFactory.getProcessingEnv(), assumeSideEffectFree, assumeDeterministic, assumePureGetters); diff --git a/framework/tests/flow/PurityFunctionalArgument.java b/framework/tests/flow/PurityFunctionalArgument.java index 57eb6d98cfad..837901bb5c9d 100644 --- a/framework/tests/flow/PurityFunctionalArgument.java +++ b/framework/tests/flow/PurityFunctionalArgument.java @@ -11,7 +11,9 @@ // one under test. The callees do not call their parameters; that is tested in // PurityFunctionalParameter.java. +import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.IntFunction; import org.checkerframework.dataflow.qual.SideEffectFree; public class PurityFunctionalArgument { @@ -126,6 +128,89 @@ void ordinaryArguments(String s) { ordinaryParameters(s, count); } + // An argument whose type is a class that implements the functional interface is checked against + // the class's implementation of the functional method, which is the code that will run. + + static class ImpureFunction implements Function { + int n = 0; + + @Override + public Integer apply(String s) { + return n++; + } + } + + static class SideEffectFreeFunction implements Function { + @Override + @SideEffectFree + public Integer apply(String s) { + return s.length(); + } + } + + void classTypedArguments(ImpureFunction impure, SideEffectFreeFunction pure, String s) { + // :: error: [purity.functional.argument] + callee(impure, s); + callee(pure, s); + } + + void anonymousClassArguments(String s) { + callee( + // :: error: [purity.functional.argument] + new Function() { + @Override + public Integer apply(String t) { + return count++; + } + }, + s); + callee( + new Function() { + @Override + @SideEffectFree + public Integer apply(String t) { + return t.length(); + } + }, + s); + } + + // A lambda's body may call a functional-interface parameter of the enclosing method, which holds + // a value that the caller of that method was required to check, whenever the lambda runs. + + @SideEffectFree + int lambdaCallsEnclosingParameter(Function f, String s) { + return callee(t -> f.apply(t), s); + } + + int unannotatedMethodsLambdaCallsParameter(Function f, String s) { + // :: error: [purity.not.sideeffectfree.call] + return callee(t -> f.apply(t), s); + } + + /** A lambda's own parameter is checked at no call site, so passing it on is not permitted. */ + @SideEffectFree + void lambdaParameterIsNotDischarged(Function f, String s) { + Consumer> c = + g -> { + // :: error: [purity.functional.argument] + callee(g, s); + }; + // :: error: [purity.not.sideeffectfree.call] + c.accept(f); + } + + // An array constructor cannot be annotated, and creating an array modifies nothing. + + @SideEffectFree + static String[] arrayCallee(IntFunction generator) { + return generator.apply(0); + } + + String[] arrayConstructorReference() { + return arrayCallee(String[]::new); + } + // A method reference assigned to an annotated functional interface is checked against its // functional method, wherever the assignment appears. diff --git a/framework/tests/flow/PurityFunctionalArgumentKinds.java b/framework/tests/flow/PurityFunctionalArgumentKinds.java index 5613b14e8087..f372375d4894 100644 --- a/framework/tests/flow/PurityFunctionalArgumentKinds.java +++ b/framework/tests/flow/PurityFunctionalArgumentKinds.java @@ -84,6 +84,30 @@ void lambdaArgumentsToDeterministicCallee(String s) { sideEffectFreeCallee(t -> deterministicOnly(t), s); } + // A functional method that returns no value is deterministic, whatever implements it, so a @Pure + // callee requires only side-effect-freeness of it. + + @Pure + static int pureRunnableCallee(Runnable r) { + return 0; + } + + @SideEffectFree + void sideEffectFreeVoid() {} + + void impureVoid() { + count++; + } + + void voidFunctionalMethod() { + pureRunnableCallee(() -> sideEffectFreeVoid()); + pureRunnableCallee(this::sideEffectFreeVoid); + // :: error: [purity.not.sideeffectfree.call] + pureRunnableCallee(() -> impureVoid()); + // :: error: [purity.functional.argument] + pureRunnableCallee(this::impureVoid); + } + // A method reference's annotations must cover the callee's. void methodReferenceArguments(String s) { diff --git a/framework/tests/flow/PurityFunctionalConstructor.java b/framework/tests/flow/PurityFunctionalConstructor.java index d962ac9ed409..a27f95110cf4 100644 --- a/framework/tests/flow/PurityFunctionalConstructor.java +++ b/framework/tests/flow/PurityFunctionalConstructor.java @@ -31,6 +31,13 @@ void constructorCallSites() { new UnannotatedHolder(t -> count++); } + /** An anonymous class's arguments are passed to the super constructor. */ + void anonymousSubclass() { + new Holder(t -> t.length()) {}; + // :: error: [purity.not.sideeffectfree.assign.field] + new Holder(t -> count++) {}; + } + /** A functional-interface parameter may be passed onward to a constructor. */ @SideEffectFree Holder passesParameterToConstructor(Function f) { diff --git a/framework/tests/flow/PurityFunctionalParameter.java b/framework/tests/flow/PurityFunctionalParameter.java index 789cc630bc8a..78499f9e009d 100644 --- a/framework/tests/flow/PurityFunctionalParameter.java +++ b/framework/tests/flow/PurityFunctionalParameter.java @@ -110,6 +110,19 @@ int otherEffectsAreReported(Function f, String s) { return f.apply(s); } + // A lambda's body may call a functional-interface parameter of the enclosing method, which + // holds a value that the caller of that method was required to check, whenever the lambda runs. + + @SideEffectFree + PureFunc lambdaCallsParameter(Function f) { + return () -> "" + f.apply("x"); + } + + PureFunc unannotatedMethodsLambdaCallsParameter(Function f) { + // :: error: [purity.not.sideeffectfree.call] + return () -> "" + f.apply("x"); + } + /** An unannotated method promises nothing, so nothing in its body is checked. */ int unannotatedEnclosingMethod(Function f, String s) { count++; From 92e3d235f9d6b24d88df9edfeeb9f2a72f23dae6 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Wed, 16 Sep 2026 16:00:11 -0700 Subject: [PATCH 08/43] Check each result expression of a conditional or switch argument The type of a conditional or switch expression in an invocation context is the parameter's type, so checking the argument against its declared type checked the parameter's own functional method, which says nothing about the code that any result expression denotes. Two pure lambdas in a conditional were reported as impure. Check each result expression on its own instead. Also: * Do not compute the super constructor of a `new` expression unless purity checking is on; for an anonymous class it searches the synthetic constructor for the super call. * Correct the `checkPurity` javadoc and the CHANGELOG: both lambda-body call sites pass the enclosing method, not null. The lambda's body is checked against the functional method it implements, but the enclosing method's parameters still hold values that its caller was required to check. * Use `elementFromTree` in `isFunctionalInterfaceParameter`; `elementFromUse` throws rather than returning null, so the null guard was dead. Extracting `functionalInterfaceParameterType` also removes a duplicate functional interface lookup on every call in a purity-annotated method. Co-Authored-By: Claude Opus 5 --- .../dataflow/util/PurityChecker.java | 46 ++++++++----- docs/CHANGELOG.md | 6 +- .../common/basetype/BaseTypeVisitor.java | 40 ++++++++++-- .../tests/flow/PurityFunctionalArgument.java | 64 +++++++++++++++++++ 4 files changed, 131 insertions(+), 25 deletions(-) diff --git a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java index 3916d269bc48..71e65f3bf8de 100644 --- a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java +++ b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java @@ -65,11 +65,13 @@ private PurityChecker() { * * @param statement the statement to check * @param annoProvider the annotation provider - * @param enclosingMethod the method whose body {@code statement} is part of, or null if {@code - * statement} is not a method body. A call to the functional method of one of that method's + * @param enclosingMethod the method declaration that lexically encloses {@code statement}, or + * null if none does. A call to the functional method of one of that method's * functional-interface parameters has the method's own purity; see {@link - * #isFunctionalInterfaceParameter}. Pass null for a lambda body, which is constrained by the - * functional method that it implements rather than by the enclosing method. + * #isFunctionalInterfaceParameter}. Pass the enclosing method for a lambda body too: the body + * is checked against the functional method that the lambda implements, but the enclosing + * method's parameters still hold values that its caller was required to check, whenever the + * lambda runs. Pass null for an arbitrary expression, which no method's contract governs. * @param env the processing environment; used only if {@code enclosingMethod} is non-null * @param assumeSideEffectFree true if all methods should be assumed to be @SideEffectFree * @param assumeDeterministic true if all methods should be assumed to be @Deterministic @@ -139,25 +141,39 @@ public static EnumSet functionalParameterKinds( */ public static boolean isFunctionalInterfaceParameter( @Nullable ExpressionTree expr, @Nullable MethodTree method, ProcessingEnvironment env) { + return functionalInterfaceParameterType(expr, method, env) != null; + } + + /** + * Returns the functional interface type of {@code expr}, if {@code expr} is an effectively final + * formal parameter of {@code method} whose type is a functional interface; otherwise returns + * null. + * + * @param expr an expression, or null + * @param method a method or constructor declaration, or null + * @param env the processing environment + * @return the functional interface type of {@code expr}, or null + */ + private static @Nullable TypeMirror functionalInterfaceParameterType( + @Nullable ExpressionTree expr, @Nullable MethodTree method, ProcessingEnvironment env) { if (expr == null || method == null || !(TreeUtils.withoutParens(expr) instanceof IdentifierTree id)) { - return false; + return null; } - Element element = TreeUtils.elementFromUse(id); + Element element = TreeUtils.elementFromTree(id); if (element == null || element.getKind() != ElementKind.PARAMETER || !ElementUtils.isEffectivelyFinal(element)) { - return false; + return null; } // Test membership in the parameter list rather than the enclosing element, because javac // gives a lambda's parameters the enclosing method as their enclosing element. A lambda's // parameter is not checked at any call site. if (!isParameterOf(element, method)) { - return false; + return null; } - TypeMirror type = functionalInterfaceType(element.asType(), env); - return type != null; + return functionalInterfaceType(element.asType(), env); } /** @@ -327,7 +343,7 @@ protected static class PurityCheckerHelper extends TreePathScanner { /** The annotation provider (typically an AnnotatedTypeFactory). */ protected final AnnotationProvider annoProvider; - /** The method whose body is being checked, or null if a method body is not being checked. */ + /** The method declaration that lexically encloses the checked statement, or null if none. */ private final @Nullable MethodTree enclosingMethod; /** The processing environment; null if {@link #enclosingMethod} is null. */ @@ -362,7 +378,8 @@ protected static class PurityCheckerHelper extends TreePathScanner { * Create a PurityCheckerHelper. * * @param annoProvider the annotation provider - * @param enclosingMethod the method whose body is being checked, or null + * @param enclosingMethod the method declaration that lexically encloses the checked statement, + * or null if none does * @param env the processing environment; used only if {@code enclosingMethod} is non-null * @param assumeSideEffectFree true if all methods should be assumed to be @SideEffectFree * @param assumeDeterministic true if all methods should be assumed to be @Deterministic @@ -410,11 +427,10 @@ private boolean isCallOnFunctionalInterfaceParameter( return false; } ExpressionTree receiver = TreeUtils.getReceiverTree(tree); - if (!isFunctionalInterfaceParameter(receiver, enclosingMethod, env)) { + TypeMirror receiverType = functionalInterfaceParameterType(receiver, enclosingMethod, env); + if (receiverType == null) { return false; } - Element receiverElement = TreeUtils.elementFromUse(receiver); - TypeMirror receiverType = functionalInterfaceType(receiverElement.asType(), env); ExecutableElement functionalMethod = TypesUtils.findFunction(receiverType, env); // Only the functional method gets the assumption; a default method such as // `Function.andThen` does not. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 697f5b2f254c..3eb321378e73 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,9 +12,9 @@ warnings that it did not previously. ### Changes for type system implementers -`PurityChecker.checkPurity()` takes two new arguments: the method whose body is -being checked (or null, as for a lambda body or an arbitrary expression) and the -processing environment. +`PurityChecker.checkPurity()` takes two new arguments: the method declaration +that lexically encloses the statement being checked (or null, as for an +arbitrary expression) and the processing environment. `JavaParserUtil`: moved `DEFAULT_LANGUAGE_LEVEL`, `parseCompilationUnit()`, `parseStubUnit()`, and `parseExpression()` into new class `StaticJavaParserUtil`. diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index f6c0e7d70c60..afb27379e3bb 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1413,10 +1413,12 @@ protected void checkConstructorResult( * purity that the callee requires of it, because the callee's body is permitted to call it. * *

    How the argument is checked depends on its form. The body of a lambda is checked directly. A - * method reference is checked against the declaration of the method it refers to. An effectively - * final functional-interface parameter of the enclosing method needs no check, since the caller - * of that method already performed one. Any other argument is checked against the functional - * method of its declared type. + * method reference is checked against the declaration of the method it refers to. Each result + * expression of a conditional or switch expression is checked on its own, because the type of + * such an expression is the parameter's type and says nothing about the code that any result + * expression denotes. An effectively final functional-interface parameter of the enclosing method + * needs no check, since the caller of that method already performed one. Any other argument is + * checked against the functional method of its declared type. * *

    This check is skipped for a requirement that the parameter's own functional method already * makes, because {@link #checkLambdaPurity} and {@link @@ -1496,6 +1498,26 @@ protected void checkFunctionalArgument( return; } + if (argument instanceof ConditionalExpressionTree conditional) { + checkFunctionalArgument( + conditional.getTrueExpression(), required, paramFunction, param, callee); + checkFunctionalArgument( + conditional.getFalseExpression(), required, paramFunction, param, callee); + return; + } + + if (argument instanceof SwitchExpressionTree switchExpression) { + SwitchExpressionScanner scanner = + new FunctionalSwitchExpressionScanner<>( + (ExpressionTree resultExpression, Void unused) -> { + checkFunctionalArgument(resultExpression, required, paramFunction, param, callee); + return null; + }, + (r1, r2) -> null); + scanner.scanSwitchExpression(switchExpression, null); + return; + } + EnumSet argKinds; if (argument instanceof MemberReferenceTree memberReference) { if (isArrayConstructorReference(memberReference)) { @@ -2563,9 +2585,13 @@ public Void visitNewClass(NewClassTree tree, Void p) { checkArguments(params, passedArguments, constructorName, constructor.getParameters()); checkVarargs(constructorType, tree); - // For an anonymous class, the arguments are passed to the super constructor; the anonymous - // class's own constructor is synthetic and carries no annotation. - checkFunctionalArguments(TreeUtils.getSuperConstructor(tree), passedArguments); + if (checkPurityAnnotations && !infer) { + // For an anonymous class, the arguments are passed to the super constructor; the anonymous + // class's own constructor is synthetic and carries no annotation. Do not compute the super + // constructor unless it is needed: for an anonymous class, doing so searches the class's + // synthetic constructor for the super call. + checkFunctionalArguments(TreeUtils.getSuperConstructor(tree), passedArguments); + } List paramBounds = CollectionsP.mapList(AnnotatedTypeVariable::getBounds, constructorType.getTypeVariables()); diff --git a/framework/tests/flow/PurityFunctionalArgument.java b/framework/tests/flow/PurityFunctionalArgument.java index 837901bb5c9d..00812a2f82fd 100644 --- a/framework/tests/flow/PurityFunctionalArgument.java +++ b/framework/tests/flow/PurityFunctionalArgument.java @@ -85,6 +85,70 @@ void methodReferenceArguments(String s) { callee(String::length, s); } + // Each result expression of a conditional or switch expression is checked on its own. The type + // of such an expression is the parameter's type, which says nothing about the code that any + // result expression denotes. + + void conditionalArguments(boolean b, String s) { + callee(b ? t -> t.length() : t -> 0, s); + // :: error: [purity.not.sideeffectfree.assign.field] + callee(b ? t -> count++ : t -> 0, s); + callee(b ? this::pureLength : String::length, s); + // :: error: [purity.functional.argument] + callee(b ? this::pureLength : this::impureLength, s); + } + + void switchArguments(int i, String s) { + callee( + switch (i) { + case 1 -> t -> t.length(); + default -> t -> 0; + }, + s); + callee( + switch (i) { + // :: error: [purity.not.sideeffectfree.assign.field] + case 1 -> t -> count++; + default -> t -> 0; + }, + s); + callee( + switch (i) { + case 1 -> this::pureLength; + // :: error: [purity.functional.argument] + default -> this::impureLength; + }, + s); + } + + // A result expression may also be the value of a `yield` statement. + + void yieldArguments(int i, String s) { + callee( + switch (i) { + case 1: + { + // :: error: [purity.functional.argument] + yield this::impureLength; + } + default: + yield this::pureLength; + }, + s); + } + + // Parentheses do not change how an argument is checked. + + void parenthesizedArguments(boolean b, String s) { + callee((t -> t.length()), s); + // :: error: [purity.not.sideeffectfree.assign.field] + callee((t -> count++), s); + // :: error: [purity.functional.argument] + callee((this::impureLength), s); + // :: error: [purity.functional.argument] + callee(b ? (this::pureLength) : (this::impureLength), s); + } + // Any other argument is checked against the functional method of its declared type. void otherArguments(String s) { From 6d5fa18e731497e774285aa74226343fdad6bf54 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Wed, 16 Sep 2026 16:31:20 -0700 Subject: [PATCH 09/43] Do not query the members of the null type `functionalMethodOf` passed the null type's element to `Elements#getAllMembers`, which throws a NullPointerException because javac's bottom-type symbol has no members. A null literal argument to a functional-interface parameter therefore crashed the purity check; the new recursion into conditional and switch result expressions extended that to `b ? null : ref` and `case 1 -> null`. Co-Authored-By: Claude Opus 5 --- .../common/basetype/BaseTypeVisitor.java | 5 +++++ framework/tests/flow/PurityFunctionalArgument.java | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index afb27379e3bb..32ad177b6d6a 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1578,6 +1578,11 @@ protected void checkFunctionalArgument( if (type.getKind() == TypeKind.TYPEVAR) { type = TypesUtils.upperBound(type); } + if (type.getKind() != TypeKind.DECLARED && type.getKind() != TypeKind.INTERSECTION) { + // Only a class or interface type declares methods. The null type, which is the type of + // the null literal, has an element whose members cannot be queried. + return null; + } TypeElement typeElement = TypesUtils.getTypeElement(type); if (typeElement == null) { return null; diff --git a/framework/tests/flow/PurityFunctionalArgument.java b/framework/tests/flow/PurityFunctionalArgument.java index 00812a2f82fd..1635225cb5e5 100644 --- a/framework/tests/flow/PurityFunctionalArgument.java +++ b/framework/tests/flow/PurityFunctionalArgument.java @@ -3,6 +3,7 @@ // // * a lambda: its body must be side-effect-free; // * a method reference: the referenced method must be @SideEffectFree; +// * a conditional or switch expression: each of its result expressions is checked on its own; // * a functional-interface parameter of an enclosing @SideEffectFree method: the caller of that // method has already discharged the obligation; // * anything else: checked against the functional method of the argument's declared type. @@ -149,6 +150,19 @@ void parenthesizedArguments(boolean b, String s) { callee(b ? (this::pureLength) : (this::impureLength), s); } + // The null literal denotes no code, so there is nothing to check. + + void nullArguments(boolean b, int i, String s) { + callee(null, s); + callee(b ? null : this::pureLength, s); + callee( + switch (i) { + case 1 -> null; + default -> this::pureLength; + }, + s); + } + // Any other argument is checked against the functional method of its declared type. void otherArguments(String s) { From 3f8af7cd0eb0c2d88ac478d94fa3991130249de6 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Thu, 17 Sep 2026 12:35:49 -0700 Subject: [PATCH 10/43] Suppress Interning Checker warnings on Element identity comparisons Co-Authored-By: Claude Opus 5 --- .../org/checkerframework/dataflow/util/PurityChecker.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java index 71e65f3bf8de..8674c7c73833 100644 --- a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java +++ b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java @@ -183,6 +183,7 @@ public static boolean isFunctionalInterfaceParameter( * @param method a method or constructor declaration * @return true if {@code element} is a formal parameter of {@code method} */ + @SuppressWarnings("interning:not.interned") // Checking for exact object. private static boolean isParameterOf(Element element, MethodTree method) { for (VariableTree parameter : method.getParameters()) { if (TreeUtils.elementFromDeclaration(parameter) == element) { @@ -434,7 +435,9 @@ private boolean isCallOnFunctionalInterfaceParameter( ExecutableElement functionalMethod = TypesUtils.findFunction(receiverType, env); // Only the functional method gets the assumption; a default method such as // `Function.andThen` does not. - return invoked == functionalMethod + @SuppressWarnings("interning:not.interned") // Checking for exact object. + boolean isFunctionalMethod = invoked == functionalMethod; + return isFunctionalMethod || env.getElementUtils() .overrides(invoked, functionalMethod, (TypeElement) invoked.getEnclosingElement()); } From 451c1ab33cec4f1c5f125c3dc3090809a8aa641c Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Thu, 17 Sep 2026 12:45:18 -0700 Subject: [PATCH 11/43] Do not infer purity that would constrain functional-interface arguments Whole-program inference cannot annotate a lambda expression or a method reference, so it cannot make an argument meet a requirement that such an inferred annotation imposes at every call site. Co-Authored-By: Claude Opus 5 --- .../common/basetype/BaseTypeVisitor.java | 76 ++++++++++++++++--- 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index 32ad177b6d6a..18081b1927e6 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1214,6 +1214,8 @@ protected void checkPurityAnnotations(MethodTree tree) { if (infer) { WholeProgramInference wpi = atypeFactory.getWholeProgramInference(); ExecutableElement methodElt = TreeUtils.elementFromDeclaration(tree); + // Do not infer a kind that would newly constrain the arguments at call sites. + additionalKinds.removeAll(kindsRequiredOfArguments(methodElt, additionalKinds)); inferPurityAnno(additionalKinds, wpi, methodElt); // The purity of overridden methods is impacted by the purity of this method. If // a superclass method is pure, but an implementation in a subclass is not, WPI @@ -1436,23 +1438,16 @@ protected void checkFunctionalArguments( if (calleeKinds.isEmpty()) { return; } - ProcessingEnvironment env = atypeFactory.getProcessingEnv(); List params = callee.getParameters(); // For a varargs call, the trailing arguments have the component type of the last parameter, // which is an array and therefore not a functional interface. int numToCheck = Math.min(params.size(), args.size()); for (int i = 0; i < numToCheck; i++) { - TypeMirror paramType = PurityChecker.functionalInterfaceType(params.get(i).asType(), env); - if (paramType == null) { + ExecutableElement paramFunction = parameterFunctionalMethod(params.get(i)); + if (paramFunction == null) { continue; } - ExecutableElement paramFunction = TypesUtils.findFunction(paramType, env); - EnumSet required = EnumSet.copyOf(calleeKinds); - required.removeAll(PurityUtils.getPurityKinds(atypeFactory, paramFunction)); - if (paramFunction.getReturnType().getKind() == TypeKind.VOID) { - // A functional method that returns no value is deterministic, whatever implements it. - required.remove(PurityKind.DETERMINISTIC); - } + EnumSet required = purityRequiredOfArgument(paramFunction, calleeKinds); if (required.isEmpty()) { continue; } @@ -1460,6 +1455,65 @@ protected void checkFunctionalArguments( } } + /** + * Returns the functional method of {@code param}'s type, or null if that type is not a functional + * interface. + * + * @param param a formal parameter + * @return the functional method of {@code param}'s type, or null + */ + private @Nullable ExecutableElement parameterFunctionalMethod(VariableElement param) { + ProcessingEnvironment env = atypeFactory.getProcessingEnv(); + TypeMirror paramType = PurityChecker.functionalInterfaceType(param.asType(), env); + return paramType == null ? null : TypesUtils.findFunction(paramType, env); + } + + /** + * Returns the purity that a method whose purity is {@code methodKinds} requires of the argument + * that is passed to a functional-interface parameter whose functional method is {@code + * paramFunction}: the kinds that {@code paramFunction} does not already promise. + * + * @param paramFunction the functional method of a parameter's type + * @param methodKinds the purity that a method requires of its functional-interface arguments + * @return the purity required of the argument, which may be empty + */ + private EnumSet purityRequiredOfArgument( + ExecutableElement paramFunction, EnumSet methodKinds) { + EnumSet required = EnumSet.copyOf(methodKinds); + required.removeAll(PurityUtils.getPurityKinds(atypeFactory, paramFunction)); + if (paramFunction.getReturnType().getKind() == TypeKind.VOID) { + // A functional method that returns no value is deterministic, whatever implements it. + required.remove(PurityKind.DETERMINISTIC); + } + return required; + } + + /** + * Returns the subset of {@code kinds} that {@code method} would require of the arguments at its + * call sites, if {@code method} were annotated with {@code kinds}: those that the functional + * method of some functional-interface parameter does not already promise. See {@link + * #checkFunctionalArguments}. + * + *

    Whole-program inference does not infer such a kind. It cannot annotate a lambda expression + * or a method reference, so it cannot make an argument meet the requirement, and inferring the + * kind would introduce errors at call sites. + * + * @param method a method or constructor + * @param kinds the purity kinds that might be inferred for {@code method} + * @return the subset of {@code kinds} that calls to {@code method} would require of arguments + */ + private EnumSet kindsRequiredOfArguments( + ExecutableElement method, EnumSet kinds) { + EnumSet result = EnumSet.noneOf(PurityKind.class); + for (VariableElement param : method.getParameters()) { + ExecutableElement paramFunction = parameterFunctionalMethod(param); + if (paramFunction != null) { + result.addAll(purityRequiredOfArgument(paramFunction, kinds)); + } + } + return result; + } + /** * Checks that one argument to a functional-interface parameter has the given purity. * @@ -2711,6 +2765,8 @@ protected void checkLambdaPurity( if (functionalMethod.getReturnType().getKind() == TypeKind.VOID) { lambdaKinds.remove(PurityKind.DETERMINISTIC); } + // Do not infer a kind that would newly constrain the arguments at call sites. + lambdaKinds.removeAll(kindsRequiredOfArguments(functionalMethod, lambdaKinds)); WholeProgramInference wpi = atypeFactory.getWholeProgramInference(); inferPurityAnno(lambdaKinds, wpi, functionalMethod); for (ExecutableElement overriddenElt : From 928b37f9cfe9c5f3aec4d1f9a0c377e14bd87780 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Thu, 17 Sep 2026 12:59:41 -0700 Subject: [PATCH 12/43] Keep refinements in a lambda that calls a functional-interface parameter The caller of the enclosing method was required to pass code with the purity that the method promises, so such a call does not invalidate the initial store of a lambda that does not leak. Co-Authored-By: Claude Opus 5 --- .../tests/optional/LambdaMethodCallValue.java | 28 +++++++++++++++++++ .../framework/flow/CFAbstractTransfer.java | 8 +++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/checker/tests/optional/LambdaMethodCallValue.java b/checker/tests/optional/LambdaMethodCallValue.java index 1802baf88620..e3b8cd12dd39 100644 --- a/checker/tests/optional/LambdaMethodCallValue.java +++ b/checker/tests/optional/LambdaMethodCallValue.java @@ -64,6 +64,34 @@ void test7(OptContainer container) { } } + // A lambda's body may call a functional-interface parameter of the enclosing method. The + // caller of that method was required to pass pure code, so the call does not discard + // refinements. (This test does not use -AcheckPurityAnnotations, so the bodies below are not + // checked against their purity annotations.) + @Pure + String test8(OptContainer container, List strs, Consumer op) { + if (container.getOpt().isPresent()) { + strs.forEach( + s -> { + container.getOpt().get(); // OK + op.accept(s); + }); + } + return ""; + } + + /** An unannotated method promises nothing about the code it is passed. */ + void test9(OptContainer container, List strs, Consumer op) { + if (container.getOpt().isPresent()) { + strs.forEach( + s -> { + // :: error: [method.invocation] + container.getOpt().get(); // Not ok: the lambda body is not pure + op.accept(s); + }); + } + } + class OptContainer { @SuppressWarnings("optional:field") // Don't care about this warning, unrelated to the test case diff --git a/framework/src/main/java/org/checkerframework/framework/flow/CFAbstractTransfer.java b/framework/src/main/java/org/checkerframework/framework/flow/CFAbstractTransfer.java index 308152a9350f..ffcab597a093 100644 --- a/framework/src/main/java/org/checkerframework/framework/flow/CFAbstractTransfer.java +++ b/framework/src/main/java/org/checkerframework/framework/flow/CFAbstractTransfer.java @@ -477,10 +477,10 @@ private boolean isExpressionOrStatementPure( PurityChecker.checkPurity( expressionOrStatement, aTypeFactory, - // This is an arbitrary expression, not a method body, so no method's parameters are - // known to hold side-effect-free values. - null, - null, + // The enclosing method's functional-interface parameters hold values that its + // caller was required to check, whenever the checked code runs. + TreePathUtil.enclosingMethod(expressionOrStatement), + aTypeFactory.getProcessingEnv(), isAssumeSideEffectFreeEnabled, isAssumeDeterministicEnabled, aTypeFactory.getChecker().hasOption("assumePureGetters")); From ba260200dd8b68e88a791306a93e6d9db3fc9d89 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Thu, 17 Sep 2026 13:12:02 -0700 Subject: [PATCH 13/43] Check a method reference like a lambda that calls the referenced method A method reference to the functional method of a functional-interface parameter denotes the code that the caller of the enclosing method was required to check, and the command-line purity assumptions apply to the referenced method as they do to a call in a lambda body. Co-Authored-By: Claude Opus 5 --- .../dataflow/util/PurityChecker.java | 48 ++++++++++---- .../common/basetype/BaseTypeVisitor.java | 33 ++++++++-- .../test/junit/PurityAssumptionsTest.java | 28 +++++++++ .../tests/flow/PurityFunctionalArgument.java | 28 +++++++++ .../AssumeSideEffectFreeArguments.java | 62 +++++++++++++++++++ 5 files changed, 182 insertions(+), 17 deletions(-) create mode 100644 framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumptionsTest.java create mode 100644 framework/tests/purity-assumptions/AssumeSideEffectFreeArguments.java diff --git a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java index 8674c7c73833..46a9370f1200 100644 --- a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java +++ b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java @@ -144,6 +144,39 @@ public static boolean isFunctionalInterfaceParameter( return functionalInterfaceParameterType(expr, method, env) != null; } + /** + * Returns true if {@code expr} is an effectively final functional-interface parameter of {@code + * method} and {@code invoked} is the functional method of {@code expr}'s type. + * + *

    The code that such an expression denotes has the purity that {@code method} promises, + * because at every call to {@code method} the argument was required to have it. Only the + * functional method carries that guarantee; a default method such as {@code Function.andThen} + * does not. + * + * @param expr an expression, or null + * @param invoked a method that {@code expr} is used to invoke or to refer to + * @param method a method or constructor declaration, or null + * @param env the processing environment + * @return true if {@code invoked} is the functional method of a functional-interface parameter of + * {@code method} + */ + public static boolean isFunctionalMethodOfParameter( + @Nullable ExpressionTree expr, + ExecutableElement invoked, + @Nullable MethodTree method, + ProcessingEnvironment env) { + TypeMirror parameterType = functionalInterfaceParameterType(expr, method, env); + if (parameterType == null) { + return false; + } + ExecutableElement functionalMethod = TypesUtils.findFunction(parameterType, env); + @SuppressWarnings("interning:not.interned") // Checking for exact object. + boolean isFunctionalMethod = invoked == functionalMethod; + return isFunctionalMethod + || env.getElementUtils() + .overrides(invoked, functionalMethod, (TypeElement) invoked.getEnclosingElement()); + } + /** * Returns the functional interface type of {@code expr}, if {@code expr} is an effectively final * formal parameter of {@code method} whose type is a functional interface; otherwise returns @@ -427,19 +460,8 @@ private boolean isCallOnFunctionalInterfaceParameter( if (env == null) { return false; } - ExpressionTree receiver = TreeUtils.getReceiverTree(tree); - TypeMirror receiverType = functionalInterfaceParameterType(receiver, enclosingMethod, env); - if (receiverType == null) { - return false; - } - ExecutableElement functionalMethod = TypesUtils.findFunction(receiverType, env); - // Only the functional method gets the assumption; a default method such as - // `Function.andThen` does not. - @SuppressWarnings("interning:not.interned") // Checking for exact object. - boolean isFunctionalMethod = invoked == functionalMethod; - return isFunctionalMethod - || env.getElementUtils() - .overrides(invoked, functionalMethod, (TypeElement) invoked.getEnclosingElement()); + return isFunctionalMethodOfParameter( + TreeUtils.getReceiverTree(tree), invoked, enclosingMethod, env); } @Override diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index 18081b1927e6..4adf45552904 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1579,8 +1579,17 @@ protected void checkFunctionalArgument( // deterministic, like any object creation. argKinds = EnumSet.of(PurityKind.SIDE_EFFECT_FREE); } else { - argKinds = - implementationPurityKinds((ExecutableElement) TreeUtils.elementFromUse(argument)); + ExecutableElement referenced = (ExecutableElement) TreeUtils.elementFromUse(argument); + argKinds = implementationPurityKinds(referenced); + MethodTree enclosingMethod = TreePathUtil.enclosingMethod(getCurrentPath()); + if (PurityChecker.isFunctionalMethodOfParameter( + memberReference.getQualifierExpression(), referenced, enclosingMethod, env)) { + // `f::apply`, where `f` is a functional-interface parameter of the enclosing method, + // denotes the code that the caller of that method was required to check. + argKinds.addAll( + PurityChecker.functionalParameterKinds( + atypeFactory, TreeUtils.elementFromDeclaration(enclosingMethod))); + } } } else { ExecutableElement argFunction = @@ -1663,17 +1672,33 @@ private static boolean isArrayConstructorReference(MemberReferenceTree tree) { /** * Returns the purity of a method that implements a functional method: its declared purity, plus - * determinism if it returns no value. + * determinism if it returns no value, plus whatever the command-line assumptions grant it. * * @param method a method or constructor * @return the purity kinds of {@code method} */ private EnumSet implementationPurityKinds(ExecutableElement method) { - EnumSet result = EnumSet.copyOf(PurityUtils.getPurityKinds(atypeFactory, method)); + EnumSet declared = PurityUtils.getPurityKinds(atypeFactory, method); + EnumSet result = EnumSet.copyOf(declared); if (method.getKind() != ElementKind.CONSTRUCTOR && method.getReturnType().getKind() == TypeKind.VOID) { result.add(PurityKind.DETERMINISTIC); } + if (!declared.isEmpty()) { + // Like PurityChecker, apply an assumption only to a method that has a purity annotation, + // so that a method reference is treated exactly like a lambda whose body calls the + // referenced method. + if (assumeSideEffectFree) { + result.add(PurityKind.SIDE_EFFECT_FREE); + } + if (assumeDeterministic) { + result.add(PurityKind.DETERMINISTIC); + } + if (assumePureGetters && ElementUtils.isGetter(method)) { + result.add(PurityKind.SIDE_EFFECT_FREE); + result.add(PurityKind.DETERMINISTIC); + } + } return result; } diff --git a/framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumptionsTest.java b/framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumptionsTest.java new file mode 100644 index 000000000000..fff39a12a52d --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumptionsTest.java @@ -0,0 +1,28 @@ +package org.checkerframework.framework.test.junit; + +import java.io.File; +import java.util.List; +import org.checkerframework.framework.test.CheckerFrameworkPerDirectoryTest; +import org.checkerframework.framework.testchecker.util.FlowTestChecker; +import org.junit.runners.Parameterized.Parameters; + +/** Tests purity checking under the command-line arguments that assume purity. */ +public class PurityAssumptionsTest extends CheckerFrameworkPerDirectoryTest { + + /** + * @param testFiles the files containing test code, which will be type-checked + */ + public PurityAssumptionsTest(List testFiles) { + super( + testFiles, + FlowTestChecker.class, + "flow", + "-AcheckPurityAnnotations", + "-AassumeSideEffectFree"); + } + + @Parameters + public static String[] getTestDirs() { + return new String[] {"purity-assumptions"}; + } +} diff --git a/framework/tests/flow/PurityFunctionalArgument.java b/framework/tests/flow/PurityFunctionalArgument.java index 1635225cb5e5..b4b6d6150a71 100644 --- a/framework/tests/flow/PurityFunctionalArgument.java +++ b/framework/tests/flow/PurityFunctionalArgument.java @@ -187,6 +187,34 @@ int unannotatedMethodPassesParameterOnward(Function f, String s return callee(f, s); } + // A method reference to the functional method of such a parameter denotes the same code, so it + // is passed onward in the same way. + + @SideEffectFree + int passesParameterMethodReference(Function f, String s) { + return callee(f::apply, s); + } + + int unannotatedMethodPassesParameterMethodReference(Function f, String s) { + // :: error: [purity.functional.argument] + return callee(f::apply, s); + } + + /** A default method is not the functional method, so the caller checked nothing about it. */ + @SideEffectFree + int passesParameterDefaultMethodReference(DefaultFunction f, String s) { + // :: error: [purity.functional.argument] + return callee(f::applyTwice, s); + } + + /** A functional interface with a default method of the same signature as its functional one. */ + @FunctionalInterface + interface DefaultFunction extends Function { + default Integer applyTwice(String s) { + return apply(s) + apply(s); + } + } + // An unannotated callee imposes no obligation on its arguments. void unannotatedCalleeTakesAnything(String s) { diff --git a/framework/tests/purity-assumptions/AssumeSideEffectFreeArguments.java b/framework/tests/purity-assumptions/AssumeSideEffectFreeArguments.java new file mode 100644 index 000000000000..8911fefb8191 --- /dev/null +++ b/framework/tests/purity-assumptions/AssumeSideEffectFreeArguments.java @@ -0,0 +1,62 @@ +// Under -AassumeSideEffectFree, an argument to a functional-interface parameter is checked with +// the same assumption whatever its form: a lambda body, a method reference, or the functional +// method of the argument's type. Like PurityChecker, the assumption applies only to a method +// that has a purity annotation. + +import java.util.function.Function; +import org.checkerframework.dataflow.qual.Deterministic; +import org.checkerframework.dataflow.qual.SideEffectFree; + +public class AssumeSideEffectFreeArguments { + + int count = 0; + + @SideEffectFree + static int callee(Function f, String s) { + return 0; + } + + @Deterministic + int deterministicLength(String s) { + // The method's own body is still checked against its own annotation. + // :: error: [purity.not.deterministic.assign.field] + count++; + return s.length(); + } + + int unannotatedLength(String s) { + count++; + return s.length(); + } + + /** A functional interface whose functional method promises determinism. */ + @FunctionalInterface + interface DeterministicFunction extends Function { + @Override + @Deterministic + Integer apply(String s); + } + + DeterministicFunction deterministicVariable = s -> s.length(); + + /** The side effect of a method that promises determinism is assumed away. */ + void annotatedMethods(String s) { + callee(t -> deterministicLength(t), s); + callee(this::deterministicLength, s); + callee(deterministicVariable, s); + } + + /** A method with no purity annotation gets no assumption, in either form. */ + void unannotatedMethods(String s) { + // :: error: [purity.not.sideeffectfree.call] + callee(t -> unannotatedLength(t), s); + // :: error: [purity.functional.argument] + callee(this::unannotatedLength, s); + } + + /** The assumption is that a called method has no side effect, not that this code has none. */ + void theAssumptionIsAboutCalls(String s) { + // :: error: [purity.not.sideeffectfree.assign.field] + callee(t -> count++, s); + } +} From d0f2701215bd73221d9b739df2f6e86e9b275b7e Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Thu, 17 Sep 2026 13:30:51 -0700 Subject: [PATCH 14/43] Check the arguments passed to a varargs functional-interface parameter In the expanded form of a varargs call, each argument from the varargs parameter onward is checked against the component type of its array. In the array form, each initializer of an array literal is checked; the elements of any other array are unknown, so they are not checked. Co-Authored-By: Claude Opus 5 --- .../common/basetype/BaseTypeVisitor.java | 73 +++++++++++++----- .../flow/PurityFunctionalConstructor.java | 15 ++++ .../tests/flow/PurityVarargsArgument.java | 77 +++++++++++++++++++ 3 files changed, 146 insertions(+), 19 deletions(-) create mode 100644 framework/tests/flow/PurityVarargsArgument.java diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index 4adf45552904..b0d77b402d9b 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -72,6 +72,7 @@ import javax.lang.model.element.Name; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; +import javax.lang.model.type.ArrayType; import javax.lang.model.type.DeclaredType; import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; @@ -1419,8 +1420,10 @@ protected void checkConstructorResult( * expression of a conditional or switch expression is checked on its own, because the type of * such an expression is the parameter's type and says nothing about the code that any result * expression denotes. An effectively final functional-interface parameter of the enclosing method - * needs no check, since the caller of that method already performed one. Any other argument is - * checked against the functional method of its declared type. + * needs no check, since the caller of that method already performed one. Each initializer of an + * array literal is checked on its own, because such an argument is the array that a varargs call + * passes in its array form. Any other argument is checked against the functional method of its + * declared type. * *

    This check is skipped for a requirement that the parameter's own functional method already * makes, because {@link #checkLambdaPurity} and {@link @@ -1428,9 +1431,11 @@ protected void checkConstructorResult( * * @param callee the invoked method or constructor * @param args the arguments to {@code callee} + * @param varargsCall true if {@code args} are in the expanded form of a varargs call, so that the + * arguments from the last parameter onward are elements of its array */ protected void checkFunctionalArguments( - ExecutableElement callee, List args) { + ExecutableElement callee, List args, boolean varargsCall) { if (!checkPurityAnnotations || infer) { return; } @@ -1439,11 +1444,18 @@ protected void checkFunctionalArguments( return; } List params = callee.getParameters(); - // For a varargs call, the trailing arguments have the component type of the last parameter, - // which is an array and therefore not a functional interface. - int numToCheck = Math.min(params.size(), args.size()); + int numParams = params.size(); + if (numParams == 0) { + return; + } + // In the expanded form of a varargs call, every argument from the last parameter onward is + // an element of that parameter's array. Otherwise each argument is passed to the parameter + // at its own index; the counts can still differ, as for an enum constructor, whose two + // parameters no call passes. + int numToCheck = varargsCall ? args.size() : Math.min(numParams, args.size()); for (int i = 0; i < numToCheck; i++) { - ExecutableElement paramFunction = parameterFunctionalMethod(params.get(i)); + int paramIndex = Math.min(i, numParams - 1); + ExecutableElement paramFunction = parameterFunctionalMethod(callee, paramIndex); if (paramFunction == null) { continue; } @@ -1451,21 +1463,29 @@ protected void checkFunctionalArguments( if (required.isEmpty()) { continue; } - checkFunctionalArgument(args.get(i), required, paramFunction, params.get(i), callee); + checkFunctionalArgument(args.get(i), required, paramFunction, params.get(paramIndex), callee); } } /** - * Returns the functional method of {@code param}'s type, or null if that type is not a functional - * interface. + * Returns the functional method of the type of {@code method}'s parameter at {@code index}, or + * null if that type is not a functional interface. For a varargs parameter, this is the + * functional method of the array's component type, which is the type of each argument that a call + * passes to it. * - * @param param a formal parameter - * @return the functional method of {@code param}'s type, or null + * @param method a method or constructor + * @param index the index of one of {@code method}'s formal parameters + * @return the functional method of that parameter's type, or null */ - private @Nullable ExecutableElement parameterFunctionalMethod(VariableElement param) { + private @Nullable ExecutableElement parameterFunctionalMethod( + ExecutableElement method, int index) { + TypeMirror paramType = method.getParameters().get(index).asType(); + if (method.isVarArgs() && index == method.getParameters().size() - 1) { + paramType = ((ArrayType) paramType).getComponentType(); + } ProcessingEnvironment env = atypeFactory.getProcessingEnv(); - TypeMirror paramType = PurityChecker.functionalInterfaceType(param.asType(), env); - return paramType == null ? null : TypesUtils.findFunction(paramType, env); + TypeMirror functionalType = PurityChecker.functionalInterfaceType(paramType, env); + return functionalType == null ? null : TypesUtils.findFunction(functionalType, env); } /** @@ -1505,8 +1525,8 @@ private EnumSet purityRequiredOfArgument( private EnumSet kindsRequiredOfArguments( ExecutableElement method, EnumSet kinds) { EnumSet result = EnumSet.noneOf(PurityKind.class); - for (VariableElement param : method.getParameters()) { - ExecutableElement paramFunction = parameterFunctionalMethod(param); + for (int i = 0; i < method.getParameters().size(); i++) { + ExecutableElement paramFunction = parameterFunctionalMethod(method, i); if (paramFunction != null) { result.addAll(purityRequiredOfArgument(paramFunction, kinds)); } @@ -1552,6 +1572,20 @@ protected void checkFunctionalArgument( return; } + if (argument instanceof NewArrayTree newArray) { + // The argument is the array of a varargs call in its array form. Each initializer of an + // array literal denotes code that the call passes, so each is checked on its own. An + // element that is stored into the array elsewhere is not checked, just as the elements of + // an array that the call receives from somewhere else are not. + List initializers = newArray.getInitializers(); + if (initializers != null) { + for (ExpressionTree initializer : initializers) { + checkFunctionalArgument(initializer, required, paramFunction, param, callee); + } + } + return; + } + if (argument instanceof ConditionalExpressionTree conditional) { checkFunctionalArgument( conditional.getTrueExpression(), required, paramFunction, param, callee); @@ -2324,7 +2358,7 @@ public Void visitMethodInvocation(MethodInvocationTree tree, Void p) { AnnotatedTypes.adaptParameters(atypeFactory, invokedMethod, tree.getArguments(), tree); checkArguments(params, tree.getArguments(), methodName, method.getParameters()); checkVarargs(invokedMethod, tree); - checkFunctionalArguments(method, tree.getArguments()); + checkFunctionalArguments(method, tree.getArguments(), TreeUtils.isVarargsCall(tree)); if (ElementUtils.isMethod( invokedMethod.getElement(), vectorCopyInto, atypeFactory.getProcessingEnv())) { @@ -2674,7 +2708,8 @@ public Void visitNewClass(NewClassTree tree, Void p) { // class's own constructor is synthetic and carries no annotation. Do not compute the super // constructor unless it is needed: for an anonymous class, doing so searches the class's // synthetic constructor for the super call. - checkFunctionalArguments(TreeUtils.getSuperConstructor(tree), passedArguments); + checkFunctionalArguments( + TreeUtils.getSuperConstructor(tree), passedArguments, TreeUtils.isVarargsCall(tree)); } List paramBounds = diff --git a/framework/tests/flow/PurityFunctionalConstructor.java b/framework/tests/flow/PurityFunctionalConstructor.java index a27f95110cf4..fc1bfa72aea4 100644 --- a/framework/tests/flow/PurityFunctionalConstructor.java +++ b/framework/tests/flow/PurityFunctionalConstructor.java @@ -38,6 +38,21 @@ void anonymousSubclass() { new Holder(t -> count++) {}; } + static class VarargsHolder { + // The parameter type is not generic, to avoid javac's warnings about generic varargs. + @SideEffectFree + VarargsHolder(Runnable... rs) {} + } + + /** A varargs constructor checks each argument that the call passes to its array. */ + void varargsConstructorCallSites() { + new VarargsHolder(() -> {}); + // :: error: [purity.not.sideeffectfree.assign.field] + new VarargsHolder(() -> count++); + // :: error: [purity.not.sideeffectfree.assign.field] + new VarargsHolder(() -> {}, () -> count++) {}; + } + /** A functional-interface parameter may be passed onward to a constructor. */ @SideEffectFree Holder passesParameterToConstructor(Function f) { diff --git a/framework/tests/flow/PurityVarargsArgument.java b/framework/tests/flow/PurityVarargsArgument.java new file mode 100644 index 000000000000..f902cab37c32 --- /dev/null +++ b/framework/tests/flow/PurityVarargsArgument.java @@ -0,0 +1,77 @@ +// At a call to a @SideEffectFree method, an argument passed to a varargs functional-interface +// parameter is checked like an argument passed to any other functional-interface parameter. +// +// In the expanded form of such a call, each argument from the varargs parameter onward is an +// element of its array, and is checked against the array's component type. In the array form, +// the argument is the array itself: each initializer of an array literal is checked, and the +// elements of any other array are unknown and therefore unchecked. + +import org.checkerframework.dataflow.qual.SideEffectFree; + +public class PurityVarargsArgument { + + int count = 0; + + @SideEffectFree + static int callee(Runnable... rs) { + return 0; + } + + @SideEffectFree + static int calleeWithLeadingParameter(String s, Runnable... rs) { + return 0; + } + + @SideEffectFree + static int calleeWithoutFunctionalParameter(String... ss) { + return 0; + } + + void impureVoid() { + count++; + } + + @SideEffectFree + void pureVoid() {} + + // The expanded form: each argument is one element of the array. + + void expandedForm(String s) { + callee(() -> {}); + callee(this::pureVoid); + // :: error: [purity.not.sideeffectfree.assign.field] + callee(() -> count++); + // :: error: [purity.functional.argument] + callee(this::impureVoid); + callee(this::pureVoid, this::pureVoid); + // :: error: [purity.functional.argument] + callee(this::pureVoid, this::impureVoid); + // :: error: [purity.functional.argument] + calleeWithLeadingParameter(s, this::impureVoid); + } + + void zeroVarargsArguments() { + callee(); + } + + // The array form: the argument is the array that the call passes. + + void arrayForm(Runnable[] rs) { + callee(new Runnable[] {this::pureVoid}); + // :: error: [purity.functional.argument] + callee(new Runnable[] {this::impureVoid}); + // :: error: [purity.not.sideeffectfree.assign.field] + callee(new Runnable[] {() -> count++}); + // An array that the call receives from elsewhere says nothing about its elements. + callee(rs); + // Neither does an array with no initializer. + callee(new Runnable[10]); + } + + // A varargs parameter whose component type is not a functional interface is unaffected. + + void notFunctional() { + calleeWithoutFunctionalParameter("a", "b"); + calleeWithoutFunctionalParameter(new String[] {"a"}); + } +} From 3163f81b143b9b44d3b9f976de32b01ca3d74170 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Fri, 18 Sep 2026 11:11:07 -0700 Subject: [PATCH 15/43] Apply the purity assumptions to a method with no purity annotation `-AassumeSideEffectFree`, `-AassumeDeterministic`, `-AassumePure`, and `-AassumePureGetters` are documented as assumptions about every called method, and the manual gives "working with an unannotated library" as the reason to use `-AassumePureGetters`. `PurityChecker.visitMethodInvocation` applied them only to a method that already carried a purity annotation, so an unannotated getter was rejected even under `-AassumePureGetters`. `AnnotatedTypeFactory`, `CFAbstractStore`, and `PurityChecker.visitNewClass` have no such restriction, so the option also meant two different things depending on which of them read it. A `void` return type now grants determinism only to a method that is also side-effect-free. That is no change for an annotated method, because the shortcut was reachable only for one annotated `@SideEffectFree` or `@Deterministic`. For an unannotated method, it is what keeps a call to a side-effecting `void` method from being treated as deterministic. Co-Authored-By: Claude Opus 5 --- .../dataflow/util/PurityChecker.java | 48 +++++------- docs/CHANGELOG.md | 6 ++ .../junit/PurityAssumeDeterministicTest.java | 28 +++++++ .../test/junit/PurityAssumeGettersTest.java | 28 +++++++ .../junit/PurityAssumeSideEffectFreeTest.java | 28 +++++++ .../AssumeDeterministic.java | 41 ++++++++++ .../AssumePureGetters.java | 77 +++++++++++++++++++ .../AssumeSideEffectFree.java | 55 +++++++++++++ 8 files changed, 284 insertions(+), 27 deletions(-) create mode 100644 framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumeDeterministicTest.java create mode 100644 framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumeGettersTest.java create mode 100644 framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumeSideEffectFreeTest.java create mode 100644 framework/tests/purity-assume-deterministic/AssumeDeterministic.java create mode 100644 framework/tests/purity-assume-getters/AssumePureGetters.java create mode 100644 framework/tests/purity-assume-side-effect-free/AssumeSideEffectFree.java diff --git a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java index dcbe8712df4d..9903d64b69ba 100644 --- a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java +++ b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java @@ -316,38 +316,32 @@ public Void visitClass(ClassTree tree, Void ignore) { return null; } - /** Represents a method that is both deterministic and side-effect free. */ - private static final EnumSet detAndSeFree = - EnumSet.of(PurityKind.DETERMINISTIC, PurityKind.SIDE_EFFECT_FREE); - @Override public Void visitMethodInvocation(MethodInvocationTree tree, Void ignore) { ExecutableElement elt = TreeUtils.elementFromUse(tree); EnumSet eltPurityKinds = PurityUtils.getPurityKinds(annoProvider, elt); - if (!eltPurityKinds.contains(PurityKind.SIDE_EFFECT_FREE) - && !eltPurityKinds.contains(PurityKind.DETERMINISTIC)) { - // The called method has no purity annotation, so the callee is not pure either. + // Each assumption applies to every called method, including one with no purity + // annotation: that is what makes the assumptions useful for an unannotated library. + boolean pureGetter = assumePureGetters && ElementUtils.isGetter(elt); + boolean seFree = + assumeSideEffectFree + || pureGetter + || eltPurityKinds.contains(PurityKind.SIDE_EFFECT_FREE); + boolean det = + assumeDeterministic + || pureGetter + || eltPurityKinds.contains(PurityKind.DETERMINISTIC) + // A side-effect-free method that returns no value changes nothing and yields + // nothing, so calling it cannot make the caller's result differ. Without + // side-effect-freedom, returning no value says nothing: the method could + // change a field that the caller goes on to return. + || (seFree && elt.getReturnType().getKind() == TypeKind.VOID); + if (!det && !seFree) { purityResult.addNotBothReason(tree, "call"); - } else { - // The called method has a purity annotation: @SideEffectFree, @Deterministic, or both. - EnumSet purityKinds = - ((assumeDeterministic && assumeSideEffectFree) - || (assumePureGetters && ElementUtils.isGetter(elt))) - // Avoid computation if not necessary - ? detAndSeFree - : eltPurityKinds; - boolean det = - assumeDeterministic - || purityKinds.contains(PurityKind.DETERMINISTIC) - || elt.getReturnType().getKind() == TypeKind.VOID; - boolean seFree = assumeSideEffectFree || purityKinds.contains(PurityKind.SIDE_EFFECT_FREE); - if (!det && !seFree) { - purityResult.addNotBothReason(tree, "call"); - } else if (!det) { - purityResult.addNotDetReason(tree, "call"); - } else if (!seFree) { - purityResult.addNotSEFreeReason(tree, "call"); - } + } else if (!det) { + purityResult.addNotDetReason(tree, "call"); + } else if (!seFree) { + purityResult.addNotSEFreeReason(tree, "call"); } return super.visitMethodInvocation(tree, ignore); } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1d33c4482e95..27948a5d0a7f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,12 @@ The Purity Checker has been improved, so `-AcheckPurityAnnotations` may issue warnings that it did not previously. +The `-AassumeSideEffectFree`, `-AassumeDeterministic`, `-AassumePure`, and +`-AassumePureGetters` command-line options now apply to a called method that has +no purity annotation, which is what their documentation promises and what makes +them useful for an unannotated library. Previously, the Purity Checker applied +them only to a method that was already annotated. + ### Changes for type system implementers `JavaParserUtil`: moved `DEFAULT_LANGUAGE_LEVEL`, `parseCompilationUnit()`, diff --git a/framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumeDeterministicTest.java b/framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumeDeterministicTest.java new file mode 100644 index 000000000000..ec65db4763e9 --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumeDeterministicTest.java @@ -0,0 +1,28 @@ +package org.checkerframework.framework.test.junit; + +import java.io.File; +import java.util.List; +import org.checkerframework.framework.test.CheckerFrameworkPerDirectoryTest; +import org.checkerframework.framework.testchecker.util.FlowTestChecker; +import org.junit.runners.Parameterized.Parameters; + +/** Tests for the {@code -AassumeDeterministic} command-line argument. */ +public class PurityAssumeDeterministicTest extends CheckerFrameworkPerDirectoryTest { + + /** + * @param testFiles the files containing test code, which will be type-checked + */ + public PurityAssumeDeterministicTest(List testFiles) { + super( + testFiles, + FlowTestChecker.class, + "flow", + "-AcheckPurityAnnotations", + "-AassumeDeterministic"); + } + + @Parameters + public static String[] getTestDirs() { + return new String[] {"purity-assume-deterministic"}; + } +} diff --git a/framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumeGettersTest.java b/framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumeGettersTest.java new file mode 100644 index 000000000000..aa168424bb83 --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumeGettersTest.java @@ -0,0 +1,28 @@ +package org.checkerframework.framework.test.junit; + +import java.io.File; +import java.util.List; +import org.checkerframework.framework.test.CheckerFrameworkPerDirectoryTest; +import org.checkerframework.framework.testchecker.util.FlowTestChecker; +import org.junit.runners.Parameterized.Parameters; + +/** Tests for the {@code -AassumePureGetters} command-line argument. */ +public class PurityAssumeGettersTest extends CheckerFrameworkPerDirectoryTest { + + /** + * @param testFiles the files containing test code, which will be type-checked + */ + public PurityAssumeGettersTest(List testFiles) { + super( + testFiles, + FlowTestChecker.class, + "flow", + "-AcheckPurityAnnotations", + "-AassumePureGetters"); + } + + @Parameters + public static String[] getTestDirs() { + return new String[] {"purity-assume-getters"}; + } +} diff --git a/framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumeSideEffectFreeTest.java b/framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumeSideEffectFreeTest.java new file mode 100644 index 000000000000..30dcaf856b1d --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/test/junit/PurityAssumeSideEffectFreeTest.java @@ -0,0 +1,28 @@ +package org.checkerframework.framework.test.junit; + +import java.io.File; +import java.util.List; +import org.checkerframework.framework.test.CheckerFrameworkPerDirectoryTest; +import org.checkerframework.framework.testchecker.util.FlowTestChecker; +import org.junit.runners.Parameterized.Parameters; + +/** Tests for the {@code -AassumeSideEffectFree} command-line argument. */ +public class PurityAssumeSideEffectFreeTest extends CheckerFrameworkPerDirectoryTest { + + /** + * @param testFiles the files containing test code, which will be type-checked + */ + public PurityAssumeSideEffectFreeTest(List testFiles) { + super( + testFiles, + FlowTestChecker.class, + "flow", + "-AcheckPurityAnnotations", + "-AassumeSideEffectFree"); + } + + @Parameters + public static String[] getTestDirs() { + return new String[] {"purity-assume-side-effect-free"}; + } +} diff --git a/framework/tests/purity-assume-deterministic/AssumeDeterministic.java b/framework/tests/purity-assume-deterministic/AssumeDeterministic.java new file mode 100644 index 000000000000..25a7f2c46fc7 --- /dev/null +++ b/framework/tests/purity-assume-deterministic/AssumeDeterministic.java @@ -0,0 +1,41 @@ +// -AassumeDeterministic assumes that every called method is deterministic, including a method with +// no purity annotation. It assumes nothing about side effects. + +import org.checkerframework.dataflow.qual.Deterministic; +import org.checkerframework.dataflow.qual.Pure; +import org.checkerframework.dataflow.qual.SideEffectFree; + +public class AssumeDeterministic { + + int field = 0; + + int unannotated() { + field++; + return field; + } + + @Deterministic + int deterministicCall() { + return unannotated(); + } + + @SideEffectFree + int sideEffectFreeCall() { + // :: error: [purity.not.sideeffectfree.call] + return unannotated(); + } + + @Pure + int pureCall() { + // :: error: [purity.not.sideeffectfree.call] + return unannotated(); + } + + /** The assumption is about the methods that a body calls, not about the body itself. */ + @Deterministic + int bodyIsStillChecked() { + // :: error: [purity.not.deterministic.assign.field] + field++; + return field; + } +} diff --git a/framework/tests/purity-assume-getters/AssumePureGetters.java b/framework/tests/purity-assume-getters/AssumePureGetters.java new file mode 100644 index 000000000000..80db9b23043d --- /dev/null +++ b/framework/tests/purity-assume-getters/AssumePureGetters.java @@ -0,0 +1,77 @@ +// -AassumePureGetters assumes that every getter is side-effect-free and deterministic, including +// a getter with no purity annotation. That is the case the option exists for: an unannotated +// library, where writing stub files for every getter would be the alternative. + +import org.checkerframework.dataflow.qual.Pure; + +public class AssumePureGetters { + + int field = 0; + + /** A getter: an instance method with no formal parameters whose name starts with "get". */ + int getField() { + return field; + } + + /** Also a getter, by the name test. */ + boolean isEmpty() { + return field == 0; + } + + /** Not a getter: the name does not start with "get", "is", "not", or "has". */ + int readField() { + return field; + } + + /** Not a getter: "get" is not followed by an uppercase letter. */ + int getterForField() { + return field; + } + + /** Not a getter: it has a formal parameter. */ + int getFieldPlus(int i) { + return field + i; + } + + /** Not a getter: it is static. */ + static int getStaticField() { + return 0; + } + + @Pure + int callGetters() { + return getField() + (isEmpty() ? 1 : 0); + } + + @Pure + int callNonGetterByName() { + // :: error: [purity.not.deterministic.not.sideeffectfree.call] + return readField(); + } + + @Pure + int callNonGetterByCase() { + // :: error: [purity.not.deterministic.not.sideeffectfree.call] + return getterForField(); + } + + @Pure + int callNonGetterWithParameter() { + // :: error: [purity.not.deterministic.not.sideeffectfree.call] + return getFieldPlus(1); + } + + @Pure + int callStaticGetter() { + // :: error: [purity.not.deterministic.not.sideeffectfree.call] + return getStaticField(); + } + + /** The assumption is about the methods that a body calls, not about the body itself. */ + @Pure + int bodyIsStillChecked() { + // :: error: [purity.not.deterministic.not.sideeffectfree.assign.field] + field++; + return getField(); + } +} diff --git a/framework/tests/purity-assume-side-effect-free/AssumeSideEffectFree.java b/framework/tests/purity-assume-side-effect-free/AssumeSideEffectFree.java new file mode 100644 index 000000000000..24964d0b4781 --- /dev/null +++ b/framework/tests/purity-assume-side-effect-free/AssumeSideEffectFree.java @@ -0,0 +1,55 @@ +// -AassumeSideEffectFree assumes that every called method is side-effect-free, including a method +// with no purity annotation. It assumes nothing about determinism. + +import org.checkerframework.dataflow.qual.Deterministic; +import org.checkerframework.dataflow.qual.Pure; +import org.checkerframework.dataflow.qual.SideEffectFree; + +public class AssumeSideEffectFree { + + int field = 0; + + int unannotated() { + field++; + return field; + } + + void unannotatedVoid() { + field++; + } + + @SideEffectFree + int sideEffectFreeCall() { + return unannotated(); + } + + @Deterministic + int deterministicCall() { + // :: error: [purity.not.deterministic.call] + return unannotated(); + } + + @Pure + int pureCall() { + // :: error: [purity.not.deterministic.call] + return unannotated(); + } + + /** + * A method that is assumed to have no side effect and that returns no value changes nothing and + * yields nothing, so calling it leaves the caller deterministic. + */ + @Pure + int pureVoidCall() { + unannotatedVoid(); + return 0; + } + + /** The assumption is about the methods that a body calls, not about the body itself. */ + @SideEffectFree + int bodyIsStillChecked() { + // :: error: [purity.not.sideeffectfree.assign.field] + field++; + return field; + } +} From 2dc8e1bc4f2883e18b272ebe20b1c67e7a6e74c9 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Fri, 18 Sep 2026 11:29:43 -0700 Subject: [PATCH 16/43] Apply the purity assumptions to a functional argument with no purity annotation `implementationPurityKinds` granted an assumption only to a method that already carried a purity annotation, so under `-AassumePureGetters` an unannotated getter was rejected as a functional argument: `Comparator.comparing(Person::getName)` did not type-check however the option was set. The documented meaning of the option, and its stated reason to exist, is an unannotated library. `PurityChecker` checks a lambda's body and still has the same restriction, so a lambda and a method reference to the same method are not yet treated alike. Co-Authored-By: Claude Opus 5 --- .../common/basetype/BaseTypeVisitor.java | 29 +++++++++---------- .../AssumeSideEffectFreeArguments.java | 14 ++++++--- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index b0d77b402d9b..04ad266fa17d 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1712,26 +1712,23 @@ private static boolean isArrayConstructorReference(MemberReferenceTree tree) { * @return the purity kinds of {@code method} */ private EnumSet implementationPurityKinds(ExecutableElement method) { - EnumSet declared = PurityUtils.getPurityKinds(atypeFactory, method); - EnumSet result = EnumSet.copyOf(declared); + EnumSet result = EnumSet.copyOf(PurityUtils.getPurityKinds(atypeFactory, method)); if (method.getKind() != ElementKind.CONSTRUCTOR && method.getReturnType().getKind() == TypeKind.VOID) { result.add(PurityKind.DETERMINISTIC); } - if (!declared.isEmpty()) { - // Like PurityChecker, apply an assumption only to a method that has a purity annotation, - // so that a method reference is treated exactly like a lambda whose body calls the - // referenced method. - if (assumeSideEffectFree) { - result.add(PurityKind.SIDE_EFFECT_FREE); - } - if (assumeDeterministic) { - result.add(PurityKind.DETERMINISTIC); - } - if (assumePureGetters && ElementUtils.isGetter(method)) { - result.add(PurityKind.SIDE_EFFECT_FREE); - result.add(PurityKind.DETERMINISTIC); - } + // Like PurityChecker, apply each assumption to every method, including one with no purity + // annotation, so that a method reference is treated exactly like a lambda whose body calls + // the referenced method. + if (assumeSideEffectFree) { + result.add(PurityKind.SIDE_EFFECT_FREE); + } + if (assumeDeterministic) { + result.add(PurityKind.DETERMINISTIC); + } + if (assumePureGetters && ElementUtils.isGetter(method)) { + result.add(PurityKind.SIDE_EFFECT_FREE); + result.add(PurityKind.DETERMINISTIC); } return result; } diff --git a/framework/tests/purity-assumptions/AssumeSideEffectFreeArguments.java b/framework/tests/purity-assumptions/AssumeSideEffectFreeArguments.java index 8911fefb8191..9cec9b8ebbdc 100644 --- a/framework/tests/purity-assumptions/AssumeSideEffectFreeArguments.java +++ b/framework/tests/purity-assumptions/AssumeSideEffectFreeArguments.java @@ -1,7 +1,7 @@ // Under -AassumeSideEffectFree, an argument to a functional-interface parameter is checked with // the same assumption whatever its form: a lambda body, a method reference, or the functional -// method of the argument's type. Like PurityChecker, the assumption applies only to a method -// that has a purity annotation. +// method of the argument's type. The assumption applies to every method, including one with no +// purity annotation, which is the case the option exists for: an unannotated library. import java.util.function.Function; import org.checkerframework.dataflow.qual.Deterministic; @@ -46,14 +46,20 @@ void annotatedMethods(String s) { callee(deterministicVariable, s); } - /** A method with no purity annotation gets no assumption, in either form. */ + /** A method with no purity annotation gets the assumption too. */ void unannotatedMethods(String s) { + // TODO: A lambda's body is checked by PurityChecker, which still applies an assumption only + // to a method that has a purity annotation. Remove this expectation once it does not. // :: error: [purity.not.sideeffectfree.call] callee(t -> unannotatedLength(t), s); - // :: error: [purity.functional.argument] callee(this::unannotatedLength, s); } + /** A value of an unannotated functional-interface type gets the assumption too. */ + void unannotatedVariable(Function f, String s) { + callee(f, s); + } + /** The assumption is that a called method has no side effect, not that this code has none. */ void theAssumptionIsAboutCalls(String s) { // :: error: [purity.not.sideeffectfree.assign.field] From f07786eaeace979b0d59da30e2e6260193296931 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Fri, 18 Sep 2026 11:37:51 -0700 Subject: [PATCH 17/43] Adapt tests to the purity annotations on java.util.Comparator The annotated JDK now declares `Comparator.compare` as `@Pure` and the comparator-returning methods as `@SideEffectFree`. `PurityFunctionalArgumentJdk`: a `Comparator` value now carries the purity that `Collections.min` and `List.sort` require of it, so the `PureComparator` sub-interface that used to supply it is unnecessary. `Optional.map` with a `Function` value takes its place as the case of a functional method that promises nothing. A comparator written at a call site is now checked against `@Pure` rather than `@SideEffectFree`. `Issue7680`: a key extractor passed to `Comparator.comparing` or `thenComparing` must be side-effect-free. `Issue579`: its override of `thenComparing`, which is now `@SideEffectFree`, calls a constructor, so that constructor must be side-effect-free too. Co-Authored-By: Claude Opus 5 --- checker/tests/nullness/java8/Issue579.java | 2 ++ framework/tests/all-systems/Issue7680.java | 4 +++ .../flow/PurityFunctionalArgumentJdk.java | 36 ++++++++----------- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/checker/tests/nullness/java8/Issue579.java b/checker/tests/nullness/java8/Issue579.java index a01ac4940300..1208899f2b37 100644 --- a/checker/tests/nullness/java8/Issue579.java +++ b/checker/tests/nullness/java8/Issue579.java @@ -2,10 +2,12 @@ // https://github.com/typetools/checker-framework/issues/579 import java.util.Comparator; +import org.checkerframework.dataflow.qual.SideEffectFree; public class Issue579 implements Comparator { private final Comparator real; + @SideEffectFree @SuppressWarnings("unchecked") Issue579(Comparator real) { this.real = (Comparator) real; diff --git a/framework/tests/all-systems/Issue7680.java b/framework/tests/all-systems/Issue7680.java index fa125a378486..c3e297c6e2a4 100644 --- a/framework/tests/all-systems/Issue7680.java +++ b/framework/tests/all-systems/Issue7680.java @@ -1,9 +1,13 @@ import java.util.Comparator; +import org.checkerframework.dataflow.qual.Pure; public class Issue7680 { interface Foo { + // Comparator.comparing and Comparator.thenComparing require a side-effect-free key extractor. + @Pure int x(); + @Pure int y(); } diff --git a/framework/tests/flow/PurityFunctionalArgumentJdk.java b/framework/tests/flow/PurityFunctionalArgumentJdk.java index 873002bba4bf..9dad1101cb12 100644 --- a/framework/tests/flow/PurityFunctionalArgumentJdk.java +++ b/framework/tests/flow/PurityFunctionalArgumentJdk.java @@ -7,24 +7,13 @@ import java.util.Comparator; import java.util.List; import java.util.Optional; +import java.util.function.Function; import java.util.stream.Stream; -import org.checkerframework.dataflow.qual.Pure; public class PurityFunctionalArgumentJdk { int count = 0; - /** - * Collections.min is @Pure, so a comparator passed to it must be @Pure, not merely pure of side - * effects. - */ - @FunctionalInterface - interface PureComparator extends Comparator { - @Override - @Pure - int compare(T a, T b); - } - // Optional.map is @SideEffectFree: "the mapper must not have side effects". void optionalMap(Optional o) { @@ -34,26 +23,29 @@ void optionalMap(Optional o) { } // Collections.min is @Pure and List.sort is @SideEffectsOnly("this"), so each requires a - // side-effect-free comparator. Comparator.compare is not annotated, so a value of that type - // does not have one. + // side-effect-free comparator. Comparator.compare is @Pure, so a value of that type has one. void comparatorArguments(Collection c, List l, Comparator comparator) { - // :: error: [purity.functional.argument] java.util.Collections.min(c, comparator); - // :: error: [purity.functional.argument] l.sort(comparator); } - /** Declaring the purity in the comparator's own type is what makes such a value passable. */ - void pureComparatorArguments(Collection c, List l, PureComparator pure) { - java.util.Collections.min(c, pure); - l.sort(pure); + /** + * A value whose functional method promises no purity cannot be passed: Function.apply is not + * annotated. + */ + void unannotatedFunctionalValue(Optional o, Function f) { + // :: error: [purity.functional.argument] + o.map(f); } - /** A comparator written at the call site is checked directly, and this one is pure. */ + /** + * A comparator written at the call site is checked directly, against the @Pure on + * Comparator.compare. + */ void comparatorWrittenHere(List l) { l.sort((a, b) -> a.length() - b.length()); - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.not.deterministic.not.sideeffectfree.assign.field] l.sort((a, b) -> count++); } From f8f578b1e8c3639588f937d3186a054b6df4d38e Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Mon, 21 Sep 2026 09:10:58 -0700 Subject: [PATCH 18/43] Add some purity annotations. --- .../framework/ajava/InsertAjavaAnnotations.java | 2 ++ .../java/org/checkerframework/javacutil/AnnotationUtils.java | 1 + 2 files changed, 3 insertions(+) diff --git a/framework/src/main/java/org/checkerframework/framework/ajava/InsertAjavaAnnotations.java b/framework/src/main/java/org/checkerframework/framework/ajava/InsertAjavaAnnotations.java index 90b4353e0354..f679bf572db1 100644 --- a/framework/src/main/java/org/checkerframework/framework/ajava/InsertAjavaAnnotations.java +++ b/framework/src/main/java/org/checkerframework/framework/ajava/InsertAjavaAnnotations.java @@ -54,6 +54,7 @@ import org.checkerframework.checker.regex.qual.Regex; import org.checkerframework.checker.signature.qual.DotSeparatedIdentifiers; import org.checkerframework.checker.signature.qual.FullyQualifiedName; +import org.checkerframework.dataflow.qual.Pure; import org.checkerframework.framework.stub.AnnotationFileParser; import org.checkerframework.framework.util.JavaParserUtil; import org.checkerframework.framework.util.StaticJavaParserUtil; @@ -520,6 +521,7 @@ public String insertAnnotations( * @return a negative integer, zero, or a positive integer if {@code insertion1} belongs before, * at the same position, or after {@code insertion2} respectively in the above ordering */ + @Pure private static int compareInsertions(Insertion insertion1, Insertion insertion2) { int cmp = Integer.compare(insertion1.position, insertion2.position); if (cmp == 0 && (insertion1.ownLine != insertion2.ownLine)) { diff --git a/javacutil/src/main/java/org/checkerframework/javacutil/AnnotationUtils.java b/javacutil/src/main/java/org/checkerframework/javacutil/AnnotationUtils.java index 89899771db89..8dba22ce3539 100644 --- a/javacutil/src/main/java/org/checkerframework/javacutil/AnnotationUtils.java +++ b/javacutil/src/main/java/org/checkerframework/javacutil/AnnotationUtils.java @@ -396,6 +396,7 @@ public static boolean containsSameByName( * @param a2 the second annotation * @return an ordering over AnnotationMirrors based on their name and values */ + @Pure public static int compareAnnotationMirrors(AnnotationMirror a1, AnnotationMirror a2) { int nameComparison = compareByName(a1, a2); if (nameComparison != 0) { From b6349bb7c4230b210b0d5b0492e2be1d102f6a53 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Mon, 21 Sep 2026 10:01:52 -0700 Subject: [PATCH 19/43] Remove change log entry. --- docs/CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 614b30af5b43..3a278da9c7eb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,12 +12,6 @@ The Checker Framework runs noticeably faster, due to performance tuning. The Purity Checker has been improved, so `-AcheckPurityAnnotations` may issue warnings that it did not previously. -The `-AassumeSideEffectFree`, `-AassumeDeterministic`, `-AassumePure`, and -`-AassumePureGetters` command-line options now apply to a called method that has -no purity annotation, which is what their documentation promises and what makes -them useful for an unannotated library. Previously, the Purity Checker applied -them only to a method that was already annotated. - ### Changes for type system implementers `PurityChecker.checkPurity()` takes two new arguments: the method declaration From 43b51bbaab6be0a526bd52aadfd896950d4ccdbd Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Tue, 22 Sep 2026 08:58:48 -0700 Subject: [PATCH 20/43] Fix two compilation errors `purityForInference` called an overload of `PurityChecker.checkPurity` that no longer exists. It now takes the method declaration that encloses the body, and its two callers pass the same one that the corresponding type-checking call passes. `kindsRequiredOfArguments` keeps that from inferring a kind that would constrain arguments at call sites. A javadoc link named `OverrideChecker#checkPurity`, which is now `checkMethodReferencePurity`. --- .../common/basetype/BaseTypeVisitor.java | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index 584d07c772f2..14b65141af72 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1152,12 +1152,22 @@ protected boolean shouldPerformContractInference() { * produced it, and would be trusted by a later run that makes no such assumption. * * @param body the path to the body, or null if the method has no body + * @param enclosingMethod the method declaration that lexically encloses {@code body}, or null if + * none does * @return the purity of {@code body}, ignoring the {@code -Aassume*} command-line options */ - private PurityResult purityForInference(@Nullable TreePath body) { + private PurityResult purityForInference( + @Nullable TreePath body, @Nullable MethodTree enclosingMethod) { return body == null ? new PurityResult() - : PurityChecker.checkPurity(body, atypeFactory, false, false, false); + : PurityChecker.checkPurity( + body, + atypeFactory, + enclosingMethod, + atypeFactory.getProcessingEnv(), + false, + false, + false); } /** @@ -1218,7 +1228,7 @@ protected void checkPurityAnnotations(MethodTree tree) { if (suggestPureMethods && !TreeUtils.isSynthetic(tree)) { // Issue a warning if the method is pure, but not annotated as such. - EnumSet additionalKinds = purityForInference(body).getKinds().clone(); + EnumSet additionalKinds = purityForInference(body, tree).getKinds().clone(); if (!infer) { // During WPI, propagate all purity kinds, even those that are already // present (because they were inferred in a previous WPI round). @@ -1442,7 +1452,7 @@ protected void checkConstructorResult( * *

    This check is skipped for a requirement that the parameter's own functional method already * makes, because {@link #checkLambdaPurity} and {@link - * BaseTypeVisitor.OverrideChecker#checkPurity} check that. + * BaseTypeVisitor.OverrideChecker#checkMethodReferencePurity} check that. * * @param callee the invoked method or constructor * @param args the arguments to {@code callee} @@ -2842,7 +2852,10 @@ protected void checkLambdaPurity( // A lambda implements the functional interface method, so it constrains that method's // purity just as an overriding method does; see the treatment of overridden methods in // `checkPurityAnnotations`. - EnumSet lambdaKinds = purityForInference(body).getKinds().clone(); + EnumSet lambdaKinds = + purityForInference(body, TreePathUtil.enclosingMethod(getCurrentPath())) + .getKinds() + .clone(); if (functionalMethod.getReturnType().getKind() == TypeKind.VOID) { lambdaKinds.remove(PurityKind.DETERMINISTIC); } From a81d8846044e44f3036abf0111226b85ae3f08d2 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Tue, 22 Sep 2026 08:59:22 -0700 Subject: [PATCH 21/43] Require determinism of a void callback that is not side-effect-free A functional method that returns no value is deterministic only if it is also side-effect-free, which is the rule that PurityChecker applies to a call. Two places granted determinism to every void method instead: * purityRequiredOfArgument removed DETERMINISTIC from what a callee requires of a functional argument. * implementationPurityKinds added DETERMINISTIC to the purity of the method that a method reference denotes. Together they let a callback violate a @Deterministic callee's promise. The body of @Deterministic int callee(Runnable r) { r.run(); return count; } credits `r.run()` with the determinism that `callee` requires of its functional arguments, but no call site required anything of them, so `callee(() -> count++)` type-checked and two calls returned different values. Determinism is now dropped only when side-effect-freeness is guaranteed: the functional method declares @SideEffectFree, the callee requires it of the argument anyway, or -AassumeSideEffectFree is in effect. In implementationPurityKinds the command-line assumptions are applied before that test, so they feed into it. --- .../common/basetype/BaseTypeVisitor.java | 31 +++++++++++----- .../flow/PurityFunctionalArgumentKinds.java | 37 ++++++++++++++++++- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index 14b65141af72..6575e6e59a4a 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1516,7 +1516,8 @@ protected void checkFunctionalArguments( /** * Returns the purity that a method whose purity is {@code methodKinds} requires of the argument * that is passed to a functional-interface parameter whose functional method is {@code - * paramFunction}: the kinds that {@code paramFunction} does not already promise. + * paramFunction}: the kinds that {@code paramFunction} does not already promise, less determinism + * when the argument is a side-effect-free method that returns no value. * * @param paramFunction the functional method of a parameter's type * @param methodKinds the purity that a method requires of its functional-interface arguments @@ -1524,10 +1525,18 @@ protected void checkFunctionalArguments( */ private EnumSet purityRequiredOfArgument( ExecutableElement paramFunction, EnumSet methodKinds) { + EnumSet paramKinds = PurityUtils.getPurityKinds(atypeFactory, paramFunction); EnumSet required = EnumSet.copyOf(methodKinds); - required.removeAll(PurityUtils.getPurityKinds(atypeFactory, paramFunction)); - if (paramFunction.getReturnType().getKind() == TypeKind.VOID) { - // A functional method that returns no value is deterministic, whatever implements it. + required.removeAll(paramKinds); + if (paramFunction.getReturnType().getKind() == TypeKind.VOID + && (assumeSideEffectFree + || paramKinds.contains(PurityKind.SIDE_EFFECT_FREE) + || methodKinds.contains(PurityKind.SIDE_EFFECT_FREE))) { + // A side-effect-free method that returns no value is deterministic: two calls return the + // same (absent) value. The argument is side-effect-free either because the functional + // method promises it or because it is required here. Without that guarantee the argument + // must still be checked for determinism, since its side effects can make a later call + // compute a different value. required.remove(PurityKind.DETERMINISTIC); } return required; @@ -1731,17 +1740,14 @@ private static boolean isArrayConstructorReference(MemberReferenceTree tree) { /** * Returns the purity of a method that implements a functional method: its declared purity, plus - * determinism if it returns no value, plus whatever the command-line assumptions grant it. + * whatever the command-line assumptions grant it, plus determinism if it is side-effect-free and + * returns no value. * * @param method a method or constructor * @return the purity kinds of {@code method} */ private EnumSet implementationPurityKinds(ExecutableElement method) { EnumSet result = EnumSet.copyOf(PurityUtils.getPurityKinds(atypeFactory, method)); - if (method.getKind() != ElementKind.CONSTRUCTOR - && method.getReturnType().getKind() == TypeKind.VOID) { - result.add(PurityKind.DETERMINISTIC); - } // Like PurityChecker, apply each assumption to every method, including one with no purity // annotation, so that a method reference is treated exactly like a lambda whose body calls // the referenced method. @@ -1755,6 +1761,13 @@ private EnumSet implementationPurityKinds(ExecutableElement method) result.add(PurityKind.SIDE_EFFECT_FREE); result.add(PurityKind.DETERMINISTIC); } + if (method.getKind() != ElementKind.CONSTRUCTOR + && method.getReturnType().getKind() == TypeKind.VOID + && result.contains(PurityKind.SIDE_EFFECT_FREE)) { + // Like PurityChecker, treat a side-effect-free method that returns no value as + // deterministic: two calls return the same (absent) value. + result.add(PurityKind.DETERMINISTIC); + } return result; } diff --git a/framework/tests/flow/PurityFunctionalArgumentKinds.java b/framework/tests/flow/PurityFunctionalArgumentKinds.java index f372375d4894..da4561325948 100644 --- a/framework/tests/flow/PurityFunctionalArgumentKinds.java +++ b/framework/tests/flow/PurityFunctionalArgumentKinds.java @@ -84,8 +84,8 @@ void lambdaArgumentsToDeterministicCallee(String s) { sideEffectFreeCallee(t -> deterministicOnly(t), s); } - // A functional method that returns no value is deterministic, whatever implements it, so a @Pure - // callee requires only side-effect-freeness of it. + // A side-effect-free functional method that returns no value is deterministic, whatever + // implements it, so a @Pure callee requires only side-effect-freeness of it. @Pure static int pureRunnableCallee(Runnable r) { @@ -95,6 +95,11 @@ static int pureRunnableCallee(Runnable r) { @SideEffectFree void sideEffectFreeVoid() {} + @SideEffectFree + int sideEffectFreeNotDeterministic() { + return count; + } + void impureVoid() { count++; } @@ -102,12 +107,40 @@ void impureVoid() { void voidFunctionalMethod() { pureRunnableCallee(() -> sideEffectFreeVoid()); pureRunnableCallee(this::sideEffectFreeVoid); + // The lambda is not deterministic, but as a side-effect-free void callback it is. + pureRunnableCallee(() -> sideEffectFreeNotDeterministic()); // :: error: [purity.not.sideeffectfree.call] pureRunnableCallee(() -> impureVoid()); // :: error: [purity.functional.argument] pureRunnableCallee(this::impureVoid); } + // A @Deterministic callee does not require side-effect-freeness of its functional arguments, so + // determinism is still required of a void callback: the callback's side effects could make a + // later call to the callee compute a different value. + + @Deterministic + static int deterministicRunnableCallee(Runnable r) { + return 0; + } + + void voidFunctionalMethodDeterministicCallee() { + deterministicRunnableCallee(() -> sideEffectFreeVoid()); + deterministicRunnableCallee(this::sideEffectFreeVoid); + // :: error: [purity.not.deterministic.call] + deterministicRunnableCallee(() -> impureVoid()); + // :: error: [purity.functional.argument] + deterministicRunnableCallee(this::impureVoid); + // :: error: [purity.not.deterministic.assign.field] + deterministicRunnableCallee(() -> count++); + // Conservative: the callback discards the value, so a side-effect-free callback is + // deterministic, but the requirement is checked against the code that the argument denotes. + // :: error: [purity.not.deterministic.call] + deterministicRunnableCallee(() -> sideEffectFreeNotDeterministic()); + // :: error: [purity.functional.argument] + deterministicRunnableCallee(this::sideEffectFreeNotDeterministic); + } + // A method reference's annotations must cover the callee's. void methodReferenceArguments(String s) { From db685ba613d3aa8295fc8e707f158c4948037be6 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Tue, 22 Sep 2026 09:13:44 -0700 Subject: [PATCH 22/43] Ignore a cast of a functional argument that denotes code checkFunctionalArgument removed only parentheses from an argument, so a cast fell through to the branch that checks an argument against the functional method of its declared type. A cast of a lambda or a method reference was therefore checked against the functional method of the cast's type, which says nothing about the code that the argument denotes: callee((Function) t -> t.length(), s); callee((Function) this::pureLength, s); Both were rejected at a call to a @SideEffectFree method, and a cast of an impure lambda was reported as purity.functional.argument rather than at the statement in its body. A cast does not change the code that a lambda, a method reference, a conditional expression, or a switch expression denotes, so the operand is now what is checked. A cast of any other expression is left in place: its type is the type of the value that the call receives, just as for an argument that is not cast. --- .../common/basetype/BaseTypeVisitor.java | 29 +++++++++++++++++-- .../tests/flow/PurityFunctionalArgument.java | 16 ++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index 6575e6e59a4a..8aa9bf1dda0b 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1448,7 +1448,8 @@ protected void checkConstructorResult( * needs no check, since the caller of that method already performed one. Each initializer of an * array literal is checked on its own, because such an argument is the array that a varargs call * passes in its array form. Any other argument is checked against the functional method of its - * declared type. + * declared type. A cast of an argument that denotes code is ignored; see {@link + * #withoutCastsOfCode}. * *

    This check is skipped for a requirement that the parameter's own functional method already * makes, because {@link #checkLambdaPurity} and {@link @@ -1568,6 +1569,30 @@ private EnumSet kindsRequiredOfArguments( return result; } + /** + * Returns {@code expr} without parentheses, and also without casts if what they wrap denotes + * code: a lambda, a method reference, a conditional expression, or a switch expression. A cast of + * such an expression does not change the code that the expression denotes, so the operand is what + * {@link #checkFunctionalArgument} checks. + * + *

    A cast of any other expression is left in place, so that the argument is checked against the + * functional method of the cast's type. That is the type of the value that the call receives, + * just as for an argument that is not cast. + * + * @param expr an expression + * @return {@code expr} without parentheses, and without casts of an expression that denotes code + */ + private static ExpressionTree withoutCastsOfCode(ExpressionTree expr) { + ExpressionTree withoutParens = TreeUtils.withoutParens(expr); + ExpressionTree operand = TreeUtils.withoutParensOrCasts(withoutParens); + return operand instanceof LambdaExpressionTree + || operand instanceof MemberReferenceTree + || operand instanceof ConditionalExpressionTree + || operand instanceof SwitchExpressionTree + ? operand + : withoutParens; + } + /** * Checks that one argument to a functional-interface parameter has the given purity. * @@ -1584,7 +1609,7 @@ protected void checkFunctionalArgument( VariableElement param, ExecutableElement callee) { ProcessingEnvironment env = atypeFactory.getProcessingEnv(); - ExpressionTree argument = TreeUtils.withoutParens(arg); + ExpressionTree argument = withoutCastsOfCode(arg); if (argument instanceof LambdaExpressionTree lambda) { TreePath lambdaPath = new TreePath(getCurrentPath(), argument); diff --git a/framework/tests/flow/PurityFunctionalArgument.java b/framework/tests/flow/PurityFunctionalArgument.java index b4b6d6150a71..dc8c5a59dcc2 100644 --- a/framework/tests/flow/PurityFunctionalArgument.java +++ b/framework/tests/flow/PurityFunctionalArgument.java @@ -150,6 +150,22 @@ void parenthesizedArguments(boolean b, String s) { callee(b ? (this::pureLength) : (this::impureLength), s); } + // A cast does not change the code that an argument denotes, so the operand is checked. + + void castArguments(String s) { + callee((Function) t -> t.length(), s); + // :: error: [purity.not.sideeffectfree.assign.field] + callee((Function) t -> count++, s); + callee((Function) this::pureLength, s); + // :: error: [purity.functional.argument] + callee((Function) this::impureLength, s); + // A cast of any other argument is checked against the functional method of the cast's type, + // just as an uncast argument is checked against the functional method of its own type. + // :: error: [purity.functional.argument] + callee((Function) annotatedVariable, s); + callee((PureFunction) unannotatedVariable, s); + } + // The null literal denotes no code, so there is nothing to check. void nullArguments(boolean b, int i, String s) { From 8d76d27149ea4c0a993851543c432300a36bda37 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Tue, 22 Sep 2026 10:02:29 -0700 Subject: [PATCH 23/43] Do not infer a purity kind that becomes required once another is discarded Discarding one kind can make another one required: determinism is required of an argument that returns no value only when the argument is side-effect-free. Iterate until what remains requires nothing of arguments. --- .../common/basetype/BaseTypeVisitor.java | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index 8aa9bf1dda0b..daa50c1d5587 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1241,7 +1241,7 @@ protected void checkPurityAnnotations(MethodTree tree) { WholeProgramInference wpi = atypeFactory.getWholeProgramInference(); ExecutableElement methodElt = TreeUtils.elementFromDeclaration(tree); // Do not infer a kind that would newly constrain the arguments at call sites. - additionalKinds.removeAll(kindsRequiredOfArguments(methodElt, additionalKinds)); + additionalKinds = kindsSafeToInfer(methodElt, additionalKinds); inferPurityAnno(additionalKinds, wpi, methodElt); // The purity of overridden methods is impacted by the purity of this method. If // a superclass method is pure, but an implementation in a subclass is not, WPI @@ -1544,27 +1544,36 @@ private EnumSet purityRequiredOfArgument( } /** - * Returns the subset of {@code kinds} that {@code method} would require of the arguments at its - * call sites, if {@code method} were annotated with {@code kinds}: those that the functional - * method of some functional-interface parameter does not already promise. See {@link - * #checkFunctionalArguments}. + * Returns the subset of {@code kinds} that whole-program inference may infer for {@code method}: + * those that no call to {@code method} would require of an argument that is passed to a + * functional-interface parameter. See {@link #checkFunctionalArguments}. * - *

    Whole-program inference does not infer such a kind. It cannot annotate a lambda expression - * or a method reference, so it cannot make an argument meet the requirement, and inferring the - * kind would introduce errors at call sites. + *

    Inference cannot annotate a lambda expression or a method reference, so it cannot make an + * argument meet such a requirement; inferring the kind would introduce errors at call sites. + * + *

    Discarding one kind can make another one required: determinism is required of an argument + * that returns no value only when the argument is known to be side-effect-free. The computation + * therefore repeats until what remains requires nothing. * * @param method a method or constructor * @param kinds the purity kinds that might be inferred for {@code method} - * @return the subset of {@code kinds} that calls to {@code method} would require of arguments + * @return the subset of {@code kinds} that calls to {@code method} would not require of arguments */ - private EnumSet kindsRequiredOfArguments( + private EnumSet kindsSafeToInfer( ExecutableElement method, EnumSet kinds) { - EnumSet result = EnumSet.noneOf(PurityKind.class); - for (int i = 0; i < method.getParameters().size(); i++) { - ExecutableElement paramFunction = parameterFunctionalMethod(method, i); - if (paramFunction != null) { - result.addAll(purityRequiredOfArgument(paramFunction, kinds)); + EnumSet result = EnumSet.copyOf(kinds); + while (!result.isEmpty()) { + EnumSet required = EnumSet.noneOf(PurityKind.class); + for (int i = 0; i < method.getParameters().size(); i++) { + ExecutableElement paramFunction = parameterFunctionalMethod(method, i); + if (paramFunction != null) { + required.addAll(purityRequiredOfArgument(paramFunction, result)); + } + } + if (required.isEmpty()) { + break; } + result.removeAll(required); } return result; } @@ -2898,7 +2907,7 @@ protected void checkLambdaPurity( lambdaKinds.remove(PurityKind.DETERMINISTIC); } // Do not infer a kind that would newly constrain the arguments at call sites. - lambdaKinds.removeAll(kindsRequiredOfArguments(functionalMethod, lambdaKinds)); + lambdaKinds = kindsSafeToInfer(functionalMethod, lambdaKinds); WholeProgramInference wpi = atypeFactory.getWholeProgramInference(); inferPurityAnno(lambdaKinds, wpi, functionalMethod); for (ExecutableElement overriddenElt : From d3faa74de5a7511eca6dded069d174e1a4bef2e0 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Tue, 22 Sep 2026 10:11:08 -0700 Subject: [PATCH 24/43] Fix nullness errors in purity inference and functional-argument checks --- .../common/basetype/BaseTypeVisitor.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index daa50c1d5587..2becd1ec68df 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1240,6 +1240,9 @@ protected void checkPurityAnnotations(MethodTree tree) { if (infer) { WholeProgramInference wpi = atypeFactory.getWholeProgramInference(); ExecutableElement methodElt = TreeUtils.elementFromDeclaration(tree); + if (wpi == null || methodElt == null) { + throw new BugInCF("No WPI or no element while inferring purity of " + tree); + } // Do not infer a kind that would newly constrain the arguments at call sites. additionalKinds = kindsSafeToInfer(methodElt, additionalKinds); inferPurityAnno(additionalKinds, wpi, methodElt); @@ -1684,8 +1687,9 @@ protected void checkFunctionalArgument( ExecutableElement referenced = (ExecutableElement) TreeUtils.elementFromUse(argument); argKinds = implementationPurityKinds(referenced); MethodTree enclosingMethod = TreePathUtil.enclosingMethod(getCurrentPath()); - if (PurityChecker.isFunctionalMethodOfParameter( - memberReference.getQualifierExpression(), referenced, enclosingMethod, env)) { + if (enclosingMethod != null + && PurityChecker.isFunctionalMethodOfParameter( + memberReference.getQualifierExpression(), referenced, enclosingMethod, env)) { // `f::apply`, where `f` is a functional-interface parameter of the enclosing method, // denotes the code that the caller of that method was required to check. argKinds.addAll( @@ -1703,7 +1707,8 @@ protected void checkFunctionalArgument( } argKinds = implementationPurityKinds(argFunction); MethodTree enclosingMethod = TreePathUtil.enclosingMethod(getCurrentPath()); - if (PurityChecker.isFunctionalInterfaceParameter(argument, enclosingMethod, env)) { + if (enclosingMethod != null + && PurityChecker.isFunctionalInterfaceParameter(argument, enclosingMethod, env)) { argKinds.addAll( PurityChecker.functionalParameterKinds( atypeFactory, TreeUtils.elementFromDeclaration(enclosingMethod))); @@ -2909,6 +2914,9 @@ protected void checkLambdaPurity( // Do not infer a kind that would newly constrain the arguments at call sites. lambdaKinds = kindsSafeToInfer(functionalMethod, lambdaKinds); WholeProgramInference wpi = atypeFactory.getWholeProgramInference(); + if (wpi == null) { + throw new BugInCF("No WPI while inferring purity of " + tree); + } inferPurityAnno(lambdaKinds, wpi, functionalMethod); for (ExecutableElement overriddenElt : ElementUtils.getOverriddenMethods(functionalMethod, types)) { From 0702c591fac28bcfee47044598d9546efe81c619 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Wed, 23 Sep 2026 07:57:23 -0700 Subject: [PATCH 25/43] Do not apply -Aassume* options when computing purity kinds safe to infer --- .../common/basetype/BaseTypeVisitor.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index 4200f95c47d0..e9566462dc1a 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1488,7 +1488,7 @@ protected void checkFunctionalArguments( if (paramFunction == null) { continue; } - EnumSet required = purityRequiredOfArgument(paramFunction, calleeKinds); + EnumSet required = purityRequiredOfArgument(paramFunction, calleeKinds, true); if (required.isEmpty()) { continue; } @@ -1525,15 +1525,17 @@ protected void checkFunctionalArguments( * * @param paramFunction the functional method of a parameter's type * @param methodKinds the purity that a method requires of its functional-interface arguments + * @param applyAssumptions whether to apply the {@code -AassumeSideEffectFree} and {@code + * -AassumePure} command-line options * @return the purity required of the argument, which may be empty */ private EnumSet purityRequiredOfArgument( - ExecutableElement paramFunction, EnumSet methodKinds) { + ExecutableElement paramFunction, EnumSet methodKinds, boolean applyAssumptions) { EnumSet paramKinds = PurityUtils.getPurityKinds(atypeFactory, paramFunction); EnumSet required = EnumSet.copyOf(methodKinds); required.removeAll(paramKinds); if (paramFunction.getReturnType().getKind() == TypeKind.VOID - && (assumeSideEffectFree + && ((applyAssumptions && assumeSideEffectFree) || paramKinds.contains(PurityKind.SIDE_EFFECT_FREE) || methodKinds.contains(PurityKind.SIDE_EFFECT_FREE))) { // A side-effect-free method that returns no value is deterministic: two calls return the @@ -1570,7 +1572,7 @@ private EnumSet kindsSafeToInfer( for (int i = 0; i < method.getParameters().size(); i++) { ExecutableElement paramFunction = parameterFunctionalMethod(method, i); if (paramFunction != null) { - required.addAll(purityRequiredOfArgument(paramFunction, result)); + required.addAll(purityRequiredOfArgument(paramFunction, result, false)); } } if (required.isEmpty()) { From f95bdb7924d4ce289855cfa35e03150be90c0c68 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Wed, 23 Sep 2026 08:22:27 -0700 Subject: [PATCH 26/43] Do not expect JavaParser nodes for a cast of a method reference JavaParser does not parse a cast of a method reference correctly (https://github.com/javaparser/javaparser/issues/3855), so JointJavacJavaParserVisitor skips it. ExpectedTreesVisitor now skips it too, so -AajavaChecks does not crash on such a cast. --- .../framework/ajava/ExpectedTreesVisitor.java | 13 +++++++++++++ .../tests/all-systems/CastMethodReference.java | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 framework/tests/all-systems/CastMethodReference.java diff --git a/framework/src/main/java/org/checkerframework/framework/ajava/ExpectedTreesVisitor.java b/framework/src/main/java/org/checkerframework/framework/ajava/ExpectedTreesVisitor.java index 89076458e270..9e90acd3bdb4 100644 --- a/framework/src/main/java/org/checkerframework/framework/ajava/ExpectedTreesVisitor.java +++ b/framework/src/main/java/org/checkerframework/framework/ajava/ExpectedTreesVisitor.java @@ -14,6 +14,7 @@ import com.sun.source.tree.IfTree; import com.sun.source.tree.ImportTree; import com.sun.source.tree.LambdaExpressionTree; +import com.sun.source.tree.MemberReferenceTree; import com.sun.source.tree.MemberSelectTree; import com.sun.source.tree.MethodInvocationTree; import com.sun.source.tree.MethodTree; @@ -26,6 +27,7 @@ import com.sun.source.tree.SynchronizedTree; import com.sun.source.tree.Tree; import com.sun.source.tree.Tree.Kind; +import com.sun.source.tree.TypeCastTree; import com.sun.source.tree.VariableTree; import com.sun.source.tree.WhileLoopTree; import com.sun.source.tree.YieldTree; @@ -383,6 +385,17 @@ public Void visitLambdaExpression(LambdaExpressionTree tree, Void p) { return null; } + @Override + public Void visitTypeCast(TypeCastTree tree, Void p) { + if (tree.getExpression() instanceof MemberReferenceTree) { + // JavaParser does not parse a cast of a method reference correctly, so + // JointJavacJavaParserVisitor skips the cast and its subtrees: + // https://github.com/javaparser/javaparser/issues/3855 + return null; + } + return super.visitTypeCast(tree, p); + } + @Override public Void visitBindingPattern(BindingPatternTree tree, Void unused) { super.visitBindingPattern(tree, unused); diff --git a/framework/tests/all-systems/CastMethodReference.java b/framework/tests/all-systems/CastMethodReference.java new file mode 100644 index 000000000000..1d9193a86f17 --- /dev/null +++ b/framework/tests/all-systems/CastMethodReference.java @@ -0,0 +1,17 @@ +import java.util.function.Function; + +// JavaParser does not parse a cast of a method reference correctly: +// https://github.com/javaparser/javaparser/issues/3855 +public class CastMethodReference { + Object simpleType() { + return (Runnable) this::hashCode; + } + + Object parameterizedType() { + return (Function) String::length; + } + + Object parenthesized() { + return (Function) (String::length); + } +} From b9a395b2b6cfe4d14211c13e96c5f6a42e2d31f2 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Wed, 23 Sep 2026 08:38:04 -0700 Subject: [PATCH 27/43] Document functional-parameter rules for all purity annotations --- docs/manual/purity-checker.tex | 62 +++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/docs/manual/purity-checker.tex b/docs/manual/purity-checker.tex index fcb8864639bb..d8ec38e51334 100644 --- a/docs/manual/purity-checker.tex +++ b/docs/manual/purity-checker.tex @@ -85,10 +85,27 @@ \end{Verbatim} -\subsectionAndLabel{Functional-interface parameters of side-effect-free methods}{purity-functional-interface-parameters} +\subsectionAndLabel{Functional-interface parameters of methods with purity annotations}{purity-functional-interface-parameters} -For a side-effect-free method, the functional method of any parameter that is -a functional interface is treated as side-effect-free. +A method annotated with a purity annotation may call the functional method of +its own functional-interface parameters. Such a call is treated as having the +purity that the method promises: + +\begin{itemize} +\item + In a \refqualclass{dataflow/qual}{SideEffectFree} method, the call is + treated as side-effect-free. +\item + In a \refqualclass{dataflow/qual}{Deterministic} method, the call is + treated as deterministic. +\item + In a \refqualclass{dataflow/qual}{Pure} method, the call is treated as + pure. +\item + In a \refqualclass{dataflow/qual}{SideEffectsOnly} method, the call is + treated as side-effect-free. The method itself may modify the expressions + listed in its annotation, but the code it is handed may not modify anything. +\end{itemize} \begin{Verbatim} @SideEffectFree @@ -97,21 +114,50 @@ } \end{Verbatim} -At calls to \, the functional method of the argument's declared type must -be \<@SideEffectFree>. +The assumption applies only when the parameter is effectively final and the +functional method is called directly on the parameter. It does not apply to a +call through a local variable or field that aliases the parameter, nor to a +method of the parameter's type other than its functional method, such as +\: \begin{Verbatim} - m(String::length, "hi"); // OK: String.length() is side-effect-free + @SideEffectFree + int m2(Function f, String s) { + Function g = f; + return g.apply(s); // error: g is not a parameter + } \end{Verbatim} -If the argument is a lambda, then the body of the lambda is checked directly -for side effects. +At each call to such a method, every argument passed to a functional-interface +parameter must have the purity that the method promises (side-effect-free for +a \<@SideEffectsOnly> method). How the argument is checked depends on its +form: + +\begin{itemize} +\item + A method reference is checked via the referenced method, which must have + the required purity annotations. +\item + A lambda is checked via its body, which must have the required purity. +\item + Any other argument is checked via the functional method of the argument's + declared type, which must have the required purity annotations. +\end{itemize} \begin{Verbatim} + m(String::length, "hi"); // OK: String.length() is side-effect-free m(s -> s.length(), "hi"); // OK: the lambda body is side-effect-free m(s -> count++, "hi"); // error: the lambda body has a side effect + + void caller(Function g) { + m(g, "hi"); // error: [purity.functional.argument] Function.apply is not @SideEffectFree + } \end{Verbatim} +If the functional method returns \, a \<@Pure> method requires only +that the argument be side-effect-free, because a side-effect-free call that +returns no value is deterministic. + %% TODO: we could implement a type system for functional interfaces with a %% qualifier \<@SideEffectFreeFunctionalInterface>. Then the default for a %% \<@SideEffectFree> method would be: From 014046f22aa5ddfc69956b422757896da623f26d Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Wed, 23 Sep 2026 09:33:31 -0700 Subject: [PATCH 28/43] Suppress warnings. --- framework/tests/all-systems/CastMethodReference.java | 1 + 1 file changed, 1 insertion(+) diff --git a/framework/tests/all-systems/CastMethodReference.java b/framework/tests/all-systems/CastMethodReference.java index 1d9193a86f17..543ee7714a2b 100644 --- a/framework/tests/all-systems/CastMethodReference.java +++ b/framework/tests/all-systems/CastMethodReference.java @@ -2,6 +2,7 @@ // JavaParser does not parse a cast of a method reference correctly: // https://github.com/javaparser/javaparser/issues/3855 +@SuppressWarnings("all") // check for crashes public class CastMethodReference { Object simpleType() { return (Runnable) this::hashCode; From 2fbd8062f66d0f112c27caa28936acdd9ff6abb3 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Wed, 23 Sep 2026 12:38:49 -0700 Subject: [PATCH 29/43] Restrict functional-parameter purity to what every overridden declaration checks A call through an overridden method, or through the functional method that a method reference implements, checks arguments against that declaration. Grant a functional-interface parameter only the purity that all of them require, and reject method references whose functional method checks less than the referenced method assumes. Clarify the manual's side-effect-free definition and the varargs argument rule. --- .../dataflow/util/PurityChecker.java | 170 +++++++++++++----- docs/manual/purity-checker.tex | 19 +- .../common/basetype/BaseTypeVisitor.java | 78 ++++++-- .../common/basetype/messages.properties | 1 + .../tests/flow/PurityFunctionalMethodRef.java | 48 +++++ .../tests/flow/PurityFunctionalOverride.java | 33 ++++ 6 files changed, 291 insertions(+), 58 deletions(-) create mode 100644 framework/tests/flow/PurityFunctionalMethodRef.java diff --git a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java index dd8240e70e5c..c5c3ea8f1936 100644 --- a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java +++ b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java @@ -72,9 +72,10 @@ private PurityChecker() { * @param annoProvider the annotation provider * @param enclosingMethod the method declaration that lexically encloses {@code statement}, or * null if none does. A call to the functional method of one of that method's - * functional-interface parameters has the method's own purity; see {@link - * #isFunctionalInterfaceParameter}. Pass the enclosing method for a lambda body too: the body - * is checked against the functional method that the lambda implements, but the enclosing + * functional-interface parameters has the purity that the method's callers required of the + * argument; see {@link #functionalParameterPurity(AnnotationProvider, ExpressionTree, + * MethodTree, ProcessingEnvironment)}. Pass the enclosing method for a lambda body too: the + * body is checked against the functional method that the lambda implements, but the enclosing * method's parameters still hold values that its caller was required to check, whenever the * lambda runs. Pass null for an arbitrary expression, which no method's contract governs. * @param env the processing environment; used only if {@code enclosingMethod} is non-null @@ -129,26 +130,6 @@ public static EnumSet functionalParameterKinds( return result; } - /** - * Returns true if {@code expr} is an effectively final formal parameter of {@code method} whose - * type is a functional interface. - * - *

    The expression must be the parameter itself. A local variable that aliases it, a field that - * holds it, or the result of a call does not qualify: only the parameter is known to hold the - * value that the caller supplied. The parameter must be effectively final because a body that - * reassigns it no longer holds that value. - * - * @param expr an expression, or null - * @param method a method or constructor declaration, or null - * @param env the processing environment - * @return true if {@code expr} is an effectively final functional-interface parameter of {@code - * method} - */ - public static boolean isFunctionalInterfaceParameter( - @Nullable ExpressionTree expr, @Nullable MethodTree method, ProcessingEnvironment env) { - return functionalInterfaceParameterType(expr, method, env) != null; - } - /** * Returns true if {@code expr} is an effectively final functional-interface parameter of {@code * method} and {@code invoked} is the functional method of {@code expr}'s type. @@ -194,6 +175,109 @@ public static boolean isFunctionalMethodOfParameter( */ private static @Nullable TypeMirror functionalInterfaceParameterType( @Nullable ExpressionTree expr, @Nullable MethodTree method, ProcessingEnvironment env) { + VariableElement parameter = effectivelyFinalParameter(expr, method); + return parameter == null ? null : functionalInterfaceType(parameter.asType(), env); + } + + /** + * Returns the purity that the functional method of {@code expr} may be assumed to have beyond its + * own annotations, if {@code expr} is an effectively final functional-interface parameter of + * {@code method}; otherwise returns an empty set. See {@link #functionalParameterPurity( + * AnnotationProvider, ExecutableElement, int, ProcessingEnvironment)}. + * + * @param annoProvider the annotation provider + * @param expr an expression, or null + * @param method a method or constructor declaration, or null + * @param env the processing environment + * @return the purity that the code {@code expr} denotes may be assumed to have + */ + public static EnumSet functionalParameterPurity( + AnnotationProvider annoProvider, + @Nullable ExpressionTree expr, + @Nullable MethodTree method, + ProcessingEnvironment env) { + VariableElement parameter = effectivelyFinalParameter(expr, method); + if (parameter == null) { + return EnumSet.noneOf(PurityKind.class); + } + ExecutableElement methodElt = TreeUtils.elementFromDeclaration(method); + return functionalParameterPurity( + annoProvider, methodElt, methodElt.getParameters().indexOf(parameter), env); + } + + /** + * Returns the purity that the argument passed to {@code method}'s parameter at {@code index} is + * known to have, beyond the annotations on the functional method of the parameter's type. It is + * empty if that parameter's type is not a functional interface. + * + *

    A call reaches {@code method} through its own declaration or through any declaration that it + * overrides, and each call checks its arguments against the declaration that it invokes; see + * {@link #functionalParameterKinds}. The result is therefore what every one of those declarations + * requires of the argument, and it is empty if any of them does not declare a + * functional-interface parameter at {@code index}, as when the overridden parameter's type is a + * type variable that the overriding class instantiates with a functional interface. + * + * @param annoProvider the annotation provider + * @param method a method or constructor + * @param index the index of one of {@code method}'s formal parameters + * @param env the processing environment + * @return the purity that the argument at {@code index} is known to have + */ + public static EnumSet functionalParameterPurity( + AnnotationProvider annoProvider, + ExecutableElement method, + int index, + ProcessingEnvironment env) { + if (!hasFunctionalInterfaceParameter(method, index, env)) { + return EnumSet.noneOf(PurityKind.class); + } + EnumSet result = functionalParameterKinds(annoProvider, method); + for (ExecutableElement overridden : + ElementUtils.getOverriddenMethods(method, env.getTypeUtils())) { + if (result.isEmpty()) { + break; + } + if (hasFunctionalInterfaceParameter(overridden, index, env)) { + result.retainAll(functionalParameterKinds(annoProvider, overridden)); + } else { + result.clear(); + } + } + return result; + } + + /** + * Returns true if {@code method}'s formal parameter at {@code index} exists and its type is a + * functional interface. A varargs parameter's type is an array, which is not. + * + * @param method a method or constructor + * @param index the index of a formal parameter + * @param env the processing environment + * @return true if the parameter at {@code index} has a functional interface type + */ + private static boolean hasFunctionalInterfaceParameter( + ExecutableElement method, int index, ProcessingEnvironment env) { + List parameters = method.getParameters(); + return 0 <= index + && index < parameters.size() + && functionalInterfaceType(parameters.get(index).asType(), env) != null; + } + + /** + * Returns the parameter that {@code expr} denotes, if {@code expr} is an effectively final formal + * parameter of {@code method}; otherwise returns null. + * + *

    The expression must be the parameter itself. A local variable that aliases it, a field that + * holds it, or the result of a call does not qualify: only the parameter is known to hold the + * value that the caller supplied. The parameter must be effectively final because a body that + * reassigns it no longer holds that value. + * + * @param expr an expression, or null + * @param method a method or constructor declaration, or null + * @return the parameter that {@code expr} denotes, or null + */ + private static @Nullable VariableElement effectivelyFinalParameter( + @Nullable ExpressionTree expr, @Nullable MethodTree method) { if (expr == null || method == null || !(TreeUtils.withoutParens(expr) instanceof IdentifierTree id)) { @@ -211,7 +295,7 @@ public static boolean isFunctionalMethodOfParameter( if (!isParameterOf(element, method)) { return null; } - return functionalInterfaceType(element.asType(), env); + return (VariableElement) element; } /** @@ -401,8 +485,9 @@ protected static class PurityCheckerHelper extends TreePathScanner { /** * The purity that {@link #enclosingMethod} promises for the functional method of its - * functional-interface parameters. Empty if there is no enclosing method or it has no purity - * annotation, in which case no call gets the assumption. + * functional-interface parameters, before accounting for the methods that it overrides. Empty + * if there is no enclosing method or it has no purity annotation, in which case no call gets + * the assumption. */ private final EnumSet functionalParameterKinds; @@ -456,28 +541,28 @@ public PurityCheckerHelper( } /** - * Returns true if {@code tree} invokes the functional method of an effectively final - * functional-interface parameter of the method being checked. - * - *

    Such a call has the purity that the method promises, because at every call to the method - * the argument was required to have it. + * Returns the purity that {@code tree} may be assumed to have beyond {@code invoked}'s own + * annotations: if {@code tree} invokes the functional method of an effectively final + * functional-interface parameter of the method being checked, the purity that every call to the + * method required of the argument; otherwise, an empty set. * * @param tree a method invocation * @param invoked the invoked method - * @return true if {@code tree} calls a functional-interface parameter of the enclosing method + * @return the purity that a call to a functional-interface parameter of the enclosing method + * may be assumed to have, or an empty set */ - private boolean isCallOnFunctionalInterfaceParameter( + private EnumSet functionalParameterCallPurity( MethodInvocationTree tree, ExecutableElement invoked) { - if (functionalParameterKinds.isEmpty()) { + ProcessingEnvironment env = this.env; + if (functionalParameterKinds.isEmpty() || env == null) { // There is no enclosing method, or it promises nothing. - return false; + return EnumSet.noneOf(PurityKind.class); } - ProcessingEnvironment env = this.env; - if (env == null) { - return false; + ExpressionTree receiver = TreeUtils.getReceiverTree(tree); + if (!isFunctionalMethodOfParameter(receiver, invoked, enclosingMethod, env)) { + return EnumSet.noneOf(PurityKind.class); } - return isFunctionalMethodOfParameter( - TreeUtils.getReceiverTree(tree), invoked, enclosingMethod, env); + return functionalParameterPurity(annoProvider, receiver, enclosingMethod, env); } @Override @@ -556,9 +641,10 @@ public Void visitClass(ClassTree tree, Void ignore) { public Void visitMethodInvocation(MethodInvocationTree tree, Void ignore) { ExecutableElement elt = TreeUtils.elementFromUse(tree); EnumSet eltPurityKinds = PurityUtils.getPurityKinds(annoProvider, elt); - if (isCallOnFunctionalInterfaceParameter(tree, elt)) { + EnumSet parameterPurity = functionalParameterCallPurity(tree, elt); + if (!parameterPurity.isEmpty()) { eltPurityKinds = EnumSet.copyOf(eltPurityKinds); - eltPurityKinds.addAll(functionalParameterKinds); + eltPurityKinds.addAll(parameterPurity); } boolean pureGetter = assumePureGetters && ElementUtils.isGetter(elt); diff --git a/docs/manual/purity-checker.tex b/docs/manual/purity-checker.tex index d8ec38e51334..806f96cfac9b 100644 --- a/docs/manual/purity-checker.tex +++ b/docs/manual/purity-checker.tex @@ -1,8 +1,9 @@ \htmlhr \chapterAndLabel{Purity Checker}{purity-checker} -A \emph{side-effect-free} method is one that changes no object that exists -when it is called. +A \emph{side-effect-free} method is one that has no externally-visible side +effects: it changes no object that exists when it is called, except in ways +that no caller can observe, such as updating a private cache. A \emph{deterministic} method returns the same value each time it is called on the same arguments. A \emph{pure} method is both side-effect-free and deterministic. @@ -128,10 +129,20 @@ } \end{Verbatim} +The assumption also does not apply if the method overrides a method that does +not make the same promise for that parameter, such as one whose parameter at +that position has a type variable as its type, because a call through the +overridden method does not check the argument. For the same reason, a method +reference to such a method is an error if a call through the functional +method that it implements would not check the argument. + At each call to such a method, every argument passed to a functional-interface parameter must have the purity that the method promises (side-effect-free for -a \<@SideEffectsOnly> method). How the argument is checked depends on its -form: +a \<@SideEffectsOnly> method). For a varargs parameter whose element type is +a functional interface, such as \, each argument that the call +passes as an element of the array is checked, as is each initializer of an +array literal such as \ that the call passes. How the +argument is checked depends on its form: \begin{itemize} \item diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index 0b7e3a2c8e54..99e654f01c8a 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1862,14 +1862,14 @@ protected void checkFunctionalArgument( ExecutableElement referenced = (ExecutableElement) TreeUtils.elementFromUse(argument); argKinds = implementationPurityKinds(referenced); MethodTree enclosingMethod = TreePathUtil.enclosingMethod(getCurrentPath()); - if (enclosingMethod != null - && PurityChecker.isFunctionalMethodOfParameter( - memberReference.getQualifierExpression(), referenced, enclosingMethod, env)) { + ExpressionTree qualifier = memberReference.getQualifierExpression(); + if (PurityChecker.isFunctionalMethodOfParameter( + qualifier, referenced, enclosingMethod, env)) { // `f::apply`, where `f` is a functional-interface parameter of the enclosing method, // denotes the code that the caller of that method was required to check. argKinds.addAll( - PurityChecker.functionalParameterKinds( - atypeFactory, TreeUtils.elementFromDeclaration(enclosingMethod))); + PurityChecker.functionalParameterPurity( + atypeFactory, qualifier, enclosingMethod, env)); } } } else { @@ -1881,13 +1881,9 @@ protected void checkFunctionalArgument( return; } argKinds = implementationPurityKinds(argFunction); - MethodTree enclosingMethod = TreePathUtil.enclosingMethod(getCurrentPath()); - if (enclosingMethod != null - && PurityChecker.isFunctionalInterfaceParameter(argument, enclosingMethod, env)) { - argKinds.addAll( - PurityChecker.functionalParameterKinds( - atypeFactory, TreeUtils.elementFromDeclaration(enclosingMethod))); - } + argKinds.addAll( + PurityChecker.functionalParameterPurity( + atypeFactory, argument, TreePathUtil.enclosingMethod(getCurrentPath()), env)); } if (!argKinds.containsAll(required)) { @@ -5084,6 +5080,64 @@ private void checkMethodReferencePurity() { purityKindsToString(superPurity), overridden); } + checkMethodReferenceFunctionalParameters(); + } + + /** + * Check that a call through the functional method passes, to each functional-interface + * parameter of the referenced method, code with the purity that the referenced method's body + * assumes of it; see {@link PurityChecker#functionalParameterPurity( + * org.checkerframework.javacutil.AnnotationProvider, ExecutableElement, int, + * ProcessingEnvironment)}. + * + *

    A call through the functional method checks its arguments against the functional method's + * declaration, not the referenced method's. That check may require less, or nothing at all, as + * when the functional method's parameter type is a type variable. + */ + private void checkMethodReferenceFunctionalParameters() { + ExecutableElement referenced = overrider.getElement(); + ExecutableElement functionalMethod = overridden.getElement(); + ProcessingEnvironment env = atypeFactory.getProcessingEnv(); + // For an unbound reference such as `C::m`, the functional method's first parameter is the + // receiver of the referenced method. + int offset = + MemberReferenceKind.getMemberReferenceKind((MemberReferenceTree) overriderTree) + .isUnbound() + ? 1 + : 0; + List referencedParams = referenced.getParameters(); + for (int i = 0; i < referencedParams.size(); i++) { + EnumSet assumed = + PurityChecker.functionalParameterPurity(atypeFactory, referenced, i, env); + if (assumed.isEmpty()) { + continue; + } + int j = i + offset; + EnumSet provided = EnumSet.noneOf(PurityKind.class); + if (j < functionalMethod.getParameters().size()) { + ExecutableElement paramFunction = parameterFunctionalMethod(functionalMethod, j); + if (paramFunction != null) { + provided = + PurityChecker.functionalParameterPurity(atypeFactory, functionalMethod, j, env); + provided.addAll(PurityUtils.getPurityKinds(atypeFactory, paramFunction)); + if (paramFunction.getReturnType().getKind() == TypeKind.VOID + && provided.contains(PurityKind.SIDE_EFFECT_FREE)) { + // A side-effect-free method that returns no value is deterministic. + provided.add(PurityKind.DETERMINISTIC); + } + } + } + if (!provided.containsAll(assumed)) { + checker.reportError( + overriderTree, + "purity.methodref.functional.parameter", + referencedParams.get(i).getSimpleName(), + overrider, + purityKindsToString(assumed), + overridden, + purityKindsToString(provided)); + } + } } /** diff --git a/framework/src/main/java/org/checkerframework/common/basetype/messages.properties b/framework/src/main/java/org/checkerframework/common/basetype/messages.properties index 015dc8860d62..c672feac9cda 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/messages.properties +++ b/framework/src/main/java/org/checkerframework/common/basetype/messages.properties @@ -65,6 +65,7 @@ cast.redundant=Redundant cast;%ntype : %s purity.deterministic.constructor=a constructor cannot be annotated @Deterministic purity.deterministic.void.method=a method without return value cannot be annotated @Deterministic purity.methodref=Incompatible purity declaration%nMethod in %s%n %s %s%n is not a valid method reference for method in %s%n %s %s +purity.methodref.functional.parameter=incompatible purity of argument to parameter %s of referenced method %s%n the referenced method assumes: %s%n a call to %s guarantees: %s purity.sideeffectsonly.overriding=Incompatible @SideEffectsOnly declaration%nMethod in %s%n %s %s%ncannot override method in %s%n %s %s%nbecause the overriding method may side-effect %s purity.not.deterministic.assign.array=array assignment not allowed in deterministic method purity.not.deterministic.assign.field=field assignment not allowed in deterministic method diff --git a/framework/tests/flow/PurityFunctionalMethodRef.java b/framework/tests/flow/PurityFunctionalMethodRef.java new file mode 100644 index 000000000000..424a321a5dde --- /dev/null +++ b/framework/tests/flow/PurityFunctionalMethodRef.java @@ -0,0 +1,48 @@ +// A method reference is checked like an override: a call through the functional method must check +// the arguments to the referenced method's functional-interface parameters at least as strictly as +// a call to the referenced method does. + +import java.util.function.Function; +import org.checkerframework.dataflow.qual.SideEffectFree; + +public class PurityFunctionalMethodRef { + + @SideEffectFree + static int applyStatic(Function f, String s) { + return f.apply(s); + } + + @SideEffectFree + int applyInstance(Function f, String s) { + return f.apply(s); + } + + interface Applier { + @SideEffectFree + int apply(Function f, String s); + } + + interface UnboundApplier { + @SideEffectFree + int apply(PurityFunctionalMethodRef receiver, Function f, String s); + } + + interface GenericApplier { + @SideEffectFree + int apply(T f, String s); + } + + interface UnannotatedApplier { + int apply(Function f, String s); + } + + void references() { + Applier a = PurityFunctionalMethodRef::applyStatic; + Applier b = this::applyInstance; + UnboundApplier c = PurityFunctionalMethodRef::applyInstance; + // :: error: [purity.methodref.functional.parameter] + GenericApplier> d = PurityFunctionalMethodRef::applyStatic; + // :: error: [purity.methodref.functional.parameter] + UnannotatedApplier e = PurityFunctionalMethodRef::applyStatic; + } +} diff --git a/framework/tests/flow/PurityFunctionalOverride.java b/framework/tests/flow/PurityFunctionalOverride.java index ea4f0e65bb20..dc4e07cb6588 100644 --- a/framework/tests/flow/PurityFunctionalOverride.java +++ b/framework/tests/flow/PurityFunctionalOverride.java @@ -59,4 +59,37 @@ int m(Function f, String s) { void callsUnannotated(Unrelated receiver, String s) { receiver.m(t -> count++, s); } + + static class GenericSuper { + @SideEffectFree + int m(T f, String s) { + return 0; + } + } + + /** + * A call through GenericSuper checks nothing about the argument, whose declared type is a type + * variable, so this body cannot assume that the argument is side-effect-free. + */ + static class GenericSub extends GenericSuper> { + @Override + int m(Function f, String s) { + // :: error: [purity.not.sideeffectfree.call] + return f.apply(s); + } + } + + void callsThroughGenericSupertype(GenericSuper> receiver, String s) { + receiver.m(t -> count++, s); + } + + /** A call through Unrelated checks nothing, so the override cannot assume anything. */ + static class AnnotatedSubOfUnrelated extends Unrelated { + @Override + @SideEffectFree + int m(Function f, String s) { + // :: error: [purity.not.sideeffectfree.call] + return f.apply(s); + } + } } From dfafa849166ec786af87db549e0461e259fe161b Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Wed, 23 Sep 2026 14:55:28 -0700 Subject: [PATCH 30/43] Assert non-nullness in `functionalParameterPurity` --- .../org/checkerframework/dataflow/util/PurityChecker.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java index c5c3ea8f1936..d4b417988065 100644 --- a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java +++ b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java @@ -200,7 +200,11 @@ public static EnumSet functionalParameterPurity( if (parameter == null) { return EnumSet.noneOf(PurityKind.class); } + assert method != null + : "@AssumeAssertion(nullness): effectivelyFinalParameter returns null if method is null"; ExecutableElement methodElt = TreeUtils.elementFromDeclaration(method); + assert methodElt != null + : "@AssumeAssertion(nullness): method has been entered, since its parameter has an element"; return functionalParameterPurity( annoProvider, methodElt, methodElt.getParameters().indexOf(parameter), env); } From d08a1f2284082f0fd8dc086540f821087731445c Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Wed, 23 Sep 2026 15:00:16 -0700 Subject: [PATCH 31/43] Use renamed purity message keys in functional-parameter tests --- .../tests/flow/PurityFunctionalArgument.java | 16 ++++++++-------- .../flow/PurityFunctionalArgumentJdk.java | 4 ++-- .../flow/PurityFunctionalArgumentKinds.java | 18 +++++++++--------- .../flow/PurityFunctionalConstructor.java | 8 ++++---- .../tests/flow/PurityFunctionalOverride.java | 8 ++++---- .../tests/flow/PurityFunctionalParameter.java | 12 ++++++------ .../tests/flow/PurityVarargsArgument.java | 4 ++-- .../AssumeSideEffectFreeArguments.java | 4 ++-- 8 files changed, 37 insertions(+), 37 deletions(-) diff --git a/framework/tests/flow/PurityFunctionalArgument.java b/framework/tests/flow/PurityFunctionalArgument.java index dc8c5a59dcc2..8816b5e06f4e 100644 --- a/framework/tests/flow/PurityFunctionalArgument.java +++ b/framework/tests/flow/PurityFunctionalArgument.java @@ -64,9 +64,9 @@ PureFunction pureLengthFunction() { void lambdaArguments(String s) { callee(t -> t.length(), s); - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] callee(t -> count++, s); - // :: error: [purity.not.sideeffectfree.call] + // :: error: [purity.call] callee(t -> impureLength(t), s); callee( t -> { @@ -92,7 +92,7 @@ void methodReferenceArguments(String s) { void conditionalArguments(boolean b, String s) { callee(b ? t -> t.length() : t -> 0, s); - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] callee(b ? t -> count++ : t -> 0, s); callee(b ? this::pureLength : String::length, s); // :: error: [purity.functional.argument] @@ -108,7 +108,7 @@ void switchArguments(int i, String s) { s); callee( switch (i) { - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] case 1 -> t -> count++; default -> t -> 0; }, @@ -142,7 +142,7 @@ void yieldArguments(int i, String s) { void parenthesizedArguments(boolean b, String s) { callee((t -> t.length()), s); - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] callee((t -> count++), s); // :: error: [purity.functional.argument] callee((this::impureLength), s); @@ -154,7 +154,7 @@ void parenthesizedArguments(boolean b, String s) { void castArguments(String s) { callee((Function) t -> t.length(), s); - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] callee((Function) t -> count++, s); callee((Function) this::pureLength, s); // :: error: [purity.functional.argument] @@ -306,7 +306,7 @@ int lambdaCallsEnclosingParameter(Function f, String s) { } int unannotatedMethodsLambdaCallsParameter(Function f, String s) { - // :: error: [purity.not.sideeffectfree.call] + // :: error: [purity.call] return callee(t -> f.apply(t), s); } @@ -318,7 +318,7 @@ void lambdaParameterIsNotDischarged(Function f, String s) { // :: error: [purity.functional.argument] callee(g, s); }; - // :: error: [purity.not.sideeffectfree.call] + // :: error: [purity.call] c.accept(f); } diff --git a/framework/tests/flow/PurityFunctionalArgumentJdk.java b/framework/tests/flow/PurityFunctionalArgumentJdk.java index 9dad1101cb12..6bcac8d32966 100644 --- a/framework/tests/flow/PurityFunctionalArgumentJdk.java +++ b/framework/tests/flow/PurityFunctionalArgumentJdk.java @@ -18,7 +18,7 @@ public class PurityFunctionalArgumentJdk { void optionalMap(Optional o) { o.map(s -> s.length()); - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] o.map(s -> count++); } @@ -45,7 +45,7 @@ void unannotatedFunctionalValue(Optional o, Function f) */ void comparatorWrittenHere(List l) { l.sort((a, b) -> a.length() - b.length()); - // :: error: [purity.not.deterministic.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] l.sort((a, b) -> count++); } diff --git a/framework/tests/flow/PurityFunctionalArgumentKinds.java b/framework/tests/flow/PurityFunctionalArgumentKinds.java index da4561325948..7cd8a76cdf43 100644 --- a/framework/tests/flow/PurityFunctionalArgumentKinds.java +++ b/framework/tests/flow/PurityFunctionalArgumentKinds.java @@ -61,26 +61,26 @@ int deterministicCallsParameter(Function f, String s) { void lambdaArgumentsToPureCallee(String s) { pureCallee(t -> 1, s); - // :: error: [purity.not.deterministic.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] pureCallee(t -> count++, s); - // :: error: [purity.not.deterministic.object.creation] + // :: error: [purity.object.creation] pureCallee(t -> new String("x").length(), s); } /** A @SideEffectFree callee constrains side effects only. */ void lambdaArgumentsToSideEffectFreeCallee(String s) { sideEffectFreeCallee(t -> new String("x").length(), s); - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] sideEffectFreeCallee(t -> count++, s); } /** A @Deterministic callee constrains determinism only. */ void lambdaArgumentsToDeterministicCallee(String s) { deterministicCallee(t -> deterministicOnly(t), s); - // :: error: [purity.not.deterministic.object.creation] + // :: error: [purity.object.creation] deterministicCallee(t -> new String("x").length(), s); // The same lambda is not side-effect-free, so it is rejected for a @SideEffectFree callee. - // :: error: [purity.not.sideeffectfree.call] + // :: error: [purity.call] sideEffectFreeCallee(t -> deterministicOnly(t), s); } @@ -109,7 +109,7 @@ void voidFunctionalMethod() { pureRunnableCallee(this::sideEffectFreeVoid); // The lambda is not deterministic, but as a side-effect-free void callback it is. pureRunnableCallee(() -> sideEffectFreeNotDeterministic()); - // :: error: [purity.not.sideeffectfree.call] + // :: error: [purity.call] pureRunnableCallee(() -> impureVoid()); // :: error: [purity.functional.argument] pureRunnableCallee(this::impureVoid); @@ -127,15 +127,15 @@ static int deterministicRunnableCallee(Runnable r) { void voidFunctionalMethodDeterministicCallee() { deterministicRunnableCallee(() -> sideEffectFreeVoid()); deterministicRunnableCallee(this::sideEffectFreeVoid); - // :: error: [purity.not.deterministic.call] + // :: error: [purity.call] deterministicRunnableCallee(() -> impureVoid()); // :: error: [purity.functional.argument] deterministicRunnableCallee(this::impureVoid); - // :: error: [purity.not.deterministic.assign.field] + // :: error: [purity.assign.field] deterministicRunnableCallee(() -> count++); // Conservative: the callback discards the value, so a side-effect-free callback is // deterministic, but the requirement is checked against the code that the argument denotes. - // :: error: [purity.not.deterministic.call] + // :: error: [purity.call] deterministicRunnableCallee(() -> sideEffectFreeNotDeterministic()); // :: error: [purity.functional.argument] deterministicRunnableCallee(this::sideEffectFreeNotDeterministic); diff --git a/framework/tests/flow/PurityFunctionalConstructor.java b/framework/tests/flow/PurityFunctionalConstructor.java index fc1bfa72aea4..7824a5d63865 100644 --- a/framework/tests/flow/PurityFunctionalConstructor.java +++ b/framework/tests/flow/PurityFunctionalConstructor.java @@ -26,7 +26,7 @@ static class UnannotatedHolder { void constructorCallSites() { new Holder(t -> t.length()); - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] new Holder(t -> count++); new UnannotatedHolder(t -> count++); } @@ -34,7 +34,7 @@ void constructorCallSites() { /** An anonymous class's arguments are passed to the super constructor. */ void anonymousSubclass() { new Holder(t -> t.length()) {}; - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] new Holder(t -> count++) {}; } @@ -47,9 +47,9 @@ static class VarargsHolder { /** A varargs constructor checks each argument that the call passes to its array. */ void varargsConstructorCallSites() { new VarargsHolder(() -> {}); - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] new VarargsHolder(() -> count++); - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] new VarargsHolder(() -> {}, () -> count++) {}; } diff --git a/framework/tests/flow/PurityFunctionalOverride.java b/framework/tests/flow/PurityFunctionalOverride.java index dc4e07cb6588..b75b558fb3e1 100644 --- a/framework/tests/flow/PurityFunctionalOverride.java +++ b/framework/tests/flow/PurityFunctionalOverride.java @@ -39,13 +39,13 @@ int m(Function f, String s) { void callsThroughSupertype(Super receiver, String s) { receiver.m(t -> t.length(), s); - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] receiver.m(t -> count++, s); } void callsThroughSubtype(Sub receiver, String s) { receiver.m(t -> t.length(), s); - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] receiver.m(t -> count++, s); } @@ -74,7 +74,7 @@ int m(T f, String s) { static class GenericSub extends GenericSuper> { @Override int m(Function f, String s) { - // :: error: [purity.not.sideeffectfree.call] + // :: error: [purity.call] return f.apply(s); } } @@ -88,7 +88,7 @@ static class AnnotatedSubOfUnrelated extends Unrelated { @Override @SideEffectFree int m(Function f, String s) { - // :: error: [purity.not.sideeffectfree.call] + // :: error: [purity.call] return f.apply(s); } } diff --git a/framework/tests/flow/PurityFunctionalParameter.java b/framework/tests/flow/PurityFunctionalParameter.java index 78499f9e009d..7bcc8c72c84e 100644 --- a/framework/tests/flow/PurityFunctionalParameter.java +++ b/framework/tests/flow/PurityFunctionalParameter.java @@ -57,20 +57,20 @@ > int callsTypeVariableParameter(T f, String @SideEffectFree int callsField(String s) { - // :: error: [purity.not.sideeffectfree.call] + // :: error: [purity.call] return field.apply(s); } @SideEffectFree int callsLocalAlias(Function f, String s) { Function g = f; - // :: error: [purity.not.sideeffectfree.call] + // :: error: [purity.call] return g.apply(s); } @SideEffectFree int callsCallResult(String s) { - // :: error: [purity.not.sideeffectfree.call] + // :: error: [purity.call] return lengthFunction().apply(s); } @@ -98,14 +98,14 @@ String annotatedSamTypeVariable(T t) { @SideEffectFree Function callsNonFunctionalMethod( Function f, Function g) { - // :: error: [purity.not.sideeffectfree.call] + // :: error: [purity.call] return f.andThen(g); } /** The rest of the body is checked as usual. */ @SideEffectFree int otherEffectsAreReported(Function f, String s) { - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] count++; return f.apply(s); } @@ -119,7 +119,7 @@ PureFunc lambdaCallsParameter(Function f) { } PureFunc unannotatedMethodsLambdaCallsParameter(Function f) { - // :: error: [purity.not.sideeffectfree.call] + // :: error: [purity.call] return () -> "" + f.apply("x"); } diff --git a/framework/tests/flow/PurityVarargsArgument.java b/framework/tests/flow/PurityVarargsArgument.java index f902cab37c32..70045a47be12 100644 --- a/framework/tests/flow/PurityVarargsArgument.java +++ b/framework/tests/flow/PurityVarargsArgument.java @@ -39,7 +39,7 @@ void pureVoid() {} void expandedForm(String s) { callee(() -> {}); callee(this::pureVoid); - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] callee(() -> count++); // :: error: [purity.functional.argument] callee(this::impureVoid); @@ -60,7 +60,7 @@ void arrayForm(Runnable[] rs) { callee(new Runnable[] {this::pureVoid}); // :: error: [purity.functional.argument] callee(new Runnable[] {this::impureVoid}); - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] callee(new Runnable[] {() -> count++}); // An array that the call receives from elsewhere says nothing about its elements. callee(rs); diff --git a/framework/tests/purity-assumptions/AssumeSideEffectFreeArguments.java b/framework/tests/purity-assumptions/AssumeSideEffectFreeArguments.java index 279d8732ecc2..2641afaf0731 100644 --- a/framework/tests/purity-assumptions/AssumeSideEffectFreeArguments.java +++ b/framework/tests/purity-assumptions/AssumeSideEffectFreeArguments.java @@ -19,7 +19,7 @@ static int callee(Function f, String s) { @Deterministic int deterministicLength(String s) { // The method's own body is still checked against its own annotation. - // :: error: [purity.not.deterministic.assign.field] + // :: error: [purity.assign.field] count++; return s.length(); } @@ -59,7 +59,7 @@ void unannotatedVariable(Function f, String s) { /** The assumption is that a called method has no side effect, not that this code has none. */ void theAssumptionIsAboutCalls(String s) { - // :: error: [purity.not.sideeffectfree.assign.field] + // :: error: [purity.assign.field] callee(t -> count++, s); } } From 5055d89b54d9fce8691a9b6cbc18e9042aa2fc05 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Thu, 24 Sep 2026 06:30:03 -0700 Subject: [PATCH 32/43] Update framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../common/basetype/BaseTypeVisitor.java | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index a693e86997d1..a296136c72cc 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1734,16 +1734,21 @@ private EnumSet purityRequiredOfArgument( EnumSet paramKinds = PurityUtils.getPurityKinds(atypeFactory, paramFunction); EnumSet required = EnumSet.copyOf(methodKinds); required.removeAll(paramKinds); - if (paramFunction.getReturnType().getKind() == TypeKind.VOID - && ((applyAssumptions && assumeSideEffectFree) - || paramKinds.contains(PurityKind.SIDE_EFFECT_FREE) - || methodKinds.contains(PurityKind.SIDE_EFFECT_FREE))) { + if (paramFunction.getReturnType().getKind() == TypeKind.VOID) { // A side-effect-free method that returns no value is deterministic: two calls return the - // same (absent) value. The argument is side-effect-free either because the functional - // method promises it or because it is required here. Without that guarantee the argument - // must still be checked for determinism, since its side effects can make a later call - // compute a different value. - required.remove(PurityKind.DETERMINISTIC); + // same (absent) value. Without a guarantee that the argument is side-effect-free, the + // argument must still be checked for determinism, since its side effects can make a later + // call compute a different value. + if (paramKinds.contains(PurityKind.SIDE_EFFECT_FREE) + || methodKinds.contains(PurityKind.SIDE_EFFECT_FREE)) { + required.remove(PurityKind.DETERMINISTIC); + } else if (applyAssumptions + && assumeSideEffectFree + && required.remove(PurityKind.DETERMINISTIC)) { + // The assumption covers the methods that the argument calls, not the argument's own + // code, so check that the argument is side-effect-free instead. + required.add(PurityKind.SIDE_EFFECT_FREE); + } } return required; } From 63f86b908951bab6959a039cb7affa8a701b9376 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Thu, 24 Sep 2026 08:49:42 -0700 Subject: [PATCH 33/43] Update docs/manual/purity-checker.tex Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- docs/manual/purity-checker.tex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/manual/purity-checker.tex b/docs/manual/purity-checker.tex index 62fc6a2d58a3..de5095995412 100644 --- a/docs/manual/purity-checker.tex +++ b/docs/manual/purity-checker.tex @@ -102,10 +102,12 @@ \item In a \refqualclass{dataflow/qual}{Pure} method, the call is treated as pure. +\iffalse \item In a \refqualclass{dataflow/qual}{SideEffectsOnly} method, the call is treated as side-effect-free. The method itself may modify the expressions listed in its annotation, but the code it is handed may not modify anything. +\fi \end{itemize} \begin{Verbatim} From ce9c27a3f6874f3770fc004629b4ef7743f664af Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Thu, 24 Sep 2026 08:50:09 -0700 Subject: [PATCH 34/43] Update framework/tests/all-systems/Issue6874.java Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- framework/tests/all-systems/Issue6874.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/framework/tests/all-systems/Issue6874.java b/framework/tests/all-systems/Issue6874.java index 0bbb858f7376..cc3c0d03ae66 100644 --- a/framework/tests/all-systems/Issue6874.java +++ b/framework/tests/all-systems/Issue6874.java @@ -5,10 +5,6 @@ import java.util.Map; public class Issue6874 { - // Collections.min is @Pure, and Comparator.compare is not annotated, so passing `x` is a true - // positive under -AcheckPurityAnnotations. This test is not about purity; the expected error is - // asserted in framework/tests/flow/PurityFunctionalArgumentJdk.java. - @SuppressWarnings("purity") public static void test(Comparator x, Collection y, Collection z) { java.util.Collections.min((true) ? y : z, x); } From fbc06a90c714fb1931b2bf73d3d0158b81fd5fe9 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Thu, 24 Sep 2026 10:24:09 -0700 Subject: [PATCH 35/43] Note that purity errors for lambdas and functional arguments require purity checking The checks run only when -AcheckPurityAnnotations (or -AsuggestPureMethods without -Ainfer) is passed; a default run and -Ainfer do not report them. --- docs/manual/purity-checker.tex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/manual/purity-checker.tex b/docs/manual/purity-checker.tex index de5095995412..80eb0cc3c2cd 100644 --- a/docs/manual/purity-checker.tex +++ b/docs/manual/purity-checker.tex @@ -73,7 +73,10 @@ The body of a lambda is checked against the purity annotations on the functional interface method that the lambda implements, not against those on -the method that creates the lambda. For example: +the method that creates the lambda. The errors shown in this section are +reported only when purity annotations are checked, such as with +\<-AcheckPurityAnnotations>; they are not reported by default or by +\<-Ainfer>. For example: \begin{Verbatim} @FunctionalInterface From 7b5da5ac022047230dd6d63fedde5f818cc731d0 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Tue, 29 Sep 2026 21:40:06 -0700 Subject: [PATCH 36/43] Add test: inherited method implementing an interface method with weaker purity requirement The test currently fails: the checker does not report that Base.apply, which assumes its functional argument is side-effect-free, implements Unannotated.apply (which imposes nothing) in class Sub. The expected diagnostic key purity.functional.parameter.inherited is a placeholder. --- .../tests/flow/PurityFunctionalInherited.java | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 framework/tests/flow/PurityFunctionalInherited.java diff --git a/framework/tests/flow/PurityFunctionalInherited.java b/framework/tests/flow/PurityFunctionalInherited.java new file mode 100644 index 000000000000..4b0b936fa4c1 --- /dev/null +++ b/framework/tests/flow/PurityFunctionalInherited.java @@ -0,0 +1,68 @@ +// A class can implement an interface method by inheriting a method from its superclass. A call +// through the interface checks its arguments against the interface method, so the inherited method +// cannot assume that its functional-interface argument is side-effect-free unless the interface +// method also requires that. +// +// Each call site is in an unannotated method, so the only diagnostic a call site can produce is the +// one under test. + +import java.util.function.Supplier; +import org.checkerframework.dataflow.qual.SideEffectFree; + +public class PurityFunctionalInherited { + + int count = 0; + + static class Base { + /** The body assumes that {@code s} is side-effect-free. */ + @SideEffectFree + public int apply(Supplier s) { + return s.get(); + } + } + + /** The interface method imposes nothing on its argument. */ + interface Unannotated { + int apply(Supplier s); + } + + // Base.apply implements Unannotated.apply only in this class, so Base.apply's assumption + // about its argument does not hold for calls through Unannotated. + // :: error: [purity.functional.parameter.inherited] + static class Sub extends Base implements Unannotated {} + + void callsThroughInterface(Unannotated receiver) { + // This call reaches Base.apply when receiver is a Sub. + receiver.apply(() -> count++); + } + + void callsThroughBase(Base receiver) { + receiver.apply(() -> 0); + // :: error: [purity.assign.field] + receiver.apply(() -> count++); + } + + /** The interface method requires what Base.apply assumes. */ + interface Annotated { + @SideEffectFree + int apply(Supplier s); + } + + static class SubOfAnnotated extends Base implements Annotated {} + + void callsThroughAnnotatedInterface(Annotated receiver) { + receiver.apply(() -> 0); + // :: error: [purity.assign.field] + receiver.apply(() -> count++); + } + + /** A subclass that overrides the method is checked against the interface method as usual. */ + static class SubOverrides extends Base implements Unannotated { + @Override + @SideEffectFree + public int apply(Supplier s) { + // :: error: [purity.call] + return s.get(); + } + } +} From 09bc9ef9e5bb08e29873d286a73151c14920150e Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Fri, 2 Oct 2026 09:38:09 -0700 Subject: [PATCH 37/43] Let an override assume what its own purity annotation requires of functional arguments A call through an overridden declaration that promises no purity does not rely on the override's purity, so it no longer limits what the override may assume of its functional-interface parameters. Only an overridden declaration that promises purity without checking the argument, such as one whose parameter type is a type variable, limits it. Code that calls a functional-interface parameter does rely on the purity of the call, so an object or method reference passed as a functional argument is now checked: the method that runs may assume of its own functional-interface parameters only what a call through the parameter's functional method checks. Also, when an argument's class inherits the implementation of the functional method from a superclass, check the argument against that implementation rather than the abstract method. --- .../dataflow/util/PurityChecker.java | 20 ++- docs/manual/purity-checker.tex | 24 ++- .../common/basetype/BaseTypeVisitor.java | 149 ++++++++++++------ .../common/basetype/messages.properties | 1 + .../tests/flow/PurityFunctionalInherited.java | 36 ++++- .../tests/flow/PurityFunctionalMethodRef.java | 27 +++- .../tests/flow/PurityFunctionalOverride.java | 6 +- 7 files changed, 188 insertions(+), 75 deletions(-) diff --git a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java index 49b79c840330..c0b770b0711d 100644 --- a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java +++ b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java @@ -259,10 +259,18 @@ public static EnumSet functionalParameterPurity( * *

    A call reaches {@code method} through its own declaration or through any declaration that it * overrides, and each call checks its arguments against the declaration that it invokes; see - * {@link #functionalParameterKinds}. The result is therefore what every one of those declarations - * requires of the argument, and it is empty if any of them does not declare a + * {@link #functionalParameterKinds}. A call through an overridden declaration that requires less + * of the argument also relies on less: it does not rely on purity that the declaration does not + * promise. The exception is an overridden declaration that promises purity but does not declare a * functional-interface parameter at {@code index}, as when the overridden parameter's type is a - * type variable that the overriding class instantiates with a functional interface. + * type variable that the overriding class instantiates with a functional interface. A call + * through it relies on the promise without checking the argument, so the result excludes the + * purity that it promises. + * + *

    Code that calls the functional method of a functional-interface parameter relies on the + * purity of that call even when the functional method promises none. Such code may run {@code + * method} with an argument that no declaration checked, so {@code BaseTypeVisitor} checks the + * code that is passed as a functional argument against the result of this method. * * @param annoProvider the annotation provider * @param method a method or constructor @@ -284,10 +292,8 @@ public static EnumSet functionalParameterPurity( if (result.isEmpty()) { break; } - if (hasFunctionalInterfaceParameter(overridden, index, env)) { - result.retainAll(functionalParameterKinds(annoProvider, overridden)); - } else { - result.clear(); + if (!hasFunctionalInterfaceParameter(overridden, index, env)) { + result.removeAll(functionalParameterKinds(annoProvider, overridden)); } } return result; diff --git a/docs/manual/purity-checker.tex b/docs/manual/purity-checker.tex index 40d08fcdac8e..278ea4349410 100644 --- a/docs/manual/purity-checker.tex +++ b/docs/manual/purity-checker.tex @@ -133,12 +133,24 @@ } \end{Verbatim} -The assumption also does not apply if the method overrides a method that does -not make the same promise for that parameter, such as one whose parameter at -that position has a type variable as its type, because a call through the -overridden method does not check the argument. For the same reason, a method -reference to such a method is an error if a call through the functional -method that it implements would not check the argument. +The assumption also does not apply if the method overrides a method that +makes the same promise but whose parameter at that position has a type +variable as its type, because a call through the overridden method relies on +the promise without checking the argument. An overridden method that makes +no promise does not limit the assumption: a call through it may pass an +argument with side effects, but that call does not rely on the purity of the +method either. + +The exception is code that calls the functional method of one of its own +functional-interface parameters, which relies on the call's purity even if +the functional method makes no promise. Suppose that class \ implements +interface \, and \ is \<@SideEffectFree> but \ is not. +Then passing a \ where an \ is required, to a parameter of a +\<@SideEffectFree> method, is an error if \ assumes that its own +functional-interface argument is side-effect-free, because a call through +\ does not check that argument. The same holds for a method +reference to \, which is also an error wherever the functional +method that it implements makes the promise without checking the argument. At each call to such a method, every argument passed to a functional-interface parameter must have the purity that the method promises (side-effect-free for diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index 77137062c0ce..fb74875b930a 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1914,6 +1914,17 @@ protected void checkFunctionalArgument( PurityChecker.functionalParameterPurity( atypeFactory, qualifier, enclosingMethod, env)); } + // The check of the method reference against its functional method already covers the + // purity that the functional method promises. + EnumSet relied = EnumSet.copyOf(required); + relied.removeAll(PurityChecker.functionalParameterKinds(atypeFactory, paramFunction)); + checkImplementationFunctionalParameters( + memberReference, + referenced, + paramFunction, + MemberReferenceKind.getMemberReferenceKind(memberReference).isUnbound() ? 1 : 0, + relied, + "purity.methodref.functional.parameter"); } } else { ExecutableElement argFunction = @@ -1927,6 +1938,8 @@ protected void checkFunctionalArgument( argKinds.addAll( PurityChecker.functionalParameterPurity( atypeFactory, argument, TreePathUtil.enclosingMethod(getCurrentPath()), env)); + checkImplementationFunctionalParameters( + arg, argFunction, paramFunction, 0, required, "purity.functional.argument.parameter"); } if (!argKinds.containsAll(required)) { @@ -1940,6 +1953,76 @@ protected void checkFunctionalArgument( } } + /** + * Checks that a call through {@code functionalMethod} passes, to each functional-interface + * parameter of {@code implementation}, code with the purity that {@code implementation}'s body + * assumes of it; see {@link + * PurityChecker#functionalParameterPurity(org.checkerframework.javacutil.AnnotationProvider, + * ExecutableElement, int, ProcessingEnvironment)}. Only the kinds in {@code relied} are checked. + * + *

    A call through the functional method checks its arguments against the functional method's + * declaration, not the implementation's. That check may require less, or nothing at all, as when + * the functional method's parameter type is a type variable or the functional method has no + * purity annotation. Then the implementation may run with an argument that lacks the purity that + * its body assumes, so the implementation may lack its own purity. That matters only to code that + * relies on the purity of calls through the functional method. + * + * @param reportTree the tree at which to report an error + * @param implementation the method that a call through {@code functionalMethod} runs + * @param functionalMethod the functional method that {@code implementation} implements + * @param offset the index of {@code functionalMethod}'s parameter that corresponds to {@code + * implementation}'s first parameter: 1 for an unbound method reference such as {@code C::m}, + * whose functional method's first parameter is the receiver, and 0 otherwise + * @param relied the purity of calls through {@code functionalMethod} that some code relies on + * @param messageKey the key of the error message to report + */ + private void checkImplementationFunctionalParameters( + Tree reportTree, + ExecutableElement implementation, + ExecutableElement functionalMethod, + int offset, + EnumSet relied, + @CompilerMessageKey String messageKey) { + if (relied.isEmpty()) { + return; + } + ProcessingEnvironment env = atypeFactory.getProcessingEnv(); + List implementationParams = implementation.getParameters(); + for (int i = 0; i < implementationParams.size(); i++) { + EnumSet assumed = + PurityChecker.functionalParameterPurity(atypeFactory, implementation, i, env); + assumed.retainAll(relied); + if (assumed.isEmpty()) { + continue; + } + int j = i + offset; + EnumSet provided = EnumSet.noneOf(PurityKind.class); + if (j < functionalMethod.getParameters().size()) { + ExecutableElement paramFunction = parameterFunctionalMethod(functionalMethod, j); + if (paramFunction != null) { + provided = + PurityChecker.functionalParameterPurity(atypeFactory, functionalMethod, j, env); + provided.addAll(PurityUtils.getPurityKinds(atypeFactory, paramFunction)); + if (paramFunction.getReturnType().getKind() == TypeKind.VOID + && provided.contains(PurityKind.SIDE_EFFECT_FREE)) { + // A side-effect-free method that returns no value is deterministic. + provided.add(PurityKind.DETERMINISTIC); + } + } + } + if (!provided.containsAll(assumed)) { + checker.reportError( + reportTree, + messageKey, + implementationParams.get(i).getSimpleName(), + implementation, + purityKindsToString(assumed), + functionalMethod, + purityKindsToString(provided)); + } + } + } + /** * Returns the method that {@code type} supplies for the functional method {@code paramFunction}, * or null if it has none. @@ -1972,12 +2055,20 @@ protected void checkFunctionalArgument( return null; } Elements elements = env.getElementUtils(); + // The members of a class that inherits its implementation from a superclass include both + // that implementation and the interface's abstract method. The implementation is what runs. + ExecutableElement abstractMatch = null; for (ExecutableElement method : ElementFilter.methodsIn(elements.getAllMembers(typeElement))) { if (method.equals(paramFunction) || elements.overrides(method, paramFunction, typeElement)) { - return method; + if (!method.getModifiers().contains(Modifier.ABSTRACT)) { + return method; + } + if (abstractMatch == null) { + abstractMatch = method; + } } } - return null; + return abstractMatch; } /** @@ -5166,58 +5257,20 @@ private void checkMethodReferencePurity() { /** * Check that a call through the functional method passes, to each functional-interface * parameter of the referenced method, code with the purity that the referenced method's body - * assumes of it; see {@link PurityChecker#functionalParameterPurity( - * org.checkerframework.javacutil.AnnotationProvider, ExecutableElement, int, - * ProcessingEnvironment)}. - * - *

    A call through the functional method checks its arguments against the functional method's - * declaration, not the referenced method's. That check may require less, or nothing at all, as - * when the functional method's parameter type is a type variable. + * assumes of it, so far as the functional method promises that purity to its callers. */ private void checkMethodReferenceFunctionalParameters() { - ExecutableElement referenced = overrider.getElement(); ExecutableElement functionalMethod = overridden.getElement(); - ProcessingEnvironment env = atypeFactory.getProcessingEnv(); - // For an unbound reference such as `C::m`, the functional method's first parameter is the - // receiver of the referenced method. - int offset = + checkImplementationFunctionalParameters( + overriderTree, + overrider.getElement(), + functionalMethod, MemberReferenceKind.getMemberReferenceKind((MemberReferenceTree) overriderTree) .isUnbound() ? 1 - : 0; - List referencedParams = referenced.getParameters(); - for (int i = 0; i < referencedParams.size(); i++) { - EnumSet assumed = - PurityChecker.functionalParameterPurity(atypeFactory, referenced, i, env); - if (assumed.isEmpty()) { - continue; - } - int j = i + offset; - EnumSet provided = EnumSet.noneOf(PurityKind.class); - if (j < functionalMethod.getParameters().size()) { - ExecutableElement paramFunction = parameterFunctionalMethod(functionalMethod, j); - if (paramFunction != null) { - provided = - PurityChecker.functionalParameterPurity(atypeFactory, functionalMethod, j, env); - provided.addAll(PurityUtils.getPurityKinds(atypeFactory, paramFunction)); - if (paramFunction.getReturnType().getKind() == TypeKind.VOID - && provided.contains(PurityKind.SIDE_EFFECT_FREE)) { - // A side-effect-free method that returns no value is deterministic. - provided.add(PurityKind.DETERMINISTIC); - } - } - } - if (!provided.containsAll(assumed)) { - checker.reportError( - overriderTree, - "purity.methodref.functional.parameter", - referencedParams.get(i).getSimpleName(), - overrider, - purityKindsToString(assumed), - overridden, - purityKindsToString(provided)); - } - } + : 0, + PurityChecker.functionalParameterKinds(atypeFactory, functionalMethod), + "purity.methodref.functional.parameter"); } /** diff --git a/framework/src/main/java/org/checkerframework/common/basetype/messages.properties b/framework/src/main/java/org/checkerframework/common/basetype/messages.properties index f909f222de1d..ffec87a4c074 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/messages.properties +++ b/framework/src/main/java/org/checkerframework/common/basetype/messages.properties @@ -66,6 +66,7 @@ purity.deterministic.constructor=a constructor cannot be annotated @Deterministi purity.deterministic.void.method=a method without return value cannot be annotated @Deterministic purity.methodref=Incompatible purity declaration%nMethod in %s%n %s %s%n is not a valid method reference for method in %s%n %s %s purity.methodref.functional.parameter=incompatible purity of argument to parameter %s of referenced method %s%n the referenced method assumes: %s%n a call to %s guarantees: %s +purity.functional.argument.parameter=incompatible purity of argument to parameter %s of %s, which the argument runs%n the argument's method assumes: %s%n a call to %s guarantees: %s purity.sideeffectsonly.overriding=Incompatible @SideEffectsOnly declaration%nMethod in %s%n %s %s%ncannot override method in %s%n %s %s%nbecause the overriding method may side-effect %s purity.assign.array=array assignment not allowed in %s method purity.assign.field=field assignment not allowed in %s method diff --git a/framework/tests/flow/PurityFunctionalInherited.java b/framework/tests/flow/PurityFunctionalInherited.java index 4b0b936fa4c1..8d9b95dadef5 100644 --- a/framework/tests/flow/PurityFunctionalInherited.java +++ b/framework/tests/flow/PurityFunctionalInherited.java @@ -1,7 +1,6 @@ // A class can implement an interface method by inheriting a method from its superclass. A call -// through the interface checks its arguments against the interface method, so the inherited method -// cannot assume that its functional-interface argument is side-effect-free unless the interface -// method also requires that. +// through the interface checks its arguments against the interface method, but such a call does not +// rely on the inherited method's purity either, so the class is not an error. // // Each call site is in an unannotated method, so the only diagnostic a call site can produce is the // one under test. @@ -26,9 +25,8 @@ interface Unannotated { int apply(Supplier s); } - // Base.apply implements Unannotated.apply only in this class, so Base.apply's assumption - // about its argument does not hold for calls through Unannotated. - // :: error: [purity.functional.parameter.inherited] + // Base.apply implements Unannotated.apply only in this class. A call through Unannotated may pass + // an argument with side effects, but the caller does not treat the call as side-effect-free. static class Sub extends Base implements Unannotated {} void callsThroughInterface(Unannotated receiver) { @@ -56,13 +54,35 @@ void callsThroughAnnotatedInterface(Annotated receiver) { receiver.apply(() -> count++); } - /** A subclass that overrides the method is checked against the interface method as usual. */ + /** + * An override, like an inherited method, may assume what its own annotation requires of its + * argument: a call through Unannotated does not rely on the override's purity. + */ static class SubOverrides extends Base implements Unannotated { @Override @SideEffectFree public int apply(Supplier s) { - // :: error: [purity.call] return s.get(); } } + + /** The body relies on the purity of calls to u.apply, but those calls check no argument. */ + @SideEffectFree + static int runsUnannotated(Unannotated u) { + return u.apply(() -> 0); + } + + /** The body relies on the purity of calls to a.apply, and those calls check their argument. */ + @SideEffectFree + static int runsAnnotated(Annotated a) { + return a.apply(() -> 0); + } + + void passesImplementations() { + // :: error: [purity.functional.argument.parameter] + runsUnannotated(new Sub()); + // :: error: [purity.functional.argument.parameter] + runsUnannotated(new SubOverrides()); + runsAnnotated(new SubOfAnnotated()); + } } diff --git a/framework/tests/flow/PurityFunctionalMethodRef.java b/framework/tests/flow/PurityFunctionalMethodRef.java index 424a321a5dde..c085b2338b5e 100644 --- a/framework/tests/flow/PurityFunctionalMethodRef.java +++ b/framework/tests/flow/PurityFunctionalMethodRef.java @@ -1,6 +1,6 @@ -// A method reference is checked like an override: a call through the functional method must check -// the arguments to the referenced method's functional-interface parameters at least as strictly as -// a call to the referenced method does. +// A method reference is checked like an override: a call through the functional method that relies +// on the referenced method's purity must check the arguments to the referenced method's +// functional-interface parameters at least as strictly as a call to the referenced method does. import java.util.function.Function; import org.checkerframework.dataflow.qual.SideEffectFree; @@ -42,7 +42,26 @@ void references() { UnboundApplier c = PurityFunctionalMethodRef::applyInstance; // :: error: [purity.methodref.functional.parameter] GenericApplier> d = PurityFunctionalMethodRef::applyStatic; - // :: error: [purity.methodref.functional.parameter] + // A call through UnannotatedApplier checks nothing, but it also does not rely on the purity of + // the referenced method. UnannotatedApplier e = PurityFunctionalMethodRef::applyStatic; } + + /** The body relies on the purity of calls to u.apply, but those calls check no argument. */ + @SideEffectFree + static int runsUnannotated(UnannotatedApplier u) { + return u.apply(s -> 0, ""); + } + + /** The body relies on the purity of calls to a.apply, and those calls check their argument. */ + @SideEffectFree + static int runsAnnotated(Applier a) { + return a.apply(s -> 0, ""); + } + + void passesReferences() { + // :: error: [purity.methodref.functional.parameter] + runsUnannotated(PurityFunctionalMethodRef::applyStatic); + runsAnnotated(PurityFunctionalMethodRef::applyStatic); + } } diff --git a/framework/tests/flow/PurityFunctionalOverride.java b/framework/tests/flow/PurityFunctionalOverride.java index b75b558fb3e1..015a8d91838f 100644 --- a/framework/tests/flow/PurityFunctionalOverride.java +++ b/framework/tests/flow/PurityFunctionalOverride.java @@ -83,12 +83,14 @@ void callsThroughGenericSupertype(GenericSuper> receiv receiver.m(t -> count++, s); } - /** A call through Unrelated checks nothing, so the override cannot assume anything. */ + /** + * A call through Unrelated checks nothing, but it also does not rely on the override's purity, so + * the override may assume what its own annotation requires. + */ static class AnnotatedSubOfUnrelated extends Unrelated { @Override @SideEffectFree int m(Function f, String s) { - // :: error: [purity.call] return f.apply(s); } } From f52852cf9f3ec988d2a7416e844c71708f23081e Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Fri, 2 Oct 2026 09:50:18 -0700 Subject: [PATCH 38/43] Check a functional subinterface argument against the method that implements the parameter's functional method A functional subinterface may implement the parameter's functional method with a default method and declare an unrelated abstract method as its own functional method. Use the argument type's functional method only if it is or overrides the parameter's functional method; otherwise use the implementation that runs. Grant the enclosing method's guarantee for a functional-interface parameter only when the argument's method is that parameter's functional method. --- .../common/basetype/BaseTypeVisitor.java | 25 ++++++--- .../flow/PurityFunctionalSubinterface.java | 51 +++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 framework/tests/flow/PurityFunctionalSubinterface.java diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index fb74875b930a..4e694e1be612 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1935,9 +1935,14 @@ protected void checkFunctionalArgument( return; } argKinds = implementationPurityKinds(argFunction); - argKinds.addAll( - PurityChecker.functionalParameterPurity( - atypeFactory, argument, TreePathUtil.enclosingMethod(getCurrentPath()), env)); + MethodTree enclosingMethod = TreePathUtil.enclosingMethod(getCurrentPath()); + if (PurityChecker.isFunctionalMethodOfParameter( + argument, argFunction, enclosingMethod, env)) { + // The argument is a functional-interface parameter of the enclosing method, and the + // caller of that method was required to check the code that argFunction runs. + argKinds.addAll( + PurityChecker.functionalParameterPurity(atypeFactory, argument, enclosingMethod, env)); + } checkImplementationFunctionalParameters( arg, argFunction, paramFunction, 0, required, "purity.functional.argument.parameter"); } @@ -2029,7 +2034,9 @@ private void checkImplementationFunctionalParameters( * *

    This is {@code paramFunction} itself when {@code type} is the functional interface, but an * argument may also be of a class type, including an anonymous class, that implements the - * interface. The implementation is what will run, so its annotations are what matter. + * interface. The implementation is what will run, so its annotations are what matter. Likewise, a + * functional subinterface may implement {@code paramFunction} with a default method and declare + * an unrelated abstract method as its own functional method; the default method is what runs. * * @param type the type of an argument * @param paramFunction the functional method of the parameter's type @@ -2038,9 +2045,16 @@ private void checkImplementationFunctionalParameters( */ private @Nullable ExecutableElement functionalMethodOf( TypeMirror type, ExecutableElement paramFunction, ProcessingEnvironment env) { + Elements elements = env.getElementUtils(); TypeMirror functionalType = PurityChecker.functionalInterfaceType(type, env); if (functionalType != null) { - return TypesUtils.findFunction(functionalType, env); + ExecutableElement function = TypesUtils.findFunction(functionalType, env); + TypeElement functionalTypeElement = TypesUtils.getTypeElement(functionalType); + if (function.equals(paramFunction) + || (functionalTypeElement != null + && elements.overrides(function, paramFunction, functionalTypeElement))) { + return function; + } } if (type.getKind() == TypeKind.TYPEVAR) { type = TypesUtils.upperBound(type); @@ -2054,7 +2068,6 @@ private void checkImplementationFunctionalParameters( if (typeElement == null) { return null; } - Elements elements = env.getElementUtils(); // The members of a class that inherits its implementation from a superclass include both // that implementation and the interface's abstract method. The implementation is what runs. ExecutableElement abstractMatch = null; diff --git a/framework/tests/flow/PurityFunctionalSubinterface.java b/framework/tests/flow/PurityFunctionalSubinterface.java new file mode 100644 index 000000000000..5f9d33be200a --- /dev/null +++ b/framework/tests/flow/PurityFunctionalSubinterface.java @@ -0,0 +1,51 @@ +// A functional subinterface may implement a functional method of its superinterface with a default +// method, and declare an unrelated abstract method as its own functional method. An argument of +// the subinterface's type runs the default method, so it is checked against that method. + +import java.util.function.Function; +import org.checkerframework.dataflow.qual.SideEffectFree; + +public class PurityFunctionalSubinterface { + + static int count = 0; + + /** G's functional method is other, which is unrelated to Function.apply. */ + interface G extends Function { + @Override + default Integer apply(String s) { + return count++; + } + + @SideEffectFree + int other(); + } + + /** Like G, but its implementation of Function.apply is side-effect-free. */ + interface PureApply extends Function { + @Override + @SideEffectFree + default Integer apply(String s) { + return 0; + } + + int other(); + } + + @SideEffectFree + static int m(Function f, String s) { + return f.apply(s); + } + + void passes(G g, PureApply p) { + // :: error: [purity.functional.argument] + m(g, ""); + m(p, ""); + } + + /** The caller of forwards checked the code that g.other runs, not the code that g.apply runs. */ + @SideEffectFree + static int forwards(G g, String s) { + // :: error: [purity.functional.argument] + return m(g, s); + } +} From 8f16515ddbd66e4fec92a09add9487681dff9feb Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Fri, 2 Oct 2026 10:36:57 -0700 Subject: [PATCH 39/43] Check code cast to a functional subinterface against the method that runs A lambda or method reference whose type is a functional subinterface with an unrelated functional method implements that method, not the parameter's functional method, so check it against the implementation of the parameter's functional method that actually runs. An override may assume of a functional-interface parameter only what callers through the overridden declaration check, so it gets nothing from an overridden declaration whose parameter's functional method its own parameter's functional method does not override. Report each purity error once per compound checker rather than once per subchecker. --- .../dataflow/util/PurityChecker.java | 146 +++++++++--------- .../common/basetype/BaseTypeVisitor.java | 108 ++++++++----- .../flow/PurityFunctionalSubinterface.java | 59 +++++++ 3 files changed, 201 insertions(+), 112 deletions(-) diff --git a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java index c0b770b0711d..e565665fbad1 100644 --- a/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java +++ b/dataflow/src/main/java/org/checkerframework/dataflow/util/PurityChecker.java @@ -75,11 +75,12 @@ private PurityChecker() { * @param enclosingMethod the method declaration that lexically encloses {@code statement}, or * null if none does. A call to the functional method of one of that method's * functional-interface parameters has the purity that the method's callers required of the - * argument; see {@link #functionalParameterPurity(AnnotationProvider, ExpressionTree, - * MethodTree, ProcessingEnvironment)}. Pass the enclosing method for a lambda body too: the - * body is checked against the functional method that the lambda implements, but the enclosing - * method's parameters still hold values that its caller was required to check, whenever the - * lambda runs. Pass null for an arbitrary expression, which no method's contract governs. + * argument; see {@link #functionalParameterCallPurity(AnnotationProvider, ExpressionTree, + * ExecutableElement, MethodTree, ProcessingEnvironment)}. Pass the enclosing method for a + * lambda body too: the body is checked against the functional method that the lambda + * implements, but the enclosing method's parameters still hold values that its caller was + * required to check, whenever the lambda runs. Pass null for an arbitrary expression, which + * no method's contract governs. * @param env the processing environment; used only if {@code enclosingMethod} is non-null * @param assumeSideEffectFree true if all methods should be assumed to be @SideEffectFree * @param assumeDeterministic true if all methods should be assumed to be @Deterministic @@ -174,75 +175,38 @@ public static EnumSet functionalParameterKinds( } /** - * Returns true if {@code expr} is an effectively final functional-interface parameter of {@code - * method} and {@code invoked} is the functional method of {@code expr}'s type. + * Returns the purity that a call of {@code invoked} on {@code expr} may be assumed to have beyond + * {@code invoked}'s own annotations, if {@code expr} is an effectively final functional-interface + * parameter of {@code method} and {@code invoked} is the functional method of {@code expr}'s type + * (or overrides it); otherwise returns an empty set. See {@link #functionalParameterPurity( + * AnnotationProvider, ExecutableElement, int, ProcessingEnvironment)}. * *

    The code that such an expression denotes has the purity that {@code method} promises, * because at every call to {@code method} the argument was required to have it. Only the * functional method carries that guarantee; a default method such as {@code Function.andThen} * does not. * - * @param expr an expression, or null - * @param invoked a method that {@code expr} is used to invoke or to refer to - * @param method a method or constructor declaration, or null - * @param env the processing environment - * @return true if {@code invoked} is the functional method of a functional-interface parameter of - * {@code method} - */ - public static boolean isFunctionalMethodOfParameter( - @Nullable ExpressionTree expr, - ExecutableElement invoked, - @Nullable MethodTree method, - ProcessingEnvironment env) { - TypeMirror parameterType = functionalInterfaceParameterType(expr, method, env); - if (parameterType == null) { - return false; - } - ExecutableElement functionalMethod = TypesUtils.findFunction(parameterType, env); - @SuppressWarnings("interning:not.interned") // Checking for exact object. - boolean isFunctionalMethod = invoked == functionalMethod; - return isFunctionalMethod - || env.getElementUtils() - .overrides(invoked, functionalMethod, (TypeElement) invoked.getEnclosingElement()); - } - - /** - * Returns the functional interface type of {@code expr}, if {@code expr} is an effectively final - * formal parameter of {@code method} whose type is a functional interface; otherwise returns - * null. - * - * @param expr an expression, or null - * @param method a method or constructor declaration, or null - * @param env the processing environment - * @return the functional interface type of {@code expr}, or null - */ - private static @Nullable TypeMirror functionalInterfaceParameterType( - @Nullable ExpressionTree expr, @Nullable MethodTree method, ProcessingEnvironment env) { - VariableElement parameter = effectivelyFinalParameter(expr, method); - return parameter == null ? null : functionalInterfaceType(parameter.asType(), env); - } - - /** - * Returns the purity that the functional method of {@code expr} may be assumed to have beyond its - * own annotations, if {@code expr} is an effectively final functional-interface parameter of - * {@code method}; otherwise returns an empty set. See {@link #functionalParameterPurity( - * AnnotationProvider, ExecutableElement, int, ProcessingEnvironment)}. - * * @param annoProvider the annotation provider * @param expr an expression, or null + * @param invoked a method that {@code expr} is used to invoke or to refer to * @param method a method or constructor declaration, or null * @param env the processing environment - * @return the purity that the code {@code expr} denotes may be assumed to have + * @return the purity that the call may be assumed to have, or an empty set */ - public static EnumSet functionalParameterPurity( + public static EnumSet functionalParameterCallPurity( AnnotationProvider annoProvider, @Nullable ExpressionTree expr, + ExecutableElement invoked, @Nullable MethodTree method, ProcessingEnvironment env) { VariableElement parameter = effectivelyFinalParameter(expr, method); if (parameter == null) { return EnumSet.noneOf(PurityKind.class); } + ExecutableElement parameterFunction = functionalMethodOfType(parameter.asType(), env); + if (parameterFunction == null || !isSameOrOverrides(invoked, parameterFunction, env)) { + return EnumSet.noneOf(PurityKind.class); + } assert method != null : "@AssumeAssertion(nullness): effectivelyFinalParameter returns null if method is null"; ExecutableElement methodElt = TreeUtils.elementFromDeclaration(method); @@ -261,11 +225,14 @@ public static EnumSet functionalParameterPurity( * overrides, and each call checks its arguments against the declaration that it invokes; see * {@link #functionalParameterKinds}. A call through an overridden declaration that requires less * of the argument also relies on less: it does not rely on purity that the declaration does not - * promise. The exception is an overridden declaration that promises purity but does not declare a - * functional-interface parameter at {@code index}, as when the overridden parameter's type is a - * type variable that the overriding class instantiates with a functional interface. A call - * through it relies on the promise without checking the argument, so the result excludes the - * purity that it promises. + * promise. The exception is an overridden declaration that promises purity but whose parameter at + * {@code index} does not have the functional method of {@code method}'s parameter: either it is + * not a functional interface, as when the overridden parameter's type is a type variable that the + * overriding class instantiates with a functional interface, or its functional method is one that + * the functional method of {@code method}'s parameter does not override, as when the overriding + * class instantiates the type variable with a functional subinterface that declares an unrelated + * functional method. A call through such a declaration relies on the promise without checking the + * code that {@code method}'s parameter runs, so the result excludes the purity that it promises. * *

    Code that calls the functional method of a functional-interface parameter relies on the * purity of that call even when the functional method promises none. Such code may run {@code @@ -283,7 +250,8 @@ public static EnumSet functionalParameterPurity( ExecutableElement method, int index, ProcessingEnvironment env) { - if (!hasFunctionalInterfaceParameter(method, index, env)) { + ExecutableElement parameterFunction = parameterFunctionalMethod(method, index, env); + if (parameterFunction == null) { return EnumSet.noneOf(PurityKind.class); } EnumSet result = functionalParameterKinds(annoProvider, method); @@ -292,7 +260,9 @@ public static EnumSet functionalParameterPurity( if (result.isEmpty()) { break; } - if (!hasFunctionalInterfaceParameter(overridden, index, env)) { + ExecutableElement overriddenFunction = parameterFunctionalMethod(overridden, index, env); + if (overriddenFunction == null + || !isSameOrOverrides(parameterFunction, overriddenFunction, env)) { result.removeAll(functionalParameterKinds(annoProvider, overridden)); } } @@ -300,20 +270,51 @@ public static EnumSet functionalParameterPurity( } /** - * Returns true if {@code method}'s formal parameter at {@code index} exists and its type is a - * functional interface. A varargs parameter's type is an array, which is not. + * Returns the functional method of the type of {@code method}'s formal parameter at {@code + * index}, or null if that parameter does not exist or its type is not a functional interface. A + * varargs parameter's type is an array, which is not. * * @param method a method or constructor * @param index the index of a formal parameter * @param env the processing environment - * @return true if the parameter at {@code index} has a functional interface type + * @return the functional method of the parameter's type, or null */ - private static boolean hasFunctionalInterfaceParameter( + private static @Nullable ExecutableElement parameterFunctionalMethod( ExecutableElement method, int index, ProcessingEnvironment env) { List parameters = method.getParameters(); - return 0 <= index - && index < parameters.size() - && functionalInterfaceType(parameters.get(index).asType(), env) != null; + if (index < 0 || index >= parameters.size()) { + return null; + } + return functionalMethodOfType(parameters.get(index).asType(), env); + } + + /** + * Returns the functional method of {@code type}, or null if {@code type} is not a functional + * interface type; see {@link #functionalInterfaceType}. + * + * @param type a type + * @param env the processing environment + * @return the functional method of {@code type}, or null + */ + private static @Nullable ExecutableElement functionalMethodOfType( + TypeMirror type, ProcessingEnvironment env) { + TypeMirror functionalType = functionalInterfaceType(type, env); + return functionalType == null ? null : TypesUtils.findFunction(functionalType, env); + } + + /** + * Returns true if {@code method} is {@code other} or overrides it. + * + * @param method a method + * @param other another method + * @param env the processing environment + * @return true if {@code method} is {@code other} or overrides it + */ + public static boolean isSameOrOverrides( + ExecutableElement method, ExecutableElement other, ProcessingEnvironment env) { + return method.equals(other) + || env.getElementUtils() + .overrides(method, other, (TypeElement) method.getEnclosingElement()); } /** @@ -613,11 +614,8 @@ private EnumSet functionalParameterCallPurity( // There is no enclosing method, or it promises nothing. return EnumSet.noneOf(PurityKind.class); } - ExpressionTree receiver = TreeUtils.getReceiverTree(tree); - if (!isFunctionalMethodOfParameter(receiver, invoked, enclosingMethod, env)) { - return EnumSet.noneOf(PurityKind.class); - } - return functionalParameterPurity(annoProvider, receiver, enclosingMethod, env); + return PurityChecker.functionalParameterCallPurity( + annoProvider, TreeUtils.getReceiverTree(tree), invoked, enclosingMethod, env); } @Override diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index 4e694e1be612..b8c98f684e2f 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1655,15 +1655,18 @@ private enum PurityViolation { * purity that the callee requires of it, because the callee's body is permitted to call it. * *

    How the argument is checked depends on its form. The body of a lambda is checked directly. A - * method reference is checked against the declaration of the method it refers to. Each result - * expression of a conditional or switch expression is checked on its own, because the type of - * such an expression is the parameter's type and says nothing about the code that any result - * expression denotes. An effectively final functional-interface parameter of the enclosing method - * needs no check, since the caller of that method already performed one. Each initializer of an - * array literal is checked on its own, because such an argument is the array that a varargs call - * passes in its array form. Any other argument is checked against the functional method of its - * declared type. A cast of an argument that denotes code is ignored; see {@link - * #withoutCastsOfCode}. + * method reference is checked against the declaration of the method it refers to. A lambda or + * method reference whose functional method does not override the parameter's, because its type is + * a functional subinterface that declares an unrelated functional method, is checked like any + * other argument, since the subinterface's implementation of the parameter's functional method is + * what runs. Each result expression of a conditional or switch expression is checked on its own, + * because the type of such an expression is the parameter's type and says nothing about the code + * that any result expression denotes. An effectively final functional-interface parameter of the + * enclosing method needs no check, since the caller of that method already performed one. Each + * initializer of an array literal is checked on its own, because such an argument is the array + * that a varargs call passes in its array form. Any other argument is checked against the + * functional method of its declared type. A cast of an argument that denotes code is ignored; see + * {@link #withoutCastsOfCode}. * *

    This check is skipped for a requirement that the parameter's own functional method already * makes, because {@link #checkLambdaPurity} and {@link @@ -1840,8 +1843,14 @@ protected void checkFunctionalArgument( ExecutableElement callee) { ProcessingEnvironment env = atypeFactory.getProcessingEnv(); ExpressionTree argument = withoutCastsOfCode(arg); - - if (argument instanceof LambdaExpressionTree lambda) { + // A lambda or method reference whose type is a functional subinterface may implement an + // abstract method that is unrelated to paramFunction; then the subinterface's implementation + // of paramFunction is what runs, so the argument is checked against that implementation. + boolean implementsParamFunction = + !(argument instanceof LambdaExpressionTree || argument instanceof MemberReferenceTree) + || implementsFunction(argument, paramFunction, env); + + if (implementsParamFunction && argument instanceof LambdaExpressionTree lambda) { TreePath lambdaPath = new TreePath(getCurrentPath(), argument); PurityResult r = PurityChecker.checkPurity( @@ -1896,7 +1905,8 @@ protected void checkFunctionalArgument( } EnumSet argKinds; - if (argument instanceof MemberReferenceTree memberReference) { + MethodTree enclosingMethod = TreePathUtil.enclosingMethod(getCurrentPath()); + if (implementsParamFunction && argument instanceof MemberReferenceTree memberReference) { if (isArrayConstructorReference(memberReference)) { // Creating an array modifies nothing that exists before the call. It is not // deterministic, like any object creation. @@ -1904,16 +1914,15 @@ protected void checkFunctionalArgument( } else { ExecutableElement referenced = (ExecutableElement) TreeUtils.elementFromUse(argument); argKinds = implementationPurityKinds(referenced); - MethodTree enclosingMethod = TreePathUtil.enclosingMethod(getCurrentPath()); - ExpressionTree qualifier = memberReference.getQualifierExpression(); - if (PurityChecker.isFunctionalMethodOfParameter( - qualifier, referenced, enclosingMethod, env)) { - // `f::apply`, where `f` is a functional-interface parameter of the enclosing method, - // denotes the code that the caller of that method was required to check. - argKinds.addAll( - PurityChecker.functionalParameterPurity( - atypeFactory, qualifier, enclosingMethod, env)); - } + // `f::apply`, where `f` is a functional-interface parameter of the enclosing method, + // denotes the code that the caller of that method was required to check. + argKinds.addAll( + PurityChecker.functionalParameterCallPurity( + atypeFactory, + memberReference.getQualifierExpression(), + referenced, + enclosingMethod, + env)); // The check of the method reference against its functional method already covers the // purity that the functional method promises. EnumSet relied = EnumSet.copyOf(required); @@ -1935,20 +1944,17 @@ protected void checkFunctionalArgument( return; } argKinds = implementationPurityKinds(argFunction); - MethodTree enclosingMethod = TreePathUtil.enclosingMethod(getCurrentPath()); - if (PurityChecker.isFunctionalMethodOfParameter( - argument, argFunction, enclosingMethod, env)) { - // The argument is a functional-interface parameter of the enclosing method, and the - // caller of that method was required to check the code that argFunction runs. - argKinds.addAll( - PurityChecker.functionalParameterPurity(atypeFactory, argument, enclosingMethod, env)); - } + // If the argument is a functional-interface parameter of the enclosing method, the caller + // of that method was required to check the code that argFunction runs. + argKinds.addAll( + PurityChecker.functionalParameterCallPurity( + atypeFactory, argument, argFunction, enclosingMethod, env)); checkImplementationFunctionalParameters( arg, argFunction, paramFunction, 0, required, "purity.functional.argument.parameter"); } if (!argKinds.containsAll(required)) { - checker.reportError( + reportErrorOnce( arg, "purity.functional.argument", param.getSimpleName(), @@ -2016,7 +2022,7 @@ private void checkImplementationFunctionalParameters( } } if (!provided.containsAll(assumed)) { - checker.reportError( + reportErrorOnce( reportTree, messageKey, implementationParams.get(i).getSimpleName(), @@ -2028,6 +2034,23 @@ private void checkImplementationFunctionalParameters( } } + /** + * Returns true if the functional method that {@code code} implements is {@code paramFunction} or + * overrides it. + * + * @param code a lambda expression or method reference + * @param paramFunction the functional method of a parameter's type + * @param env the processing environment + * @return true if {@code code} implements {@code paramFunction} + */ + private static boolean implementsFunction( + ExpressionTree code, ExecutableElement paramFunction, ProcessingEnvironment env) { + TypeMirror functionalType = PurityChecker.functionalInterfaceType(TreeUtils.typeOf(code), env); + return functionalType == null + || PurityChecker.isSameOrOverrides( + TypesUtils.findFunction(functionalType, env), paramFunction, env); + } + /** * Returns the method that {@code type} supplies for the functional method {@code paramFunction}, * or null if it has none. @@ -2049,10 +2072,7 @@ private void checkImplementationFunctionalParameters( TypeMirror functionalType = PurityChecker.functionalInterfaceType(type, env); if (functionalType != null) { ExecutableElement function = TypesUtils.findFunction(functionalType, env); - TypeElement functionalTypeElement = TypesUtils.getTypeElement(functionalType); - if (function.equals(paramFunction) - || (functionalTypeElement != null - && elements.overrides(function, paramFunction, functionalTypeElement))) { + if (PurityChecker.isSameOrOverrides(function, paramFunction, env)) { return function; } } @@ -2222,8 +2242,20 @@ private void reportPurityError(PurityViolation violation, ImpurityReason r) { args = new Object[] {violation.purityAdjective}; } // The same tree can be checked more than once: an initializer is checked as part of every - // constructor that runs it, and every checker of a compound checker checks purity - // independently. Report each message at each tree only once. + // constructor that runs it. + reportErrorOnce(tree, msgKey, args); + } + + /** + * Reports an error at {@code tree}, unless this checker or another checker of the same compound + * checker has already reported it there. Every checker of a compound checker checks purity + * independently, so a purity error would otherwise be reported once per checker. + * + * @param tree the tree at which to report the error + * @param msgKey the message key + * @param args the arguments of the message + */ + private void reportErrorOnce(Tree tree, @CompilerMessageKey String msgKey, Object... args) { TreePath path = atypeFactory.getPath(tree); if (path == null) { checker.reportError(tree, msgKey, args); diff --git a/framework/tests/flow/PurityFunctionalSubinterface.java b/framework/tests/flow/PurityFunctionalSubinterface.java index 5f9d33be200a..e7d38290243b 100644 --- a/framework/tests/flow/PurityFunctionalSubinterface.java +++ b/framework/tests/flow/PurityFunctionalSubinterface.java @@ -48,4 +48,63 @@ static int forwards(G g, String s) { // :: error: [purity.functional.argument] return m(g, s); } + + /** R's functional method is go, which is unrelated to Runnable.run. */ + interface R extends Runnable { + @Override + default void run() { + count++; + } + + @SideEffectFree + void go(); + } + + /** Like R, but its implementation of Runnable.run is side-effect-free. */ + interface PureRun extends Runnable { + @Override + @SideEffectFree + default void run() {} + + void go(); + } + + @SideEffectFree + static void takesRunnable(Runnable r) {} + + /** A lambda cast to R implements R.go, but a call to r.run() runs R.run. */ + void castsCode() { + // :: error: [purity.functional.argument] + takesRunnable((R) () -> {}); + // :: error: [purity.functional.argument] + takesRunnable((R) PurityFunctionalSubinterface::pureStatic); + takesRunnable((PureRun) () -> {}); + } + + @SideEffectFree + static void pureStatic() {} + + interface Takes { + @SideEffectFree + void take(F f); + } + + /** + * A call through Takes checks its argument against Runnable.run, not against PureRun.go, so the + * body may not assume that p.go() is side-effect-free. + */ + static class TakesPureRun implements Takes { + @Override + @SideEffectFree + public void take(PureRun p) { + // :: error: [purity.call] + p.go(); + } + } + + /** A lambda passed to Takes.take implements R.go, but the callee runs R.run. */ + void passesToInstantiation(Takes t) { + // :: error: [purity.functional.argument] + t.take(() -> {}); + } } From 8078b4e8daa8aa20a73174e02d6c1ee90309f678 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Fri, 2 Oct 2026 12:06:00 -0700 Subject: [PATCH 40/43] Check an argument to a class type variable parameter against its type at the call site A parameter whose declared type is a type variable of its class may be a functional interface at a call whose receiver instantiates the type variable with one, and an implementation of the method may call it. Check such an argument against the functional method of the parameter's type at the call site. A parameter whose type is a type variable of the method itself, such as that of List.of, is not checked, since no implementation knows that the argument is code. --- docs/manual/purity-checker.tex | 14 ++- .../common/basetype/BaseTypeVisitor.java | 84 ++++++++++++++++-- .../flow/PurityClassTypeVariableArgument.java | 88 +++++++++++++++++++ .../tests/flow/PurityFunctionalOverride.java | 12 ++- 4 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 framework/tests/flow/PurityClassTypeVariableArgument.java diff --git a/docs/manual/purity-checker.tex b/docs/manual/purity-checker.tex index 278ea4349410..fca8bc3ab5d9 100644 --- a/docs/manual/purity-checker.tex +++ b/docs/manual/purity-checker.tex @@ -136,7 +136,8 @@ The assumption also does not apply if the method overrides a method that makes the same promise but whose parameter at that position has a type variable as its type, because a call through the overridden method relies on -the promise without checking the argument. An overridden method that makes +the promise without checking the argument when the type variable is not +instantiated with a functional interface at the call. An overridden method that makes no promise does not limit the assumption: a call through it may pass an argument with side effects, but that call does not rely on the purity of the method either. @@ -157,8 +158,15 @@ a \<@SideEffectsOnly> method). For a varargs parameter whose element type is a functional interface, such as \, each argument that the call passes as an element of the array is checked, as is each initializer of an -array literal such as \ that the call passes. How the -argument is checked depends on its form: +array literal such as \ that the call passes. A +parameter whose type is a type variable of its class, such as \ in +\<@SideEffectFree int apply(T t)> in \code{interface I}, is a +functional-interface parameter at a call whose receiver instantiates \ +with a functional interface: a call through \code{I>} checks +its argument against \. A parameter whose type is a type +variable of the method itself, such as the parameter of \, is +not: the method cannot call its argument. How the argument is checked +depends on its form: \begin{itemize} \item diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index b8c98f684e2f..378042a39224 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -71,11 +71,13 @@ import javax.lang.model.element.Modifier; import javax.lang.model.element.Name; import javax.lang.model.element.TypeElement; +import javax.lang.model.element.TypeParameterElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.ArrayType; import javax.lang.model.type.DeclaredType; import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; +import javax.lang.model.type.TypeVariable; import javax.lang.model.util.ElementFilter; import javax.lang.model.util.Elements; import javax.tools.Diagnostic; @@ -1672,13 +1674,25 @@ private enum PurityViolation { * makes, because {@link #checkLambdaPurity} and {@link * BaseTypeVisitor.OverrideChecker#checkMethodReferencePurity} check that. * + *

    A parameter whose declared type is a type variable of the callee's class is checked against + * the functional method of its type at the call site, in which the receiver's type arguments are + * substituted for the class's type variables. A call through {@code I>} of + * {@code @SideEffectFree int apply(T t)} may run an implementation whose parameter is a {@code + * Supplier} and that calls it. A parameter whose declared type is a type variable of the + * callee itself, such as the parameter of {@code List.of(E)}, is not checked: no implementation + * of the callee knows that the argument is a functional interface. + * * @param callee the invoked method or constructor + * @param calleeType the type of {@code callee} at the call site, or null if it is not known * @param args the arguments to {@code callee} * @param varargsCall true if {@code args} are in the expanded form of a varargs call, so that the * arguments from the last parameter onward are elements of its array */ protected void checkFunctionalArguments( - ExecutableElement callee, List args, boolean varargsCall) { + ExecutableElement callee, + @Nullable AnnotatedExecutableType calleeType, + List args, + boolean varargsCall) { if (!checkPurityAnnotations || infer) { return; } @@ -1695,10 +1709,22 @@ protected void checkFunctionalArguments( // an element of that parameter's array. Otherwise each argument is passed to the parameter // at its own index; the counts can still differ, as for an enum constructor, whose two // parameters no call passes. + List paramTypesAtCall = + calleeType == null || calleeType.getParameterTypes().size() != numParams + ? null + : calleeType.getParameterTypes(); int numToCheck = varargsCall ? args.size() : Math.min(numParams, args.size()); for (int i = 0; i < numToCheck; i++) { int paramIndex = Math.min(i, numParams - 1); ExecutableElement paramFunction = parameterFunctionalMethod(callee, paramIndex); + if (paramFunction == null + && paramTypesAtCall != null + && isClassTypeVariable(parameterArgumentType(callee, paramIndex, null))) { + paramFunction = + functionalMethodOfType( + parameterArgumentType( + callee, paramIndex, paramTypesAtCall.get(paramIndex).getUnderlyingType())); + } if (paramFunction == null) { continue; } @@ -1722,15 +1748,57 @@ protected void checkFunctionalArguments( */ private @Nullable ExecutableElement parameterFunctionalMethod( ExecutableElement method, int index) { - TypeMirror paramType = method.getParameters().get(index).asType(); - if (method.isVarArgs() && index == method.getParameters().size() - 1) { + return functionalMethodOfType(parameterArgumentType(method, index, null)); + } + + /** + * Returns the type of each argument that a call passes to {@code method}'s parameter at {@code + * index}: the parameter's type, or for a varargs parameter the array's component type. + * + * @param method a method or constructor + * @param index the index of one of {@code method}'s formal parameters + * @param paramType the type of that parameter at a call site, or null to use its declared type + * @return the type of each argument passed to that parameter + */ + private static TypeMirror parameterArgumentType( + ExecutableElement method, int index, @Nullable TypeMirror paramType) { + if (paramType == null) { + paramType = method.getParameters().get(index).asType(); + } + if (method.isVarArgs() + && index == method.getParameters().size() - 1 + && paramType.getKind() == TypeKind.ARRAY) { paramType = ((ArrayType) paramType).getComponentType(); } + return paramType; + } + + /** + * Returns the functional method of {@code type}, or null if {@code type} is not a functional + * interface. + * + * @param type a type + * @return the functional method of {@code type}, or null + */ + private @Nullable ExecutableElement functionalMethodOfType(TypeMirror type) { ProcessingEnvironment env = atypeFactory.getProcessingEnv(); - TypeMirror functionalType = PurityChecker.functionalInterfaceType(paramType, env); + TypeMirror functionalType = PurityChecker.functionalInterfaceType(type, env); return functionalType == null ? null : TypesUtils.findFunction(functionalType, env); } + /** + * Returns true if {@code type} is a type variable that a class or interface declares, rather than + * a method or constructor. + * + * @param type a type + * @return true if {@code type} is a type variable of a class or interface + */ + private static boolean isClassTypeVariable(TypeMirror type) { + return type.getKind() == TypeKind.TYPEVAR + && ((TypeParameterElement) ((TypeVariable) type).asElement()).getGenericElement() + instanceof TypeElement; + } + /** * Returns the purity that a method whose purity is {@code methodKinds} requires of the argument * that is passed to a functional-interface parameter whose functional method is {@code @@ -2824,7 +2892,8 @@ public Void visitMethodInvocation(MethodInvocationTree tree, Void p) { AnnotatedTypes.adaptParameters(atypeFactory, invokedMethod, tree.getArguments(), tree); checkArguments(params, tree.getArguments(), methodName, method.getParameters()); checkVarargs(invokedMethod, tree); - checkFunctionalArguments(method, tree.getArguments(), TreeUtils.isVarargsCall(tree)); + checkFunctionalArguments( + method, invokedMethod, tree.getArguments(), TreeUtils.isVarargsCall(tree)); if (ElementUtils.isMethod( invokedMethod.getElement(), vectorCopyInto, atypeFactory.getProcessingEnv())) { @@ -3176,7 +3245,10 @@ public Void visitNewClass(NewClassTree tree, Void p) { // constructor unless it is needed: for an anonymous class, doing so searches the class's // synthetic constructor for the super call. checkFunctionalArguments( - TreeUtils.getSuperConstructor(tree), passedArguments, TreeUtils.isVarargsCall(tree)); + TreeUtils.getSuperConstructor(tree), + constructorType, + passedArguments, + TreeUtils.isVarargsCall(tree)); } List paramBounds = diff --git a/framework/tests/flow/PurityClassTypeVariableArgument.java b/framework/tests/flow/PurityClassTypeVariableArgument.java new file mode 100644 index 000000000000..64ab38d7e03b --- /dev/null +++ b/framework/tests/flow/PurityClassTypeVariableArgument.java @@ -0,0 +1,88 @@ +import java.util.function.Supplier; +import org.checkerframework.dataflow.qual.SideEffectFree; + +/** A parameter whose type is a type variable of its class is checked at the call site. */ +public class PurityClassTypeVariableArgument { + static int count; + + static class Base { + @SideEffectFree + public int apply(Supplier s) { + return s.get(); + } + } + + interface I { + @SideEffectFree + int apply(T t); + } + + /** + * Sub inherits Base.apply as its implementation of I>.apply, so a call through + * I> checks its argument at the call site. + */ + static class Sub extends Base implements I> {} + + static class DirectOverride implements I> { + @Override + @SideEffectFree + public int apply(Supplier s) { + // :: error: [purity.call] + return s.get(); + } + } + + void callThroughInterface(I> i) { + // :: error: [purity.assign.field] + i.apply(() -> count++); + i.apply(() -> 1); + } + + static class Box { + @SideEffectFree + Box(T t) {} + + @SideEffectFree + void put(T t) {} + + @SafeVarargs + @SideEffectFree + final void putAll(T... ts) {} + } + + @SideEffectFree + static T id(T t) { + return t; + } + + void others(Box b) { + // Method type variables are not checked. + java.util.List l = java.util.List.of(() -> count++); + Runnable r = id(() -> count++); + // Class type variables are. + // :: error: [purity.assign.field] + b.put(() -> count++); + // :: error: [purity.assign.field] + b.putAll(() -> count++); + // :: error: [purity.assign.field] + new Box(() -> count++); + // :: error: [purity.assign.field] + new Box(() -> count++) {}; + } + + static class SubBox extends Box { + SubBox() { + super(() -> {}); + } + + void self() { + // Within Box's own subclass, T is Runnable. + // :: error: [purity.assign.field] + put(() -> count++); + } + } + + void callWithSub() { + callThroughInterface(new Sub()); + } +} diff --git a/framework/tests/flow/PurityFunctionalOverride.java b/framework/tests/flow/PurityFunctionalOverride.java index 015a8d91838f..7acfda4d1ef9 100644 --- a/framework/tests/flow/PurityFunctionalOverride.java +++ b/framework/tests/flow/PurityFunctionalOverride.java @@ -68,8 +68,8 @@ int m(T f, String s) { } /** - * A call through GenericSuper checks nothing about the argument, whose declared type is a type - * variable, so this body cannot assume that the argument is side-effect-free. + * A call through GenericSuper whose type argument is a type variable checks nothing about the + * argument, so this body cannot assume that the argument is side-effect-free. */ static class GenericSub extends GenericSuper> { @Override @@ -79,10 +79,18 @@ int m(Function f, String s) { } } + /** The argument is checked against the type of the parameter at the call site. */ void callsThroughGenericSupertype(GenericSuper> receiver, String s) { + receiver.m(t -> t.length(), s); + // :: error: [purity.assign.field] receiver.m(t -> count++, s); } + /** Here the type of the parameter at the call site is T, which is not a functional interface. */ + void callsThroughTypeVariable(GenericSuper receiver, T f, String s) { + receiver.m(f, s); + } + /** * A call through Unrelated checks nothing, but it also does not rely on the override's purity, so * the override may assume what its own annotation requires. From bbc75308dcc6924a3745c52a078f3ba98333d6fb Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Fri, 2 Oct 2026 12:07:11 -0700 Subject: [PATCH 41/43] Add a failing test for an inherited implementation of a generic interface method --- .../PurityInheritedImplementationGap.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 framework/tests/flow/PurityInheritedImplementationGap.java diff --git a/framework/tests/flow/PurityInheritedImplementationGap.java b/framework/tests/flow/PurityInheritedImplementationGap.java new file mode 100644 index 000000000000..cf6b32c720f3 --- /dev/null +++ b/framework/tests/flow/PurityInheritedImplementationGap.java @@ -0,0 +1,36 @@ +// An inherited implementation that assumes the purity of a functional-interface argument, paired +// with an interface method that only a subclass implements with it, is not checked. + +import java.util.function.Supplier; +import org.checkerframework.dataflow.qual.SideEffectFree; + +public class PurityInheritedImplementationGap { + static int count; + + static class Base { + @SideEffectFree + public int apply(Supplier s) { + return s.get(); + } + } + + interface I { + @SideEffectFree + int apply(T t); + } + + /** Base.apply implements I>.apply, but only in Sub. */ + // :: error: [purity.functional.argument.parameter] + static class Sub extends Base implements I> {} + + /** The parameter type at this call is T, so the argument is not checked. */ + @SideEffectFree + static int callThrough(I i, T t) { + return i.apply(t); + } + + static int unannotated() { + // callThrough is side-effect-free, yet this increments count. + return callThrough(new Sub(), () -> count++); + } +} From 8063daab3e01cc1d24d1368b9eff80319ba24ac1 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Mon, 5 Oct 2026 12:43:55 -0700 Subject: [PATCH 42/43] Check an argument to a method type variable parameter against its type at the call site A parameter whose declared type is a type variable of the method itself, such as that of List.of, is now checked like one whose type is a type variable of its class: if the call instantiates the type variable with a functional interface, the argument is checked against that interface's functional method. A method that is passed a functional-interface argument is assumed to call it. --- docs/manual/purity-checker.tex | 16 +++++----- .../common/basetype/BaseTypeVisitor.java | 31 +++++-------------- .../flow/PurityClassTypeVariableArgument.java | 7 +++-- .../PurityInheritedImplementationGap.java | 8 ++--- 4 files changed, 24 insertions(+), 38 deletions(-) diff --git a/docs/manual/purity-checker.tex b/docs/manual/purity-checker.tex index fca8bc3ab5d9..6cc30edb1763 100644 --- a/docs/manual/purity-checker.tex +++ b/docs/manual/purity-checker.tex @@ -159,14 +159,14 @@ a functional interface, such as \, each argument that the call passes as an element of the array is checked, as is each initializer of an array literal such as \ that the call passes. A -parameter whose type is a type variable of its class, such as \ in -\<@SideEffectFree int apply(T t)> in \code{interface I}, is a -functional-interface parameter at a call whose receiver instantiates \ -with a functional interface: a call through \code{I>} checks -its argument against \. A parameter whose type is a type -variable of the method itself, such as the parameter of \, is -not: the method cannot call its argument. How the argument is checked -depends on its form: +parameter whose type is a type variable is a functional-interface parameter +at a call that instantiates the type variable with a functional interface. +For \ in \<@SideEffectFree int apply(T t)> in \code{interface I}, a +call through \code{I>} checks its argument against +\. Likewise, a call \ count++)> that instantiates +\ in \ with \ checks its argument against +\: a method that is passed a functional-interface argument is +assumed to call it. How the argument is checked depends on its form: \begin{itemize} \item diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index 7883b059ba46..5dd21c689776 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -70,13 +70,11 @@ import javax.lang.model.element.Modifier; import javax.lang.model.element.Name; import javax.lang.model.element.TypeElement; -import javax.lang.model.element.TypeParameterElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.ArrayType; import javax.lang.model.type.DeclaredType; import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; -import javax.lang.model.type.TypeVariable; import javax.lang.model.util.ElementFilter; import javax.lang.model.util.Elements; import javax.tools.Diagnostic; @@ -1674,13 +1672,13 @@ private enum PurityViolation { * makes, because {@link #checkLambdaPurity} and {@link * BaseTypeVisitor.OverrideChecker#checkMethodReferencePurity} check that. * - *

    A parameter whose declared type is a type variable of the callee's class is checked against - * the functional method of its type at the call site, in which the receiver's type arguments are - * substituted for the class's type variables. A call through {@code I>} of - * {@code @SideEffectFree int apply(T t)} may run an implementation whose parameter is a {@code - * Supplier} and that calls it. A parameter whose declared type is a type variable of the - * callee itself, such as the parameter of {@code List.of(E)}, is not checked: no implementation - * of the callee knows that the argument is a functional interface. + *

    A parameter whose declared type is a type variable is checked against the functional method + * of its type at the call site, in which the receiver's type arguments and the callee's inferred + * or explicit type arguments are substituted for the type variables. A call through {@code + * I>} of {@code @SideEffectFree int apply(T t)} may run an implementation whose + * parameter is a {@code Supplier} and that calls it. A callee that receives a + * functional-interface argument through a type variable of its own, such as {@code List.of(E)}, + * is assumed to call it, too. * * @param callee the invoked method or constructor * @param calleeType the type of {@code callee} at the call site, or null if it is not known @@ -1719,7 +1717,7 @@ protected void checkFunctionalArguments( ExecutableElement paramFunction = parameterFunctionalMethod(callee, paramIndex); if (paramFunction == null && paramTypesAtCall != null - && isClassTypeVariable(parameterArgumentType(callee, paramIndex, null))) { + && parameterArgumentType(callee, paramIndex, null).getKind() == TypeKind.TYPEVAR) { paramFunction = functionalMethodOfType( parameterArgumentType( @@ -1786,19 +1784,6 @@ private static TypeMirror parameterArgumentType( return functionalType == null ? null : TypesUtils.findFunction(functionalType, env); } - /** - * Returns true if {@code type} is a type variable that a class or interface declares, rather than - * a method or constructor. - * - * @param type a type - * @return true if {@code type} is a type variable of a class or interface - */ - private static boolean isClassTypeVariable(TypeMirror type) { - return type.getKind() == TypeKind.TYPEVAR - && ((TypeParameterElement) ((TypeVariable) type).asElement()).getGenericElement() - instanceof TypeElement; - } - /** * Returns the purity that a method whose purity is {@code methodKinds} requires of the argument * that is passed to a functional-interface parameter whose functional method is {@code diff --git a/framework/tests/flow/PurityClassTypeVariableArgument.java b/framework/tests/flow/PurityClassTypeVariableArgument.java index 64ab38d7e03b..19eb84d8fc8f 100644 --- a/framework/tests/flow/PurityClassTypeVariableArgument.java +++ b/framework/tests/flow/PurityClassTypeVariableArgument.java @@ -1,7 +1,7 @@ import java.util.function.Supplier; import org.checkerframework.dataflow.qual.SideEffectFree; -/** A parameter whose type is a type variable of its class is checked at the call site. */ +/** A parameter whose type is a type variable is checked at the call site. */ public class PurityClassTypeVariableArgument { static int count; @@ -56,10 +56,11 @@ static T id(T t) { } void others(Box b) { - // Method type variables are not checked. + // Method type variables are checked, too. + // :: error: [purity.assign.field] java.util.List l = java.util.List.of(() -> count++); + // :: error: [purity.assign.field] Runnable r = id(() -> count++); - // Class type variables are. // :: error: [purity.assign.field] b.put(() -> count++); // :: error: [purity.assign.field] diff --git a/framework/tests/flow/PurityInheritedImplementationGap.java b/framework/tests/flow/PurityInheritedImplementationGap.java index cf6b32c720f3..dd6ca09e39f7 100644 --- a/framework/tests/flow/PurityInheritedImplementationGap.java +++ b/framework/tests/flow/PurityInheritedImplementationGap.java @@ -1,5 +1,6 @@ // An inherited implementation that assumes the purity of a functional-interface argument, paired -// with an interface method that only a subclass implements with it, is not checked. +// with an interface method that only a subclass implements with it, is checked at a call whose +// parameter type is a type variable of the callee. import java.util.function.Supplier; import org.checkerframework.dataflow.qual.SideEffectFree; @@ -20,17 +21,16 @@ interface I { } /** Base.apply implements I>.apply, but only in Sub. */ - // :: error: [purity.functional.argument.parameter] static class Sub extends Base implements I> {} - /** The parameter type at this call is T, so the argument is not checked. */ + /** The parameter type at this call is T, which is inferred to be Supplier. */ @SideEffectFree static int callThrough(I i, T t) { return i.apply(t); } static int unannotated() { - // callThrough is side-effect-free, yet this increments count. + // :: error: [purity.assign.field] return callThrough(new Sub(), () -> count++); } } From 403df4ea1f34a6fc8398df84427edc55f625a2e4 Mon Sep 17 00:00:00 2001 From: Suzanne Millstein Date: Tue, 6 Oct 2026 10:08:56 -0700 Subject: [PATCH 43/43] Do not infer purity for a method with a parameter of an Object-bounded type variable A call may instantiate such a type variable with a functional interface, and then the argument is checked against the method's purity. Whole-program inference cannot make a lambda or method reference meet that requirement, so inferring @Pure for a method such as ` Z identity(Z p)` caused purity warnings at callers that pass an impure lambda. Also give the List.of example in the manual an explicit Runnable type argument. --- docs/manual/purity-checker.tex | 4 ++-- .../common/basetype/BaseTypeVisitor.java | 23 ++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/manual/purity-checker.tex b/docs/manual/purity-checker.tex index 6cc30edb1763..60ebaffc6ed1 100644 --- a/docs/manual/purity-checker.tex +++ b/docs/manual/purity-checker.tex @@ -163,8 +163,8 @@ at a call that instantiates the type variable with a functional interface. For \ in \<@SideEffectFree int apply(T t)> in \code{interface I}, a call through \code{I>} checks its argument against -\. Likewise, a call \ count++)> that instantiates -\ in \ with \ checks its argument against +\. Likewise, a call \code{List.of(() -> count++)} +that instantiates \ in \ with \ checks its argument against \: a method that is passed a functional-interface argument is assumed to call it. How the argument is checked depends on its form: diff --git a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java index 5dd21c689776..4657295e502e 100644 --- a/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java +++ b/framework/src/main/java/org/checkerframework/common/basetype/BaseTypeVisitor.java @@ -1826,7 +1826,11 @@ private EnumSet purityRequiredOfArgument( * functional-interface parameter. See {@link #checkFunctionalArguments}. * *

    Inference cannot annotate a lambda expression or a method reference, so it cannot make an - * argument meet such a requirement; inferring the kind would introduce errors at call sites. + * argument meet such a requirement; inferring the kind would introduce errors at call sites. A + * parameter whose type is a type variable bounded by {@code Object} is a functional-interface + * parameter at any call that instantiates the type variable with a functional interface, whose + * functional method may make no promise, so no kind is inferred for a method with such a + * parameter. * *

    Discarding one kind can make another one required: determinism is required of an argument * that returns no value only when the argument is known to be side-effect-free. The computation @@ -1845,6 +1849,9 @@ private EnumSet kindsSafeToInfer( ExecutableElement paramFunction = parameterFunctionalMethod(method, i); if (paramFunction != null) { required.addAll(purityRequiredOfArgument(paramFunction, result, false)); + } else if (mayBeInstantiatedWithFunctionalInterface( + parameterArgumentType(method, i, null))) { + required.addAll(result); } } if (required.isEmpty()) { @@ -1855,6 +1862,20 @@ private EnumSet kindsSafeToInfer( return result; } + /** + * Returns true if {@code type} is a type variable that a call may instantiate with a functional + * interface, even though its upper bound is not one. A type variable whose upper bound is a + * functional interface is handled by {@link #parameterFunctionalMethod}. A type variable bounded + * by an interface with no abstract methods, such as {@code Serializable}, is not handled, + * although a call may instantiate it with an intersection that includes a functional interface. + * + * @param type a type + * @return true if {@code type} is a type variable whose upper bound is {@code Object} + */ + private static boolean mayBeInstantiatedWithFunctionalInterface(TypeMirror type) { + return type.getKind() == TypeKind.TYPEVAR && TypesUtils.isObject(TypesUtils.upperBound(type)); + } + /** * Returns {@code expr} without parentheses, and also without casts if what they wrap denotes * code: a lambda, a method reference, a conditional expression, or a switch expression. A cast of