knittl
knittl

Reputation: 265131

Why are Checker Framework annotations not returned by Field#getDeclaredAnnotations?

I wanted to extend a bit of code that processes annotations to also support the Nullable annotation from the Checker Framework. To my surprise, this didn't work. Upon further inspection, it seems like the Checker Framework annotation is never picked by by reflection, even though meta-annotated with @Retention(RUNTIME).

class AnnotationTest {
    @javax.annotation.Nullable
    Object javax;

    @jakarta.annotation.Nullable
    Object jakarta;

    @org.checkerframework.checker.nullness.qual.Nullable
    Object checker;

    @Test
    void annotations() {
        final Field[] fields = getClass().getDeclaredFields();
        for (final Field field : fields) {
            System.out.println(field.getName() + " annotated with " + Arrays.toString(field.getDeclaredAnnotations()));
        }
    }
}

Output:

javax annotated with [@javax.annotation.Nullable()]
jakarta annotated with [@jakarta.annotation.Nullable()]
checker annotated with []

It is a Spring Boot project and it doesn't matter if the test is executed with IntelliJ or Gradle. Why are the other annotations picked up, but not the Checker Framework annotation?

For completeness sake, here's the definition of the annotation:

@SubtypeOf({})
@ImplicitFor(literals = LiteralKind.NULL, typeNames = java.lang.Void.class)
@DefaultInUncheckedCodeFor({TypeUseLocation.RETURN, TypeUseLocation.UPPER_BOUND})
@Documented
@Retention(RetentionPolicy.RUNTIME) // <-- retained at runtime!
@Target({ElementType.TYPE_USE, ElementType.TYPE_PARAMETER})
public @interface Nullable {}

Upvotes: 4

Views: 190

Answers (3)

rzwitserloot
rzwitserloot

Reputation: 102775

It's there. You're asking the wrong node.

javax.annotation.Nullable and checker's nullable are completely different. This is a general trend: There are ~15 frameworks with nullity annotations and none of them are the same. It's a big mess.

For what it is worth, checkerframework is more correct and most others are less correct. The reason they are wrong is possibly incompetence (hey, this stuff is way more complicated than it seems like), but more likely: Backwards compatibility.

Java 1.5 started with the annotations; the target TYPE_USE wasn't a thing back then. Later on (1.8?), TYPE_USE was introduced. Checkerframework either was started after TYPE_USE was available, or made a backwards incompatible update to move to it. javax/jakarta's nonnull predates it (or was designed by someone who didn't know what they were doing / has a wildly different idea of what non-null even means conceptually - but I think it predates).

These are the 2 completely different notions of nullity here:

TYPE_USE

The key difference is that checkerframework correctly states that it is targeted solely at types: @Target(ElementType.TYPE_USE). This is 'the new one'. That means given:

@Nullable private String foo;

The node that @Nullable applies to is just the String part. The field has no annotations here. None at all.. The field has a type and that has an annotation.

This conceptual notion of null is one that expands the type system itself. Any type has a nullity status.

The downside of this view is that its complicated in terms of generics. Just like there are 4 different ways to express 'list of numbers' in terms of generics:

  • List<? extends Number>
  • List<? super Number>
  • List<Number>
  • List (legacy / raw mode)

You need at least 4 levels of nullity (think of a method that accepts a List<String> and we are discussing the nullity of String)

  • non-null string: When reading from the list, the code does not need to nullcheck. When writing to it, only string refs that are necessarily non-null are allowed. Only List<@NonNull String> may be passed to this method.
  • nullable strings: Dereferencing refs read from the list isn't allowed without nullchecking. However, you can write whatever you want (null is fine, strings are fine). You can pass a List<@Nullable String> to this method.
  • legacy/raw: You can do whatever. (Current java code will have to be in legacy/raw mode. The JDK sources itself don't use nullity annotations after all).
  • 'either way': The method will do both: It will ensure it never derefs what it reads / always nullchecks first, and it will ensure it never writes to the list / only writes known non-null entities. This method therefore needs to do the most work, but, in trade, it gets to accept both a List<@Nullable String> as well as a List<@NonNull String>. Which can be very useful, and is not expressible by any nullity framework... except.... checker-framework! Which allows this with @PolyNull, more or less.

Just like generics, this stuff is intrinsically complicated. It just is, so while this view of nullity is all sorts of difficult, it is an accurate representation of how things work, and thus, 'correct'.

ElementType.FIELD

In contrast, an annotation can state that it applies to fields, methods, and method parameters. In that case, if you write:

@Nullable private String foo;

The field has an annotation and its type doesn't.

This is a completely different view of nullity. You're just annotating whether a field/method's return value/parameter allows null or does not. It is not possible to get into details. You can't convey the concept of a list of non-nullable sets containing nullable strings. Because you can't carry this nullity concept into generics, it isn't composable and therefore you cannot possibly get to a world where every type at every location has known nullity.

But it is simple.

More generally type use is just 'correct', in that you can of course have a nullable list of non-nullable sets containing nullable strings. Which you can express with TYPE_USE but cannot express without it.

The confused thing: Both

The javax/jakarta nullility annotations don't have a @Target annotation. That means their target is 'everything except type parameters' (because the JLS says that's what 'no @Target annotation' means).

Therefore jakarta's annotations don't actually explain what that annotation even means 'this cannot be null', sure, but, the 'this' in that sentence is ambiguous.

In the case where an annotation can apply to both types and fields/methods/parameters (such as jakarta's), this code becomes ambiguous:

public @Nullable String field;

Given that the annotation can apply both to the field as a whole as well as the the field's type specifically, how is one to interpret this code? As per the compiler's specs, in such a case, the annotation applies to both. Both field.getAnnotations() as well as field.getAnnotatedType().getAnnotations() will find it, and indeed it's as if you wrote @Nullable public {@Nullable String} field (two separate things both annotated), except of course that's not legal java syntax.

This seems great, except, it isn't: You now have 2 mostly unrelated completely different ways to analyse nullity. You don't want that.

Can you see it?

Sure!

field.getAnnotatedType().getDeclaredAnnotations()

is what you're looking for.

Upvotes: 5

mernst
mernst

Reputation: 8117

The reason is that the Checker Framework's Nullable is a type qualifier:

@Target({ElementType.TYPE_USE, ElementType.TYPE_PARAMETER})

It is a bug that the javax version (illegally placed in the javax namespace by the abandoned JSR 305 project) and the jakarta version are declaration qualifiers, since nullness is a quality of a type, not a declaration.

You can verify that the @Nullable annotation appears on the type by running the following code:

import java.lang.reflect.AnnotatedType;
import java.lang.reflect.Field;
import java.util.Arrays;

class AnnotationTest {

  @org.checkerframework.checker.nullness.qual.Nullable Object checker;

  public static void main(String[] args) {

    final Field[] fields = AnnotationTest.class.getDeclaredFields();
    for (final Field field : fields) {
      System.out.println("field " + field.getName() + " annotated with "
              + Arrays.toString(field.getDeclaredAnnotations()));
      AnnotatedType atype = field.getAnnotatedType();
      System.out.println(
          "type " + atype.getType() + " annotated with "
              + Arrays.toString(atype.getDeclaredAnnotations()));
    }
  }
}

whose output is

field checker annotated with []
type class java.lang.Object annotated with [@org.checkerframework.checker.nullness.qual.Nullable()]

Upvotes: 2

Hasan Cetinkaya
Hasan Cetinkaya

Reputation: 31

Try the following in the annotations method:

@Test
void annotations() {
    final Field[] fields = getClass().getDeclaredFields();
    for (final Field field : fields) {
        // direct annotations
        System.out.println(field.getName() + " annotated with " + Arrays.toString(field.getDeclaredAnnotations()));
        
        // type-use annotations
        AnnotatedType annotatedType = field.getAnnotatedType();
        System.out.println("Type-use annotations on " + field.getName() + ": " + Arrays.toString(annotatedType.getDeclaredAnnotations()));
    }
}

Upvotes: 0

Related Questions