On 7/14/26 14:50, Paul King wrote:
Hi Jochen,

AI read below. It could be wrong. I am still pondering but won't get
time again until my tomorrow. So this is something to read in the
meantime.

take your time. I recently feel like I do reviews and comments on PRs in 100% of my spare time ;)
Some comments inline

[...]
1) The doCall-only contract has never held on the dynamic path.

I tested a Closure subclass declaring only `String call(String)` (no doCall)
across releases:

                        dynamic cl("foo")    GDK collect(cl)
   Groovy 3.0.21-25     works                MissingMethodException
   Groovy 4.0.32        works                MissingMethodException
   Groovy 5.0.6         works                MissingMethodException
   Groovy 6.0.0-alpha-1 works                MissingMethodException
   master (12164/65)    works                works

This is an obvious outcome. call(Object) delegates to call(Object...), which looks for doCall. GDK does not see call(String), only call(Object), thus the failure. Which is exactly why overloading call methods in Closure was never really supported and only sometimes working if you override the call method instead. But if you override call(Object) and then do the call through call(Object...), it was still failing, since the dispatch direction was designed the other way around. The only choice left, if you want to avoid doCall, is then to override call(Object...) instead. That aligns with the other mail I think. The javadoc of Closure even mentions, that if you want to use the short form like cl("foo") you have to provide a doCall method. I will not deny that cl("foo") works, but I don`t think that was really intended. More like accidentally worked and then nobody changed it.

[...] > That leaves a genuine decision rather than a bug report: either custom call
overloads are accepted as the (now consistent) de-facto contract, or
doCall-only is enforced on BOTH paths — which is a behavioural break with
3.x-6.x dynamic dispatch.

Which is why I wanted to start a discussion in the first place.

[...]
2) On PackedClosure,
[...]
- one shared MetaClass for all packed closures: conceded, and it is the one
   semantic difference that cannot be engineered away — per-literal metaclass
   is definitionally what packing removes. Class-level metaClass changes on
   PackedClosure would affect every packed closure. The delegate/
   resolveStrategy axis is already fenced by the runtime guard; this axis
   needs documenting as a caveat of the opt-in feature (and is one more reason
   packing must stay off for code that plays MOP games).

the problem is that it will stay an opt-in feature, since it cannot replace the regular construct for the same case.

- "the call path seems to be the only way to invoke the real method": true,
   but I'd frame what that path now IS: a statically compiled, metaclass-free,
   inlinable route (adapter -> per-arity dispatch table -> direct invocation of
   the typed hoisted body). For statically compiled callers, invokevirtual
   call() on a packed closure reaches a typed body with no MOP involvement at
   all — which is the closest thing we currently have to your point (6),
   since the typed doCall of a closure class is hidden behind Closure<T> for
   static compilation, but a packed closure's entry is structurally fixed.
   Recent measurements: packed closures now run 1.8-4.7x FASTER than generated
   closure classes on capturing shapes with byte-identical behaviour, largely
   because of this property.

With GeneratedClosure the class is part of the same package and module of the class it was declared in. So if a class of module A gets an instance of GeneratedClosure from module B, and I do a dynamic call from A to B using that Closure, then the call will be a call from A via doCall into B, requiring JPMS to allow it. Now in case of PackedClosure, as long as we not bypass the call method and use the target directly, we effectively make it a call from A into <Groovy Runtime> into B. Bypassing any possible restriction between A and B. Of course I am only talking about A and B as modules written in Groovy. And yes, this is currently a theoretical case.

Now... the other part to think about is what happens if the MOP involvement is intended? Like if EMC is used. Do we say it does not work if PackedClosure is enabled? As I said, that probably disqualifies it as an incubation feature that will become a standard without flag. If, on the other hand, we move the call code into a special metaclass, we should still achieve similar results in performance while we ensure a potential MOP path stays more open. And we can enable potential direct invocation paths with invokedynamic. Plus, most likely, optimizing that path will also suggest similar optimizations for ClosureMetaClass. Then the main difference between the two would be that PackedClosure saves on class loading overhead.

- one point in the architecture's favour under your criterion (2): dynamic
   call sites invoking closures are megamorphic across per-literal closure
   classes, but monomorphic on the single PackedClosure class — the per-target
   selection moves into a switch the JIT handles well. Indy caching gets
   easier, not harder.

which is why I would like to have this as standard feature, not as opt-in
 [...]


bye Jochen

Reply via email to