Re: GROOVY-12115/PR#2645 feedback request "@ClassTag - poor man's type reification"
Paul King <[email protected]> Mon, 27 Jul 2026 08:31:09 +1000
| Newsgroups | gmane.comp.lang.groovy.devel |
|---|---|
| Message-ID | <CAMbkE7SLs_9qzjqodecLY_==RPk-pmwJRgdcShofEBHD42zz3Q@mail.gmail.com> |
Responses inline. On Mon, Jul 27, 2026 at 5:35=E2=80=AFAM Jochen Theodorou <[email protected]= > wrote: > > On 7/26/26 14:03, Paul King wrote: > [...] > > So far so good. But there are two flavours: > > > > 1. New/opt-in overloads - uncontroversial, you're choosing the tagged > > method explicitly. > > 2. *Preemption* - this handles the case where both a tagged method and > > non-tagged method could both match. If we automatically preferred the > > tagged method, this would be a silent behaviour change, but it is > > exactly what we sometimes want. So, it's currently gated behind a > > config allowlist: `CompilerConfiguration.classTagPreemptionTargets`, > > defaulting to just `withDefault`. > > if we are going by the book then essentially method selection here > selects two equal methods and should fail compilation. So the question > is that if you want this pass compilation, why is one method better than > the other. Without a solid answer to this it should fail compilation. > "it is exactly what we *sometimes* want" is not solid imho. My vote > would be for no preemption targets and compilation failure. Do we have > such cases in DGM? I perhaps should have included more background in my previous email. In GROOVY-11807, we have a case where the "plain" `withDefault` method lets a `String` key slip into a `Map<Number,?>`, then blows up later with a `GroovyCastException` when you read `keySet()`. It silently corrupts the map. PR #2641 fixed this by adding *checked* `withDefault` overloads that wrap the backing map with `asChecked(...)` so bad keys/values fail fast at insertion. But the user has to supply the classes that the compiler already knows. GROOVY-12115 is about a reify mechanism so the user doesn't have to supply those classes - well at least for static Groovy. The Java style long-hand remains for dynamic Groovy. So when preemption picks the tagged overload, it isn't arbitrating between two equals - it's routing existing `withDefault { }` code onto the **correct, fail-fast** path instead of the known-buggy lenient one, well at least under @CompileStatic/@TypeChecked. And they aren't two equals because they have different arity, we are treating them as pseudo equals with reification in mind. So preemption means existing `withDefault {}` call sites keep compiling and silently become type-safe with zero source edits, but it's a behavior change for anyone who *relied* on the lenient behavior. The "withDefault" case is the only case where we need preemption and it's the case we just added to solve GROOVY-11807 (with this potential change in mind). If we didn't care about breaking binary compatibility we might consider deprecating or removing the under-constrained/lenient `withDefault` variant but, apart from binary compatibility, that variant is actually perfectly fine for dynamic Groovy with raw maps which could be the case for many scripts. > [...] > > My gut feel is leave as is, we can always add a system property or > > dedicated commandline option later. And I think just "withDefault" to > > start with. Anyone designing new APIs can just factor in ClassTag from > > the start - it will only be where existing APIs exists and folks want > > to retrofit the "reified" shorthand after the fact where more > > allowlist items would be needed. > > Well, if nothing int DGM requires a rethinking of this I would still > vote for compilation error. A failing program that with a change then is > compilable is not break of compatibility The only case we have that needs fixing is "withDefault". Other cases that are good candidates for retrofitting with @ClassTag, like "asChecked", don't have the no-arg historical baggage. We can opt-in to adding @ClassTag if we want to provide our users with Scala-like brevity: `listWithGenerics.asChecked()` As another example, consider a `flip()` that inverts a map with unique values, returning a **runtime-checked** inverse so later inserts stay honest: Map<String, Integer> ages =3D [Alice: 42, Bob: 39] // Today =E2=80=94 you must restate both types the compiler already knows= , // because the *returned* map needs real Class tokens to enforce itself at runtime: Map<Integer, String> byAge =3D ages.flip(Integer, String) The tokens aren't decoration - erasure means the returned object has no idea it's `Map<Integer,String>` unless you hand it `Integer.class`/`String.class`. Without them, `flip()` can only give back a raw, unchecked map and you lose the guarantee. // With @ClassTag =E2=80=94 the compiler reads K and V from the receiver'= s Map<String,Integer> // and injects Integer.class, String.class at the declared positions: Map<Integer, String> byAge =3D ages.flip() > > One forward-looking note: `@ClassTag` deliberately only reifies the > > erased `Class`, which is all today's `isInstance`-style consumers > > need. A fuller "TypeTag" tier carrying the complete generic signature > > (=C3=A0 la Scala) could be a Groovy 7 conversation, but it's out of sco= pe > > here. > > my main issue right now is that we introduce a concept from Scala, but > only partially and it got deprecated in Scala 3 as well. I did of course > not look at the new type system in Scala 3 all that much, so I cannot > really tell - but I wonder if we would here introduce something just to > deprecate it later. Here is AI's read on the Scala situation, so I don't think it's a problem for us: ---- It's **`ClassTag` that survived into Scala 3; `TypeTag`/`WeakTypeTag` are the ones that got dropped.** - `scala.reflect.ClassTag` is alive and actively used in Scala 3 (array creation, generic pattern matching). It is **not deprecated** =E2=80= =94 the only "deprecated" smell is that it extends a `ClassManifestDeprecatedApis` ancestor trait; `ClassTag` itself is current. - `TypeTag`/`WeakTypeTag` lived in `scala.reflect.runtime.universe`, which depended on **runtime reflection** =E2=80=94 and Scala 3 dropped runt= ime reflection wholesale. So they didn't get deprecated *on their own merits*; they fell with the subsystem they rode on. The gap is now filled by third-party libs (izumi-reflect) and, for macros, `scala.quoted.Type` + `TypeTest`/`Typeable`. Two things follow for us: 1. Starting with `ClassTag` is starting with the **durable** abstraction, not the doomed one =E2=80=94 the opposite of introducing-only-to-deprecate. 2. **Groovy can host both tiers without conflict**, and the Scala worry doesn't transfer: TypeTag died in Scala 3 because Scala 3 killed *runtime reflection* =E2=80=94 Groovy has never lacked runtime type machine= ry. A future `TypeTag` (full generic signature) would *complement* `ClassTag` (erased `Class`, all today's `isInstance` consumers need), not replace it. We'd only need it when a consumer must inspect nested generics, e.g. List<List<String>>; nothing about shipping `ClassTag` now forecloses it or forces its deprecation later. Sources for the Scala point, if you want to cite/verify: - [ClassTag =E2=80=94 Scala 3 API](https://www.scala-lang.org/api/3.1.1/scala/reflect/ClassTag.html) - [Dropped Features =E2=80=94 Scala 3 Migration Guide](https://docs.scala-lang.org/scala3/guides/migration/incompat-dropped= -features.html) - [TypeTags and Manifests =E2=80=94 Scala Reflection docs](https://docs.scala-lang.org/overviews/reflection/typetags-manifests.h= tml) - [izumi-reflect (Scala-3 TypeTag replacement)](https://github.com/zio/izumi-reflect) ----