This is an automated email from the ASF dual-hosted git repository. paulk-asert pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/groovy.git
commit 566c8f2c045296ab0f1db4f85f7ab7e6f0f675fd Author: Paul King <[email protected]> AuthorDate: Wed May 6 18:39:06 2026 +1000 GROOVY-11998: Better support of intersection types (part 5) Closure literal native intersection. StaticTypesClosureWriter generates a class implementing all components for (R & S) { -> ... }, docs. --- .../classgen/asm/sc/StaticTypesClosureWriter.java | 31 ++++ src/spec/doc/core-differences-java.adoc | 19 ++ src/spec/doc/core-semantics.adoc | 59 ++++++ src/spec/test/CoercionTest.groovy | 97 ++++++++++ .../lang/IntersectionClosureLiteralTest.groovy | 203 +++++++++++++++++++++ 5 files changed, 409 insertions(+) diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java index b2e9dda3e7..24201148ea 100644 --- a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java +++ b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java @@ -44,6 +44,7 @@ import static org.codehaus.groovy.ast.tools.GeneralUtils.constX; import static org.codehaus.groovy.ast.tools.GeneralUtils.nullX; import static org.codehaus.groovy.ast.tools.GeneralUtils.returnS; import static org.codehaus.groovy.ast.tools.GeneralUtils.varX; +import static org.codehaus.groovy.transform.stc.StaticTypesMarker.LAMBDA_MARKERS; /** * Writer responsible for generating closure classes in statically compiled mode. @@ -69,10 +70,40 @@ public class StaticTypesClosureWriter extends ClosureWriter { for (MethodNode method : methods) { visitor.visitMethod(method); } + // GROOVY-11998: when the closure literal is the source of an intersection + // cast, declare the additional marker interfaces on the generated class so + // the resulting object IS-A every component without going through a + // runtime proxy. Markers are only added when they are true marker + // interfaces (no abstract methods we'd be obliged to implement). + addIntersectionMarkers(closureClass, expression); closureClass.putNodeMetaData(StaticCompilationMetadataKeys.STATIC_COMPILE_NODE, Boolean.TRUE); return closureClass; } + @SuppressWarnings("unchecked") + private static void addIntersectionMarkers(final ClassNode closureClass, final ClosureExpression expression) { + Object md = expression.getNodeMetaData(LAMBDA_MARKERS); + if (!(md instanceof List)) return; + List<ClassNode> markers = (List<ClassNode>) md; + for (ClassNode marker : markers) { + if (marker == null || !marker.isInterface()) continue; + if (closureClass.implementsInterface(marker)) continue; + // Only add interfaces with no abstract methods (true markers). For + // interfaces that declare unimplemented abstract methods, we'd + // have to synthesise method bodies — out of scope here, fall back + // to the runtime proxy path in IntersectionCastSupport.asType. + if (hasAbstractMethods(marker)) continue; + closureClass.addInterface(marker); + } + } + + private static boolean hasAbstractMethods(final ClassNode iface) { + for (MethodNode m : iface.getMethods()) { + if (m.isAbstract() && !m.isDefault() && !m.isStatic()) return true; + } + return false; + } + private static void createDirectCallMethod(final ClassNode closureClass, final MethodNode doCallMethod) { // in case there is no "call" method on the closure, create a "fast invocation" path // to avoid going through ClosureMetaClass by call(Object...) method diff --git a/src/spec/doc/core-differences-java.adoc b/src/spec/doc/core-differences-java.adoc index f153b566c9..a3725ce055 100644 --- a/src/spec/doc/core-differences-java.adoc +++ b/src/spec/doc/core-differences-java.adoc @@ -238,6 +238,25 @@ Runnable run = { println 'run' } list.each { println it } // or list.each(this.&println) ---- +Since Groovy 6.0, intersection-type casts (https://docs.oracle.com/javase/specs/jls/se21/html/jls-15.html#jls-15.16[JLS §15.16]) +are supported on lambdas and method references, so a lambda can opt into `Serializable` +or other marker interfaces, just like in Java: + +[source,groovy] +---- +Runnable r = (Runnable & Serializable) () -> println('hi') // serialisable lambda +---- + +Groovy additionally accepts the `as` form, where parentheses are required around +the intersection: + +[source,groovy] +---- +def r = { -> println 'hi' } as (Runnable & Serializable) +---- + +See the <<{core-semantics}#intersection-cast,Intersection-type cast and coercion>> section in the semantics guide. + == GStrings diff --git a/src/spec/doc/core-semantics.adoc b/src/spec/doc/core-semantics.adoc index bd7b7ca1ad..ab9bebac5a 100644 --- a/src/spec/doc/core-semantics.adoc +++ b/src/spec/doc/core-semantics.adoc @@ -759,6 +759,65 @@ The type of the exception depends on the call itself: * `MissingMethodException` if the arguments of the call do not match those from the interface/class * `UnsupportedOperationException` if the arguments of the call match one of the overloaded methods of the interface/class +[[intersection-cast]] +=== Intersection-type cast and coercion + +Since Groovy 6.0, the cast and `as` operators accept _intersection types_ — a +class component (at most one) and any number of interface components joined by +`&`, mirroring Java's +https://docs.oracle.com/javase/specs/jls/se21/html/jls-15.html#jls-15.16[JLS §15.16] +intersection cast. The most common use is to opt a lambda or method reference +into `Serializable`: + +[source,groovy] +---- +include::../test/CoercionTest.groovy[tags=intersection_cast_lambda,indent=0] +---- + +For statically compiled lambdas and method references, the additional +interfaces are threaded through `LambdaMetafactory.altMetafactory` via +`FLAG_MARKERS` / `FLAG_SERIALIZABLE` — there is no runtime proxy and the +result implements every component natively, so it can be serialised and +restored: + +[source,groovy] +---- +include::../test/CoercionTest.groovy[tags=intersection_cast_serializable_roundtrip,indent=0] +---- + +The same syntax works in the `as` form, with parentheses around the intersection: + +[source,groovy] +---- +include::../test/CoercionTest.groovy[tags=intersection_as_coercion_marker,indent=0] +---- + +For closure literals and maps, `as` builds a +{@link groovy.util.ProxyGenerator} aggregate that implements every interface +component: + +[source,groovy] +---- +include::../test/CoercionTest.groovy[tags=intersection_as_coercion_map,indent=0] +---- + +Method references support the same intersection-cast forms: + +[source,groovy] +---- +include::../test/CoercionTest.groovy[tags=intersection_method_reference,indent=0] +---- + +The well-formedness rules follow the JLS: + +* at most one component may be a class — and if present, it must come first; +* the class component (if any) must not be `final`; +* primitive components are not allowed; +* when the operand is a lambda, method reference or closure literal, exactly + one component may have an abstract method (the SAM-bearing functional + interface). The remainder must be marker-compatible interfaces (no abstract + methods, or abstract methods identical to the SAM). + === String to enum coercion Groovy allows transparent `String` (or `GString`) to enum values coercion. Imagine you define the following enum: diff --git a/src/spec/test/CoercionTest.groovy b/src/spec/test/CoercionTest.groovy index f44b6f46fd..767cb23ab3 100644 --- a/src/spec/test/CoercionTest.groovy +++ b/src/spec/test/CoercionTest.groovy @@ -374,6 +374,103 @@ final class CoercionTest { ''' } + // GROOVY-11998 + @Test + void testIntersectionCastLambda() { + assertScript ''' + // tag::intersection_cast_lambda[] + // A lambda that is also Serializable + Runnable r = (Runnable & java.io.Serializable) () -> println("hi") + assert r instanceof Runnable + assert r instanceof java.io.Serializable + r.run() + // end::intersection_cast_lambda[] + ''' + } + + // GROOVY-11998 + @Test + void testIntersectionCastSerializableRoundTrip() { + assertScript ''' + import groovy.transform.CompileStatic + + // tag::intersection_cast_serializable_roundtrip[] + @CompileStatic + class T { + static Runnable make() { + return (Runnable & java.io.Serializable) () -> println("hi") + } + } + + // The lambda factory uses LambdaMetafactory.altMetafactory with + // FLAG_SERIALIZABLE so the result can be safely written to and read + // from an ObjectStream. + def r = T.make() + def baos = new ByteArrayOutputStream() + new ObjectOutputStream(baos).withCloseable { it.writeObject(r) } + def restored = null + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray())).withCloseable { + restored = it.readObject() + } + assert restored instanceof Runnable + // end::intersection_cast_serializable_roundtrip[] + ''' + } + + // GROOVY-11998 + @Test + void testIntersectionAsCoercion() { + assertScript ''' + // tag::intersection_as_coercion_marker[] + // Closures already implement Runnable, Serializable and Cloneable, so + // intersections of those is an identity coercion (no proxy is built). + def cl = { -> 1 } + def coerced = cl as (Runnable & java.io.Serializable) + assert coerced.is(cl) // same instance + // end::intersection_as_coercion_marker[] + ''' + } + + // GROOVY-11998 + @Test + void testIntersectionAsCoercionMap() { + assertScript ''' + // tag::intersection_as_coercion_map[] + interface Action { void perform() } + + def calls = 0 + def proxy = ([perform: { -> calls++ }] as (Action & java.io.Serializable)) + assert proxy instanceof Action + assert proxy instanceof java.io.Serializable + proxy.perform() + proxy.perform() + assert calls == 2 + // end::intersection_as_coercion_map[] + ''' + } + + // GROOVY-11998 + @Test + void testIntersectionMethodReference() { + assertScript ''' + import groovy.transform.CompileStatic + import java.util.function.Function + + // tag::intersection_method_reference[] + @CompileStatic + class T { + static Function<String, Integer> make() { + return (Function<String, Integer> & java.io.Serializable) String::length + } + } + + Function<String, Integer> f = T.make() + assert f instanceof java.io.Serializable + assert f.apply("hello") == 5 + // end::intersection_method_reference[] + ''' + } + @Test void testAsVsAsType() { assertScript ''' diff --git a/src/test/groovy/groovy/lang/IntersectionClosureLiteralTest.groovy b/src/test/groovy/groovy/lang/IntersectionClosureLiteralTest.groovy new file mode 100644 index 0000000000..b8bd4cee3b --- /dev/null +++ b/src/test/groovy/groovy/lang/IntersectionClosureLiteralTest.groovy @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.lang + +import org.junit.jupiter.api.Test + +/** + * Tests for native intersection support of closure literals + * (GROOVY-11998 PR5). + * + * Under {@code @CompileStatic}, a closure literal cast to an intersection + * has its generated class declare the marker interfaces so that the cast + * is a no-op (no runtime proxy needed). + */ +final class IntersectionClosureLiteralTest { + + @Test + void 'closure cast to (Runnable & MyMarker) implements MyMarker via addInterface'() { + def shell = new GroovyShell() + shell.evaluate(''' + import groovy.transform.CompileStatic + + interface MyMarker {} + + @CompileStatic + class T { + static Object castForm() { + return (Runnable & MyMarker) { -> } + } + } + + def c = T.castForm() + assert c instanceof Runnable + assert c instanceof MyMarker + // Generated closure class itself implements MyMarker - no proxy wrapping + assert MyMarker.isAssignableFrom(c.class) + ''') + } + + @Test + void 'closure cast to (Runnable & Serializable & MyMarker) implements all three'() { + def shell = new GroovyShell() + shell.evaluate(''' + import groovy.transform.CompileStatic + + interface MyMarker {} + + @CompileStatic + class T { + static Object make() { + return (Runnable & java.io.Serializable & MyMarker) { -> } + } + } + + def c = T.make() + assert c instanceof Runnable + assert c instanceof java.io.Serializable + assert c instanceof MyMarker + ''') + } + + @Test + void 'as form on closure with custom marker also picks up addInterface'() { + def shell = new GroovyShell() + shell.evaluate(''' + import groovy.transform.CompileStatic + + interface MyMarker {} + + @CompileStatic + class T { + static Object asForm() { + return ({ -> "hi" } as (Runnable & MyMarker)) + } + } + + def c = T.asForm() + assert c instanceof Runnable + assert c instanceof MyMarker + ''') + } + + @Test + void 'static cast form succeeds where dynamic would need a runtime proxy'() { + // Without PR5, (R & MyMarker) cast form on a closure literal would throw + // GroovyCastException at runtime because the closure isn't MyMarker. + // PR5 makes the generated closure class implement MyMarker directly so + // the cast is a true no-op. + def shell = new GroovyShell() + shell.evaluate(''' + import groovy.transform.CompileStatic + + interface MyMarker {} + + @CompileStatic + class T { + static Object castForm() { + return (Runnable & MyMarker) { -> } + } + } + + def c = T.castForm() + // The closure subclass itself declares MyMarker — verify by class introspection + assert MyMarker.isAssignableFrom(c.class) + assert !c.class.name.startsWith('jdk.proxy') + assert !c.class.name.contains('groovyProxy') + ''') + } + + @Test + void 'closure cast to Cloneable & Serializable is identity (already implemented by Closure)'() { + def shell = new GroovyShell() + shell.evaluate(''' + import groovy.transform.CompileStatic + + @CompileStatic + class T { + static Object make() { + def cl = { -> 1 } + return cl as (Cloneable & java.io.Serializable) + } + } + def c = T.make() + assert c instanceof Cloneable + assert c instanceof java.io.Serializable + ''') + } + + @Test + void 'marker interface with non-abstract default methods can still be added'() { + def shell = new GroovyShell() + shell.evaluate(''' + import groovy.transform.CompileStatic + + interface MarkerWithDefault { + default String tag() { 'tag' } + } + + @CompileStatic + class T { + static Object make() { + return (Runnable & MarkerWithDefault) { -> } + } + } + + def c = T.make() + assert c instanceof Runnable + assert c instanceof MarkerWithDefault + assert c.tag() == 'tag' + ''') + } + + @Test + void 'closure literal intersection is serialisable when intersection includes Serializable'() { + def shell = new GroovyShell() + shell.evaluate(''' + import groovy.transform.CompileStatic + import java.io.ByteArrayOutputStream + import java.io.ByteArrayInputStream + import java.io.ObjectOutputStream + import java.io.ObjectInputStream + + interface MyMarker {} + + @CompileStatic + class T { + static Object make() { + return (Runnable & java.io.Serializable & MyMarker) { -> } + } + } + + def c = T.make() + assert c instanceof java.io.Serializable + assert c instanceof MyMarker + + def baos = new ByteArrayOutputStream() + new ObjectOutputStream(baos).withCloseable { it.writeObject(c) } + def bytes = baos.toByteArray() + def restored = null + new ObjectInputStream(new ByteArrayInputStream(bytes)).withCloseable { + restored = it.readObject() + } + assert restored instanceof Runnable + assert restored instanceof MyMarker + ''') + } +}
