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:
| Operator | Trait | Method |
|---|---|---|
+= | AddAssign | add_assign |
-= | SubAssign | sub_assign |
*= | MulAssign | mul_assign |
/= | DivAssign | div_assign |
%= | RemAssign | rem_assign |
&= | BitAndAssign | bitand_assign |
|= | BitOrAssign | bitor_assign |
^= | BitXorAssign | bitxor_assign |
<<= | ShlAssign | shl_assign |
>>= | ShrAssign | shr_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 —
Addalone 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.