GEP-15
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) andx += 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 currentplus+ reassign pattern. -
Maintain full backward compatibility: existing code that uses
+=withplus()must continue to work identically when noplusAssignmethod exists. -
Support
+=onfinalfields/variables whenplusAssignis available and the compiler can resolve it at compile time (i.e. under@CompileStaticor@TypeChecked— see Resolution algorithm). -
Work correctly in both
@CompileStaticand dynamic Groovy.
Non-goals
-
Changing the behavior of
++and--operators (these usenext()/previous()and are conceptually different). -
Changing subscript compound assignment (
a[i] += b), which uses the existinggetAt/putAtpattern. A future GEP might explore a two-argumentplusAssign(key, value)convention wherea[key] += bmaps toa.plusAssign(key, b), allowing containers to handle compound updates atomically. Multi-dimensional cases likea[i][j] += bwould resolve naturally by peeling subscripts from the left: evaluatea.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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Resolution algorithm
When the compiler encounters x += y:
-
Look for a method
plusAssign(y)on the type ofx. -
If found, call
x.plusAssign(y)directly. No reassignment occurs. This works even whenxisfinal. -
If not found, fall back to the current behavior:
x = x.plus(y). This requiresxto be reassignable. -
If
xisfinaland noplusAssignexists, report a compile error (in@CompileStaticmode).
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 += 1continues to use the existing fast-path for primitive arithmetic. TheplusAssignlookup only applies when the base operator would resolve to a method call. -
Expression value. When
plusAssignis called, the expression value ofx += yisx(the mutated object), not the return value ofplusAssign. This differs from the current behavior where the expression value is the result ofx.plus(y). The contract holds regardless of how the*Assignmethod is reached — instance method, extension method, category method, or@OperatorRename(plusAssign=…)redirection — so a user-renamedaddInPlacemethod that returnsvoidstill leavesxvisible as the value of the surrounding expression. -
Property setter is not invoked. When
obj.prop += yresolves to aAssignonprop’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 `*Assignmethod instead. -
Extension methods.
plusAssignis discoverable as an extension method (DGM or category), not just as an instance method. -
@OperatorRenamesupport. The existing@OperatorRenameannotation 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
-
Existing
plusAssignmethods. If a class already defines a method literally namedplusAssign,+=will now call it instead of desugaring toplus+ reassign. This is unlikely in practice but must be documented in release notes. -
Expression value change.
val = (x += y)— withplusAssign, the captured value isx(the mutated object), not the result ofplus. This only affects code that uses compound assignment as an expression. -
Final fields. In
@CompileStaticand@TypeChecked,finalfields with a type that hasplusAssigncan now use+=. Previously this was always an error. (Pure dynamic mode is unchanged — still an error — per the carve-out in Resolution algorithm.) -
Property setter not invoked. For
obj.prop op= ywhere the property type has a*Assignmethod, the setter is no longer called (only the getter runs, then*Assignmutates 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*Assignmethod or define*Assignon the container type rather than the property’s type.
References and useful links
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.