Re: some things about Closure

Jochen Theodorou <[email protected]> Tue, 14 Jul 2026 21:42:58 +0200
Newsgroups gmane.comp.lang.groovy.devel
Message-ID <[email protected]>
On 7/14/26 14:50, Paul King wrote:
> Hi Jochen,
>=20
> 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=
=20
100% of my spare time ;)
Some comments inline

[...]
> 1) The doCall-only contract has never held on the dynamic path.
>=20
> I tested a Closure subclass declaring only `String call(String)` (no doC=
all)
> across releases:
>=20
>                         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...),=20
which looks for doCall. GDK does not see call(String), only=20
call(Object), thus the failure. Which is exactly why overloading call=20
methods in Closure was never really supported and only sometimes working=
=20
if you override the call method instead. But if you override=20
call(Object) and then do the call through call(Object...), it was still=20
failing, since the dispatch direction was designed the other way around.=
=20
The only choice left, if you want to avoid doCall, is then to override=20
call(Object...) instead. That aligns with the other mail I think. The=20
javadoc of Closure even mentions, that if you want to use the short form=
=20
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=20
intended. More like accidentally worked and then nobody changed it.

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

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

[...]
> 2) On PackedClosure,=20
[...]
> - one shared MetaClass for all packed closures: conceded, and it is the =
one
>    semantic difference that cannot be engineered away =E2=80=94 per-lite=
ral 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 axi=
s
>    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=20
replace the regular construct for the same case.

> - "the call path seems to be the only way to invoke the real method": tr=
ue,
>    but I'd frame what that path now IS: a statically compiled, metaclass=
-free,
>    inlinable route (adapter -> per-arity dispatch table -> direct invoca=
tion of
>    the typed hoisted body). For statically compiled callers, invokevirtu=
al
>    call() on a packed closure reaches a typed body with no MOP involveme=
nt at
>    all =E2=80=94 which is the closest thing we currently have to your po=
int (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 fixe=
d.
>    Recent measurements: packed closures now run 1.8-4.7x FASTER than gen=
erated
>    closure classes on capturing shapes with byte-identical behaviour, la=
rgely
>    because of this property.

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

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

> - one point in the architecture's favour under your criterion (2): dynam=
ic
>    call sites invoking closures are megamorphic across per-literal closu=
re
>    classes, but monomorphic on the single PackedClosure class =E2=80=94 =
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