Testing the Consistency of Reflection and Annotation processing

Intro

Java SE and the Java ecosystem offer a multitude of Metaprogramming APIs. They serve the same purpose just in different contexts. Annotation processing to analyse classes in the during compilation process, or Reflection to do the same at runtime. They have context dependent differences. Type erasure for example. Some Type information is lost at the end of the compilation and no longer present at runtime. Map<Long, List<String>> at compiletime becomes just Map at runtime. With these differences acounted for, there is still a lot of API surface left that should be consistent. But is it?

This project is an Abstraction for Metaprogramming

Overview

The Api

The Api is request based. As a caller you can request for example a field of a class. Accessing fields may or may not be supported.

@Test
void request()
{
   //adapter for the reflection api
   C_Class systemClass = R_Adapter.generalize(System.class);
   //request the field "out" for the class java.lang.System
   Response<C_Field> out = Provider.request(systemClass,
                                            Operations.DECLARED_GET_FIELD,
                                            "out");

   switch (out)
   {
      //the implementation may not support this operation
      //e.g. it's impossible to access fields with reflection
      case Response.Unsupported<C_Field> unsupported -> Assertions.fail();
      //the implementation may support this operation, but there is no
      //result for this instance
      //e.g. the class java.lang.System does not have a field called "out"
      case Response.Empty<C_Field> empty -> Assertions.fail();
      //accessing fields via reflection is possible and java.lang.System
      //does have a field called "out" therefore a result is expected
      case Response.Result<C_Field> result -> assertNotNull(result.value());
   }
}

Or use a convenience method if Optional.empty() or throwing an Exception is a fitting default behavior.

@Test
void requestOrEmpty()
{
   //adapter for the reflection api
   C_Class systemClass = R_Adapter.generalize(System.class);
   //request the field "out" for the class java.lang.System.
   //If its unsupported an Empty Optional is returned
   Optional<C_Field> out = Provider.requestOrEmpty(systemClass,
                                                   Operations.DECLARED_GET_FIELD,
                                                   "out");

   assertTrue(out.isPresent());
}
@Test
void requestOrThrow()
{
   //adapter for the reflection api
   C_Class systemClass = R_Adapter.generalize(System.class);
   //request the field "out" for the class java.lang.System.
   //If its unsupported an Exception is thrown
   C_Field out = Provider.requestOrThrow(systemClass,
                                         Operations.DECLARED_GET_FIELD,
                                         "out");

   assertNotNull(out);
}

Consistency Tests

Consistency Tests are written in the Style of a Technology Compatibility Kit (TCK). Testing consistency works like this. We start with a String representation of some code. This code gets compiled using the javax.tools.JavaCompiler and the Annotation processing part is executed and tested with the consumer supplied to the method "test". The compiled code is intercepted and loaded with a custom Classloader into the runtime. There the reflection part is executed and tested.

@Test
void classDeclarationRendering()
{
   String expected =
   "public class InterpolateGenericsExample<A extends Comparable<B>, B extends Comparable<A>> {}\n";

   String name = "InterpolateGenericsExample.java";
   String content =
   "public class InterpolateGenericsExample<A extends Comparable<B>, B extends Comparable<A>> {}";
   TckTest.withSource(name, content)
          .test(implementation ->
               {
                  C_Class cClass = Provider.requestOrThrow(implementation,
                                                           Operations. GET_CLASS,
                                                           "InterpolateGenericsExample");

                  assertEquals(expected, Renderer.render(RenderingContext.DEFAULT, cClass)
                                                 .declaration());
               });
}

This tests:

  • Feature

    • class rendering

  • Api

    • Provider.request(Operations.NAMEABLE_GET_NAME, aClass) and others used internally by the render

  • Adapters

    • ReflectionAdapter#generalize

    • LangModelAdapter#generalize

  • Java

    • java.lang.Class#getSimpleName

    • javax.lang.model.element.TypeElement#getSimpleName

Findings

1. Provides directive duplicates

With the java platform module system you can enforce encapsulation. One way is to declare the implementations of service provider interfaces (Spi) using "provides [Spi-Name] with [Implementation-Name]".

For the module java.desktop this looks like this:

module java.desktop {

   //...

    provides javax.sound.midi.spi.MidiDeviceProvider with
        com.sun.media.sound.MidiInDeviceProvider,
        com.sun.media.sound.MidiOutDeviceProvider,
        com.sun.media.sound.RealTimeSequencerProvider,
        com.sun.media.sound.SoftProvider;
    provides javax.sound.midi.spi.MidiFileReader with
        com.sun.media.sound.StandardMidiFileReader;
    provides javax.sound.midi.spi.MidiFileWriter with
        com.sun.media.sound.StandardMidiFileWriter;
    provides javax.sound.midi.spi.SoundbankReader with
        com.sun.media.sound.AudioFileSoundbankReader,
        com.sun.media.sound.DLSSoundbankReader,
        com.sun.media.sound.JARSoundbankReader,
        com.sun.media.sound.SF2SoundbankReader;

      //...
}

Reflection

Using Reflection we can list the provides directives like this.

ModuleFinder.ofSystem()
            .find("java.desktop")
            .orElseThrow()
            .descriptor()
            .provides()
            .toList();

resulting in

ModuleDescriptor.Provides
    service javax.sound.midi.spi.MidiDeviceProvider
    providers
        com.sun.media.sound.MidiInDeviceProvider
        com.sun.media.sound.MidiOutDeviceProvider
        com.sun.media.sound.RealTimeSequencerProvider
        com.sun.media.sound.SoftProvider
ModuleDescriptor.Provides
    service javax.sound.midi.spi.MidiFileReader
    providers
        com.sun.media.sound.StandardMidiFileReader
ModuleDescriptor.Provides
    service javax.sound.midi.spi.MidiFileWriter
    providers
        com.sun.media.sound.StandardMidiFileWriter
ModuleDescriptor.Provides
    service javax.sound.midi.spi.SoundbankReader
    providers
        com.sun.media.sound.AudioFileSoundbankReader
        com.sun.media.sound.DLSSoundbankReader
        com.sun.media.sound.JARSoundbankReader
        com.sun.media.sound.SF2SoundbankReader

Annotation Processing

Accessing the same information with Annotation Processing is a bit more work.

@SupportedSourceVersion(SourceVersion.RELEASE_21)
@SupportedAnnotationTypes("*")
public class Processor extends AbstractProcessor
{
   @Override
   public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv)
   {
      ModuleElement javaDesktop = processingEnv.getElementUtils().getModuleElement("java.desktop");
      javaDesktop.getDirectives()
                 .stream()
                 .filter(directive -> directive.getKind().equals(ModuleElement.DirectiveKind.PROVIDES))
                 .map(ModuleElement.ProvidesDirective.class::cast)
                 .toList();

      return false;
   }
}

And the results contain duplicates.

ModuleElement.ProvidesDirective
    service javax.sound.midi.spi.MidiDeviceProvider
    impls
        com.sun.media.sound.MidiInDeviceProvider
        com.sun.media.sound.MidiOutDeviceProvider
ModuleElement.ProvidesDirective
    service javax.sound.midi.spi.MidiDeviceProvider
    impls
        com.sun.media.sound.MidiInDeviceProvider
        com.sun.media.sound.MidiOutDeviceProvider
        com.sun.media.sound.RealTimeSequencerProvider
ModuleElement.ProvidesDirective
    service javax.sound.midi.spi.MidiDeviceProvider
    impls
        com.sun.media.sound.MidiInDeviceProvider
        com.sun.media.sound.MidiOutDeviceProvider
        com.sun.media.sound.RealTimeSequencerProvider
        com.sun.media.sound.SoftProvider
ModuleElement.ProvidesDirective
    service javax.sound.midi.spi.MidiFileReader
    impls
        com.sun.media.sound.StandardMidiFileReader
ModuleElement.ProvidesDirective
    service javax.sound.midi.spi.MidiFileWriter
    impls
        com.sun.media.sound.StandardMidiFileWriter
ModuleElement.ProvidesDirective
    service javax.sound.midi.spi.SoundbankReader
    impls
        com.sun.media.sound.AudioFileSoundbankReader
        com.sun.media.sound.DLSSoundbankReader
        com.sun.media.sound.JARSoundbankReader
        com.sun.media.sound.SF2SoundbankReader
ModuleElement.ProvidesDirective
    service javax.sound.midi.spi.SoundbankReader
    impls
        com.sun.media.sound.AudioFileSoundbankReader
        com.sun.media.sound.DLSSoundbankReader
        com.sun.media.sound.JARSoundbankReader
ModuleElement.ProvidesDirective
    service javax.sound.midi.spi.SoundbankReader
    impls
        com.sun.media.sound.AudioFileSoundbankReader
        com.sun.media.sound.DLSSoundbankReader
ModuleElement.ProvidesDirective
    service javax.sound.midi.spi.SoundbankReader
    impls
        com.sun.media.sound.AudioFileSoundbankReader

2. Receiver Parameter

Receiver parameter got introduced with java 8 and JSR 308 but are very rarely used. To the point that I have never seen them in the wild.

The Java language Specification describes them as followed

The receiver parameter is an optional syntactic device for an instance method or an inner class’s constructor. For an instance method, the receiver parameter represents the object for which the method is invoked. For an inner class’s constructor, the receiver parameter represents the immediately enclosing instance of the newly constructed object. In both cases, the receiver parameter exists solely to allow the type of the represented object to be denoted in source code, so that the type may be annotated (§9.7.4). The receiver parameter is not a formal parameter; more precisely, it is not a declaration of any kind of variable (§4.12.3), it is never bound to any value passed as an argument in a method invocation expression or class instance creation expression, and it has no effect whatsoever at run time.

— https://docs.oracle.com/javase/specs/jls/se21/html/jls-8.html#jls-FormalParameter

The points we will focus on are, that they can be used in two places, and are a special kind of parameter.

The Handling is inconsistent in Reflection.

Method

If we get the parameter for a method like this with Executable#getParameters() the result does not contain the Receiver.

public class MethodExample {
   public void foo(@MyAnnotation MethodExample MethodExample.this) {}
}
@Test
void methodReceiver()
{
   Executable method = Arrays.stream(MethodExample.class.getMethods())
                             .filter(method1 -> method1.getName().equals("foo"))
                             .findFirst()
                             .get();

   Parameter[] methodParameters = method.getParameters();
   Assertions.assertEquals(0, methodParameters.length);
}

Constructor

If we get the parameter for a constructor with Executable#getParameters() the result does contain the Receiver.

public class ConstructorExample {
   public class Inner {
      public Inner(@MyAnnotation ConstructorExample ConstructorExample.this) {}
   }
}
@Test
void constructorReceiver()
{
   Executable constructor = ConstructorExample.Inner.class.getConstructors()[0];

   Parameter[] constructorParameters = constructor.getParameters();
   Assertions.assertEquals(1, constructorParameters.length);
   Assertions.assertEquals(ConstructorExample.class, constructorParameters[0].getType());
}

Conclusion

Metaprogramming Apis scale with the complexity of the language. Java has many features. The Reflection and Annotation-Processing Apis cover a lot of them. The only inconsistencies I found are in rarely used Apis for rarely used language features. Both are reported.