Records and annotations

Brian Goetz brian.goetz at
Thu Jun 6 20:48:33 UTC 2019

Recall that some time ago, we were discussing some different directions 
for how to handle annotations on record components.

Approach A: Record components are a new kind of annotation target 
location; if an annotation is meta-annotated with this target kind, it 
can be applied to record components.  Expose reflection over annotations 
on record components as with other features.

Approach B: Annotations on record components are merely “pushed down” to 
the corresponding JLS-mandated API elements (constructor parameters, 
accessor methods, fields), according to the allowed target kinds of the 
annotation (if the annotation is only valid on fields, it is only pushed 
down to fields.).

Approach B+: Like B, except that we continue to reify the provenance of 
the annotations, and expose them through reflection as annotations on 
the record component _in addition to_ annotations on the mandated API 

In an alternate universe where we had done records first, and were now 
adding annotations, we’d surely pick A.  However, in the current 
universe, picking A would put us in an adoption bind; we have to wait 
for specific annotations to acquire knowledge of the new target kinds 
(through the @Target meta-annotation), and for frameworks to be aware of 
annotations on record components, before we can migrate classes 
dependent on those annotations/frameworks to be records.  Further, 
library authors suffer a familiar problem: if @Foo is meta-annotated 
with a target kind of RECORD_COMPONENT, then that means it must have 
been compiled against a Java 14+ JDK, which means that the resulting 
classes are dependent on JDK 14+, unless they use something like MR Jars 
to have two versions in one JAR.  This would further impede adoption.

For guidance in our A/B choice, we can look to enums. Enum constants are 
surely a first-class language element, and can be annotated, but they do 
not have their own annotation target kind; instead, the compiler pushes 
down the annotations onto the fields that carry the enum constants. 
  While this might be an uneasy dependence on the translation strategy, 
in fact this translation strategy is mandated (because we want migrating 
between a class with static constant fields and an enum to be a 
binary-compatible migration.).

Records are in a similar boat as enums; while there is a translation 
strategy going on here, the elements of it are mandated by the language 
specification.  So I think the trick that enums use is a reasonable one 
to carry forward to records, allowing us to seriously consider B/B+.   
(Strategy A also has a lot of accidental detail; class file attributes 
for various kinds of options and bookkeeping to manage exactly what is 
being annotated, reflection API surface, etc.).

The following type-checking strategy applies to B and B+:

  - A record component may be annotated by a declaration annotation with 
no target kind meta annotation, or whose target kind includes one or 
  - The type of a record component may be annotated by a type annotation

Strategy B then entails pushing down annotations through tree 
manipulation to the right places.  For PARAMETER annotations, they are 
pushed down to the parameters of the implicit constructor; for FIELD 
annotations, to the fields; for METHOD annotations, to the accessor. 
  And for type annotations, to the corresponding type use in constructor 
parameters, field declarations, and accessor methods.  (And if the 
annotation is applicable to more than one of these, it is pushed down to 
all applicable targets.)

But wait!  What if the author also explicitly declares, say, the 
accessor method?

     record R(int a) {
         int a() { return a; }

No problem, we can still push the annotation down, and there is 
precedent for annotations being “inherited” in this way.

But wait!  What if the author explicitly declares the same annotation, 
but with conflicting values?

     record R(@Foo(1) int a) {
         @Foo(2) int a() { return a; }

We can still push down @Foo(1), and then look to see if @Foo is a 
repeating annotation.  If it is, great; if not, then a() has two @Foo 
annotations, which results in a compilation error.  So we always push 
down, and then enforce arity rules.

By pushing annotations down in this manner, existing reflection can pick 
up the annotations on the various class members with no additional work 
or reflection API surface.  Are we done?

We might be done, or we might want to do more (strategy B+).  In B+, we 
_additionally_ reify which annotations were present on the component, 
and (possibly) expose additional reflection API surface to query 
annotations on record components. Why would we want to do this?  Well, 
one reason that occurs to me is that we’ve been holding the move of 
“abstract records” and records extending abstract records in our back 
pocket.  In this case, we might wish to copy annotations down from a 
record component in a superclass to the corresponding pseudo-component 
in the subclass, for example.  But, I’m not particularly compelled by 
this — I think the strategy we took for enums is mostly good enough.  So 
I’m voting for pure B.

More information about the amber-spec-experts mailing list