GROOVY-12255: Compile switch expressions as first-class AST (no closure desugar) - #2784
GROOVY-12255: Compile switch expressions as first-class AST (no closure desugar)#2784daniellansun wants to merge 10 commits into
Conversation
There was a problem hiding this comment.
⚠️ Performance Alert ⚠️
Possible performance regression was detected for benchmark.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.50.
| Benchmark suite | Current: 257866e | Previous: ad907ac | Ratio |
|---|---|---|---|
org.apache.groovy.bench.dispatch.CallsiteBench.dispatch_8_megamorphic_java |
2366.43538113778 ops/ms |
1495.2458815769692 ops/ms |
1.58 |
This comment was automatically generated by workflow using github-action-benchmark.
JMH summary — classic (commit
|
| Group | Speedup | Calibrated | n |
|---|---|---|---|
| bench | 0.957 × | 0.976 × | 99 |
| core | 1.405 × | 1.040 × | 83 |
| grails | 1.022 × | 0.975 × | 80 |
⚠️ 1 benchmark at least 1.5× slower than the 90-day baseline:
org.apache.groovy.perf.grails.MetaclassChangeBench.burstThenSteadyState— 1.79× slower (calibrated)
⚠️ Runner speed differs ≥15% from the historical baseline hardware for: core-ag, core-hz. Raw speedups are not meaningful for those parts — use the calibrated column.
Runner calibration (this run vs baseline hardware): bench 0.98× (26 rulers) · core-ag 1.30× (3 rulers) · core-hz 1.43× (3 rulers) · grails-ad 0.98× (3 rulers) · grails-ez 1.11× (3 rulers)
Baseline: dev/bench/jmh/<part>/classic/data.js on gh-pages, trailing 90 days. Daily dashboard · Per-suite raw data
JMH summary — indy (commit
|
| Group | Speedup | Calibrated | n |
|---|---|---|---|
| bench | 1.231 × | 0.981 × | 99 |
| core | 2.686 × | 2.817 × | 83 |
| grails | 4.464 × | 3.730 × | 80 |
⚠️ 4 benchmarks at least 1.5× slower than the 90-day baseline:
org.apache.groovy.bench.AryBench.groovyCS ( {"n":"1000000"} )— 2.44× slower (calibrated)org.apache.groovy.bench.StaticMethodCallIndyBench.staticChain_groovyCS— 1.63× slower (calibrated)org.apache.groovy.bench.StaticMethodCallIndyBench.instanceSum_groovy— 1.54× slower (calibrated)org.apache.groovy.bench.AryBench.groovyCS ( {"n":"10"} )— 1.50× slower (calibrated)
⚠️ Runner speed differs ≥15% from the historical baseline hardware for: bench, grails-ez. Raw speedups are not meaningful for those parts — use the calibrated column.
Runner calibration (this run vs baseline hardware): bench 1.24× (26 rulers) · core-ag 0.96× (3 rulers) · core-hz 0.94× (3 rulers) · grails-ad 0.98× (3 rulers) · grails-ez 1.41× (3 rulers)
Baseline: dev/bench/jmh/<part>/indy/data.js on gh-pages, trailing 90 days. Daily dashboard · Per-suite raw data
|
I am still reviewing but an initial AI assessment below:
|
Compile switch expressions as first-class AST (fix review comments)
|
@daniellansun I added a commit that potentially addresses the issues raised above. We can delete if you have another preference. |
Compile switch expressions as first-class AST (improve test coverage to keep Sonar happy)
f0c6688 to
425d42f
Compare
blackdrag
left a comment
There was a problem hiding this comment.
Additionally to what Paul said I added some review comments. Some of the comments annotate only one place, but actually mean many similar. The fully qualified class name usage is such a case.
Tighten last-arm completion, copy switch-expression trees without mutating the original, and drop historical desugar notes from javadoc.
Type the label stream, build each arm once, and drop the ad-hoc AST walk for yield/throw in favour of mayCompleteNormally.
Thank you for the review. The inline notes are addressed below; the same FQCN cleanup was applied at the other sites introduced by this PR. |
Thank you — including for
Happy to adjust further if any of these should take a different shape. |
|
AI read:
|
There was a problem hiding this comment.
A summary for the review would be the following:
- please use more of the provided infrastructure of OperandStack and CompileStack
- tests where the switch is using a Reference
- split between static compiler and non-static compiler and tests
- maybe we can reuse some code for the normal case statement as well?
| * have been passed through {@code transformer}. The original tree is not | ||
| * mutated. Unknown statement types are returned as-is. | ||
| */ | ||
| private static Statement copyAndTransform(final Statement statement, final ExpressionTransformer transformer) { |
There was a problem hiding this comment.
actually I think this does not belong in here. I think this should be its own class and become something like the ClassCodeTransformer/ClassCodeVisitorSupport. I actually think the way you use this here violates the idea of ExpressionTransformer. transformExpression is not supposed to create new statements. Which actually shows here that using CaseStatement is a logical problem for the otherwise relatively strict difference between statements and expressions we have. in fact... the code makes copies of statements inside the cases, but it does not keep all the metadata, like the metadata map, source positions or for example variable scopes. We do not know when transformExpression is called. I think for the normal switch-case statement we do not copy statements, especially not nested statements. Why should we here? So my suggestion is if you need this transformExpression call, make a new copy-visitor, that also copies metadata and use it through a different method. If you only wanted to provide a transformExpression implementation, then I do not think it is right to transform nested statements. The part on if a CaseStatement should actually be a CaseExpression for a SwitchExpression I leave mainly to you, but I think I see strong idicators for it, as it would be strange to have an expression consisting of statements.
There was a problem hiding this comment.
Agreed. transformExpression no longer copies arm statements. It rewrites
the selector and case-label expressions only and shares the arm bodies,
the same way SwitchStatement is rewritten by visit rather than by a
deep copy.
When the transformer is also a GroovyCodeVisitor (ResolveVisitor,
ClassCodeExpressionTransformer, …), those shared arms are then
visited so nested expressions still go through resolve and rewrite.
That is visit, not a new statement tree. A plain
ExpressionTransformer that is not a visitor leaves the arms untouched.
On CaseExpression: we kept CaseStatement. A ClosureExpression
already holds a statement body; a switch-expression arm is the same
shape (expression label, statement body). Introducing CaseExpression
would duplicate visitor surface without changing that. Happy to reopen
if you would rather split the node.
| return false; // duplicate case value | ||
| } | ||
| } | ||
| if (keyToBody.isEmpty()) return false; |
There was a problem hiding this comment.
same here... why no compilation error?
| ClassNode storedSelectorType = ClassHelper.isPrimitiveType(selectorType) | ||
| ? ClassHelper.getWrapper(selectorType) | ||
| : selectorType; | ||
| operandStack.box(); |
There was a problem hiding this comment.
(a) if you simply do operandStack.box(); ClassNode storedSelectorType = operandStack.getTopOperand(); you would not need the primitive type check here. You are not using selectorType past this point anyway.
(b) why are you actually boxing? For a tableswitch or a lookupswitch you have to unbox again.
(c) what if the variable is a reference? For example what if the same variable is used in a switch expression and also used in a Closure, where it is written even? like
´´´
def x = 1
def cl = {x=2}
cl()
def y = switch(x)....
´´´
Then for x getTopOperand would return most likely int, But it actually is a Reference, potentially endangering all manipulation you do on that variable that does not use OperandStack. That wrong type extends to the temporary variable you define in the line after this.
There was a problem hiding this comment.
Agreed.
(a)(b) The dynamic writer boxes once, because it only does isCase. The
static writer keeps the type OperandStack reports after visiting the
selector: a primitive int is stored as int and fed to tableswitch
with no box/unbox; a wrapper is stored as a wrapper, null-checked, then
unboxed only on that path.
(c) The selector is evaluated once through the normal visit (which
unwraps a holder via OperandStack) and stored in a non-holder temp.
Later loads use OperandStack.load on that temp, never a raw ALOAD of
the original slot. Tests:
referenceSelectorWrittenByClosure,
referenceSelectorWrittenByClosureUnderCompileStatic,
referenceWrapperSelectorWrittenByClosureUnderCompileStatic,
staticReferenceSelectorWrittenByClosure,
staticIntegerReferenceSelectorWrittenByClosure.
| caseStatement.getExpression().visit(acg); | ||
| operandStack.box(); | ||
| effective.getBinaryExpressionHelper().getIsCaseMethod().call(mv); | ||
| operandStack.replace(ClassHelper.boolean_TYPE); |
There was a problem hiding this comment.
why not emit a method call here and let others handle this properly? This looks like it enforces the dynamic isCase call even in static compilation.
There was a problem hiding this comment.
Agreed. The static writer emits caseValue.isCase(selector) as a
MethodCallExpression with a resolved target (instance isCase, or a
DGM overload more specific than Object). Only if that resolution is
ambiguous does it fall back to
BinaryExpressionHelper.writeIsCase (ScriptBytecodeAdapter). The
dynamic writer uses that shared helper from the start.
| int nameLocal = compileStack.defineTemporaryVariable("$switchEnumName", ClassHelper.STRING_TYPE, true); | ||
| int caseIndexLocal = compileStack.defineTemporaryVariable("$switchCase", ClassHelper.int_TYPE, false); | ||
|
|
||
| List<String> ordered = new ArrayList<>(nameToBody.keySet()); |
There was a problem hiding this comment.
Besides the cases that should probably be a compilation error you do not need a map. You could work with a List from the get-go.
There was a problem hiding this comment.
Agreed. Keys and jump targets are collected as parallel lists. A map is
kept only where the algorithm needs one (duplicate detection, hash
buckets for the Java 7 string dispatch, sorted int keys for
tableswitch).
| } | ||
| if (!caseStatement.getCode().isEmpty()) { | ||
| caseStatement.getCode().visit(acg); | ||
| } |
There was a problem hiding this comment.
same question for EmptyStatement.
| CompileStack.SwitchExpressionContext context = compileStack.getSwitchExpressionContext(); | ||
| if (context == null) { | ||
| throw new GroovyBugError("yield outside of a switch expression"); | ||
| } |
There was a problem hiding this comment.
I think that check should be in CompileStack
There was a problem hiding this comment.
Agreed. CompileStack.requireSwitchExpressionContext() now owns the
guard; writeYield just uses the returned context.
| } | ||
| } | ||
| ''') | ||
| assert bytecode.hasSequence(['TABLESWITCH']) || bytecode.hasSequence(['LOOKUPSWITCH']) |
There was a problem hiding this comment.
why "or"? It is either one or the other. Same for staticStringSwitchUsesLookupSwitch. Also this is a static compilation test so far, thus it should be under sc
There was a problem hiding this comment.
Agreed. Dense int asserts TABLESWITCH only; sparse int asserts
LOOKUPSWITCH only; String asserts the Java 7 pair (LOOKUPSWITCH on
hashCode, then TABLESWITCH on the case index). Those tests live in
src/test/groovy/org/codehaus/groovy/classgen/asm/sc/SwitchExpressionStaticCompileTest.groovy.
Move tableswitch/lookupswitch and resolved isCase onto StaticTypesSwitchExpressionWriter. Store and load the selector through OperandStack so Reference-backed locals stay correct, share isCase emission with switch statements, and stop copying arm statements in transformExpression.
Agreed on all four.
|
|
See also: https://openjdk.org/jeps/361 |
Collapse grouping to one ArmGroup, share colon/comma labels on the following body, jump string/enum equals straight to the arm, and let ClassCodeExpressionTransformer walk switch expressions in place.
Seed empty colon suffixes with the default label so case 1: default: yield is not a compile error, keep OperandStack in step with the switch and equals sequences, and lock the tableswitch / lookupswitch shape with bytecode tests.
Add a dual-mode conformance suite for the JEP 361 examples and extend STC coverage for non-exhaustive statements and target-type pushdown.
|
blackdrag
left a comment
There was a problem hiding this comment.
I think we are almost there. The split looks promising, the type extension issue is in my opinion the only thing that really blocks this.
| } | ||
|
|
||
| @Test | ||
| void compileStaticYieldInsideTryFinallyStringSwitch() { |
There was a problem hiding this comment.
why is this specific to static compilation? Also why does it matter if r is int or String?
| } | ||
|
|
||
| @Test | ||
| void compileStaticYieldInsideTryFinallyIntSwitch() { |
There was a problem hiding this comment.
why is this specific to static compilation?
| if (true) yield 1 | ||
| } | ||
| ''') | ||
| assert err.message.contains('yield') || err.message.contains('throw') |
There was a problem hiding this comment.
again the question as of why yield or throw?
| @Test | ||
| void compileStaticSparseIntKeysStillDispatch() { | ||
| assertScript ''' | ||
| @groovy.transform.CompileStatic |
There was a problem hiding this comment.
I am randomly using this line to anchor the comment, but it actually is about several places with CompileStatic. You already have tests that ensure the bytecode contains structures you expect from static compilation. Which means I assume the test here is to finalize that the behavior is consistent. But for this you need a base to compare to, which should be the same test without static compilation. So I suggest you do something like
def script = """..."""
assertScript script
assertScript "@groovy.transform.CompileStatic\n" + script
And that way you ensure baseline (dynamic Groovy) and static compiler align in behavior. You should look at each @CompileStatic using test in this class and ask yourself if it is really specific to the static compiler. If not you should change it like suggested, if it is special, then is should go into the static compilation test suite instead - or at least the test should have something explaining why this is only with static compilation.
| } | ||
| } | ||
| ''') | ||
| assert err.message.contains("cannot continue to label 'outer'") |
There was a problem hiding this comment.
This is a compilation error, right? You should assert the exception type as well.
| } | ||
| } | ||
| ''') | ||
| assert err.message.contains("cannot break to label 'outer'") |
There was a problem hiding this comment.
same case as for continue
| if (transformer instanceof ClassCodeExpressionTransformer visitor) { | ||
| visitor.visitSwitchExpression(this); | ||
| return this; | ||
| } |
There was a problem hiding this comment.
I think this code should be in ClassCodeExpressionTransformer. The other part of the method looks good to me now
| private MethodNode resolveIsCaseTarget(final Expression caseValue, final ClassNode selectorType) { | ||
| ClassNode caseType = controller.getTypeChooser().resolveType(caseValue, controller.getClassNode()); | ||
| ClassNode switchArg = ClassHelper.isPrimitiveType(selectorType) | ||
| ? ClassHelper.getWrapper(selectorType) : selectorType; |
There was a problem hiding this comment.
minor: getWrapper is already doing this check for you.
| * @since 6.0.0 | ||
| */ | ||
| @Override | ||
| public void visitSwitchExpression(final SwitchExpression expression) { |
There was a problem hiding this comment.
I already mentioned it before but I think I was way to unspecific and then you misunderstood me. There are basically 2 cases for the switch expression: the generic isCase variant and the intrinsic variant. I think you need to check isCase here and if it is the isCase variant, you should actually go through method selection here to have a direct method call target be chosen for isCase. If there is no target this is a compilation error. This should then handle instance and DGM, other extensions, as well as making the typechecking extensions work for the isCase call. The isCase write is then actually a direct method call write only, which is handled by writeDirectMethodCall in StaticInvocationWriter. The intrinsic cases are to be handled by StaticTypesSwitchExpressionWriter directly, while for isCase should then go through the invocation writer mechanism.
| } | ||
|
|
||
| private static MethodNode chooseInstanceIsCase(final ClassNode caseType, final ClassNode switchArg) { | ||
| if (caseType == null) return null; |
There was a problem hiding this comment.
in combination with my comment on StaticTypeCheckingVisitor you would actually only have to check for the direct method call target here and then write it, if it exists.
…g transform Resolve a non-intrinsic case label as label.isCase(selector) in the type checker and store the chosen MethodNode on the CaseStatement so static codegen can emit a direct call. Keep ClassCodeExpressionTransformer.transform generic: SwitchExpression.transformExpression still rewrites only the selector and labels, and ResolveVisitor, StaticImportVisitor, static compilation and GINQ visit the node themselves, the same way they walk closures.
✅ All tests passed ✅🏷️ Commit: b2b9b73 Learn more about TestLens at testlens.app. |



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