[
https://issues.apache.org/jira/browse/GROOVY-12063?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18088445#comment-18088445
]
Paul King commented on GROOVY-12063:
------------------------------------
_Some findings from digging into this, in case they're useful for deciding
disposition - some other issues touching similar spots in the codebase are
mentioned._
h4. It's a compile-time change, not a runtime/MOP one
Isolating compile vs. runtime with a cross matrix (compile the reporter's
example with one
version, run on another) shows the behaviour follows the {*}bytecode{*}, not
the runtime:
||compiled by||run on 4.0.27||run on 5.0.6||
|4.0.27|expected|expected|
|5.0.6|null|null|
The emitted access differs:
* 4.0.x: {{ScriptBytecodeAdapter.getField(Outer$1, Outer.class, "NAME")}} ->
reads the enclosing static field
* 5.0.x: {{getProperty(this)}} on the inner class -> for a {{Map}} that's an
entry lookup -> {{null}}
So the {{MetaClassImpl}} map-property-precedence work and GROOVY-11823 are not
involved here
(GROOVY-11823 isn't even in 5.0.6 — the {{this$dist$}} forwarders are still
present).
h4. Root cause
The change is the removal of {{AsmClassGenerator.checkStaticOuterField}} in
GROOVY-10985 / GROOVY-7024 ("self property precedence"), commit
{{{}1ea6927ad9c6ec7e05266e1385e3787866c888ad{}}}, first shipped in
{*}5.0.0-alpha-1{*}. That method
used to rewrite an unqualified name in an inner class to a static-field access
on the
enclosing class. With it gone, the name falls through to an
implicit-{{{}this{}}} property read.
(The "regression in 5.0.5" in the description is just where it was first
noticed — it's
actually present from the first 5.0 alpha.)
h4. Not Map-specific
The removed logic was general; {{Map}} just makes it silent. An {{ArrayList}}
AIC misbehaves too:
{code:groovy}
class Outer {
static final String VALUE = 'expected'
static final Object OBJ = new ArrayList() { { add(VALUE) } }
}
// 4.0.27 -> [expected] 5.0.x -> [[]] (VALUE = list.VALUE spreads over the
empty list)
{code}
Plain inner classes still resolve the outer field, but only because their
{{getProperty}}
throws {{{}MissingProperty{}}}, which the runtime {{propertyMissing ->
this$dist$get}} dispatch
then rescues. {{Map}} (entry -> null) and {{List}} (spread -> []) are exactly
the receivers
whose {{getProperty}} {_}succeeds{_}, so the fallback never fires and the
enclosing member is
silently shadowed.
h4. On the suggested workarounds
Qualifying ({{{}Outer.NAME{}}}) reliably works. However, {{@CompileStatic}} did
*not* resolve
this anonymous-inner-class-initializer shape in my testing — both 5.0.6 and
6.0.0-alpha-1
still produced {{['k':null]}} under {{{}@CompileStatic{}}}:
{code:groovy}
@CompileStatic
class Outer {
static final String NAME = 'expected'
static Map<String, String> m = new HashMap<String, String>() { { put('k',
NAME) } }
}
assert Outer.m['k'] == 'expected' // fails: actual is null
{code}
So {{@CompileStatic}} may not be a dependable suggestion for this pattern.
h4. Summary
This is defensible as the intended self-property-precedence rule from
GROOVY-10985, and
qualification is a clean fix. The debatable part is that for a
{{{}Map{}}}/{{{}List{}}} receiver the
rule produces a *silent {{null}}* (vs. the 3.x/4.x field read, and vs. a hard
error), which is
what bit Grails. Raising it here so the team can decide whether the
silent-shadow case
warrants a diagnostic or special-casing, rather than treating it purely as
documentation.
> Anonymous inner class extending Map: enclosing-class field references in the
> instance initializer compile to dynamic getProperty on 'this' and silently
> resolve to null - regression in 5.0.x
> ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
>
> Key: GROOVY-12063
> URL: https://issues.apache.org/jira/browse/GROOVY-12063
> Project: Groovy
> Issue Type: Bug
> Components: class generator, Compiler
> Affects Versions: 6.0.0-alpha-1, 5.0.5, 5.0.6, 5.0.7
> Environment: JDK 21; reproduces with and without @CompileStatic, and
> in both indy and classic codegen
> Reporter: James Fredley
> Priority: Major
>
> h2. Summary
> In an anonymous inner class that extends {{java.util.Map}} (e.g.
> {{HashMap}}), bareword references to the *enclosing class's fields* inside
> the AIC instance-initializer block are no longer resolved to those fields. As
> of Groovy 5.0.x they compile to dynamic property reads on {{this}}
> ({{this.getProperty("FIELD")}}); because {{this}} is a {{Map}}, the MOP turns
> that into a key lookup {{this.get("FIELD")}} on the still-empty map, so the
> reference silently evaluates to {{null}}. This is a regression: 3.0.x and
> 4.0.x resolve it correctly. The {{Map}} base class makes it fail *silently* -
> a non-Map AIC would instead throw {{MissingPropertyException}}.
> h2. Steps to reproduce
> Single self-contained script:
> {code:groovy}
> class Holder {
> static final String VALUE = 'expected'
> static final Map<String, String> MAP = new HashMap<String, String>() {
> {
> put('literalKey', VALUE) // 'VALUE' is the enclosing class's
> static field
> }
> }
> }
> println "Groovy ${GroovySystem.version} -> ${Holder.MAP}"
> assert Holder.MAP.get('literalKey') == 'expected'
> {code}
> h2. Actual result (Groovy 5.0.5 / 5.0.6 / 5.0.7-SNAPSHOT / 6.0.0-alpha-1)
> {noformat}
> Groovy 5.0.7-SNAPSHOT -> [literalKey:null]
> Assertion failed:
> assert Holder.MAP.get('literalKey') == 'expected'
> | | | |
> | | null false
> {noformat}
> h2. Expected result (as on Groovy 3.0.24 / 4.0.32)
> {noformat}
> Groovy 4.0.32 -> [literalKey:expected]
> (assertion passes)
> {noformat}
> h2. Affected versions
> || Groovy || Holder.MAP || Result ||
> | 3.0.24 | {{[literalKey:expected]}} | correct |
> | 4.0.32 | {{[literalKey:expected]}} | correct |
> | 5.0.5 | {{[literalKey:null]}} | broken |
> | 5.0.6 | {{[literalKey:null]}} | broken |
> | 5.0.7-SNAPSHOT | {{[literalKey:null]}} | broken |
> | 6.0.0-alpha-1 | {{[literalKey:null]}} | broken |
> Notes: reproduces for a plain class, a {{@CompileStatic}} class, and a
> {{@CompileStatic}} interface - so it is not specific to {{@CompileStatic}}.
> The literal key survives (an {{LDC}} constant); only the bareword field
> reference degrades to {{null}}. If both key and value are field references,
> the map collapses to {{[null:null]}}.
> h2. Bytecode evidence
> {{Holder$1.<init>}} (the anonymous HashMap initializer), {{javap -c}}, Groovy
> 5.0.7-SNAPSHOT:
> {noformat}
> 13: aload_0 // receiver = this (the
> HashMap)
> 14: ldc "literalKey" // key: literal ->
> correct
> 16: aload_0
> 17: invokedynamic #0:getProperty:(LHolder$1;) // value:
> this.getProperty("VALUE") -> BUG
> 22: invokedynamic #1:invoke:(...) // this.put(key, value),
> dynamic
> 28: return
> {noformat}
> The BootstrapMethods table confirms the call-site name is the source
> identifier ({{IndyInterface.bootstrap(..., "VALUE", 12)}}). The enclosing
> {{<clinit>}} is correct - {{VALUE}} is assigned before {{new Holder$1()}}
> runs - so the defect is purely the AIC initializer compiling the bareword as
> a dynamic read on {{this}} instead of {{GETSTATIC}}. At runtime
> {{MetaClassImpl.getProperty}} special-cases {{Map}} receivers as {{((Map)
> this).get(name)}}; the map is empty during its own initializer, so the read
> returns {{null}}. The JDK is not involved - the emitted bytecode is wrong.
> h2. Root cause
> * The bareword {{VALUE}} inside the AIC initializer is resolved as a dynamic
> property read on {{this}} ({{invokedynamic getProperty}}) rather than a
> {{GETSTATIC}} of the enclosing class's field.
> * Because the AIC extends {{Map}}, Groovy's MOP turns that property read into
> a map-key lookup {{this.get("VALUE")}}; the map is empty while its own
> initializer runs, so it returns {{null}} instead of failing fast.
> * Groovy 3.0.x / 4.0.x resolve the bareword to the enclosing-class field
> correctly.
> h2. Real-world impact
> Apache Grails {{grails.gorm.validation.ConstrainedProperty}} builds its
> default-messages map with exactly this idiom ({{new HashMap<String,String>()
> { { put(CODE, MESSAGE) } }}}); under Groovy 5 the map ends up effectively
> empty ({{[null:null]}}), so default validation messages fail to resolve
> (apache/grails-core PR #15557).
> h2. Workarounds
> * Avoid the anonymous-Map-with-instance-initializer idiom; use
> {{Map.of(...)}}, a literal map, or a statically compiled static block.
> * Or fully qualify the references: {{put('literalKey', Holder.VALUE)}}.
> h2. Suggested fix
> Resolve bareword identifiers inside an AIC instance-initializer against the
> lexical scope (enclosing-class members) before falling back to a dynamic
> property read on {{this}}, regardless of whether {{this}} is a {{Map}} - i.e.
> restore 4.0.x name-resolution for AIC initializer blocks.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)