Compound Assignment Operators

How compound assignment operators like += and -= work in Rust, the traits behind them, and when to implement them on your own types

Rust's +=, -=, *=, and other compound assignment operators are not just shorthand for x = x + y. Each one is backed by its own trait, separate from the corresponding binary operator trait. That independence matters the moment a type owns a resource that can be mutated in place rather than replaced.

The Traits That Drive Compound Assignment

Every compound assignment operator maps to a trait in std::ops. The operator += calls AddAssign::add_assign, -= calls SubAssign::sub_assign, and so on through the full set:

OperatorTraitMethod
+=AddAssignadd_assign
-=SubAssignsub_assign
*=MulAssignmul_assign
/=DivAssigndiv_assign
%=RemAssignrem_assign
&=BitAndAssignbitand_assign
|=BitOrAssignbitor_assign
^=BitXorAssignbitxor_assign
<<=ShlAssignshl_assign
>>=ShrAssignshr_assign

All of them share the same shape. The definition of AddAssign from the standard library looks like this:

pub trait AddAssign<Rhs = Self> {
    fn add_assign(&mut self, rhs: Rhs);
}

The method takes &mut self — a mutable reference to the left-hand operand — and the right-hand value by ownership (or by reference, depending on the generic parameter). It returns nothing. A compound assignment expression a += b becomes exactly AddAssign::add_assign(&mut a, b) and evaluates to (), the unit value. You cannot write let c = a += 1; the way you might in C.

Assignment itself is not overloadable:

The simple = operator has no backing trait. Moving or copying a value into a variable is a core language operation bound to Rust's ownership rules, and there is no way to intercept or customize it. Compound assignment operators are overridable because they perform an extra operation — the addition, subtraction, or whatever — not because they include =.

Why They Are Separate from the Binary Operators

A type that implements Add for a + b does not automatically get AddAssign for a += b. The two traits are completely independent. This is by design.

When a type holds a heap allocation — a Vec, a String, a large matrix buffer — executing a = a + b through Add must allocate a brand new value, copy the contents of both operands into it, and then replace a with the result. The AddAssign trait lets you short-circuit all of that: you can modify the existing allocation in place, reusing the memory that is already there.

For a custom Buffer type that stores audio samples in a Vec<f32>, Add would look like this:

impl Add for Buffer {
    type Output = Buffer;
    fn add(self, rhs: Buffer) -> Buffer {
        let mut result = self; // move self into result
        result.samples.extend(rhs.samples);
        result
    }
}

Every addition allocates a new Vec. The compound version can instead append directly:

impl AddAssign for Buffer {
    fn add_assign(&mut self, rhs: Buffer) {
        self.samples.extend(rhs.samples);
    }
}

Now a += b extends a's existing vector. If the vector has spare capacity, no allocation happens at all. The trait separation gives you a hook to express that performance difference.

Implementing one does not give you the other:

If you implement AddAssign but not Add, users cannot write a + b — only a += b. This inconsistency confuses anyone who reaches for the binary operator expecting symmetry. For types that feel arithmetic, implement both and make them agree. If only in-place mutation makes sense, skip Add and document why.

How a Beginner Should Think About It

Imagine a whiteboard. The binary operator + reads the current number, calculates a new number, and then writes it on a fresh sticky note that you replace the old one with. Compound assignment erases a few digits and writes new ones directly on the same sticky note. The difference is whether you reuse the material you already have or create a new one.

If your type is just a handful of Copy fields — a 2D point, an RGBA color — the distinction doesn't matter. Copying a few bytes is cheaper than the machinery of an in-place mutation. But as soon as your type owns an allocation, AddAssign becomes the path that avoids throwing away and re-requesting memory.

Implementing AddAssign for a Simple Type

A Point struct with two f64 fields is a good place to start. The fields are Copy, so there is no heap to worry about, but the trait mechanics are the same.

use std::ops::AddAssign;
#[derive(Debug, Clone, Copy, PartialEq)]
struct Point {
    x: f64,
    y: f64,
}
impl AddAssign for Point {
    fn add_assign(&mut self, rhs: Point) {
        self.x += rhs.x;
        self.y += rhs.y;
    }
}
fn main() {
    let mut p = Point { x: 1.0, y: 2.0 };
    p += Point { x: 3.0, y: 4.0 };
    println!("{:?}", p); // Point { x: 4.0, y: 6.0 }
}

The method accepts rhs by value. Because Point is Copy, the caller's Point is still usable afterward — the compiler copies the bytes into the function. The self reference is mutable, so the addition happens directly on p's fields.

A generic version lets you add Point<T> for any component type that supports compound addition:

impl<T: AddAssign> AddAssign for Point<T> {
    fn add_assign(&mut self, rhs: Point<T>) {
        self.x += rhs.x;
        self.y += rhs.y;
    }
}

If you derive Copy and Clone:

For small Copy types, an AddAssign implementation like this works without any ownership pitfalls. The reader can use + or += with equal ease, and the two operators will compute the same result. This is the ideal case to aim for.

Dealing with Ownership — When the Right-Hand Side Is Too Expensive to Consume

The default AddAssign trait takes rhs by value. That consumes the right-hand operand, moving it into the method. If the right-hand side is a large struct that does not implement Copy, the caller loses access to it after the operation. This is often fine — the right-hand side was temporary anyway — but not always.

For a Matrix type that owns a Vec<f64>, you might want a += &b to work without destroying b. You can implement AddAssign for a reference right-hand side:

use std::ops::AddAssign;
struct Matrix {
    data: Vec<f64>,
    rows: usize,
    cols: usize,
}
impl AddAssign for Matrix {
    fn add_assign(&mut self, rhs: Matrix) {
        // Consume rhs, move its data.
        for (s, r) in self.data.iter_mut().zip(rhs.data.into_iter()) {
            *s += r;
        }
    }
}
impl AddAssign<&Matrix> for Matrix {
    fn add_assign(&mut self, rhs: &Matrix) {
        // Borrow rhs, keep it alive.
        for (s, r) in self.data.iter_mut().zip(rhs.data.iter()) {
            *s += *r;
        }
    }
}

Now a += b consumes b, but a += &b leaves b intact. This pattern is common in the standard library: numeric types implement compound assignment for both T and &T.

Consuming the right-hand side silently:

If you only implement AddAssign for by-value rhs and the type is not Copy, every += destroys the right-hand operand. A user who writes a += b and then tries to read b again will get a compile error: "use of moved value." If that behavior is surprising, add the &Rhs implementation or document the move clearly.

A Realistic Example — Appending to a Custom Buffer

Consider a Log type that collects lines of text. Implementing AddAssign<&str> lets you append log entries in place without allocating a new Log object.

use std::ops::AddAssign;
#[derive(Debug)]
struct Log {
    entries: Vec<String>,
}
impl AddAssign<&str> for Log {
    fn add_assign(&mut self, line: &str) {
        self.entries.push(line.to_string());
    }
}
fn main() {
    let mut log = Log { entries: vec![] };
    log += "Server started";
    log += "Connection accepted";
    println!("{:?}", log);
    // Log { entries: ["Server started", "Connection accepted"] }
}

The operator += now reads like appending. For a type that is fundamentally a growable collection, AddAssign is a natural fit. If you also implement Add, that might create a new Log containing both the existing entries and the new line, which would mean an unnecessary clone of the entire log. Skipping Add is defensible here — in-place mutation is the primary operation, and a named method like .merged_with() would be clearer for the rare case where a copy is desired.

Notice the right-hand side is a &str, not String. The to_string() call inside the method allocates — but only for the new line, not for the existing ones. That's the right trade-off.

Common Mistake — Expecting a Return Value

In many languages, x += 1 evaluates to the new value of x, letting you write while (x += 1) < 10. Rust's compound assignment expressions always evaluate to (). Trying to use them in an expression context where a value is expected produces a type mismatch error.

let mut x = 0;
// This does not compile:
// let y = x += 1;

If you need the updated value, perform the operation and then use the variable:

let mut x = 0;
x += 1;
let y = x; // y is 1

Compound assignment is a statement, not an expression:

This is a deliberate design choice that prevents accidental misuse of mutated values inside complex expressions. Rust's ownership and borrowing rules already make such side-effect chaining hard to reason about, so the language eliminates the temptation.

When to Implement Compound Assignment (and When Not To)

Implement AddAssign (and friends) when:

  • The operation naturally modifies the left-hand side in a way that matches the operator's meaning.
  • You can avoid a heap allocation by mutating an existing resource.
  • The type is a container, accumulator, or stateful object where in-place updates are the norm.

Skip compound assignment when:

  • The type is a pure value with no internal allocation — Add alone is simpler and sufficient.
  • The operator would carry a meaning that isn't obvious from the symbol alone. A type that represents database connections probably should not use += to run a query; a named method is far clearer.
  • The compound operation would be dramatically more expensive than the binary version in a surprising way.

A type that implements AddAssign should ideally also implement Add unless the binary operator genuinely doesn't make sense. The standard library's numeric types always pair the two.

The Full Picture — Independence and Consistency

Every *Assign trait stands on its own. You could implement AddAssign for a type and define it to do something completely unrelated to addition — but that would break the reader's trust. Operators carry mathematical connotations, and Rust's community expects overloaded operators to respect those connotations.

A good implementation of AddAssign for a type that also implements Add should satisfy:

// After these two operations, the value should be the same.
let mut x = original;
x += delta;          // via AddAssign
let y = original + delta; // via Add
assert_eq!(x, y);

If that assertion fails, something is wrong.

Compound assignment traits apply to built-in types too:

All of Rust's integer and floating-point types implement the full set of arithmetic compound assignment traits, as do bool for the bitwise ones. Those implementations are what make i32 += 1 work out of the box. Your own types plug into the same infrastructure.

Summary

Compound assignment operators in Rust are a separate layer of trait overloading, not an automatic side effect of binary operators. They give you a place to optimize in-place mutation for types that manage heap resources, while still allowing separate semantics for + versus += when that makes sense. The key decisions are: whether to implement the trait at all, whether to accept the right-hand side by value or reference, and whether to keep the behavior consistent with the corresponding binary operator.

A type that owns an allocation should almost always provide AddAssign (or its equivalent) to let callers reuse memory. A small Copy type can get by with just Add, but adding AddAssign costs almost nothing and keeps the interface symmetrical. The one thing you can never overload is assignment itself — that belongs to Rust's ownership system, not to operator traits.