Repository navigation
GROOVY-12492: Convert @CompileStatic closures to functional interfaces without a runtime proxy (incubating) - #3026
codeconsole wants to merge 12 commits into
Conversation
0ffd703 to
896ef4b
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #3026 +/- ##
==================================================
+ Coverage 73.6711% 73.7166% +0.0456%
- Complexity 42584 42649 +65
==================================================
Files 1678 1679 +1
Lines 146907 147173 +266
Branches 27790 27856 +66
==================================================
+ Hits 108228 108491 +263
+ Misses 28528 28519 -9
- Partials 10151 10163 +12
🚀 New features to boost your workflow:
|
daniellansun
left a comment
There was a problem hiding this comment.
Thanks for tackling GROOVY-12492. Under @CompileStatic, turning a closure literal that the type checker already bound to a SAM into a small compile-time adapter instead of an invokedynamic cast / asType → Proxy+ConvertedClosure path is a coherent incubating direction, and it matches the native-image pain the current design creates. The split between SamAdapterFactory.prepare (attach a factory on the generated closure class), StaticTypesClosureWriter.getClosureInstanceType (keep a precise stack type), and the early hook in OperandStack.doConvertAndCast is easy to follow, and StaticCompileClosureSamTest is unusually thorough for an incubating switch.
I am leaving a COMMENT (not an approval): the happy paths look carefully engineered, but a few correctness / contract edges should be tightened before this lands on master, especially where the docs and the code disagree, and where the adapter intentionally diverges from ConversionHandler without tests locking that divergence.
Observations / concerns
-
Trait-context claim vs
prepare(). The docs (invokedynamic-support.adoc) and the factory javadoc state that a closure written in a trait method keeps the run-time conversion.SamAdapterFactory.prepareonly declines when the target type (or one of its super-interfaces) is a trait (Traits.isTrait(face)), or when a same-name/arity default method exists, etc. It does not consult enclosing trait context the wayClosureWriter.isInTraitContext()does for packing. Trait helper classes often still go throughStaticTypesClosureWriterwhen the method is statically checked (StaticTypesWriterController#getClosureWriter). As written, a@CompileStaticclosure literal inside a trait method that STC annotates withPARAMETER_TYPEwould still get a$SamAdapter. Please either:- decline in
preparewhenisInTraitContext()-equivalent is true, and add a regression test; or - drop/soften the “closure written in a trait method” claim and add a test that shows the adapter is used there.
Right now the stated contract and the implementation can disagree.
- decline in
-
equals/hashCodedivergence is user-visible and untested.ConversionHandlerdefines delegate-basedequals/hashCode(andProxy.equalsroutes into the handler).$SamAdapterinherits identity semantics fromObject. That is called out as an intentional difference, which is fine for an opt-in incubating flag, butStaticCompileClosureSamTestnever asserts it. Please add an explicit test that two adapters wrapping closures that would compare equal under today’s proxy do not compare equal (and thathashCodeis not the delegate’s), so the contract cannot drift silently if someone later “helpfully” mirrorsConversionHandler. -
Direct
doCallpath vsClosure.call/ MetaClass. The fast path inAdapterMethod.writeDirectCallinvokesdoCallwhen every argument is already the parameter type (or null for a reference) and no parameter is an array. That matches “whatcall(Object...)would pass through unchanged” for the defaultClosureMetaClass, but it skips any MetaClass customization that interceptsdoCallon the generated closure class, and it also skips arity/default/InvokerHelperlogic that only lives oncall. The suite covers wrong-type →MissingMethodExceptionvia the checked fallback, which is good; please also state in the$SamAdapter/ option javadoc (not only the PR text) that MetaClass interception ofdoCallis out of scope on the direct path. A one-line test with anExpandoMetaClass/metaClass.doCall = …on the closure class would make the boundary obvious for future readers. -
writeReturnand primitivedoCallresults.AdapterMethod.writeReturnemitsDefaultTypeTransformation.castToType(Object, Class)when the SAM return type is a reference andisAssignable(valueType, returnType)is false.castToTypeexpects a reference on the stack. If STC ever leavesdoCallwith a primitive return type while the SAM method returns a boxed/reference type, the direct path would push a primitive intocastToTypeand fail verification (or need an implicit box that is not there). Today’s tests (primitiveParameter,boxedParameter,longResult, …) appear to land on shapes that work; I am not asking for a speculative rewrite, but a guard (box beforecastToTypewhenvalueTypeis primitive) or an assert inwriteDirectCallthat thedoCallreturn is a reference whenever the SAM return is would make this robust against future STC typing tweaks. -
Packing interaction is documented but only weakly locked.
writePackedClosurenever builds a per-literal closure class, soSamAdapterFactory.preparedoes not run and conversion stays at run time — that matches the adoc.withClosurePackingonly checks functional results with both flags on; it does not assertProxy.isProxyClass(or the absence of$SamAdapter). Given how easy it is for packing eligibility to change, a single assertion that a packed SAM conversion remains a proxy (or, if packing declines for that literal, remains an adapter) would pin the intended interplay. -
Shared
OperandStackhook and stack typing. Routing throughOperandStack.doConvertAndCastbefore the coerce / indy-cast branches is the right place to catch both casts andas, and the earlyClassNodeUtils.isCompatibleWith(top, targetType)return correctly avoids wrapping when the value is already aClosure/Object. Two softer notes:- This introduces a
transform.sc.StaticCompilationMetadataKeysdependency into sharedclassgen.asm.OperandStack(dynamic compilation stays a no-op because the metadata is absent). Acceptable givenClosureWriteralready touches SC keys, but worth being conscious of as more SC policy lands in shared operand handling. getClosureInstanceTypechanging the post-writeClosurestack type fromCLOSURE_TYPEto the concrete closure class is necessary for the metadata lookup; please double-check any codegen that pattern-matchesCLOSURE_TYPEspecifically on the stack after a closure literal (as opposed toisDerivedFrom(Closure)). I did not spot a break, but it is a behavioral surface beyond the adapter itself.
- This introduces a
-
Default-method exclusion is conservative — confirm intentional breadth.
preparedeclines the whole adapter if any interface intype.getAllInterfaces()declares a default method with the SAM’s name and arity (seeOverloadedin the test). That correctly avoids overridingFunction-style defaults you meant to keep, but it also declines when an unrelated super-interface happens to carry a same-name default. If that is deliberate (prefer proxy over a wrong override), a one-sentence comment inpreparewould help; if not, narrowing the check to defaults that would actually be overridden by the adapter method body would recover more native-image-friendly cases. -
Serialization contract. Generating
Serializableadapters without aserialVersionUID, with a synthetic class name tied to the closure class, matches “serialized form names the adapter class.” The round-trip test with a customresolveClassis appreciated. For incubating this is fine; just be aware that any later rename ($SamAdapter→ something else) or nesting change is a serialization break for anyone who opted in and persisted values — worth a short note next toCLOSURE_SAM_CONVERSION. -
Bridge methods copy the full body.
adapterMethodsemits a primary parameterized method plus erased bridges, each with a fullAdapterMethod(try/catch, direct/callpaths). That guarantees every descriptor reaches the closure the same way, at the cost of bytecode size and duplicated exception logic. Delegating bridges (apply(Object) { return apply((String) …); }) would be more conventional; not blocking, but if adapter count grows under a flag-on bootstrap (the 203 adapters mentioned in the PR text), size may matter. -
Performance / native-image claims vs evidence in-repo. The ~26 ns vs ~2.8 ns proxy-vs-adapter figure and the GraalVM CE 25 smoke are useful motivation but are not reproduced by the test suite or a checked-in microbenchmark. For an incubating option that is fine; I would avoid implying measured production gains in the adoc beyond “avoids reflective proxies / Graal proxy entries.” The bytecode assertions (
no INVOKEDYNAMIC cast,NEW …$SamAdapter) are the right in-repo evidence. -
Flag plumbing.
-Dgroovy.target.closure.samviaSystemUtil.getBooleanSafe,@IncubatingonCompilerConfiguration.CLOSURE_SAM_CONVERSION, and forwarding inorg.apache.groovy-tested.gradlemirrorclosure.pack/lambda.hoist. Good. Please confirm (or document) that there is noCompilerConfigurationinstance field / joint-compile API on purpose, so embedders know the only knob is the process-wide property (the test’swithSamtoggle already assumes that).
Test gaps I would still like to see
- Trait-method closure + SAM target (item 1), with an assert on proxy vs
$SamAdapter. - Explicit
equals/hashCodedivergence (item 2). - Optional: non-public SAM type in another package (
isAccessible→ fall back to proxy). - Optional: assignment/cast to a super-interface of
PARAMETER_TYPE(e.g.Shoutliteral used as rawFunction) to lockSamAdapterFactory.apply+ClassNodeUtils.isCompatibleWith(type, target). - Optional: MetaClass
doCallintercept demonstrating the direct-path boundary (item 3).
Non-blocking nits
SamAdapterFactoryis package-privatefinalwith solid javadoc — thank you; a short@since/ JIRA pointer onCLOSURE_SAM_CONVERSIONwould match sibling incubating constants.- Constructor of
$SamAdapterdoes not null-checkclosure(ConversionHandlerrejects null). Unreachable from the emittedNEW/INVOKESPECIALsequence; only matters if something else constructs the adapter later. withSammutates global system properties aroundGroovyShell/compile— fine for this suite, but it is not safe for parallel test execution if the module ever runs concurrent classgen tests with different flag values.
Stance
Directionally I am in favour: opt-in, off by default, SC-only, with a clear escape back to the proxy for traits-as-targets, abstract classes, Runnable/Callable, conditionals, and packing. The main blockers from a review stance are aligning the trait-context contract with the code, locking the equals/hashCode behavioural break in tests, and hardening or documenting the direct-doCall / writeReturn edges. I am not approving yet; happy to re-review once those are addressed.
|
Pushed f904db9. The review was written against 896ef4b; replies describe the current head, in the order of the review:
Test gaps:
Nits:
Closures and lambdas in trait methods, fixed with tests that fail without each change (each also run with the option on, so the adapter works for the shape):
With the option off, compiling 1,670 sources from the trait, closure and lambda tests three ways (6,813 class files) gives the same bytes as before these commits, except for three classes that have the shapes fixed above. |
daniellansun
left a comment
There was a problem hiding this comment.
Thanks for the extensive follow-up on tip f904db9. I re-checked SamAdapterFactory / AdapterMethod and your replies against my earlier COMMENT on 896ef4b.
Prior asks — closed on the incubating path
-
Trait context. The guide no longer claims trait-method closures keep the proxy. Rehydrate now preserves the closure class type, so closures written in traits get adapters with proxy-equivalent behaviour (
closuresWrittenInATraitGetTheirAdapter). That resolves the docs/code disagreement — by using the adapter, not by declining. -
equals / hashCode.
equalsAndHashCodeAreThoseOfObjectlocks identity semantics vs ConversionHandler / Proxy. -
Direct doCall / MetaClass. Javadoc on CLOSURE_SAM_CONVERSION, SamAdapterFactory, and AdapterMethod states the boundary;
metaClassInterceptingDoCallIsNotConsultedByTheDirectCallshows String bypasses intercepting doCall while GString still goes through call(). -
writeReturn. Boxing primitives / null for void before castToType is the right hardening even if today’s doCall returns wrappers.
-
Packing. withClosurePacking already asserts proxy vs adapter per literal — acknowledged.
6–8. Stack typing check, narrowed default-method exclusion (only erased-descriptor clashes), serialization note — all addressed.
9–11. Full bridges kept for GString-through-raw-Function (good reason); guide avoids performance claims; system-property-only knob documented like closure.pack / lambda.hoist. Null-reject ctor and @ResourceLock(SYSTEM_PROPERTIES) are welcome.
The trait/closure/lambda correctness fixes found while probing (owner/thisObject typing, lambda owner in traits, this.m(), intersection-typed closure calls, static closure fields on traits) look legitimate and well tested. Thank you for separating “option on” behaviour from “option off bytecode identical except the shapes those fixes change.”
Why I am not approving yet
-
Merge conflicts. Tip is dirty against current master. Please rebase before any merge consideration.
-
Scope. The incubating SAM-adapter option is a coherent, off-by-default story. The same PR also carries spaceship / instanceof-narrowing / SAM-via-type-parameter / trait lambda / ClassInfo-adjacent fixes. Those are good, but they enlarge review and backport surface. Prefer either:
- land the orthogonal STC/SC/trait fixes under their own JIRA(s) first (or as clearly labelled commits that can cherry-pick), then the incubating option; or
- keep them here but call out in the PR description which commits are “required for the option” vs “bugs found while testing,” so release notes and 6.0.x backport decisions stay honest.
-
Incubating contract reminder (non-blocking). Process-wide
-Dgroovy.target.closure.samwith no CompilerConfiguration field is intentional; embedders need the Javadoc. Serialized adapters and identity equals remain the user-visible differences — already documented.
Stance
COMMENT, not approval: conflicts must clear, and I would like a slightly sharper split (or explicit labelling) between the incubating SAM adapter and the orthogonal correctness fixes before I approve. The incubating design itself looks mergeable once the tip is green and conflict-free. Happy to re-review promptly after that.
…t a runtime proxy Under @CompileStatic a closure literal that is passed to a parameter of a single-abstract-method type, assigned to or returned as such a type, cast to it or coerced with "as" is compiled to a Closure subclass and converted at run time. ClosureWriter.writeClosure leaves the instance typed as Closure on the operand stack, so OperandStack.doConvertAndCast emits an invokedynamic cast, or asType for "as", and the runtime wraps the closure in a java.lang.reflect.Proxy with a ConvertedClosure handler (CachedSAMClass.coerceToSAM, TypeTransformers.createSAMTransform). Every call then goes through the proxy and Closure.call(Object...), which finds doCall reflectively. In a GraalVM native image each such interface has to be registered as a proxy although the code is statically compiled. The new incubating option -Dgroovy.target.closure.sam=true (CompilerConfiguration.CLOSURE_SAM_CONVERSION) makes the conversion at compile time. When the type checker recorded that a closure literal is converted to an interface (the PARAMETER_TYPE metadata), StaticTypesClosureWriter stores a SamAdapterFactory on the generated closure class and types the new instance as that class. Converting it to the interface in OperandStack.doConvertAndCast then wraps it in an adapter, a final class generated as a nested class of the closure class the first time a conversion needs it. Like the proxy, the adapter implements the interface and Serializable, is neither a Closure nor a GroovyObject, and its toString is the closure's. Its method does what ConvertedClosure does: it calls Closure.call(Object...), converts the result with DefaultTypeTransformation.castToType, unwraps a GroovyRuntimeException and wraps a checked exception the method does not declare in an UndeclaredThrowableException, as the proxy does. Where call(Object...) would hand every argument to doCall unchanged (each one already an instance of the parameter type, or null for a reference type, and no parameter an array), it calls doCall directly, so the common case needs no reflection. When the interface binds the type arguments of its super-interface, as an interface extending Consumer<Event> does, the method is declared with the bound types and each erased declaration gets a bridge with the same body, so a caller of the erased method sees what the proxy would have done. Conversions to other types are unaffected, so a closure whose recorded target is not where its value goes stays a closure. The converted value therefore behaves as the proxy did, including for dynamic callers, and its class is the visible difference. Since that and the generated code change, the option is off by default, like groovy.target.closure.pack and groovy.target.lambda.hoist, and the build forwards it to the test JVM and to the compiler as it does those. A trait or abstract class as the target, an interface with a default method of the same name and arity, a closure in a trait method, a closure chosen by a conditional or Elvis expression, and a closure that packing packs keep the run-time conversion. The native-image section of the invokedynamic guide describes the option.
With groovy.target.closure.sam, a closure literal converted to a sealed functional interface got an adapter class implementing that interface. The adapter is not one of the permitted subclasses, so the class completion check rejected it and the whole compilation unit failed to compile, naming a class the user never wrote. Without the option such a program compiles, and only the conversion fails at run time, because a proxy cannot implement a sealed interface either. A sealed interface now keeps the run-time conversion, so the option no longer changes whether the program compiles. This holds for a sealed interface declared in Groovy and for one compiled by javac.
… the closure The adapter of a closure converted to an interface that is not public was generated whenever the interface was in the closure's package. The package name alone does not make the access legal: a run-time package is a package name and a class loader. A package-private interface loaded by a parent class loader, and a statically compiled class of the same package compiled by a child GroovyClassLoader, are in different run-time packages. The adapter then failed with IllegalAccessError when the GroovyClassLoader defined it, eagerly, so parseClass itself failed and no member of the class could be used. Without the option only the conversion fails, at first use. A type the adapter names that is not public must now also be compiled with the closure, which guarantees the same class loader; any other keeps the run-time conversion, so the option no longer turns that failure into a class definition failure. This covers the interface and the types of its method, which the adapter names in its bytecode as well.
Several tests named a case that keeps the run-time conversion but only checked its result, which is the same either way: the destructuring closures, the closure chosen by a conditional expression, and the closures under groovy.target.closure.pack. They now assert that the value is a proxy. Under packing, the literal returned as it is escapes its method and is not packed, so it gets an adapter, and the test checks that too. New tests pin the other cases that keep the run-time conversion: a default parameter value, an argument of an enum constant (an adapter when written with as), an argument of an anonymous class constructor, a named constructor argument, a protected nested interface from another package, a javac-compiled interface that redeclares the method it inherits (javac's bridge is a default method), and as-targets that have no single abstract method. disabledByDefault also checks that no adapter class is generated without the option, against the six generated with it.
…time The guide said the adapter behaves as the proxy would apart from being a different class, and the CLOSURE_SAM_CONVERSION Javadoc that it behaves as the proxy does. It now lists what code can observe: equals and hashCode are identity, a metaclass intercepting doCall is not consulted on the direct path, the serialized form differs, dynamic code can read the closure from the private closure field, an interface binding a super-interface's type arguments gets an extra public method, and the adapter is a synthetic member class. It also says when the call still needs reflection. The adapter calls doCall directly only when every argument already has the parameter's type; otherwise Closure.call finds doCall reflectively, so a native image needs doCall registered. That is always so for an array or varargs parameter and for a primitive the closure takes as another primitive, and for a converted argument such as a GString it depends on the value. The list of conversions that still happen at run time is now complete: sealed interfaces, non-public interfaces Groovy does not compile with the closure, Java interfaces redeclaring the inherited method, as-targets without a single abstract method, destructuring closures, default parameter values, enum constant and anonymous class constructor arguments, named constructor arguments, and a closure whose value goes to another interface than the recorded one, besides the cases listed before.
The guide listed a closure written in a trait among the conversions that stay at run time, but nothing in SamAdapterFactory.prepare said so, and the code did not agree with itself: a closure written directly in a trait method kept the proxy, while one nested in another closure or in a lambda of the same method got an adapter. The difference came from the trait transformation. After type checking, a closure written directly in a trait method is replaced by closure.rehydrate($self, $self, $self), whose result is typed Closure, so the conversion of the copy found no adapter factory on the operand stack and fell back to the run-time cast. A nested closure is not rehydrated and was adapted. rehydrate returns a clone, an instance of the closure's own class, so the adapter can wrap the copy as it wraps any other instance. When the receiver of rehydrate is a closure literal whose class has an adapter factory, StaticInvocationWriter now casts the result back to that class and types it as such, and the conversion that follows uses the adapter. The rehydrated copy keeps its owner, delegate and this object, so the closure behaves as it did through the proxy. A closure written in a trait, at any depth, therefore gets its adapter like any other, and the guide no longer lists it as an exception. Without the option the closure class has no factory and nothing changes.
The adapter's direct path passes the value doCall returns to
writeReturn, which calls castToType, or returns it, as a reference.
ClosureWriter.createClosureClass declares doCall with the wrapper of a
primitive inferred return type, and with Void's for void, so no
generated closure reaches writeReturn with a primitive. A doCall that
did return one would have produced code the verifier rejects ("Type
integer is not assignable to reference type" for an int returned
through Function.apply).
writeReturn now boxes a primitive value first, and pushes null for a
void one, which is what Closure.call returns for them. The code
generated for the closures the compiler creates is unchanged.
…override
SamAdapterFactory.prepare kept the run-time conversion for any interface
with a default method of the SAM's name and number of parameters. Its
Javadoc gives the reason as a default method the adapter's method would
override, but a default with another descriptor is not overridden: it
overloads the abstract method, and the adapter inherits it as the proxy
does. So an interface such as
interface Overloaded extends Function<String, String> {
default String apply(Integer i) { 'int' }
}
or one that inherits an unrelated same-name default from another
interface kept the proxy for no reason.
prepare now declines only when a default method has the erased
descriptor of one of the methods the adapter declares, the interface
method with its bound types or one of the bridges, which is the case of
the default bridge javac adds to a Java interface that redeclares the
method it inherits. Calls to the overloading default, statically and
from dynamic code, including with a null or GString argument, give the
same results as through the proxy. The guide's list of conversions that
stay at run time says which default methods keep the proxy.
The compiler only constructs an adapter around the closure instance it has just created, so the closure is never null there. The constructor is public, though, and an adapter created reflectively around null failed only when its interface method was called, with a NullPointerException from the field access. ConversionHandler rejects a null delegate when the proxy is created. The constructor now calls Objects.requireNonNull on its argument, so such an adapter cannot be created.
…e adapter The guide listed how the adapter differs from the proxy, but the CLOSURE_SAM_CONVERSION and SamAdapterFactory Javadoc did not say so, and AdapterMethod's Javadoc claimed the direct call of doCall gives "the same result", which is not the case when a metaclass intercepts doCall on the closure class: Closure.call dispatches through the metaclass, the direct call does not. CLOSURE_SAM_CONVERSION now states that, and that a converted value serializes as an instance of the generated class, so values serialized with the option may not deserialize once that class changes; the guide says the same of the serialized form. It also says that, like CLOSURE_PACKING, the option is read from the system properties of the compiling JVM whenever a closure is compiled, with no CompilerConfiguration setting, and carries @SInCE 7.0.0 as the incubating ERROR_RECOVERY does. The SamAdapterFactory and AdapterMethod Javadoc state the metaclass boundary and the identity equals and hashCode.
… use of the adapter Three behaviours of the adapter that the guide describes had no test: - equals and hashCode are those of Object. Two proxies of one closure are equal and hash as the closure; an adapter is equal only to itself, also against a second adapter or a proxy of the same closure, and its hash code is its identity hash. - a metaclass that intercepts doCall on the closure class is not consulted when the adapter calls doCall directly, for a String argument, and is consulted when the adapter goes through Closure.call, for a GString; the proxy consults it for both. - a literal converted to a sub-interface such as Shout, which binds the type arguments of Function, and used as a raw Function, assigned, coerced with as or passed, is a Shout and converts a GString for the erased apply(Object) as the proxy does.
The test toggles groovy.target.closure.sam, and for one test groovy.target.closure.pack, as system properties around each compilation, since the compiler reads them per closure. A test running concurrently in the same JVM could see the wrong value. The class now declares @ResourceLock(Resources.SYSTEM_PROPERTIES), as PackedDispatcherFactoryTest and AotLinkModeTest do, so JUnit does not run it in parallel with other tests that use the system properties. The class Javadoc no longer says the adapter has exactly the proxy's behavior.
f904db9 to
e27017a
Compare
|
@daniellansun Thanks. I've split it as you suggested. This PR is now only the incubating option: the adapter, its follow-ups, and the trait The fixes found while testing it are now separate:
Each is based on master and has a test that runs without the option. The tests here that combined the option with one of those shapes (a closure for a type parameter or assigned to a property, trait closure |
✅ All tests passed ✅🏷️ Commit: e27017a Learn more about TestLens at testlens.app/docs. |
Under
@CompileStatic, a closure literal that goes to a functional interface is still converted at run time. That covers a literal passed to a single-abstract-method parameter, assigned to or returned as such a type, cast to one, or coerced withas.ClosureWriter.writeClosureleaves the new instance typed asClosureon the operand stack.OperandStack.doConvertAndCasttherefore emits an invokedynamiccast, orasTypeforas. The runtime then wraps the closure in ajava.lang.reflect.Proxywith aConvertedClosurehandler (CachedSAMClass.coerceToSAM,TypeTransformers.createSAMTransform), whose every call goes throughClosure.call(Object...)and a reflective dispatch todoCall.compiles to
invokedynamic cast:(Lgroovy/lang/Closure;)Ljava/util/function/IntBinaryOperator;, although the same code written as a lambda usesLambdaMetafactory. In a GraalVM native image, each such interface must be registered as a proxy even though the code is statically compiled. Without that, the conversion fails withGroovyCastException. GROOVY-11998 already declares extra interfaces on closure classes for intersection casts, but only marker interfaces.The option
This adds an incubating option,
-Dgroovy.target.closure.sam=true(CompilerConfiguration.CLOSURE_SAM_CONVERSION), that makes the conversion at compile time. When the type checker recorded the interface a closure literal is converted to (thePARAMETER_TYPEmetadata),StaticTypesClosureWritergives the closure class an adapter factory and types the new instance as that class. Converting it to the interface then wraps it in<closure class>$SamAdapter, a class generated the first time a conversion needs it.The adapter does what the proxy does:
Serializable;Closurenor aGroovyObject;toStringis the closure's;call(Object...)and converts the result withcastToType, soPredicatestill applies Groovy truth;GroovyRuntimeException, and wraps a checked exception the method does not declare in anUndeclaredThrowableException.When every argument already has
doCall's parameter type, or isnullfor a reference type, and no parameter is an array, it callsdoCalldirectly. That is exactly whencall(Object...)would pass the arguments through unchanged, so the result is the same without reflection. When the interface binds a super-interface's type arguments, asinterface Shout extends Function<String,String>does, the erased declarations get bridges with the same body.Otherwise the adapter calls
Closure.call, as the proxy does, and that findsdoCallreflectively. In a native image the closure class'sdoCallmethods then still need reflection metadata. That is always the case for an array or varargs parameter, and for a primitive parameter of the interface method that the closure declares as a different primitive, such as aninttaken as along. For an argument that reaches the closure only after a conversion, such as aGStringthat dynamic code or a rawFunctionpasses for aStringparameter, it depends on the value. The proxy took this path on every call, so nothing regresses.What keeps the run-time conversion
Conversions to any other type are unaffected, so a closure whose value goes to
ClosureorObjectstays a closure. These keep the run-time conversion, as without the option:protectedinterface nested in a class of another package, or a package-private Java interface, keeps the run-time conversion;as, an interface without a single abstract method;{ a, b -> }for aConsumer<List<String>>;groovy.target.closure.packpacks;RunnableandCallable, which a closure already implements.A sealed interface and a non-public interface are the cases where an adapter would change whether the program compiles or loads. An adapter for a sealed interface is not a permitted subclass, so the compilation would fail. An adapter for a package-private interface from another class loader would fail with
IllegalAccessErrorwhen the class is defined. Without the option, only the conversion fails, at first use, and that stays so with it.What changes with the option on
The class of the converted value is the visible difference, which is why the option is off by default, like
groovy.target.closure.packandgroovy.target.lambda.hoist:Proxy.isProxyClassis false, andProxy.getInvocationHandlerthrows, so code that dug the closure out throughConvertedClosurecan no longer do so;equalsandhashCodeare identity rather thanConversionHandler's;doCallon the generated closure class is not consulted;f.closurereturns it, where the proxy throwsMissingPropertyException;apply(String)besideapply(Object);The native-image section of the invokedynamic guide describes the option, these differences, when
doCallstill needs metadata, and the conversions that stay at run time.Alternatives considered
ClosureMetaClassonly dispatchesdoCallandcall, so dynamic callers of the interface method failed withMissingMethodException:[f]*.apply(x),InvokerHelper.invokeMethod(f, 'apply', x),f.&apply,f.with { apply(x) }. It also made the value aClosure, which changescase f:,grep(f)andf.andThen { }, and SAM methods namedcall,runorandThencollide withClosure's.Serializable,toStringwould change,LambdaMetafactory's argumentcheckcastwould stop a dynamic caller'sGStringfrom being coerced toString, and the body would need translating from closure semantics: implicitit, arity adaptation, default parameters, list destructuring,owneranddelegate.Tests
StaticCompileClosureSamTesthas 31 tests. It checks the bytecode, which has no invokedynamiccastand noasTypebut anew …$SamAdapterbefore the call. It checks against today's behavior:GStringcoercion,null, andMissingMethodExceptionfor a wrong argument type;longanddoubleslots;it, zero-argumentSupplierand default parameters;Predicate, void,Comparator), default methods, default methods that overload the SAM's method, and bridged interfaces called through the erased method;Function;owner,delegateandthisObject, recursion, nested closures, captured-variable mutation;equalsandhashCode, which are identity, and a meta-classdoCallinterception, which the direct path does not consult;GroovyRuntimeExceptionunwrapping;*.,InvokerHelper,.&,with,andThen { },grep;call,runandandThen;For the cases that keep the run-time conversion, it asserts that the value is a proxy: a protected nested interface of another package, a javac-compiled interface that redeclares its inherited method,
astargets without a single abstract method, destructuring closures, default parameter values, enum constant, anonymous-class and named constructor arguments, the conditional expression, and closures under closure packing. For a sealed interface, declared in Groovy or compiled by javac, and for a package-private interface loaded by a parent class loader, it asserts that the class compiles and loads and that only the conversion fails, at run time, as without the option.11,655 tests in the
groovy,gls,bugsand STC/SC packages pass with the option off and with it on. The build forwards the option to the test JVM and to the compiler as it doesclosure.pack, so the option-on run also compiled the test sources with it. With the option off, the 8,508 class files compiled from Groovy's root test sources are byte-identical to master's. The Checkstyle and CodeNarc gates pass.A native image (GraalVM CE 25.0.4) of a statically compiled program using
IntStream.reduce, anIntBinaryOperatorvariable and aFunctionvariable, with only the metadata the Groovy jar ships, fails at the conversion withGroovyCastExceptionwithout the option and runs with it. In a rough JVM loop, two interface calls took about 26 ns through the proxy and 2.8 ns through the adapter.Backport
Grails 9 builds with Groovy 6.0.x. Since the option is off by default, a backport to
GROOVY_6_0_Xwould change nothing unless it is set, and would let Grails compile its statically compiled code without these proxies.JIRA: GROOVY-12492