For as long as C# has supported operator overloading, a += b on a custom type has quietly meant "build a whole new value and reassign it." C# 14 finally lets a type say otherwise.
Continuing this Part's tour of C# 14 additions — partial constructors (329) closed a gap for source generators. This lesson is unrelated to generators entirely; it's about performance, and specifically about what actually happens when your code writes a += b on a type you defined yourself.
Lesson 011 introduced operator overloading — defining what +, ==, and similar operators mean for your own types. What lesson 011 didn't cover, because it wasn't true until C# 14, is that a type can now also define its own dedicated behavior for a compound assignment operator like += — one that mutates an existing instance directly, instead of always being sugar for "compute a new value with operator+, then reassign it."
Before C# 14, writing a += b on your own type always meant exactly the same thing as writing a = a + b — the compiler expanded it for you, every time, whether or not that was the cheapest way to get the job done. C# 14 lets a type define its own += directly, as an instance operator that updates a in place — no new value constructed, no reassignment.
C# 14 introduces user-defined compound assignment operators — instance operator declarations (no static, returning void) that give a type explicit control over what a compound assignment like +=, -=, *=, or similar actually does, separately from the type's ordinary binary operator+ overload. When a type defines an instance operator +=, the compiler calls it directly on assignment; when it doesn't, a += b continues to desugar to a = a + b exactly as it always has — this is a fully backward-compatible, opt-in addition.
The old desugaring rule is simple, predictable, and exactly the problem: a += b reconstructing a brand-new value every single time, even in a hot loop where a is a mutable struct or a reference type you fully control and could safely update in place. For a large mutable struct, that means the whole struct gets rebuilt and copied back on every iteration — real, avoidable work — instead of just the one or two fields that actually changed. For a mutable reference type, it's worse: a = a + b means operator+ allocates a brand-new object on the heap, every single time, purely so it can be immediately reassigned over the old one — exactly the kind of avoidable allocation lesson 174's memory allocation coverage and lesson 231's zero-allocation programming techniques spend real effort eliminating elsewhere.
C# 14 gives the type author an escape hatch: define an instance operator += and the compiler calls it directly, in place, whenever it can prove the target is genuinely being compound-assigned (not just read). The type's author — the one person who actually knows whether "update in place" is safe and correct for this type — gets to opt in specifically where it pays off, while every type that doesn't define one keeps behaving exactly as it always has.
The two operators can coexist on the same type, each doing what's cheapest for its own situation — a plain a + b genuinely needs a new value (the original a has to remain unchanged), while a += b was always going to end with a holding the new result anyway, so mutating it directly loses nothing.
a's own state is updated, and no reassignment of the variable a even occurs — there was never a new object to assign.a += b is treated exactly as a = a + b, using the type's ordinary static operator+ — unchanged from every version of C# before this one.a += b meaning the same thing regardless of which path ran — the compound operator is an optimization of HOW the result is reached, never a license to compute something different.public struct Accumulator
{
public int Total { get; private set; }
// The ordinary operator, from lesson 011 — static, returns a NEW value
public static Accumulator operator +(Accumulator left, int amount)
=> new Accumulator { Total = left.Total + amount };
// C# 14 — instance compound-assignment operator: mutates THIS in place
public void operator +=(int amount)
{
Total += amount;
}
}
var acc = new Accumulator();
var next = acc + 5; // uses operator+ — builds a NEW Accumulator, acc is untouched
acc += 5; // uses operator+= — mutates acc.Total directly, no new instance
Meaning: acc + 5 and acc += 5 both conceptually "add 5," but they now take genuinely different paths to get there. The first has to build a new Accumulator because acc is expected to remain unchanged. The second was always going to end with acc holding the new total anyway — C# 14 lets that be a direct mutation instead of a full reconstruction.
Picture a running-statistics type updated potentially millions of times inside a tight loop — a game's per-frame physics accumulator, or a metrics counter processing a high-volume stream. This is exactly the kind of hot-path scenario lesson 231's zero-allocation programming coverage cares about, and it's where the old desugaring rule stops being a minor inefficiency and starts being a measurable one:
public struct RunningStats
{
public double Sum { get; private set; }
public long Count { get; private set; }
public readonly double Average => Count == 0 ? 0 : Sum / Count;
public static RunningStats operator +(RunningStats left, double sample)
=> new RunningStats { Sum = left.Sum + sample, Count = left.Count + 1 };
// C# 14 — update the two fields directly; no new RunningStats value
// has to be constructed and copied back over "this" on every sample
public void operator +=(double sample)
{
Sum += sample;
Count += 1;
}
}
var stats = new RunningStats();
foreach (var sample in incomingSensorReadings) // potentially millions of samples
{
stats += sample; // in-place field updates, every iteration
}
Console.WriteLine($"Average: {stats.Average}");
For a struct like this, the old a = a + b path meant constructing an entire new RunningStats value and copying it back over stats on every single sample — real, repeated work scaling with every field the struct carries. The instance operator += updates exactly the two fields that changed and nothing else. The effect is even more direct for a mutable reference type doing the equivalent job: without a defined operator +=, stats = stats + sample would mean operator+ allocating a brand-new object on the managed heap on every single iteration, purely to be thrown away and replaced a moment later — exactly the churn lesson 174's coverage of memory allocation warns against in a hot path.
Imagine that every time you wanted to repaint one wall of your house a slightly different color, the only process available was: demolish the entire house, build an identical new one next door with the new wall color, then move everything you own into the new house and demolish the old one. That's absurd for a single wall — but it's exactly what a = a + b asks a type to do on every compound assignment: reconstruct the whole value from scratch, just to change what might be one field. A user-defined operator += is the sane alternative: send a painter to repaint just that one wall, in the house that's already standing. The house you end up with looks identical either way — the difference is entirely in how much unnecessary demolition and rebuilding happened to get there.
A user-defined compound assignment operator is declared as an instance member — no static keyword, unlike lesson 011's binary operator+ — and returns void. It operates on this implicitly, the same way any other ordinary instance method does; there's no return value to reassign because nothing new was ever constructed to assign back. The compiler only routes a compound assignment expression through this operator when it can confirm the left-hand side is a genuine, mutable variable being assigned to — not a read-only expression, and not a value it can't safely mutate in place.
This also explains why the feature naturally has no effect on a readonly struct: a readonly struct's instance members already can't mutate this under any circumstances, by the same rule that's applied to every other instance method on a readonly struct — nothing new to learn there, the existing readonly rule simply continues to apply. And because operator+ and operator += are entirely separate declarations, defining one never implicitly defines or changes the other — a type can have a binary operator+ with no compound counterpart (falling back to the old desugaring), a compound operator with no binary counterpart (making plain a + b unavailable), or both together, each doing its own job.
A well-designed compound assignment operator should always produce the same conceptual result as the old a = a + b desugaring would have — this feature is about avoiding unnecessary reconstruction, not about giving += a different meaning than + implies. Making the two diverge in behavior would violate the same operator-overloading intuition lesson 011 already asked you to respect for ordinary operators.
Plain a + b (where a must remain unchanged) still needs the static, value-returning operator+. Defining only operator += speeds up compound assignment specifically — it doesn't give you the ability to write a + b as a standalone expression at all.
Implementing operator += with different rounding, clamping, or validation logic than operator+ uses, so a += b and a = a + b silently produce different results on the same type. Keep both operators computing the same conceptual result — the compound operator should only change the mechanism (mutate vs. reconstruct), never the outcome.
Adding a custom operator += to an ordinary business/DTO type used a handful of times per request, purely because it seems like a modern best practice. This feature earns its complexity in measurably hot loops — the kind lesson 231's zero-allocation programming techniques already target. For code that isn't allocation- or copy-sensitive, the plain desugared behavior is simpler and every bit as correct.
Trying to define a mutating operator += on a readonly struct and being surprised it can't mutate any state. A readonly struct's instance members can never mutate this, for the same reason any other instance method on one can't — this feature doesn't create an exception to that existing rule.
You've seen how a type can now take control of what += actually does under the hood. Let's confirm it clicked.
1. Before C# 14, what did a += b always mean for a custom type?
Correct: B
Why B is correct: This is exactly the old, universal rule the lesson opened with — compound assignment on a custom type was always sugar for the binary operator plus reassignment, with no way for the type to intervene.
Why A is incorrect: No dedicated compound operator was needed or even possible before C# 14 — the desugaring to a = a + b handled it automatically as long as operator+ existed.
Why C is incorrect: The opposite was true — the old desugaring meant constructing a new value and reassigning it, never an automatic in-place mutation.
Why D is incorrect: The old desugaring applied uniformly to both classes and structs — it wasn't restricted to one category of type.
Reinforcement: "Always sugar for a = a + b" is the baseline behavior this whole feature is a deliberate, opt-in exception to.
2. A type defines only a static operator+ and no instance operator +=. What happens when code writes a += b on that type in C# 14?
Correct: B
Why B is correct: The feature is fully backward-compatible and opt-in — a type with no compound operator defined behaves exactly as it always has, falling back to the familiar a = a + b desugaring.
Why A is incorrect: Defining a compound operator is entirely optional; existing operator-overloaded types keep compiling and behaving exactly as before with no changes required.
Why C is incorrect: The compiler never auto-synthesizes a mutation from operator+ — mutation only happens when the type explicitly defines its own operator +=.
Why D is incorrect: There's no runtime exception involved — this is a purely compile-time choice between two well-defined, always-successful code paths.
Reinforcement: No compound operator defined means no behavior change at all — this feature only does something when a type opts in.
3. Why can't a readonly struct meaningfully define a mutating operator +=?
Correct: B
Why B is correct: This is a consequence of an existing rule, not a new restriction invented for this feature — a readonly struct's instance members were already unable to mutate this, and a mutating compound operator is just another instance member subject to that same rule.
Why A is incorrect: Non-readonly structs can define compound assignment operators without issue — the restriction is specific to the readonly modifier, not structs in general.
Why C is incorrect: The lesson's own primary examples (Accumulator, RunningStats) are structs defining compound assignment operators successfully — mutable structs are actually the type category this feature helps the most.
Why D is incorrect: Readonly structs support ordinary operator overloading (like operator+) just fine — only mutation-based members, compound assignment included, are off-limits.
Reinforcement: This feature doesn't create a new exception to the readonly struct mutation rule — it's simply subject to the same rule every other instance member already follows.
4. What's the recommended relationship between what operator+ and operator += compute, on a well-designed type?
Correct: A
Why A is correct: This is exactly what Common Confusion #1 and Common Mistake #1 both warned about — the compound operator is meant to be a mechanism-level optimization, not an opportunity to give += different semantics than + implies.
Why B is incorrect: This is precisely the anti-pattern Common Mistake #1 called out — letting the two operators drift into different results is exactly what NOT to do.
Why C is incorrect: While the compiler doesn't technically enforce it, the lesson is explicit that a well-designed type keeps the two operators computing the same conceptual outcome — divergence is a design mistake, not a supported use case.
Why D is incorrect: Plain a + b expressions still need operator+ — defining only operator += doesn't give a type the ability to support ordinary addition expressions.
Reinforcement: Same outcome, cheaper mechanism — that's the entire design intent behind this feature.
Last in this Part: modern file-based apps (331) — closing out Part XII with a look at how C# 14 changes not what you write inside a file, but what has to exist around it before you can run it.
dotnetmadeeasy.com — Learn C# and .NET, the right way.