James Daugherty created GROOVY-12476:
----------------------------------------
Summary: STC: toString() on an interface-typed receiver selects
DGM toString(Object) over the runtime override (regression from 4.x)
Key: GROOVY-12476
URL: https://issues.apache.org/jira/browse/GROOVY-12476
Project: Groovy
Issue Type: Bug
Components: Static compilation, Static Type Checker
Affects Versions: 5.0.3, 5.0.8, 5.1.3, 6.0.0
Environment: OpenJDK 25.0.4 (25.0.4+9-LTS), macOS. Groovy 4.0.33 is
unaffected; 5.0.3, 5.0.8, 5.1.3, 6.0.0-RC-3 and 6.0.0 reproduce.
Reporter: James Daugherty
Attachments: InterfaceToString.groovy
h3. Summary
Under {{@CompileStatic}} (and {{@TypeChecked}}), a call to {{toString()}} on a
receiver whose *static type is an interface* ({{List}}, {{Map}}, or any
user-defined interface) now resolves to the extension method
{{DefaultGroovyMethods#toString(Object)}} instead of
{{java.lang.Object#toString()}}. The extension renders through
{{InvokerHelper.format}}, which formats any {{Collection}} or {{Map}} itself
and never calls the runtime class's {{toString()}} override.
Groovy 4.0.33 resolved the same call to {{Object#toString()}} and emitted a
virtual call, which reached the override. Dynamic dispatch selects the override
in every version, so adding {{@CompileStatic}} now changes a program's output.
Nothing fails at compile time.
h3. Reproducer
{code:groovy}
import groovy.transform.CompileStatic
interface Element {}
class JsonList extends ArrayList<Object> implements Element {
@Override
String toString() { '[{"name":"name","value":"x"}]' }
}
@CompileStatic
class Check {
static String viaInterface(Element e) { e.toString() }
static String viaList(List e) { e.toString() }
static String viaClass(JsonList e) { e.toString() }
static String viaObject(Object e) { e.toString() }
}
JsonList list = new JsonList()
list << [name: 'name', value: 'x']
println "Groovy ${GroovySystem.version}"
println "static, receiver typed Element : ${Check.viaInterface(list)}"
println "static, receiver typed List : ${Check.viaList(list)}"
println "static, receiver typed JsonList: ${Check.viaClass(list)}"
println "static, receiver typed Object : ${Check.viaObject(list)}"
println "dynamic : ${list.toString()}"
{code}
Groovy 4.0.33:
{noformat}
static, receiver typed Element : [{"name":"name","value":"x"}]
static, receiver typed List : [{"name":"name","value":"x"}]
static, receiver typed JsonList: [{"name":"name","value":"x"}]
static, receiver typed Object : [[name:name, value:x]]
dynamic : [{"name":"name","value":"x"}]
{noformat}
Groovy 5.0.3, 5.0.8, 5.1.3, 6.0.0-RC-3 and 6.0.0 (identical output):
{noformat}
static, receiver typed Element : [[name:name, value:x]]
static, receiver typed List : [[name:name, value:x]]
static, receiver typed JsonList: [{"name":"name","value":"x"}]
static, receiver typed Object : [[name:name, value:x]]
dynamic : [{"name":"name","value":"x"}]
{noformat}
Run with {{java -cp groovy-<version>.jar groovy.ui.GroovyMain
InterfaceToString.groovy}} on OpenJDK 25.0.4.
h3. Analysis
Both versions collect the same two candidates for the call:
{{java.lang.Object#toString()}}, which
{{StaticTypeCheckingVisitor#findMethodsWithGenerated}} adds for interface
receivers, and the extension {{DefaultGroovyMethods#toString(Object)}}. They
rank them differently:
* 4.0.33 {{StaticTypeCheckingSupport#chooseBestMethod}} computes one summed
distance per candidate: the parameter/argument distance (for an extension
method the receiver is prepended as the self argument), plus the
declaring-class distance, plus the extension bonus. The self-argument distance
from the interface to {{Object}} outweighs the bonus, so {{Object#toString()}}
wins.
* 5.x and 6.x {{StaticTypeCheckingSupport#chooseBestMethods}} (introduced by
the GROOVY-8788 fix, commit 14946ff27a60d74b5d07c1b9a63f02624283c863 in
5.0.0-alpha-1, which also removed {{getExtensionDistance}}) scores in phases.
Phase 1 compares declared parameters only, so the extension's self parameter no
longer counts and both candidates score 0. Phase 2 compares declaring class to
receiver; both declare in {{Object}}, so they tie again. Phase 3 prefers the
extension method on a tie (the GROOVY-6849 rule), so the extension wins for
every interface-typed receiver.
A receiver typed as a class ({{JsonList}}, {{ArrayList}}) is unaffected because
the class's own {{toString()}} has declaring-class distance 0 and wins phase 2.
An {{Object}}-typed receiver already resolved to the extension in 4.x. The
regression is that interface types, which per JLS 9.2 implicitly declare the
public methods of {{Object}}, are now scored like {{Object}} instead of like a
class, so {{toString()}} through an interface-typed variable, parameter or
return type no longer reaches the override.
h3. Where it bit
Grails 8 (Groovy 5.1.3): a statically compiled service normalized JSON with
{{grails.converters.JSON.parse(text).toString()}}. {{JSON.parse}} returns the
interface {{JSONElement}}; the runtime {{JSONArray}} overrides {{toString()}}
to render JSON. Under Groovy 5 the stored text silently became Groovy list
notation ({{[[name:name, value:x]]}}) and failed to parse later. The same
expression is correct in 4.0.33 and in dynamic Groovy.
h3. Expected
{{toString()}} on an interface-typed receiver should compile to a virtual
{{Object#toString()}} call, as 4.0.33 did and as dynamic Groovy does, or at
least agree with dynamic dispatch for a class that declares its own
{{toString()}}.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)