Skip to content

GROOVY-12240: Initialize argument-less enum constants with a direct constructor call - #2772

Open
codeconsole wants to merge 1 commit into
apache:masterfrom
codeconsole:GROOVY-enum-noarg-direct-ctor
Open

GROOVY-12240: Initialize argument-less enum constants with a direct constructor call#2772
codeconsole wants to merge 1 commit into
apache:masterfrom
codeconsole:GROOVY-enum-noarg-direct-ctor

Conversation

@codeconsole

Copy link
Copy Markdown

https://issues.apache.org/jira/browse/GROOVY-12240

Motivation

EnumVisitor creates every enum constant through a synthetic helper:

def $INIT(Object[] para) {
    return this(*para)
}

this(*para) is a spread constructor call, so InvocationWriter.makeDirectConstructorCall refuses it — it bails on SpreadExpression, and again on !controller.isConstructor() — and the body compiles to ScriptBytecodeAdapter.despreadList plus selectConstructorAndTransformArguments. The meta class then picks the constructor at run time by reflecting over getDeclaredConstructors(). The static initializer reaches $INIT itself through a dynamic call site.

For enum Colors { RED, GREEN, BLUE } that is the whole constant-creation path, even though the only arguments are the compiler-supplied name and ordinal, both known at compile time.

Where reflection over the enum isn't available the class simply cannot initialize. In a GraalVM native image built without reachability metadata for the enum, getDeclaredConstructors() returns nothing and class initialization throws

groovy.lang.GroovyRuntimeException: Could not find matching constructor for: com.example.MyEnum(String, Integer)

(note the boxed Integer — the ordinal has been through Object[]). This kills the application in a static initializer before any user code runs.

@CompileStatic does not help: Groovy already compiles the call site statically (StaticTypeCheckingVisitor, GROOVY-10845); it is $INIT's own body that is necessarily dynamic.

Change

When every constant of an enum is a plain identifier, the arguments are provably [name, ordinal], and the static initializer now calls the enum's (String,int) constructor directly.

static {};                                   static {};
 0: ldc           // class Colors             0: new           // class Colors
 2: ldc           // String RED               3: dup
 4: iconst_0                                  4: ldc           // String RED
 5: invokestatic  Integer.valueOf             6: iconst_0
 8: invokedynamic invoke:(Class;String;       7: invokespecial "<init>":(Ljava/lang/String;I)V
                  Integer;)Object;           10: putstatic     Field RED:LColors;
13: invokedynamic cast:(Object;)LColors;
18: putstatic     Field RED:LColors;

The same shape javac emits for a Java enum: 21 bytes and two indy call sites per constant become 13 bytes and none.

When the new path applies

Only when all of the following hold:

  • every constant is a plain identifier — no arguments, no named arguments, no class body;
  • the enum is not abstract and has no EnumConstantClassNode inner classes;
  • the enum declares no constructor, or declares one callable with no user-supplied argument;
  • and, checked at bytecode generation once every transform has run, a (String,int) constructor actually exists.

Anything else keeps the existing $INIT path.

How the fallback works

EnumConstantInit is a BytecodeExpression that holds the original $INIT call. It hands that call to every visitor except AsmClassGenerator, and hands it to AsmClassGenerator too when the expected constructor isn't present. So type checking, scope resolution and AST transforms see exactly the tree they see today, and a shape that cannot use the direct call degrades to today's bytecode rather than to a different failure.

A concrete case: @TupleConstructor(defaults = false) enum E { ONE; String value } compiles today and fails at class initialization with Could not find matching constructor. It has no (String,int) constructor, so it keeps $INIT and keeps failing in exactly that way. There is a test for it.

$INIT is unchanged and still generated for every enum.

Scope, stated honestly

This is a compile-time change. It only helps code compiled by a Groovy that carries the fix; bytecode already compiled by an earlier Groovy keeps its $INIT path whichever Groovy runs it. I confirmed this by building a GraalVM native image of an application against a patched Groovy: the framework's own enums, compiled by an earlier Groovy, still failed with Could not find matching constructor until reachability metadata was restored.

Enums whose constants take arguments (RED(255, 0, 0)) are not addressed and still require reachability metadata in a native image. Fixing those would mean relaxing InvocationWriter.makeDirectConstructorCall to work outside a constructor, which is a much wider change and deliberately left alone.

One semantic narrowing worth reviewer attention: a plain enum's <clinit> no longer touches the meta class, so anything relying on intercepting an enum constructor via ExpandoMetaClass before class initialization would no longer see it. I believe this is unreachable in practice — the enum is initialized once, before any such hook could be installed, and Java enums offer no equivalent — but it is a real change.

Tests

  • EnumConstantInitBytecodeTest (new) — asserts the emitted <clinit> instruction sequence for the direct case, that $INIT is still generated with its usual body, and that constants with arguments / named arguments / a body / a mix, and the missing-constructor case, all keep $INIT.
  • gls.enums.EnumTest — behaviour coverage for the new path: values, ordinals, valueOf, next/previous, MIN_VALUE/MAX_VALUE, ranges, EnumSet, compareTo, serialization identity, and enums with an explicit no-arg or all-defaults constructor.

Also verified by hand across packaged, nested and doubly-nested enums, @CompileStatic, @TypeChecked, and a 400-constant enum (correct iconst/bipush/sipush selection at 5/6/127/128/399).

./gradlew :test passes in full: 16,548 tests, 0 failures.

Related to GROOVY-12234.

…onstructor call

EnumVisitor routes every enum constant through the synthetic $INIT(Object[])
helper, whose body is a spread constructor call. That compiles to
ScriptBytecodeAdapter.despreadList plus selectConstructorAndTransformArguments,
so the meta class picks the constructor at run time by reflecting over
getDeclaredConstructors(); the static initializer in turn reaches $INIT itself
through a dynamic call site.

For a constant that supplies no arguments of its own the arguments are only the
compiler-supplied name and ordinal, both of which are known at compile time.
Emit a direct call to the (String,int) constructor of the enum for those, so
the static initializer needs neither the meta class nor reflection. That
matters where reflection over the enum is not available: in a GraalVM native
image built without reachability metadata for the enum, getDeclaredConstructors()
is empty and class initialization fails with

    groovy.lang.GroovyRuntimeException: Could not find matching constructor
    for: com.example.MyEnum(String, Integer)

@CompileStatic does not help, because it is $INIT's own body that is dynamic.

The direct call is used only when every constant of the enum is a plain
identifier. Constants with arguments, with named arguments or with a class
body keep the $INIT path, as do abstract enums, the classes generated for
constant bodies and enums without a constructor that takes just the name and
the ordinal. $INIT is still generated in every case, and only the bytecode
generator is shown the direct call, so type checking and every other visitor
see the same tree as before.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 69.9971%. Comparing base (2f85f42) to head (4d53a4e).
⚠️ Report is 13 commits behind head on master.

Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##               master      #2772        +/-   ##
==================================================
+ Coverage     69.9869%   69.9971%   +0.0102%     
- Complexity      35529      35553        +24     
==================================================
  Files            1557       1558         +1     
  Lines          131686     131724        +38     
  Branches        24174      24178         +4     
==================================================
+ Hits            92163      92203        +40     
+ Misses          31189      31174        -15     
- Partials         8334       8347        +13     
Files with missing lines Coverage Δ
...org/codehaus/groovy/classgen/EnumConstantInit.java 100.0000% <100.0000%> (ø)
...java/org/codehaus/groovy/classgen/EnumVisitor.java 92.4051% <100.0000%> (+0.4588%) ⬆️

... and 17 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@testlens-app

testlens-app Bot commented Aug 7, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 4d53a4e
▶️ Tests: 108926 executed
⚪️ Checks: 31/31 completed


Learn more about TestLens at testlens.app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants