Paul King created GROOVY-12464:
----------------------------------

             Summary: PurityChecker: mutation through operators (<<, subscript 
assignment, GEP-15 compound-assignment methods such as plusAssign) is not 
detected, while the equivalent method calls are
                 Key: GROOVY-12464
                 URL: https://issues.apache.org/jira/browse/GROOVY-12464
             Project: Groovy
          Issue Type: Bug
          Components: groovy-typecheckers
    Affects Versions: 6.0.0
            Reporter: Paul King


h2. Problem

Inside a {{@Pure}} method, PurityChecker refuses a mutating method call on a 
parameter or field, but accepts the same mutation written as an operator. 
Probed on 6.0.0 with {{@TypeChecked(extensions = 
'groovy.typecheckers.PurityChecker')}}:

||Inside a @Pure method||Result||
|{{s.add(v)}} on a {{List}} parameter|refused: "call to 's.add()' — purity 
cannot be verified"|
|{{s.leftShift(v)}} on a {{List}} parameter|refused|
|{{s << v}} on a {{List}} parameter|*passes*|
|{{sb.append(v)}} on a {{StringBuilder}} parameter|refused|
|{{sb << v}} on a {{StringBuilder}} parameter|*passes*|
|{{s.putAt(0, v)}} on a {{List}} parameter|refused|
|{{s\[0\] = v}} on a {{List}} parameter|*passes*|
|{{m\["k"\] = v}} on a {{Map}} parameter|*passes*|
|{{m.k = v}} on a {{Map}} parameter|refused: "property assignment to 'm.k'"|
|{{s\[0\]++}} on a {{List}} parameter|*passes*|
|{{s\[0\] += v}} on a {{List}} parameter|*passes*|
|{{items.add(v)}} on a static {{List}} field|refused|
|{{items << v}} on a static {{List}} field|*passes*|
|{{items += v}} on a static {{List}} field|refused: "field assignment to 
'items'"|
|{{a.plusAssign(v)}} on a parameter whose class defines {{plusAssign}}|refused|
|{{a += v}} on that parameter (GEP-15: calls {{plusAssign}}, mutating in 
place)|*passes*|
|{{a <<= v}} on a parameter whose class defines {{leftShiftAssign}}|*passes*|
|{{a += v}} with {{@OperatorRename(plusAssign = "addInPlace")}}|*passes*|

Each row marked *passes* changes an object the method does not own: a caller's 
argument or a field. So a method that the checker accepts as {{@Pure}} can 
still have side effects, and anything relying on the annotation, such as 
{{@Memoized}}, parallel evaluation or a reader of the code, is misled.

{code:groovy}
@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker')
class Example {
    @Pure
    static int record(List<Integer> sink, int v) {
        sink << v        // accepted today; sink.add(v) is refused
        v
    }
}
{code}

The GEP-15 rows matter because the same source means different things depending 
on the receiver's type. Since Groovy 6.0, when static type checking resolves a 
{{*Assign}} method ({{plusAssign}}, {{minusAssign}}, {{leftShiftAssign}} and 
the rest), {{a += v}} calls it and mutates {{a}} in place. A probe confirmed 
that the caller's object changes. Only when no {{*Assign}} method exists does 
{{a += v}} mean {{a = a.plus(v)}}, which merely rebinds the local variable.

Not affected, and correct as is:
* {{s += v}} on a {{List}} parameter: the GDK defines no {{*Assign}} methods, 
so this builds a new list and rebinds the local parameter variable, and the 
caller's list is unchanged. If a {{*Assign}} extension for {{List}} were added 
later, the fix below would cover it automatically.
* Pure operators such as {{a + b}} on {{BigDecimal}} parameters.

h2. Cause

{{PurityChecker}}'s visitor only looks at a {{BinaryExpression}} when its 
operator is an assignment, and then only to report a write to a field or a 
property ({{checkFieldWrite}}).
* A subscript target ({{s\[0\] = v}}) is neither a {{PropertyExpression}} nor a 
{{VariableExpression}}, so nothing is reported. The postfix and prefix visitors 
have the same gap for {{s\[0\]++}}.
* {{<<}} is not an assignment, so the expression is ignored, even though static 
type checking resolves it to {{leftShift}}. Operator forms never reach 
{{checkInstanceCall}}, which is what refuses the named calls.
* For a compound assignment whose target is a parameter or local variable, 
{{checkFieldWrite}} finds no field, so nothing is reported, even when static 
type checking has recorded a {{*Assign}} target 
({{StaticTypesMarker.COMPOUND_ASSIGN_TARGET}}) and the code will mutate the 
receiver in place. For a field or property target the existing "field 
assignment" message fires, which is the right outcome even though the wording 
describes a reassignment.

h2. Proposed fix

Treat the mutating operator forms like the method calls they stand for:
* {{a << b}}: check it as the call {{a.leftShift(b)}}, using the target static 
type checking resolved for the expression.
* Subscript assignment {{a\[i\] = v}}, compound subscript assignment ({{a\[i\] 
+= v}} and similar) and subscript {{++}}/{{--}}: check them as {{a.putAt(i, 
...)}} on the receiver {{a}}.
* A compound assignment carrying {{COMPOUND_ASSIGN_TARGET}} metadata: check it 
as a call to the recorded method on the receiver. That covers every GEP-15 
operator, extension-method {{*Assign}} implementations and {{@OperatorRename}} 
without listing names.

Other operators ({{+}}, {{-}}, comparisons and so on) stay as they are, so this 
adds no new reports for pure arithmetic.

h3. Local variables

Today {{s << v}}, {{s\[0\] = v}} and a GEP-15 {{a += v}} pass on an object 
created inside the method, while {{s.add(v)}} on the same local list is 
refused. Building a result in a local list is a common idiom in pure code. 
Applying the fix to locals would make it fail, so the minimal fix applies only 
when the receiver is a parameter, a field, a property or {{this}}, and leaves 
locals as they are.

That leaves one gap: a local that aliases a parameter ({{def t = s; t << v}}). 
Closing it properly needs a freshness rule, accepting mutation only of locals 
initialised with a literal or a constructor call and never reassigned. That 
rule should then also apply to the named calls, so {{s.add(v)}} on a fresh 
local list would be accepted too. It is suggested as a follow-up rather than 
part of this fix.

h2. Compatibility

The fix reports problems in code that compiles today, so builds using 
PurityChecker may start failing. PurityChecker is {{@Incubating}}, but because 
builds can newly fail, 6.1.0 seems the right target rather than a 6.0.x patch 
release.

h2. Tests

* Each *passes* row in the table above is refused after the fix, with a message 
naming the operator and its receiver.
* The refused rows stay refused, and {{s += v}} on a list parameter and 
{{BigDecimal}} arithmetic still pass.
* The local-list forms ({{s << v}}, {{s\[0\] = v}} on a list created in the 
method) still pass.
* GEP-15: {{a += v}} and {{a <<= v}} on a parameter whose class defines 
{{plusAssign}}/{{leftShiftAssign}} are refused, including with 
{{@OperatorRename}}; {{a += v}} on a type without {{*Assign}} still passes.
* The same cases with {{@Pure}} graded to allow some effects behave 
consistently with the named-call forms.




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to