Repository navigation
GROOVY-12491: Call the trait helper's $init$ directly from implementing classes - #3025
codeconsole wants to merge 6 commits into
Conversation
11e9e02 to
579b43c
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #3025 +/- ##
==================================================
+ Coverage 73.6711% 73.6861% +0.0150%
- Complexity 42584 42598 +14
==================================================
Files 1678 1678
Lines 146907 146926 +19
Branches 27790 27794 +4
==================================================
+ Hits 108228 108264 +36
+ Misses 28528 28515 -13
+ Partials 10151 10147 -4
🚀 New features to boost your workflow:
|
daniellansun
left a comment
There was a problem hiding this comment.
Thanks for the focused GROOVY-12491 fix. Pinning the trait-helper $init$ call with MethodCallExpression.setMethodTarget in TraitComposer.applyTrait is the same pattern already used for trait forwarders (createForwarderMethod) and for $static$init$, and it correctly yields a direct INVOKESTATIC …$Trait$Helper.$init$ for dynamic, @TypeChecked, and @CompileStatic implementers alike. Deferring any broader “compile object initializers statically” change (as the later commit on this PR does) keeps the blast radius small. TraitInitBytecodeTest is unusually careful about bytecode shape, init order, precompiled traits, and the intentional MetaClass bypass.
I am leaving a COMMENT (not an approval): the happy path looks right, but a few contract / robustness edges should be tightened before this lands.
Observations / concerns
-
getDeclaredMethodcan returnnullwith no guard.
initCall.setMethodTarget(helperClassNode.getDeclaredMethod(Traits.INIT_METHOD, new Parameter[]{new Parameter(trait, Traits.THIS_OBJECT)}))assumes the helper always declares$init$(TraitType)under exactClassNodeparameter equality. If the lookup misses (precompiled helper with a differently-represented parameter type, a future helper shape, generics erasure mismatch),setMethodTarget(null)silently falls back to the old MetaClass path — the opposite of what the bytecode assertions require, and easy to miss without a failure. Please either:- assert / throw a
GroovyBugErrorwhen the method is missing (preferred; this is a compiler invariant), or - resolve via
helperClassNode.getMethods(Traits.INIT_METHOD)filtered by arity/$selfand document why soft fallback is acceptable.
A regression that deliberately breaks the lookup (or a comment + test on precompiled generic traits with bridge/$selftypes) would lock this.
- assert / throw a
-
setImplicitThis(false)is not set on the init call.
Forwarders set bothsetMethodTargetandsetImplicitThis(false). The receiver here is aClassExpression, so today’sInvocationWriter.writeDirectMethodCallstill emits a static call, but the AST is inconsistent with the sibling$static$init$/ forwarder pattern. SettinginitCall.setImplicitThis(false)(and optionallysetSafe(false)) would make the intent explicit and protect against future MCE default changes. -
Intentional MetaClass / EMC behavioural break needs a sharper user-facing note.
testTraitInitializerIsNotDispatchedThroughMetaClasscorrectly shows that an EMC static$init$on the helper is ignored for compile-time implementers and forwithTraits/as. That is a real, observable change for anyone who intercepted trait field init via the helper’s MetaClass. The test comment is good; please also put a one-line note onTraits.INIT_METHODor in the trait release notes / JIRA so this is not only discoverable by reading the test. If runtime trait proxies have a separate init path that already bypassed MetaClass, say so explicitly so readers do not over-generalize. -
$static$init$already targeted; instance$init$is the gap — confirm field-level helpers.
Static init already usedsetMethodTarget(albeit on a stubMethodNode). Instance$init$is what this PR fixes. The per-fieldbaseName + fieldNode.getName()static calls around the field-helper path still go throughcallXwithout a target. Out of scope is fine, but worth a sentence in the PR discussion or a follow-up JIRA if those can still miss on native-image / shrinker setups for the same reason$init$could. -
Init ordering is locked for one shape only.
testInitializersOfSeveralTraitsAreCalledDirectlyInOrder/testInitializationOrderIsUnchangedpinimplements B, C→C, B, Aand the relative order vs instance fields / object initializer / ctor. Good. Please also cover:implements C, B(declaration-order sensitivity), and- a class that both extends a trait-implementing superclass and adds another trait (super’s
$init$vs this class’s),
so a future TraitComposer reorder cannot silently change construction semantics under the new direct calls.
-
Anonymous implementers / SAM-style
new Trait() {}.
testTraitFieldsAreInitializedcoversGreeter.anonymous(). Bytecode assertion for the anonymous class’s<init>(direct$init$) would match the thoroughness of the named-class cases and catch any AIC-specific composer path that still omits the target.
Test gaps I would still like to see
- Failure mode when
$init$cannot be resolved on the helper (item 1), or at least an assert thatgetMethodTarget() != nullafter compose in a unit-level composer test. - Trait declaration order swap + trait on superclass + additional trait on subclass (item 5).
- Optional: bytecode assert on
new Greeting() {}(item 6). - Optional:
@CompileStatictrait (not only implementer) with field initializers consumed by a dynamic implementer.
Non-blocking nits
- The production diff is a four-line change; the new test class is the bulk — appreciated. A
@since/ JIRA pointer next toTraits.INIT_METHODwould help future bisects. assertDirectInitCallsmatches onINVOKESTATIC …$Trait$Helper.$init$ (L…;)Vand rejects"$init$"string constants — solid. Consider also rejectingINVOKEDYNAMICwith$init$as bootstrap/name arg if indy ever grows a path here.- Temp-dir cleanup in
testInitializerOfPrecompiledTraitIsCalledDirectlyis good;traitUnit.compile()without an explicit phase is fine given defaults.
Stance
Directionally I am in favour: small, idiomatic, aligned with existing trait forwarder targeting, and well tested on the bytecode surface. The main review asks are hardening the getDeclaredMethod miss path, aligning MCE flags with forwarders, and making the MetaClass bypass an explicit documented contract rather than only a test comment. I am not approving yet; happy to re-review once those are addressed.
|
Pushed 073ef08. Replies in the order of the review:
Test gaps:
Nits:
|
daniellansun
left a comment
There was a problem hiding this comment.
Thanks for the follow-up on tip 073ef08. I re-checked TraitComposer’s init targeting and your reply to my earlier COMMENT on 865a434.
Prior asks on the $init$ path — closed
-
Missing helper method.
getDeclaredMethodmiss now throws GroovyBugError naming helper and trait;testPrecompiledTraitHelperWithoutInitializerIsRejected(ASM-stripped helper) fails without the guard. Parameter matching by class name covers generic / @selftype / precompiled cases. -
MCE flags.
setImplicitThis(false)on$init$,$static$init$, and the field initializer calls aligns with forwarders; bytecode unchanged across the compared class files. -
MetaClass bypass. Note on
Traits.INIT_METHOD(and JIRA) states that compile-time implementers and runtimewithTraits/as(via ProxyGeneratorAdapter → TraitComposer) no longer dispatch$init$through the helper’s MetaClass. Test covers compiled, @TypeChecked, and both runtime forms. -
Field-level helpers.
$init$<field>/$static$init$<field>are now direct invokestatic in every mode, including against Groovy 4/5 precompiled traits — closes the native-image hole I left as a follow-up.
5–6. Implements-clause order, subclass + superclass traits, and anonymous-class <init> bytecode assertions are in place. @CompileStatic trait with dynamic / TC / SC implementers covered.
Why I am not approving yet
-
Merge conflicts. Tip is dirty against current master. Please rebase / resolve first.
-
Scope beyond GROOVY-12491. The tip also includes ClassInfo.remove preserving DGM/extension methods, JDK 17 method-handle asType-cache hygiene, outer-method resolution from inner classes, joint-compilation trait initializer double-run, Object methods in SC closures, Class methods from static context, and related closure-delegation fixes. Several of those are important (especially ClassInfo.remove for the integration-guide sweep), but they are distinct risk domains from “call
$init$ with INVOKESPECIAL/INVOKESTATIC.” Prefer:- a focused PR (or clearly separable commit series) for trait
$init$/ field-init direct calls + TraitInitBytecodeTest, aimed at the Grails native-image backport; and - ClassInfo / MH-cache / closure-Object-method / joint-init as their own JIRA(s), so a ClassInfo regression cannot block the trait-init win (or vice versa).
- a focused PR (or clearly separable commit series) for trait
If you prefer to keep one PR, please at least label the commits in the description by JIRA so reviewers and release notes can reason about cherry-picks to GROOVY_6_0_X.
Residual on the trait-init slice (non-blocking once conflicts clear)
- Dynamic traits still need field-helper metadata in native images — already stated; fine.
- Rejecting INVOKEDYNAMIC bootstrap args that name
$init$in the bytecode asserts is a nice hardening.
Stance
COMMENT, not approval, while the tip conflicts and while ClassInfo/MH/closure work rides along unmarked. The trait-init design itself looks ready: idiomatic setMethodTarget, hardened miss path, and excellent bytecode coverage. Happy to approve a conflict-free, clearly scoped $init$ slice (or this PR after rebase + labelling) on a short re-review. Thank you for addressing every earlier point carefully.
…ng classes
TraitComposer adds the instance initializer of each trait to the object
initializer of the implementing class as Helper.$init$(this), but builds
that call without a method target. The $static$init$ call it adds to the
static initializer has one, so <clinit> already calls the helper with an
invokestatic, while every constructor dispatched $init$ through the meta
class of the helper:
dynamic class: INVOKEDYNAMIC invoke(Ljava/lang/Class;LGreeter;)Ljava/lang/Object;
@CompileStatic class: INVOKESTATIC org/codehaus/groovy/runtime/ScriptBytecodeAdapter.invokeMethodN
The second is a meta class dispatch in a statically compiled class. The
type checker resolves the call, but it checks object initializer
statements against a placeholder constructor without a declaring class,
so StaticCompilationVisitor does not treat them as statically compiled
and never copies the resolved target onto the call.
As a result every instantiation of a class that implements a trait made
one dynamic dispatch per trait, and a GraalVM native image had to
register the $init$ method of every trait helper for reflection, or
construction failed with "No signature of static method: $init$".
Set the target to the $init$(TraitType) that the helper declares. The
helper comes from the AST for a trait compiled in the same unit and from
the class file for a precompiled trait, so the real method node is used
either way. With a target, the dynamic and the static invocation writers
both emit
ALOAD 0
INVOKESTATIC Greeting$Trait$Helper.$init$ (LGreeting;)V
This includes the classes that withTraits and "as Trait" generate at
runtime, which are composed the same way. What the initializers do and
the order in which the initializers of several traits run are unchanged.
A method added to the meta class of a trait helper no longer replaces
$init$, as it already did not replace $static$init$.
…init$ TraitComposer passed the result of looking up the helper's $init$(TraitType) straight to setMethodTarget. Had the lookup found nothing, the initializer call would silently have gone through the meta class again. TraitASTTransformation generates $init$ for every trait. A trait compiled in the same unit is transformed in semantic analysis, before its implementers are composed in canonicalization, and the helper of a precompiled trait is the class the compiler wrote for it. A miss is therefore a compiler bug or a damaged helper class. Throw a GroovyBugError that names the helper and the trait, as Traits.findHelpers already does when the helper class is missing.
The forwarder methods TraitComposer generates call the helper with implicitThis set to false. The $init$ and $static$init$ calls it adds to the initializers kept the default of true, although their receiver is the helper class. Set it to false on both, so that the AST says what the calls are. With a static method target neither invocation writer reads the flag, so the generated bytecode is unchanged.
A final trait field with an initial value gets its own initializer in the helper: $init$<field>(TraitType), or $static$init$<field>(Class) for a static field. TraitComposer assigns its result to the field of the implementing class in the object or static initializer, but built the call as a StaticMethodCallExpression, which has no method target. In dynamic and @TypeChecked classes it was therefore dispatched through the meta class of the helper with invokedynamic, the same problem $init$ had. @CompileStatic classes already called it directly, because the static compiler resolves the call. Build it as a MethodCallExpression with the initializer as its method target, as for $init$. Looking the initializer up by name and parameter replaces hasPossibleStaticMethod: TraitASTTransformation creates it with exactly that parameter, and a final field without an initial value still has no initializer and gets no statement. Constructors and static initializers now run, for example, ALOAD 0 INVOKESTATIC Greeting$Trait$Helper.$init$Greeting__name (LGreeting;)Ljava/lang/String; LDC LGreeter;.class INVOKESTATIC Greeting$Trait$Helper.$static$init$Greeting__KIND (Ljava/lang/Class;)Ljava/lang/String; in every mode, also for a precompiled trait. A method added to the meta class of the helper no longer replaces these initializers either. The bytecode assertions of TraitInitBytecodeTest now look only at the methods they name; the trace they read holds the whole class.
…ta class Implementing classes now call $init$ and the initializers of final trait fields directly, as they already called $static$init$. A method added to the meta class of a trait helper therefore no longer replaces any of them. Say so on Traits.INIT_METHOD, including that the classes withTraits and as generate at run time are composed by TraitComposer too and behave the same. Extend the meta class test to the field initializers and to a @TypeChecked class. Without the previous commit, the dynamic and @TypeChecked classes and the run-time trait classes still dispatch the field initializers through the meta class.
Pin the order of the direct initializer calls for an implements clause in the other order (implements C, B runs B, A, C) and for subclasses: a subclass calls the initializers of the traits it declares, after the superclass constructor has run those of its own traits. A subclass that declares a trait of its superclass again, here through a sub-trait, gets its own copy of that trait's state, so its initializer runs once more. This order is the same as in Groovy 6.0.0. Check the constructors of anonymous classes that implement a trait, in a method and in a closure, and a @CompileStatic trait used by dynamic, @TypeChecked and @CompileStatic classes, whose helper sets the fields through the field helper interface without any dynamic call. The bytecode assertions now also reject an invokedynamic call site whose bootstrap arguments name an initializer.
073ef08 to
733d5c8
Compare
|
@daniellansun Thanks. This PR is now only the trait initializer change: the six
None of them is needed by the trait change. The trait change applies cleanly to |
✅ All tests passed ✅🏷️ Commit: 733d5c8 Learn more about TestLens at testlens.app/docs. |
This makes the constructor of a class that implements a trait call the trait helper's
$init$directly, instead of dispatching the call through the meta class. The dispatch cost a dynamic call on every instantiation. In a GraalVM native image it also needed reflection metadata, or construction failed at run time. It came up while making Grails start as a native image without recorded reachability metadata.The trait initializer call
When a class implements a trait,
TraitComposer.applyTraitaddsHelper.$init$(this)to the class's object initializer without a method target. The$static$init$call beside it has one, and so do the trait forwarder methods (GROOVY-11776).StaticGreeter.<init>:ldc Greeting$Trait$Helper; ldc "$init$"; … invokestatic ScriptBytecodeAdapter.invokeMethodNDynamicGreeter.<init>:invokedynamic invoke:(Class,LDynamicGreeter;)Object<clinit>s:invokestatic Greeting$Trait$Helper.$static$init$(Class)A native image therefore had to register every trait helper's$init$ ". Grails scans for and registers every trait helper for this reason alone.
$init$for reflection, or construction failed with "No signature of static method:The change sets the target to the
$init$(TraitType)that the helper declares: the AST node for a trait compiled in the same unit, and, for a precompiled trait, the nodeTraits.findHelpersbuilds by reflection from the loaded helper class.TraitASTTransformationalways generates$init$. A helper that does not declare$init$(TraitType)fails compilation with aGroovyBugError, asTraits.findHelpersalready does for a missing helper class.Constructors now run
ALOAD 0; INVOKESTATIC Greeting$Trait$Helper.$init$ (LGreeting;)Vin dynamic,@TypeCheckedand@CompileStaticclasses. That includes the classeswithTraitsandas Traitgenerate at run time. The initializers do the same work in the same order. A method added to a helper's meta class no longer replaces$init$, just as it already could not replace$static$init$.The initializers of final trait fields are called directly too. A final field with an initial value has its own initializer in the helper,
$init$<field>(TraitType), or$static$init$<field>(Class)for a static field, which the implementing class calls to set its copy of the field. Dynamic and@TypeCheckedclasses called it with invokedynamic;@CompileStaticclasses already called it directly. Constructors and static initializers now call it withinvokestaticin every mode, also for traits compiled by Groovy 4 or 5, and a helper's meta class no longer replaces it.In a
@CompileStaticclass, the object initializer, which holds the instance initializer blocks and the$init$call, is still compiled as before: its calls go through the meta class unless they carry a target, and the$init$call now does, so the call is direct there too. Compiling the initializer blocks of@CompileStaticclasses statically would change their type checking and semantics, and is not part of this change.Native image
Building without reflection metadata now works for a class that implements a statically compiled trait, or a trait with no fields or initializer blocks. A dynamic trait's
$init$still sets its fields through the implementing class's field-helper accessors with invokedynamic, so those accessors still need metadata.Tests
TraitInitBytecodeTestchecks constructor bytecode in dynamic, type-checked and static classes for:@SelfTypetrait;@CompileStatictrait;$init$, which must fail compilation.It also checks field initialization, initialization order, runtime trait application, and that a meta-class override of
$init$is no longer called. Without the change, 37 of its 47 tests fail: all the bytecode tests, the missing-$init$test and the meta-class test. The 10 field and order tests pass either way.The
groovy,gls,bugsandorg.codehaus.groovypackages pass (15,542 tests). The Checkstyle and CodeNarc gates pass.Backport
Grails 9 builds with Groovy 6.0.x, and the javap above is from 6.0.0. The change applies cleanly to
GROOVY_6_0_X. A backport would let Grails 9 stop registering$Trait$Helperclasses for$init$. The field helpers of traits that are not statically compiled still need metadata.JIRA: GROOVY-12491