-
Notifications
You must be signed in to change notification settings - Fork 2
Description
Or, propagating TYPE_USE annotations (#19) is insufficient.
Or, builders are really validators.
I've been fooling around with nullability analysis to get a sense of the state of the ecosystem primo 2024. For this experiment I'm using
- Maven, because JetBrains claims 60% of Java projects use Maven and because I hate it less than Gradle.
- Checker Framework's nullness checker, because it's well established, can run without a third-party host, and NullAway depends on it anyway.
- JSpecify, because it's ~the only general TYPE_USE nullability annotation library and it specifically lacks certain tool specific support.
- avaje-record-builder, because avaje-record-builder has some recognition of TYPE_USE annotations, and anyway, because I suspect any dialogue about the state of third-party nullability analysis in combination with a record builder generator has a better chance of being fruitful here no matter the outcome.
I'm filing this issue for posterity to record that this combination of tools presently is not workable. The essence of why is the same as the essence of uber/NullAway#121, the outcome of which is not clear to me. I am aware of the extensive history represented by #19 and Randgalt/record-builder#111.
There are variations I have not experimented with much or at all and so cannot really comment on:
- Checker Framework standalone execution, which offers flexible control of what to analyse when and possibly trivially allows for ignoring problematic elements like generated code.
- Checker Framework via Gradle, but I get the sense that Checker Framework pushes Gradle hard and that the Gradle integration is richer than the Maven integration.
- NullAway, which itself is much laxer than Checker Framework, and via Error Prone offers flexible analysis inclusion and exclusion rules even in Maven integration.
- io.soabase.record-builder:record-builder:38, which is unable to recognize TYPE_USE annotations.
Given a record
package example; // avaje/avaje-record-builder#41
@io.avaje.recordbuilder.RecordBuilder
public record Nullity(
@org.jspecify.annotations.NonNull String a,
@org.jspecify.annotations.Nullable String b) {}
avaje-record-builder-1.0 generates the moral equivalent of
package example;
import io.avaje.recordbuilder.Generated;
import java.util.function.Consumer;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
@Generated("avaje-record-builder")
public class NullityBuilder {
private String a;
private String b;
private NullityBuilder() {} // initialization.fields.uninitialized
private NullityBuilder(@NonNull String a, @Nullable String b) {
this.a = a;
this.b = b; // assignment
}
public static NullityBuilder builder() { return new NullityBuilder(); }
public static NullityBuilder builder(Nullity from) { return new NullityBuilder(from.a(), from.b()); }
public Nullity build() { return new Nullity(a, b); }
public NullityBuilder a(@NonNull String a) {
this.a = a;
return this;
}
public NullityBuilder b(@Nullable String b) {
this.b = b; // assignment
return this;
}
}
When compiled (POM below), Checker Framework produces three errors:
[ERROR] /.../src/avaje-record-builder-nullity/target/generated-sources/annotations/example/NullityBuilder.java:[14,10] error: [initialization.fields.uninitialized] the constructor does not initialize fields: a, b
[ERROR] /.../src/avaje-record-builder-nullity/target/generated-sources/annotations/example/NullityBuilder.java:[19,13] error: [assignment] incompatible types in assignment.
found : @Initialized @Nullable String
required: @Initialized @NonNull String
[ERROR] /.../src/avaje-record-builder-nullity/target/generated-sources/annotations/example/NullityBuilder.java:[51,15] error: [assignment] incompatible types in assignment.
found : @Initialized @Nullable String
required: @Initialized @NonNull String
The line numbers refer to the canonical generated lines, not the compressed version shown above. The compressed version is annotated with the error codes.
There are two errors in the generated code, one "obvious" and one subtle. Other limitations in Checker Framework, Checker Framework's Maven integration, and avaje-record-builder combine to make those two errors somewhere between difficult and impossible to work around (I have not found a way).
assignment
: Although theNullityBuilder b(@Nullable String b)
receiver correctly hadorg.jspecify.annotations.Nullable
propagated from theNullity.b
component the annotation was not propagated to theNullityBuilder.b
field, which Checker Framework consequently resolves to@NonNull
.@Nullable
should have been propagated all the way to the field.- This is the "obvious" error as far as Checker Framework's rules go.
initialization.fields.uninitialized
: Thebuilder()
factory instantiates a builder in its default state according to the JLS, i.e. with all fields uninitialized and implicitly null. This is valid Java but Checker Framework effectively resolves those fields to@NonNull
, which is a contradiction and therefore rejected. This behaviour is consistent with the various rules in play but the rules that are in play are not representative of a builder's role. The trouble is thatNullityBuilder.[@o.j.a.NonNull] String a
is an incorrect declaration:NullityBuilder.a
must actually be the moral equivalent of@o.j.a.Nullable @javax.validation.constraints.NotNull String
, comparable to Checker Framework's@org.checkerframework.checker.nullness.qual.MonotonicNonNull
. This is because the builder is stateful and cannot start out in a state ofNullityBuilder.a
being non-null. A knock-on consequence is that the builder must validate that the values passed to the record's@NonNull
components are in fact non-null. Checker Framework makes this extra difficult by rejectingObjects.requireNonNull
.- This is the subtle error.
- It is reasonable, and perhaps technically necessary, but not truly important, that the
NullityBuilder a(@NonNull String a)
receiver had the@NonNull
annotation propagated. It is reasonable because passing a@Nullable
value is clearly nonsensical but it is unimportant becauseNullityBuilder.a
cannot practically be@NonNull
. - Arguably
NullityBuilder.a
and all other@NonNull
record components could be promoted to parameters on thebuilder()
method. This would undermine the utility of the resulting builder, however. - See also https://checkerframework.org/manual/#initialization-warnings-constructor
POM:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId>
<artifactId>avaje-record-builder-nullity</artifactId>
<version>1.0-SNAPSHOT</version>
<name>avaje-record-builder-nullity</name>
<properties>
<checker.version>3.42.0</checker.version>
<maven-compiler-plugin.version>3.11.0</maven-compiler-plugin.version>
<maven.compiler.release>17</maven.compiler.release>
<record.builder.version>1.0</record.builder.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-record-builder</artifactId>
<version>${record.builder.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>0.3.0</version>
</dependency>
<dependency>
<groupId>org.checkerframework</groupId>
<artifactId>checker</artifactId>
<version>${checker.version}</version>
</dependency>
<dependency>
<groupId>org.checkerframework</groupId>
<artifactId>checker-qual</artifactId>
<version>${checker.version}</version>
</dependency>
<dependency>
<groupId>org.checkerframework</groupId>
<artifactId>checker-util</artifactId>
<version>${checker.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
<configuration>
<compilerArgs>
</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>io.avaje</groupId>
<artifactId>avaje-record-builder</artifactId>
<version>${record.builder.version}</version>
</path>
</annotationProcessorPaths>
<annotationProcessors>
<annotationProcessor>io.avaje.recordbuilder.internal.RecordProcessor</annotationProcessor>
</annotationProcessors>
</configuration>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>checkerframework</id>
<activation>
<jdk>[9,)</jdk>
</activation>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<execution>
<id>default-compile</id>
<configuration>
<fork>true</fork>
<annotationProcessorPaths combine.children="append">
<path>
<groupId>org.checkerframework</groupId>
<artifactId>checker</artifactId>
<version>${checker.version}</version>
</path>
</annotationProcessorPaths>
<annotationProcessors combine.children="append">
<annotationProcessor>org.checkerframework.checker.nullness.NullnessChecker</annotationProcessor>
</annotationProcessors>
<compilerArgs combine.children="append">
<arg>-Xmaxerrs</arg>
<arg>10000</arg>
<arg>-Xmaxwarns</arg>
<arg>10000</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
</compilerArgs>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>