[
https://issues.apache.org/jira/browse/GROOVY-12115?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Paul King updated GROOVY-12115:
-------------------------------
Description:
h1. Summary
Introduce a parameter annotation (working name {{{}@ClassTag{}}}) that lets the
static compiler *auto-supply a reified {{Class<K>}} argument* from a method
receiver's generic type, so callers can omit type tokens that today exist only
to work around erasure.
Split off from GROOVY-11807. That issue ships the *runtime* half — explicit
{{{}Class<K>{}}}-token overloads that enforce types at run time. This issue is
the *compile-time ergonomics* half: making the token optional under static
compilation.
h2. Background and origin
On the JVM, a method that needs the runtime type of a generic type parameter
cannot
recover it (erasure), so the API must take an explicit {{Class<K>}} token.
Groovy
already does this in several places, e.g. {{asChecked}} (since 5.0.0) and the
new
key-checked {{withDefault}} overloads from GROOVY-11807:
{code:java}
// DefaultGroovyMethods
public static <T> Collection<T> asChecked(Collection<T> self, Class<T> type)
// -> Collections.checkedCollection
public static <K,V> Map<K,V> asChecked(Map<K,V> self, Class<K> keyType,
Class<V> valueType) // -> Collections.checkedMap
// GROOVY-11807 (merged for 6.0.0), Class params carry a /* @ClassTag
GROOVY-12115 */ marker
public static <K,V> Map<K,V> withDefault(Map<K,V> self, Class<K> keyType,
Class<V> valueType, Closure<V> init)
public static <K,V> Map<K,V> withDefault(Map<K,V> self, Class<K> keyType,
Closure<V> init)
{code}
In every case the {{Class}} argument is *redundant with a type the compiler
already knows* (the receiver's type argument). {{@ClassTag}} removes the
redundancy under static compilation.
h2. The problem
* Verbose: {{list.asChecked(String)}} / {{map.asChecked(Number, Foo)}} restate
the
element/key/value types the static type already declares.
* Not transparent: the safety-improving variant of {{withDefault}} can't be
selected
unless the caller manually threads the {{{}Class{}}}.
* The tokens cannot be inferred at run time — only the compiler, with the
static type
in hand, can supply them.
h2. Proposal: {{@ClassTag}}
A parameter annotation marking a {{Class<X>}} parameter as
{*}compiler-supplied{*}. Under
{{@CompileStatic}} / {{@TypeChecked}} the type checker may select an overload
whose only
extra parameter is a {{@ClassTag Class<X>}} and synthesise {{X.class}} from the
receiver's matching type argument, rewriting the call. Dynamic Groovy ignores
the
annotation and binds the original overload.
{code:java}
@Documented
@Incubating
@Retention(RetentionPolicy.RUNTIME) // read off (possibly reflection-backed)
extension-method
// parameters by the STC, so CLASS/SOURCE
do not suffice
@Target(ElementType.PARAMETER)
public @interface ClassTag {
// Optional override naming the type variable to reify, for when the
parameter
// type cannot carry it. Usage e.g.: @ClassTag("K")
String value() default "";
}
{code}
h3. Annotation shape and binding
* *Param-level, not method+index.* An earlier sketch put {{@OverrideCall(1)}}
on the
method with an index. Moving the marker onto the parameter makes the *position*
implicit (where it sits) and lets the *parameter type* {{Class<K>}} encode
*which*
generic to reify — strictly more self-describing than a bare index (which never
said
K-vs-V).
* *Default binding* reads the type variable from {{{}Class<X>{}}}. The type
checker sees
{{<X>}} pre-erasure (and the Signature attribute retains it), so this is
reliable.
* *Optional override* {{@ClassTag("K")}}, for edge cases the parameter type
can't
express: a {{Class<?>}}/raw token, or a method whose own type variables don't
line up with the
receiver's. The STC *rejects a name that does not resolve to a type variable in
scope*, so typos
fail at compile time — *but only for methods compiled from source in the
current unit*. An extension
method from an already-compiled library (e.g. a Java DGM method) is never
visited as source, so a
typo in its override silently disables injection rather than being reported.
Library {{@ClassTag}}
parameters therefore use the no-override form in practice, where the name is
read from
{{Class<X>}} and cannot be mistyped.
h3. Multiple tokens and fill order
Some consumers need more than one token, e.g. asChecked(Map, @ClassTag Class<K>,
@ClassTag Class<V>). Rule: each {{@ClassTag}} parameter is synthesised *at its
declared position*; the caller's supplied arguments map to the remaining
(non-tag)
parameters left-to-right. For {{asChecked()}} that means "fill slots 1 and 2
with
{{{}K.class{}}}, {{V.class}} in declaration order." (This generalises the
single-hole case
the {{@OverrideCall(1)}} index handled.)
When more than one overload could absorb tokens, the checker prefers the one
that
reifies the *most* tokens (strongest checking); if equally-specific candidates
disagree
on the tokens, the call is too ambiguous and *no injection happens* (the call
binds as
written).
h3. Overload selection / preemption
For a call that *already matches* a no-token overload (e.g.
{{withDefault\{...}}}), the
{{@ClassTag}} overload can *preempt* it for a transparent upgrade. Preemption
means
unchanged source changes behaviour under static typing, so it is *gated*:
* *Config allowlist.* Preemption applies only to method names in
{{CompilerConfiguration.classTagPreemptionTargets}} (default holds only
{{withDefault}}).
Additive injection (supplying an otherwise-mandatory token, e.g. for
{{asChecked}}) is
never gated.
* *Object-erasure guard.* When every token would erase to {{Object}} (e.g. an
untyped/{{def}} receiver, or a bare {{[:]}} literal) there is nothing to check,
so the
lenient overload is kept — no silent upgrade to a checked view that can never
reject
anything.
* *Applicability decides, with rollback.* A candidate tag overload is matched
by arity
(non-tag parameter count = supplied argument count) and reifiability; whether
its
remaining parameters accept the supplied argument types is decided by retrying
normal
overload selection with the synthesised tokens inserted. If the retry finds no
applicable method the insertion is rolled back — injection can never make a
call fail
that would otherwise succeed, nor change the error reported for a call as
written.
Policy for API authors stands: use preemption *only when the new behaviour is an
unambiguous bug-fix* (the old behaviour has no legitimate users). Otherwise
*mint a new
name* (e.g. {{withCheckedDefault}}) so the opt-in is by name and nothing
existing
changes meaning. {{@ClassTag}} still earns its keep there by letting static
callers
omit the {{Class}}.
h2. Examples (before / after, under @CompileStatic)
The receiver must carry statically-known type arguments — a typed variable,
parameter,
field or return value. A bare literal receiver ({{[]}} / {{[:]}}) is
{{ArrayList<Object>}} / {{LinkedHashMap<Object,Object>}}-typed, so nothing can
be
reified and no injection happens ({{asChecked()}} then fails to resolve;
{{withDefault}}
stays lenient).
||API||Today||With @ClassTag||
|asChecked (list)|{{list.asChecked(String)}}|{{list.asChecked()}}|
|asChecked (map)|{{map.asChecked(Number, Foo)}}|{{map.asChecked()}}|
|withDefault (map)|{{map.withDefault(Number)\{...}}}|{{map.withDefault\{...}}}
(transparently checked)|
{code:groovy}
// asChecked: the Class was mandatory -> @ClassTag makes it omissible (opt-in,
additive)
Map<Number,Foo> map = [:]
Map<Number,Foo> m = map.asChecked() // compiler injects
Number.class, Foo.class
// withDefault: a no-Class form already exists -> @ClassTag overload preempts
it (allowlisted)
Map<Number,String> base = [:]
Map<Number,String> md = base.withDefault{ 'n/a' } // same source as today
((Map) md).put('x', 'y') // now ClassCastException
(key checked)
{code}
h2. Boundaries and edges
* *Static-only.* The injection is an STC rule. In dynamic Groovy:
** {{withDefault}} keeps binding the *unchecked* overload (no K to inject) —
same
source, different behaviour by compilation mode.
** {{asChecked()}} (no Class) won't resolve at all — there is no zero-arg
method and
no injection -> {{{}MissingMethodException{}}}. The shorter spelling is
{*}static-only syntax{*}.
* *K must be statically known.* {{Map<Number,?>}} works; {{def}} / raw {{Map}}
/
wildcard-only don't — no token is synthesised, so {{withDefault}} stays lenient
and
{{asChecked()}} won't compile.
* *Fail-soft (as implemented).* When a token can't be reified the compiler
silently
degrades: no injection, and the call binds as written. Additive use then fails
loud
naturally (the token-less call doesn't resolve); preemption keeps the lenient
binding.
Whether the preemption case should warn rather than stay silent remains a
discussion point (see the questions section).
* *Escape hatch.* The explicit {{Class}} form always works — in every mode,
and when
the type is unknown.
* *Fidelity is erased.* {{Class<K>}} reifies only the *erased* class:
{{List<String>}} and {{List<Integer>}} are indistinguishable (see TypeTag
section).
* *Override validation is source-only.* {{@ClassTag("name")}} typo-checking
runs when the declaring
method is type-checked from source; compiled extension methods are not
re-validated by the consumer.
h2. Retrofit candidates (codebase audit)
||Tier||API||Why it fits / note||
|1|{{asChecked(...)}} — 9 overloads, since 5.0.0|Textbook case: explicit
{{Class}} is purely an erasure-workaround over {{{}Collections.checkedXxx{}}}.
Also the multi-token (K,V) stress test.|
|1|{{withDefault}} / {{withCheckedDefault}} (GROOVY-11807)|Type-laundering
insert via the untyped {{{}Map.get(Object){}}}; reified K lets it check.
Unifies with checked maps: {{withDefault = checkedMap + default}} once K is
reified.|
|2|{{ListWithDefault}} ({{{}(T) initClosure.call(...){}}})|Default cast to
element type; weaker — the closure return is already statically checkable.|
|3 (new method)|{{collection.toArray()}} -> {{T[]}}|Reified array creation (the
Kotlin {{reified}} / Scala {{ClassTag}} array case). New API, not a retrofit of
{{{}asType{}}}.|
|none|{{{}asType{}}}/{{{}as{}}}, {{{}newInstance(Class){}}},
{{{}find{}}}/{{{}grep{}}}-by-Class, {{castToType}}|The {{Class}} is the user's
{*}intent{*}, not an erasure-workaround — auto-supplying would be wrong.|
Net: the realistic footprint is small and clustered around *checked-view and
default-growing collection APIs*, not a sprawling refactor.
h2. Reification fidelity: ClassTag vs a possible TypeTag tier (weaker)
{{@ClassTag}} supplies an *erased* {{{}Class<K>{}}}. A richer future tier could
inject a
*full-generic* token (a super-type token {{{}TypeToken<K>{}}}), analogous to
Scala's
{{TypeTag}} / {{scala.quoted.Type}} versus its {{{}ClassTag{}}}. Kotlin's
{{inline fun <reified T>}} is the JVM precedent for the cheap tier.
*This extension now looks weak given the boundaries above:*
* The concrete candidates ({{{}asChecked{}}} ->
{{{}Collections.checkedMap{}}}, {{{}withDefault{}}})
only ever do {{isInstance}} checks, which are erased anyway — the erased class
suffices.
* Restricting the source to the *receiver's type argument* and to *static-only*
injection further limits where full-generic fidelity would pay off.
Recommendation: keep {{ClassTag}} (erased) as the design; note
{{{}TypeTag{}}}/{{{}TypeToken{}}}
as a *possible later tier* only if a real full-generic consumer appears. The
annotation
*name* could encode the fidelity ({{{}@ClassTag{}}} now, reserving {{@TypeTag}}
for the full
tier), or favour familiarity ({{{}@Reified{}}}, after Kotlin) — see open
questions.
h2. Comparison with Scala / Kotlin / Java
||Mechanism||How the type is obtained||Fidelity||Propagation||
|Groovy {{@ClassTag}} |compiler synthesises {{Class}} from the receiver's type
arg|erased class|fail-soft |
|Scala {{ClassTag}}|implicit/{{{}using{}}} evidence materialised from the
static type|erased class|fail-loud (context bound {{{}[T: ClassTag]{}}})|
|Scala {{TypeTag}} / {{quoted.Type}}|implicit/{{{}using{}}} evidence|full
generic|fail-loud|
|Kotlin {{reified}}|{{inline}} + reify the type parameter|actual type
(inlined)|enforced by {{inline}}|
|Java baseline|explicit {{Class<T>}} token ({{{}EnumMap{}}},
{{{}Collections.checkedMap{}}}, {{{}getAnnotation(Class<T>){}}})|erased
class|manual|
|Guava/Gson {{TypeToken<T>}}|super-type token|full generic|manual|
In short: {{@ClassTag}} is Scala's {{ClassTag}} idea (compiler-supplied runtime
type
evidence) delivered via an annotation + STC call-rewrite instead of implicit
parameters; it auto-supplies the *Java {{Class<T>}} token* idiom rather than
inventing
a new runtime representation.
h2. Proposed scope / deliverables
# The {{@ClassTag}} annotation (parameter target; {{RUNTIME}} retention, since
extension-method parameters are read off reflection-backed class nodes; optional
{{value}} override).
# STC support: overload selection that prefers a {{@ClassTag}} overload and
synthesises
the {{X.class}} argument(s) from the receiver's type arguments; validation of
{{{}value{}}}; well-defined multi-token fill order.
# First consumers: retrofit {{asChecked}} and the GROOVY-11807 {{withDefault}}
overloads (validating the K,V multi-token case).
# Decide and document: fail-soft vs fail-loud; preemption-vs-new-name policy.
*Out of scope:* the full-generic {{{}TypeTag{}}}/{{{}TypeToken{}}} tier; any
change to dynamic
dispatch (dynamic callers keep passing the {{Class}} explicitly).
h2. Questions (and proposed stance)
Each item records the stance the proposed implementation currently takes; all
remain open
to revision pending community review.
* *Preemption control surface and default allowlist.* Preemption means
unchanged source
can change behaviour under static typing — should it be governed by a hardcoded
rule,
new-name-only, or configuration? *Proposed stance:* a config-level allowlist,
{{CompilerConfiguration.classTagPreemptionTargets}} — an opt-in set of method
names whose
shipped default holds only {{withDefault}} (the unambiguous bug-fix case).
Additive
injection (supplying an otherwise-mandatory token, e.g. for {{asChecked}}) is
never gated.
Clearing the set disables preemption entirely; adding a name opts a user API
in. Trade-off
acknowledged: semantics become configuration-dependent (a build or IDE
constructing a
different {{CompilerConfiguration}} resolves the call differently), which is
why the
shipped default is minimal and the feature is {{@Incubating}}.
* *Diagnostic when a token cannot be reified.* Stay silent, warn, or error
(Scala
context-bound style)? *Proposed stance:* stay silent (fail-soft). Additive use
already
fails loud naturally (no token is synthesised, so the call does not resolve).
For
preemption, a non-reifiable receiver (raw/wildcard, or every token erasing to
{{Object}})
keeps the lenient binding — the code behaves exactly as it did before the
feature existed,
so silence is the honest signal. A warning tier could be added later if
experience shows
the silent keep confuses users; a hard error would defeat the
transparent-upgrade goal.
* *Annotation name.* {{@ClassTag}} vs {{@Reified}} vs {{@TypeTag}}? *Proposed
stance:*
{{@ClassTag}} — honest that only the *erased* class is supplied (Scala's
precedent for
exactly this fidelity), and it reserves {{@TypeTag}} for a possible later
full-generic
tier. {{@Reified}} (after Kotlin) would overpromise: Kotlin's {{reified}}
delivers the
actual type via inlining, which this is not.
* *Retention and API status.* Is a public {{RUNTIME}} annotation the intended
surface,
versus an internal marker? *Proposed stance:* yes — public, {{RUNTIME}},
{{@Incubating}}.
{{RUNTIME}} (not {{CLASS}} as first sketched) is forced by mechanics:
extension-method
parameters are read off a reflection-backed {{ClassNode}} that only exposes
runtime-visible annotations. Public because third-party APIs (not just DGM) are
meant to
be able to opt in; {{@Incubating}} preserves the freedom to adjust the surface.
* *Override-validation reach.* {{@ClassTag("name")}} typo-checking is
source-only; the
same annotation on an already-compiled (e.g. Java DGM) extension method is not
re-validated by the consumer. Acceptable, or do we want a check at the
library's own build
time? *Proposed stance:* acceptable. Library authors are steered to the
no-override form
(the type-variable name is read from {{Class<X>}} and cannot be mistyped); the
override
exists for raw/wildcard tokens and is validated whenever the declaring method
is compiled
from source. A library-build-time check could be added later without changing
the surface.
I also updated the two cross-references elsewhere in the description that said
"see open questions" (the fail-soft bullet in Boundaries, and the
annotation-name note in the Reification fidelity section) to say "see the
questions section", since the heading changed.
One leftover you might tweak while pasting: deliverable #4 still reads "Decide
and document: fail-soft vs fail-loud; preemption-vs-new-name policy" — with
stances now recorded, it could become "Document the decided stances (see
questions section)". I left it since you only asked for the questions section.
h2. Related issues
* GROOVY-11807 — runtime key-checked {{withDefault}} (the quick-win this
builds on);
merged for 6.0.0. Its new {{Class}} parameters carried {{/* @ClassTag
GROOVY-12115 */}}
markers as the seam for this work; the PR for this issue replaces them with the
real
annotation.
was:
h1. Summary
Introduce a parameter annotation (working name {{{}@ClassTag{}}}) that lets the
static compiler *auto-supply a reified {{Class<K>}} argument* from a method
receiver's generic type, so callers can omit type tokens that today exist only
to work around erasure.
Split off from GROOVY-11807. That issue ships the *runtime* half — explicit
{{{}Class<K>{}}}-token overloads that enforce types at run time. This issue is
the *compile-time ergonomics* half: making the token optional under static
compilation.
h2. Background and origin
On the JVM, a method that needs the runtime type of a generic type parameter
cannot
recover it (erasure), so the API must take an explicit {{Class<K>}} token.
Groovy
already does this in several places, e.g. {{asChecked}} (since 5.0.0) and the
new
key-checked {{withDefault}} overloads from GROOVY-11807:
{code:java}
// DefaultGroovyMethods
public static <T> Collection<T> asChecked(Collection<T> self, Class<T> type)
// -> Collections.checkedCollection
public static <K,V> Map<K,V> asChecked(Map<K,V> self, Class<K> keyType,
Class<V> valueType) // -> Collections.checkedMap
// GROOVY-11807 (merged for 6.0.0), Class params carry a /* @ClassTag
GROOVY-12115 */ marker
public static <K,V> Map<K,V> withDefault(Map<K,V> self, Class<K> keyType,
Class<V> valueType, Closure<V> init)
public static <K,V> Map<K,V> withDefault(Map<K,V> self, Class<K> keyType,
Closure<V> init)
{code}
In every case the {{Class}} argument is *redundant with a type the compiler
already knows* (the receiver's type argument). {{@ClassTag}} removes the
redundancy under static compilation.
h2. The problem
* Verbose: {{list.asChecked(String)}} / {{map.asChecked(Number, Foo)}} restate
the
element/key/value types the static type already declares.
* Not transparent: the safety-improving variant of {{withDefault}} can't be
selected
unless the caller manually threads the {{{}Class{}}}.
* The tokens cannot be inferred at run time — only the compiler, with the
static type
in hand, can supply them.
h2. Proposal: {{@ClassTag}}
A parameter annotation marking a {{Class<X>}} parameter as
{*}compiler-supplied{*}. Under
{{@CompileStatic}} / {{@TypeChecked}} the type checker may select an overload
whose only
extra parameter is a {{@ClassTag Class<X>}} and synthesise {{X.class}} from the
receiver's matching type argument, rewriting the call. Dynamic Groovy ignores
the
annotation and binds the original overload.
{code:java}
@Documented
@Incubating
@Retention(RetentionPolicy.RUNTIME) // read off (possibly reflection-backed)
extension-method
// parameters by the STC, so CLASS/SOURCE
do not suffice
@Target(ElementType.PARAMETER)
public @interface ClassTag {
// Optional override naming the type variable to reify, for when the
parameter
// type cannot carry it. Usage e.g.: @ClassTag("K")
String value() default "";
}
{code}
h3. Annotation shape and binding
* *Param-level, not method+index.* An earlier sketch put {{@OverrideCall(1)}}
on the
method with an index. Moving the marker onto the parameter makes the *position*
implicit (where it sits) and lets the *parameter type* {{Class<K>}} encode
*which*
generic to reify — strictly more self-describing than a bare index (which never
said
K-vs-V).
* *Default binding* reads the type variable from {{{}Class<X>{}}}. The type
checker sees
{{<X>}} pre-erasure (and the Signature attribute retains it), so this is
reliable.
* *Optional override* {{@ClassTag("K")}}, for edge cases the parameter type
can't
express: a {{Class<?>}}/raw token, or a method whose own type variables don't
line up with the
receiver's. The STC *rejects a name that does not resolve to a type variable in
scope*, so typos
fail at compile time — *but only for methods compiled from source in the
current unit*. An extension
method from an already-compiled library (e.g. a Java DGM method) is never
visited as source, so a
typo in its override silently disables injection rather than being reported.
Library {{@ClassTag}}
parameters therefore use the no-override form in practice, where the name is
read from
{{Class<X>}} and cannot be mistyped.
h3. Multiple tokens and fill order
Some consumers need more than one token, e.g. asChecked(Map, @ClassTag Class<K>,
@ClassTag Class<V>). Rule: each {{@ClassTag}} parameter is synthesised *at its
declared position*; the caller's supplied arguments map to the remaining
(non-tag)
parameters left-to-right. For {{asChecked()}} that means "fill slots 1 and 2
with
{{{}K.class{}}}, {{V.class}} in declaration order." (This generalises the
single-hole case
the {{@OverrideCall(1)}} index handled.)
When more than one overload could absorb tokens, the checker prefers the one
that
reifies the *most* tokens (strongest checking); if equally-specific candidates
disagree
on the tokens, the call is too ambiguous and *no injection happens* (the call
binds as
written).
h3. Overload selection / preemption
For a call that *already matches* a no-token overload (e.g.
{{withDefault\{...}}}), the
{{@ClassTag}} overload can *preempt* it for a transparent upgrade. Preemption
means
unchanged source changes behaviour under static typing, so it is *gated*:
* *Config allowlist.* Preemption applies only to method names in
{{CompilerConfiguration.classTagPreemptionTargets}} (default holds only
{{withDefault}}).
Additive injection (supplying an otherwise-mandatory token, e.g. for
{{asChecked}}) is
never gated.
* *Object-erasure guard.* When every token would erase to {{Object}} (e.g. an
untyped/{{def}} receiver, or a bare {{[:]}} literal) there is nothing to check,
so the
lenient overload is kept — no silent upgrade to a checked view that can never
reject
anything.
* *Applicability decides, with rollback.* A candidate tag overload is matched
by arity
(non-tag parameter count = supplied argument count) and reifiability; whether
its
remaining parameters accept the supplied argument types is decided by retrying
normal
overload selection with the synthesised tokens inserted. If the retry finds no
applicable method the insertion is rolled back — injection can never make a
call fail
that would otherwise succeed, nor change the error reported for a call as
written.
Policy for API authors stands: use preemption *only when the new behaviour is an
unambiguous bug-fix* (the old behaviour has no legitimate users). Otherwise
*mint a new
name* (e.g. {{withCheckedDefault}}) so the opt-in is by name and nothing
existing
changes meaning. {{@ClassTag}} still earns its keep there by letting static
callers
omit the {{Class}}.
h2. Examples (before / after, under @CompileStatic)
The receiver must carry statically-known type arguments — a typed variable,
parameter,
field or return value. A bare literal receiver ({{[]}} / {{[:]}}) is
{{ArrayList<Object>}} / {{LinkedHashMap<Object,Object>}}-typed, so nothing can
be
reified and no injection happens ({{asChecked()}} then fails to resolve;
{{withDefault}}
stays lenient).
||API||Today||With @ClassTag||
|asChecked (list)|{{list.asChecked(String)}}|{{list.asChecked()}}|
|asChecked (map)|{{map.asChecked(Number, Foo)}}|{{map.asChecked()}}|
|withDefault (map)|{{map.withDefault(Number)\{...}}}|{{map.withDefault\{...}}}
(transparently checked)|
{code:groovy}
// asChecked: the Class was mandatory -> @ClassTag makes it omissible (opt-in,
additive)
Map<Number,Foo> map = [:]
Map<Number,Foo> m = map.asChecked() // compiler injects
Number.class, Foo.class
// withDefault: a no-Class form already exists -> @ClassTag overload preempts
it (allowlisted)
Map<Number,String> base = [:]
Map<Number,String> md = base.withDefault{ 'n/a' } // same source as today
((Map) md).put('x', 'y') // now ClassCastException
(key checked)
{code}
h2. Boundaries and edges
* *Static-only.* The injection is an STC rule. In dynamic Groovy:
** {{withDefault}} keeps binding the *unchecked* overload (no K to inject) —
same
source, different behaviour by compilation mode.
** {{asChecked()}} (no Class) won't resolve at all — there is no zero-arg
method and
no injection -> {{{}MissingMethodException{}}}. The shorter spelling is
{*}static-only syntax{*}.
* *K must be statically known.* {{Map<Number,?>}} works; {{def}} / raw {{Map}}
/
wildcard-only don't — no token is synthesised, so {{withDefault}} stays lenient
and
{{asChecked()}} won't compile.
* *Fail-soft (as implemented).* When a token can't be reified the compiler
silently
degrades: no injection, and the call binds as written. Additive use then fails
loud
naturally (the token-less call doesn't resolve); preemption keeps the lenient
binding.
Whether the preemption case should warn rather than stay silent remains open
(see open
questions).
* *Escape hatch.* The explicit {{Class}} form always works — in every mode,
and when
the type is unknown.
* *Fidelity is erased.* {{Class<K>}} reifies only the *erased* class:
{{List<String>}} and {{List<Integer>}} are indistinguishable (see TypeTag
section).
* *Override validation is source-only.* {{@ClassTag("name")}} typo-checking
runs when the declaring
method is type-checked from source; compiled extension methods are not
re-validated by the consumer.
h2. Retrofit candidates (codebase audit)
||Tier||API||Why it fits / note||
|1|{{asChecked(...)}} — 9 overloads, since 5.0.0|Textbook case: explicit
{{Class}} is purely an erasure-workaround over {{{}Collections.checkedXxx{}}}.
Also the multi-token (K,V) stress test.|
|1|{{withDefault}} / {{withCheckedDefault}} (GROOVY-11807)|Type-laundering
insert via the untyped {{{}Map.get(Object){}}}; reified K lets it check.
Unifies with checked maps: {{withDefault = checkedMap + default}} once K is
reified.|
|2|{{ListWithDefault}} ({{{}(T) initClosure.call(...){}}})|Default cast to
element type; weaker — the closure return is already statically checkable.|
|3 (new method)|{{collection.toArray()}} -> {{T[]}}|Reified array creation (the
Kotlin {{reified}} / Scala {{ClassTag}} array case). New API, not a retrofit of
{{{}asType{}}}.|
|none|{{{}asType{}}}/{{{}as{}}}, {{{}newInstance(Class){}}},
{{{}find{}}}/{{{}grep{}}}-by-Class, {{castToType}}|The {{Class}} is the user's
{*}intent{*}, not an erasure-workaround — auto-supplying would be wrong.|
Net: the realistic footprint is small and clustered around *checked-view and
default-growing collection APIs*, not a sprawling refactor.
h2. Reification fidelity: ClassTag vs a possible TypeTag tier (weaker)
{{@ClassTag}} supplies an *erased* {{{}Class<K>{}}}. A richer future tier could
inject a
*full-generic* token (a super-type token {{{}TypeToken<K>{}}}), analogous to
Scala's
{{TypeTag}} / {{scala.quoted.Type}} versus its {{{}ClassTag{}}}. Kotlin's
{{inline fun <reified T>}} is the JVM precedent for the cheap tier.
*This extension now looks weak given the boundaries above:*
* The concrete candidates ({{{}asChecked{}}} ->
{{{}Collections.checkedMap{}}}, {{{}withDefault{}}})
only ever do {{isInstance}} checks, which are erased anyway — the erased class
suffices.
* Restricting the source to the *receiver's type argument* and to *static-only*
injection further limits where full-generic fidelity would pay off.
Recommendation: keep {{ClassTag}} (erased) as the design; note
{{{}TypeTag{}}}/{{{}TypeToken{}}}
as a *possible later tier* only if a real full-generic consumer appears. The
annotation
*name* could encode the fidelity ({{{}@ClassTag{}}} now, reserving {{@TypeTag}}
for the full
tier), or favour familiarity ({{{}@Reified{}}}, after Kotlin) — see open
questions.
h2. Comparison with Scala / Kotlin / Java
||Mechanism||How the type is obtained||Fidelity||Propagation||
|Groovy {{@ClassTag}} |compiler synthesises {{Class}} from the receiver's type
arg|erased class|fail-soft |
|Scala {{ClassTag}}|implicit/{{{}using{}}} evidence materialised from the
static type|erased class|fail-loud (context bound {{{}[T: ClassTag]{}}})|
|Scala {{TypeTag}} / {{quoted.Type}}|implicit/{{{}using{}}} evidence|full
generic|fail-loud|
|Kotlin {{reified}}|{{inline}} + reify the type parameter|actual type
(inlined)|enforced by {{inline}}|
|Java baseline|explicit {{Class<T>}} token ({{{}EnumMap{}}},
{{{}Collections.checkedMap{}}}, {{{}getAnnotation(Class<T>){}}})|erased
class|manual|
|Guava/Gson {{TypeToken<T>}}|super-type token|full generic|manual|
In short: {{@ClassTag}} is Scala's {{ClassTag}} idea (compiler-supplied runtime
type
evidence) delivered via an annotation + STC call-rewrite instead of implicit
parameters; it auto-supplies the *Java {{Class<T>}} token* idiom rather than
inventing
a new runtime representation.
h2. Proposed scope / deliverables
# The {{@ClassTag}} annotation (parameter target; {{RUNTIME}} retention, since
extension-method parameters are read off reflection-backed class nodes; optional
{{value}} override).
# STC support: overload selection that prefers a {{@ClassTag}} overload and
synthesises
the {{X.class}} argument(s) from the receiver's type arguments; validation of
{{{}value{}}}; well-defined multi-token fill order.
# First consumers: retrofit {{asChecked}} and the GROOVY-11807 {{withDefault}}
overloads (validating the K,V multi-token case).
# Decide and document: fail-soft vs fail-loud; preemption-vs-new-name policy.
*Out of scope:* the full-generic {{{}TypeTag{}}}/{{{}TypeToken{}}} tier; any
change to dynamic
dispatch (dynamic callers keep passing the {{Class}} explicitly).
h2. Open questions
* *Preemption model and default allowlist.* Preemption means unchanged source
can change
behaviour under static typing, so it is gated to an opt-in set of method names
on
{{CompilerConfiguration}} (default holds only {{withDefault}}); additive
injection (supplying
an otherwise-mandatory token, e.g. for {{asChecked}}) is never gated. Is a
config-level
allowlist the right control surface — versus a hardcoded rule, or new-name-only
— and what
should the shipped default contain?
* *Diagnostic when a token cannot be reified.* Additive use already fails loud
(no token is
synthesised, so the call does not resolve). For preemption, a statically-typed
but
non-reifiable receiver (raw/wildcard, or every token erasing to {{Object}})
silently keeps the
lenient binding rather than injecting. Stay silent, warn, or error (Scala
context-bound style)?
* *Annotation name.* {{@ClassTag}} (honest about erased fidelity) vs
{{@Reified}} (familiar,
after Kotlin) vs reserving {{@TypeTag}} for a later full-generic tier.
* *Retention and API status.* The annotation is public and {{@Incubating}};
retention is
{{RUNTIME}} (not {{CLASS}} as first sketched) because extension-method
parameters are read off a
reflection-backed {{ClassNode}} that only exposes runtime-visible annotations.
Is a public
{{RUNTIME}} annotation the intended surface, versus an internal marker?
* *Override-validation reach.* {{@ClassTag("name")}} typo-checking is
source-only; the same
annotation on an already-compiled (e.g. Java DGM) extension method is not
re-validated by the
consumer. Acceptable, or do we want a check at the library's own build time?
h2. Related issues
* GROOVY-11807 — runtime key-checked {{withDefault}} (the quick-win this
builds on);
merged for 6.0.0. Its new {{Class}} parameters carried {{/* @ClassTag
GROOVY-12115 */}}
markers as the seam for this work; the PR for this issue replaces them with the
real
annotation.
> Reify a receiver's type argument as a Class parameter under @CompileStatic
> (@ClassTag)
> --------------------------------------------------------------------------------------
>
> Key: GROOVY-12115
> URL: https://issues.apache.org/jira/browse/GROOVY-12115
> Project: Groovy
> Issue Type: New Feature
> Components: Static compilation, Static Type Checker
> Reporter: Eric Milles
> Assignee: Paul King
> Priority: Major
> Labels: GEP
>
> h1. Summary
> Introduce a parameter annotation (working name {{{}@ClassTag{}}}) that lets
> the static compiler *auto-supply a reified {{Class<K>}} argument* from a
> method receiver's generic type, so callers can omit type tokens that today
> exist only to work around erasure.
> Split off from GROOVY-11807. That issue ships the *runtime* half — explicit
> {{{}Class<K>{}}}-token overloads that enforce types at run time. This issue
> is the *compile-time ergonomics* half: making the token optional under static
> compilation.
> h2. Background and origin
> On the JVM, a method that needs the runtime type of a generic type parameter
> cannot
> recover it (erasure), so the API must take an explicit {{Class<K>}} token.
> Groovy
> already does this in several places, e.g. {{asChecked}} (since 5.0.0) and the
> new
> key-checked {{withDefault}} overloads from GROOVY-11807:
> {code:java}
> // DefaultGroovyMethods
> public static <T> Collection<T> asChecked(Collection<T> self, Class<T>
> type) // -> Collections.checkedCollection
> public static <K,V> Map<K,V> asChecked(Map<K,V> self, Class<K> keyType,
> Class<V> valueType) // -> Collections.checkedMap
> // GROOVY-11807 (merged for 6.0.0), Class params carry a /* @ClassTag
> GROOVY-12115 */ marker
> public static <K,V> Map<K,V> withDefault(Map<K,V> self, Class<K> keyType,
> Class<V> valueType, Closure<V> init)
> public static <K,V> Map<K,V> withDefault(Map<K,V> self, Class<K> keyType,
> Closure<V> init)
> {code}
> In every case the {{Class}} argument is *redundant with a type the compiler
> already knows* (the receiver's type argument). {{@ClassTag}} removes the
> redundancy under static compilation.
> h2. The problem
> * Verbose: {{list.asChecked(String)}} / {{map.asChecked(Number, Foo)}}
> restate the
> element/key/value types the static type already declares.
> * Not transparent: the safety-improving variant of {{withDefault}} can't be
> selected
> unless the caller manually threads the {{{}Class{}}}.
> * The tokens cannot be inferred at run time — only the compiler, with the
> static type
> in hand, can supply them.
> h2. Proposal: {{@ClassTag}}
> A parameter annotation marking a {{Class<X>}} parameter as
> {*}compiler-supplied{*}. Under
> {{@CompileStatic}} / {{@TypeChecked}} the type checker may select an overload
> whose only
> extra parameter is a {{@ClassTag Class<X>}} and synthesise {{X.class}} from
> the
> receiver's matching type argument, rewriting the call. Dynamic Groovy ignores
> the
> annotation and binds the original overload.
> {code:java}
> @Documented
> @Incubating
> @Retention(RetentionPolicy.RUNTIME) // read off (possibly reflection-backed)
> extension-method
> // parameters by the STC, so CLASS/SOURCE
> do not suffice
> @Target(ElementType.PARAMETER)
> public @interface ClassTag {
> // Optional override naming the type variable to reify, for when the
> parameter
> // type cannot carry it. Usage e.g.: @ClassTag("K")
> String value() default "";
> }
> {code}
> h3. Annotation shape and binding
> * *Param-level, not method+index.* An earlier sketch put
> {{@OverrideCall(1)}} on the
> method with an index. Moving the marker onto the parameter makes the
> *position*
> implicit (where it sits) and lets the *parameter type* {{Class<K>}} encode
> *which*
> generic to reify — strictly more self-describing than a bare index (which
> never said
> K-vs-V).
> * *Default binding* reads the type variable from {{{}Class<X>{}}}. The type
> checker sees
> {{<X>}} pre-erasure (and the Signature attribute retains it), so this is
> reliable.
> * *Optional override* {{@ClassTag("K")}}, for edge cases the parameter type
> can't
> express: a {{Class<?>}}/raw token, or a method whose own type variables don't
> line up with the
> receiver's. The STC *rejects a name that does not resolve to a type variable
> in scope*, so typos
> fail at compile time — *but only for methods compiled from source in the
> current unit*. An extension
> method from an already-compiled library (e.g. a Java DGM method) is never
> visited as source, so a
> typo in its override silently disables injection rather than being reported.
> Library {{@ClassTag}}
> parameters therefore use the no-override form in practice, where the name is
> read from
> {{Class<X>}} and cannot be mistyped.
> h3. Multiple tokens and fill order
> Some consumers need more than one token, e.g. asChecked(Map, @ClassTag
> Class<K>,
> @ClassTag Class<V>). Rule: each {{@ClassTag}} parameter is synthesised *at its
> declared position*; the caller's supplied arguments map to the remaining
> (non-tag)
> parameters left-to-right. For {{asChecked()}} that means "fill slots 1 and 2
> with
> {{{}K.class{}}}, {{V.class}} in declaration order." (This generalises the
> single-hole case
> the {{@OverrideCall(1)}} index handled.)
> When more than one overload could absorb tokens, the checker prefers the one
> that
> reifies the *most* tokens (strongest checking); if equally-specific
> candidates disagree
> on the tokens, the call is too ambiguous and *no injection happens* (the call
> binds as
> written).
> h3. Overload selection / preemption
> For a call that *already matches* a no-token overload (e.g.
> {{withDefault\{...}}}), the
> {{@ClassTag}} overload can *preempt* it for a transparent upgrade. Preemption
> means
> unchanged source changes behaviour under static typing, so it is *gated*:
> * *Config allowlist.* Preemption applies only to method names in
> {{CompilerConfiguration.classTagPreemptionTargets}} (default holds only
> {{withDefault}}).
> Additive injection (supplying an otherwise-mandatory token, e.g. for
> {{asChecked}}) is
> never gated.
> * *Object-erasure guard.* When every token would erase to {{Object}} (e.g. an
> untyped/{{def}} receiver, or a bare {{[:]}} literal) there is nothing to
> check, so the
> lenient overload is kept — no silent upgrade to a checked view that can never
> reject
> anything.
> * *Applicability decides, with rollback.* A candidate tag overload is
> matched by arity
> (non-tag parameter count = supplied argument count) and reifiability; whether
> its
> remaining parameters accept the supplied argument types is decided by
> retrying normal
> overload selection with the synthesised tokens inserted. If the retry finds no
> applicable method the insertion is rolled back — injection can never make a
> call fail
> that would otherwise succeed, nor change the error reported for a call as
> written.
> Policy for API authors stands: use preemption *only when the new behaviour is
> an
> unambiguous bug-fix* (the old behaviour has no legitimate users). Otherwise
> *mint a new
> name* (e.g. {{withCheckedDefault}}) so the opt-in is by name and nothing
> existing
> changes meaning. {{@ClassTag}} still earns its keep there by letting static
> callers
> omit the {{Class}}.
> h2. Examples (before / after, under @CompileStatic)
> The receiver must carry statically-known type arguments — a typed variable,
> parameter,
> field or return value. A bare literal receiver ({{[]}} / {{[:]}}) is
> {{ArrayList<Object>}} / {{LinkedHashMap<Object,Object>}}-typed, so nothing
> can be
> reified and no injection happens ({{asChecked()}} then fails to resolve;
> {{withDefault}}
> stays lenient).
> ||API||Today||With @ClassTag||
> |asChecked (list)|{{list.asChecked(String)}}|{{list.asChecked()}}|
> |asChecked (map)|{{map.asChecked(Number, Foo)}}|{{map.asChecked()}}|
> |withDefault
> (map)|{{map.withDefault(Number)\{...}}}|{{map.withDefault\{...}}}
> (transparently checked)|
> {code:groovy}
> // asChecked: the Class was mandatory -> @ClassTag makes it omissible
> (opt-in, additive)
> Map<Number,Foo> map = [:]
> Map<Number,Foo> m = map.asChecked() // compiler injects
> Number.class, Foo.class
> // withDefault: a no-Class form already exists -> @ClassTag overload preempts
> it (allowlisted)
> Map<Number,String> base = [:]
> Map<Number,String> md = base.withDefault{ 'n/a' } // same source as today
> ((Map) md).put('x', 'y') // now ClassCastException
> (key checked)
> {code}
> h2. Boundaries and edges
> * *Static-only.* The injection is an STC rule. In dynamic Groovy:
> ** {{withDefault}} keeps binding the *unchecked* overload (no K to inject) —
> same
> source, different behaviour by compilation mode.
> ** {{asChecked()}} (no Class) won't resolve at all — there is no zero-arg
> method and
> no injection -> {{{}MissingMethodException{}}}. The shorter spelling is
> {*}static-only syntax{*}.
> * *K must be statically known.* {{Map<Number,?>}} works; {{def}} / raw
> {{Map}} /
> wildcard-only don't — no token is synthesised, so {{withDefault}} stays
> lenient and
> {{asChecked()}} won't compile.
> * *Fail-soft (as implemented).* When a token can't be reified the compiler
> silently
> degrades: no injection, and the call binds as written. Additive use then
> fails loud
> naturally (the token-less call doesn't resolve); preemption keeps the lenient
> binding.
> Whether the preemption case should warn rather than stay silent remains a
> discussion point (see the questions section).
> * *Escape hatch.* The explicit {{Class}} form always works — in every mode,
> and when
> the type is unknown.
> * *Fidelity is erased.* {{Class<K>}} reifies only the *erased* class:
> {{List<String>}} and {{List<Integer>}} are indistinguishable (see TypeTag
> section).
> * *Override validation is source-only.* {{@ClassTag("name")}} typo-checking
> runs when the declaring
> method is type-checked from source; compiled extension methods are not
> re-validated by the consumer.
> h2. Retrofit candidates (codebase audit)
> ||Tier||API||Why it fits / note||
> |1|{{asChecked(...)}} — 9 overloads, since 5.0.0|Textbook case: explicit
> {{Class}} is purely an erasure-workaround over
> {{{}Collections.checkedXxx{}}}. Also the multi-token (K,V) stress test.|
> |1|{{withDefault}} / {{withCheckedDefault}} (GROOVY-11807)|Type-laundering
> insert via the untyped {{{}Map.get(Object){}}}; reified K lets it check.
> Unifies with checked maps: {{withDefault = checkedMap + default}} once K is
> reified.|
> |2|{{ListWithDefault}} ({{{}(T) initClosure.call(...){}}})|Default cast to
> element type; weaker — the closure return is already statically checkable.|
> |3 (new method)|{{collection.toArray()}} -> {{T[]}}|Reified array creation
> (the Kotlin {{reified}} / Scala {{ClassTag}} array case). New API, not a
> retrofit of {{{}asType{}}}.|
> |none|{{{}asType{}}}/{{{}as{}}}, {{{}newInstance(Class){}}},
> {{{}find{}}}/{{{}grep{}}}-by-Class, {{castToType}}|The {{Class}} is the
> user's {*}intent{*}, not an erasure-workaround — auto-supplying would be
> wrong.|
> Net: the realistic footprint is small and clustered around *checked-view and
> default-growing collection APIs*, not a sprawling refactor.
> h2. Reification fidelity: ClassTag vs a possible TypeTag tier (weaker)
> {{@ClassTag}} supplies an *erased* {{{}Class<K>{}}}. A richer future tier
> could inject a
> *full-generic* token (a super-type token {{{}TypeToken<K>{}}}), analogous to
> Scala's
> {{TypeTag}} / {{scala.quoted.Type}} versus its {{{}ClassTag{}}}. Kotlin's
> {{inline fun <reified T>}} is the JVM precedent for the cheap tier.
> *This extension now looks weak given the boundaries above:*
> * The concrete candidates ({{{}asChecked{}}} ->
> {{{}Collections.checkedMap{}}}, {{{}withDefault{}}})
> only ever do {{isInstance}} checks, which are erased anyway — the erased
> class suffices.
> * Restricting the source to the *receiver's type argument* and to
> *static-only*
> injection further limits where full-generic fidelity would pay off.
> Recommendation: keep {{ClassTag}} (erased) as the design; note
> {{{}TypeTag{}}}/{{{}TypeToken{}}}
> as a *possible later tier* only if a real full-generic consumer appears. The
> annotation
> *name* could encode the fidelity ({{{}@ClassTag{}}} now, reserving
> {{@TypeTag}} for the full
> tier), or favour familiarity ({{{}@Reified{}}}, after Kotlin) — see open
> questions.
> h2. Comparison with Scala / Kotlin / Java
> ||Mechanism||How the type is obtained||Fidelity||Propagation||
> |Groovy {{@ClassTag}} |compiler synthesises {{Class}} from the receiver's
> type arg|erased class|fail-soft |
> |Scala {{ClassTag}}|implicit/{{{}using{}}} evidence materialised from the
> static type|erased class|fail-loud (context bound {{{}[T: ClassTag]{}}})|
> |Scala {{TypeTag}} / {{quoted.Type}}|implicit/{{{}using{}}} evidence|full
> generic|fail-loud|
> |Kotlin {{reified}}|{{inline}} + reify the type parameter|actual type
> (inlined)|enforced by {{inline}}|
> |Java baseline|explicit {{Class<T>}} token ({{{}EnumMap{}}},
> {{{}Collections.checkedMap{}}}, {{{}getAnnotation(Class<T>){}}})|erased
> class|manual|
> |Guava/Gson {{TypeToken<T>}}|super-type token|full generic|manual|
> In short: {{@ClassTag}} is Scala's {{ClassTag}} idea (compiler-supplied
> runtime type
> evidence) delivered via an annotation + STC call-rewrite instead of implicit
> parameters; it auto-supplies the *Java {{Class<T>}} token* idiom rather than
> inventing
> a new runtime representation.
> h2. Proposed scope / deliverables
> # The {{@ClassTag}} annotation (parameter target; {{RUNTIME}} retention,
> since
> extension-method parameters are read off reflection-backed class nodes;
> optional
> {{value}} override).
> # STC support: overload selection that prefers a {{@ClassTag}} overload and
> synthesises
> the {{X.class}} argument(s) from the receiver's type arguments; validation of
> {{{}value{}}}; well-defined multi-token fill order.
> # First consumers: retrofit {{asChecked}} and the GROOVY-11807
> {{withDefault}}
> overloads (validating the K,V multi-token case).
> # Decide and document: fail-soft vs fail-loud; preemption-vs-new-name policy.
> *Out of scope:* the full-generic {{{}TypeTag{}}}/{{{}TypeToken{}}} tier; any
> change to dynamic
> dispatch (dynamic callers keep passing the {{Class}} explicitly).
> h2. Questions (and proposed stance)
> Each item records the stance the proposed implementation currently takes; all
> remain open
> to revision pending community review.
> * *Preemption control surface and default allowlist.* Preemption means
> unchanged source
> can change behaviour under static typing — should it be governed by a
> hardcoded rule,
> new-name-only, or configuration? *Proposed stance:* a config-level allowlist,
> {{CompilerConfiguration.classTagPreemptionTargets}} — an opt-in set of method
> names whose
> shipped default holds only {{withDefault}} (the unambiguous bug-fix case).
> Additive
> injection (supplying an otherwise-mandatory token, e.g. for {{asChecked}}) is
> never gated.
> Clearing the set disables preemption entirely; adding a name opts a user API
> in. Trade-off
> acknowledged: semantics become configuration-dependent (a build or IDE
> constructing a
> different {{CompilerConfiguration}} resolves the call differently), which is
> why the
> shipped default is minimal and the feature is {{@Incubating}}.
> * *Diagnostic when a token cannot be reified.* Stay silent, warn, or error
> (Scala
> context-bound style)? *Proposed stance:* stay silent (fail-soft). Additive
> use already
> fails loud naturally (no token is synthesised, so the call does not resolve).
> For
> preemption, a non-reifiable receiver (raw/wildcard, or every token erasing to
> {{Object}})
> keeps the lenient binding — the code behaves exactly as it did before the
> feature existed,
> so silence is the honest signal. A warning tier could be added later if
> experience shows
> the silent keep confuses users; a hard error would defeat the
> transparent-upgrade goal.
> * *Annotation name.* {{@ClassTag}} vs {{@Reified}} vs {{@TypeTag}}?
> *Proposed stance:*
> {{@ClassTag}} — honest that only the *erased* class is supplied (Scala's
> precedent for
> exactly this fidelity), and it reserves {{@TypeTag}} for a possible later
> full-generic
> tier. {{@Reified}} (after Kotlin) would overpromise: Kotlin's {{reified}}
> delivers the
> actual type via inlining, which this is not.
> * *Retention and API status.* Is a public {{RUNTIME}} annotation the
> intended surface,
> versus an internal marker? *Proposed stance:* yes — public, {{RUNTIME}},
> {{@Incubating}}.
> {{RUNTIME}} (not {{CLASS}} as first sketched) is forced by mechanics:
> extension-method
> parameters are read off a reflection-backed {{ClassNode}} that only exposes
> runtime-visible annotations. Public because third-party APIs (not just DGM)
> are meant to
> be able to opt in; {{@Incubating}} preserves the freedom to adjust the
> surface.
> * *Override-validation reach.* {{@ClassTag("name")}} typo-checking is
> source-only; the
> same annotation on an already-compiled (e.g. Java DGM) extension method is not
> re-validated by the consumer. Acceptable, or do we want a check at the
> library's own build
> time? *Proposed stance:* acceptable. Library authors are steered to the
> no-override form
> (the type-variable name is read from {{Class<X>}} and cannot be mistyped);
> the override
> exists for raw/wildcard tokens and is validated whenever the declaring method
> is compiled
> from source. A library-build-time check could be added later without changing
> the surface.
> I also updated the two cross-references elsewhere in the description that
> said "see open questions" (the fail-soft bullet in Boundaries, and the
> annotation-name note in the Reification fidelity section) to say "see the
> questions section", since the heading changed.
> One leftover you might tweak while pasting: deliverable #4 still reads
> "Decide and document: fail-soft vs fail-loud; preemption-vs-new-name policy"
> — with stances now recorded, it could become "Document the decided stances
> (see questions section)". I left it since you only asked for the questions
> section.
> h2. Related issues
> * GROOVY-11807 — runtime key-checked {{withDefault}} (the quick-win this
> builds on);
> merged for 6.0.0. Its new {{Class}} parameters carried {{/* @ClassTag
> GROOVY-12115 */}}
> markers as the seam for this work; the PR for this issue replaces them with
> the real
> annotation.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)