Paul King created GROOVY-12480:
----------------------------------

             Summary: PurityChecker: mutation through a local that aliases a 
parameter or field is not detected, and mutating a value built locally is 
refused when written as a named call
                 Key: GROOVY-12480
                 URL: https://issues.apache.org/jira/browse/GROOVY-12480
             Project: Groovy
          Issue Type: Improvement
          Components: groovy-typecheckers
            Reporter: Paul King


h2. Problem

GROOVY-12464 makes PurityChecker treat mutating operators ({{<<}}, subscript 
stores, GEP-15 {{*Assign}} methods) like the method calls they stand for, but 
only when the receiver is a parameter, a field or property, {{this}}, or a 
variable captured by a checked closure. Locals were left alone so that building 
up a result in a local list, a common idiom in pure code, keeps passing. That 
leaves two inconsistencies, probed with GROOVY-12464 applied:

||Inside a @Pure method||Result||
|{{def t = s; t << v}} with {{s}} a {{List}} parameter|*passes*, although the 
caller's list changes|
|{{def t = items; t << v}} with {{items}} a static field|*passes*, although the 
field's list changes|
|{{def t = s; t.add(v)}}|refused: "call to 't.add()' — purity cannot be 
verified"|
|{{List<Integer> s = []; s << v}}|passes|
|{{List<Integer> s = []; s.add(v)}}|*refused*, although only a list the method 
created changes|
|{{def sb = new StringBuilder(); sb.append(v)}}|*refused*|
|{{def r = xs.collect \{ ... \}; r << 0}}|passes|
|{{def r = xs.collect \{ ... \}; r.add(0)}}|*refused*|

So the operator form misses mutation through an alias, while the named-call 
form refuses mutation that is harmless. Each verdict depends on how the 
mutation is written, not on whose object changes.

h2. Proposed fix

Decide by whether the local is *fresh*: it holds an object the method created, 
and no other reference to that object can exist.

* A pre-pass over each checked method (and each checked closure) marks a local 
as fresh when it is initialised with a fresh value and every later assignment 
to it is also a fresh value, including assignments inside closures and multiple 
assignment.
* Operators and named calls then use the same rule:
** On a fresh local, a mutating operator or an otherwise-unverified call is 
accepted.
** On a local that is not fresh, it is checked as it would be on a parameter, 
so {{def t = s; t << v}} is refused.
* The receiver-type checks are unchanged and still apply to fresh locals: 
logging, metrics, I/O and non-deterministic types report unless allowed, and 
constructing an I/O or non-deterministic type is still reported where it 
happens.

h2. Open questions

# *Which types can be mutated through a fresh local?* Freshness proves the 
object is not shared, not that its methods have no effects outside it: {{def t 
= new Thread(r); t.start()}} is fresh. Proposed: accept unverified calls on a 
fresh local only when its type is a JDK collection or map ({{java.util}}), 
{{StringBuilder}}/{{StringBuffer}}, or an array. Keep the warning for other 
types, unless the callee is {{@Pure}}.
# *Which initialisers are fresh?* Literals ({{[]}}, {{[:]}}, array literals) 
and constructor calls are fresh. If only those count, {{def r = xs.collect \{ 
... \}; r << 0}}, which passes today, would be refused, because a method's 
return value is not known to be fresh. Proposed: also treat as fresh the 
results of the GDK and stream methods that build a new collection without 
changing their receiver ({{collect}}, {{findAll}}, {{toList}}, {{toSorted}}, 
{{toUnique}}, {{collectEntries}}, {{groupBy}} and similar), reusing the 
existing {{CLOSURE_PURE_METHODS}} list where it applies.
# *Reassignment.* Proposed: any reassignment to a value that is not fresh makes 
the local not fresh. A reassignment to a fresh value ({{acc = []}}) keeps it 
fresh. Results of {{+}} on collections are not treated as fresh in a first 
version.

h2. Compatibility

The change goes in both directions: mutation through an alias is newly refused, 
and named calls on fresh locals are newly accepted. With a narrow answer to 
question 2, code that mutates a local initialised from a method call would also 
be newly refused. PurityChecker is {{@Incubating}}; like GROOVY-12464 this 
suits 6.1.0 rather than a 6.0.x release.

h2. Tests

* Refused: {{def t = s; t << v}}, {{t\[0\] = v}} and {{t += v}} (with 
{{plusAssign}}) for a parameter {{s}}; the same through an alias of a field; an 
alias assigned later ({{def t = []; t = s; t << v}}); an alias assigned inside 
a closure.
* Accepted: {{s.add(v)}}, {{s << v}}, {{s\[0\] = v}} on a list literal or {{new 
ArrayList<>()}}; {{sb.append(v)}} on a new {{StringBuilder}}; the same after 
reassigning to a fresh value.
* Depending on question 1: {{t.start()}} on {{new Thread(r)}} still warns.
* Depending on question 2: {{r << 0}} and {{r.add(0)}} on the result of 
{{xs.collect \{ ... \}}}.
* Effect categories still apply to fresh locals, e.g. {{new StringWriter()}} 
followed by a write is judged by the existing I/O rules.
* The docs list "mutation through aliases" as not detected; update them.

h2. Related

Follow-up to GROOVY-12464, whose description suggests this rule.



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

Reply via email to