GEP-15


Metadata
Number

GEP-15

Title

Compound assignment operator overloading

Version

2

Type

Feature

Status

Draft

Leader

Paul King

Created

2026-04-12

Last modification

2026-04-28

Abstract

Groovy supports operator overloading: + maps to plus(), - maps to minus(), << maps to leftShift(), and so on. However, compound assignment operators (+=, -=, <<=, etc.) are always desugared to x = x.op(y) — there is no way to override += independently of +. This GEP proposes adding support for dedicated compound assignment methods such as plusAssign, minusAssign, leftShiftAssign, etc.

Motivation

The current desugaring of x += y to x = x.plus(y) creates a new object and reassigns the variable. This has several drawbacks:

  • Mutable data structures are forced into a create-and-reassign pattern when in-place mutation is the intended semantics. For example, a mutable list’s += creates a new list rather than appending in place.

  • Final fields and variables cannot use compound assignment at all, even when the underlying object is mutable and supports in-place mutation.

  • Intent is unclear: the class author cannot distinguish between x + y (produce a new value) and x += y (mutate in place).

Languages like Kotlin and Scala already support this distinction. Kotlin maps += to plusAssign() when available, falling back to plus() with reassignment. Scala allows mutable collections to define += directly.

Requirements

  • Support dedicated compound assignment methods (plusAssign, minusAssign, etc.) that are called in preference to the current plus + reassign pattern.

  • Maintain full backward compatibility: existing code that uses += with plus() must continue to work identically when no plusAssign method exists.

  • Support += on final fields/variables when plusAssign is available and the compiler can resolve it at compile time (i.e. under @CompileStatic or @TypeChecked — see Resolution algorithm).

  • Work correctly in both @CompileStatic and dynamic Groovy.

Non-goals

  • Changing the behavior of ++ and -- operators (these use next()/previous() and are conceptually different).

  • Changing subscript compound assignment (a[i] += b), which uses the existing getAt/putAt pattern. A future GEP might explore a two-argument plusAssign(key, value) convention where a[key] += b maps to a.plusAssign(key, b), allowing containers to handle compound updates atomically. Multi-dimensional cases like a[i][j] += b would resolve naturally by peeling subscripts from the left: evaluate a.getAt(i) to get the inner container, then apply the single-dimension rule to that result.

Operator method name mapping

Operator Assign method Fallback method

+=

plusAssign

plus

-=

minusAssign

minus

*=

multiplyAssign

multiply

/=

divAssign

div

%=

remainderAssign

remainder

**=

powerAssign

power

<<=

leftShiftAssign

leftShift

>>=

rightShiftAssign

rightShift

>>>=

rightShiftUnsignedAssign

rightShiftUnsigned

&=

andAssign

and

|=

orAssign

or

^=

xorAssign

xor

Resolution algorithm

When the compiler encounters x += y:

  1. Look for a method plusAssign(y) on the type of x.

  2. If found, call x.plusAssign(y) directly. No reassignment occurs. This works even when x is final.

  3. If not found, fall back to the current behavior: x = x.plus(y). This requires x to be reassignable.

  4. If x is final and no plusAssign exists, report a compile error (in @CompileStatic mode).

The final relaxation in step 2 requires the compiler to resolve the Assign method *at compile time. Under @CompileStatic and @TypeChecked the resolution happens during type-checking and the relaxation applies. In purely dynamic compilation the dispatch is deferred to runtime, so the JVM-level final-reassignment check cannot be safely lifted; final + compound-assign continues to be a compile-time error in that case even when a plusAssign method would have responded at runtime. This is an asymmetry between the two modes and is intentional: the alternative would require the compiler to emit conditional bytecode that, in the runtime-dispatched fallback branch, would violate JVM rules for final fields.

When both plusAssign and plus exist on the same type, plusAssign takes precedence. Languages like Kotlin apply an ambiguity resolution rule where the selection between plusAssign and plus depends on the mutability of the receiver (val vs var). This is not typical for Groovy method selection. If a class author defined plusAssign, they intend it to be used for +=.

Design considerations

  • Primitives are unaffected. int x = 0; x += 1 continues to use the existing fast-path for primitive arithmetic. The plusAssign lookup only applies when the base operator would resolve to a method call.

  • Expression value. When plusAssign is called, the expression value of x += y is x (the mutated object), not the return value of plusAssign. This differs from the current behavior where the expression value is the result of x.plus(y). The contract holds regardless of how the *Assign method is reached — instance method, extension method, category method, or @OperatorRename(plusAssign=…) redirection — so a user-renamed addInPlace method that returns void still leaves x visible as the value of the surrounding expression.

  • Property setter is not invoked. When obj.prop += y resolves to a Assign on prop’s type, the getter runs once and the setter is *skipped; the receiver’s mutation is observed via `prop’s state, not via setter dispatch. Frameworks that wrap setters for change-tracking (e.g. observable beans, dirty-checking ORMs) need to hook the `*Assign method instead.

  • Extension methods. plusAssign is discoverable as an extension method (DGM or category), not just as an instance method.

  • @OperatorRename support. The existing @OperatorRename annotation will be extended to support renaming the assign variants, e.g. @OperatorRename(plusAssign="addInPlace"). The renamed method is authoritative for compound assignment (no fallback to the base operator rename), and the expression-value contract above continues to apply.

Examples

A mutable accumulator:

class Accumulator {
    int total = 0
    void plusAssign(int n) { total += n }
    Accumulator plus(int n) { new Accumulator(total: total + n) }
}

def acc = new Accumulator()
acc += 5          // calls acc.plusAssign(5), mutates in place
assert acc.total == 5

def acc2 = acc + 3 // calls acc.plus(3), returns new Accumulator
assert acc2.total == 8
assert acc.total == 5

A final field with in-place mutation:

@CompileStatic
class EventBus {
    final List<String> listeners = []
    // List has leftShiftAssign via extension method or subclass
}

def bus = new EventBus()
bus.listeners <<= "listener1"  // calls listeners.leftShiftAssign("listener1")

Static compilation (@CompileStatic)

In @CompileStatic mode, the type checker resolves plusAssign at compile time using findMethod(). If found, it records the target method on the expression via node metadata and the bytecode generator emits a direct method call with no reassignment to the variable.

If not found, the existing desugaring to x = x.plus(y) applies.

Dynamic Groovy

In dynamic Groovy, the runtime checks for plusAssign via the Meta-Object Protocol (MOP). If respondsTo(target, "plusAssign", arg) succeeds, it is called. Otherwise, the fallback to plus with reassignment applies.

This requires a new runtime helper method (e.g., in ScriptBytecodeAdapter) that encapsulates the try-assign-then-fallback logic.

Breaking behavior

  1. Existing plusAssign methods. If a class already defines a method literally named plusAssign, += will now call it instead of desugaring to plus + reassign. This is unlikely in practice but must be documented in release notes.

  2. Expression value change. val = (x += y) — with plusAssign, the captured value is x (the mutated object), not the result of plus. This only affects code that uses compound assignment as an expression.

  3. Final fields. In @CompileStatic and @TypeChecked, final fields with a type that has plusAssign can now use +=. Previously this was always an error. (Pure dynamic mode is unchanged — still an error — per the carve-out in Resolution algorithm.)

  4. Property setter not invoked. For obj.prop op= y where the property type has a *Assign method, the setter is no longer called (only the getter runs, then *Assign mutates the result). Frameworks that observe writes via setter interception (observable beans, GORM-style dirty tracking, JavaFX property bindings) must move the observation point to the *Assign method or define *Assign on the container type rather than the property’s type.

Reference implementation

TBD

JIRA issues

TBD

Update history

1 (2026-04-12) Initial draft

2 (2026-04-28) Clarifications surfaced during reference implementation: final-relaxation is @CompileStatic/@TypeChecked-only; expression-value contract applies to @OperatorRename(*Assign=…) paths; property-setter suppression added to design considerations and breaking behaviour.