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