Operator Overloading
Every operator in Rust, +, -, *, [], even unary -, is sugar for a single trait method in std::ops. a + b is Add::add(a, b). There's no special compiler magic beyond that: implementing operator overloading is implementing a trait, exactly like any other trait in this series. The interesting part isn't the mechanics, it's knowing when an operator clarifies an API and when it just obscures one.
Arithmetic Operators: Add, Sub, Mul
std::ops::Add has one required method, add, taking self and a right-hand operand and returning the result.
use std::ops::Add;
#[derive(Debug, Clone, Copy)]
struct Point { x: f64, y: f64 }
impl Add for Point {
type Output = Point;
fn add(self, other: Point) -> Point {
Point { x: self.x + other.x, y: self.y + other.y }
}
}
let a = Point { x: 1.0, y: 2.0 };
let b = Point { x: 3.0, y: 4.0 };
let c = a + b; // Point { x: 4.0, y: 6.0 }
Sub and Mul follow the identical shape, just with sub/mul and the corresponding operator. There's nothing more to it for Copy types like Point above, both operands are copied into add by value, which is cheap for two f64 fields.
For non-Copy types, the default by-value signature forces a clone on every use unless you also implement the trait for references:
impl Add for Matrix { // consumes both operands
type Output = Matrix;
fn add(self, other: Matrix) -> Matrix { /* ... */ }
}
impl Add for &Matrix { // borrows both operands instead
type Output = Matrix;
fn add(self, other: &Matrix) -> Matrix { /* ... */ }
}
let sum = &m1 + &m2; // no clone needed, both Matrix values still usable afterward
Without the &Matrix impl, m1 + m2 consumes both, and m1.clone() + m2.clone() becomes the only way to add them while keeping the originals. For any type where cloning is non-trivial, implement the operator for references alongside the by-value version.
Gotcha: the by-value signature is silently expensive.
a + bmoves both operands intoadd, so for a heap-owning type, every+you write either consumes the operands or forces a.clone()at the call site. The fix is theimpl Add for &Matrixabove, but it's easy to ship the by-value-only version and not notice the clones piling up until a profile shows them.
Compound Assignment: AddAssign and Friends
+=, -=, *= are separate traits (AddAssign, SubAssign, MulAssign), not automatically derived from Add. Implement them when in-place mutation makes sense for the type:
use std::ops::AddAssign;
impl AddAssign for Point {
fn add_assign(&mut self, other: Point) {
self.x += other.x;
self.y += other.y;
}
}
let mut p = Point { x: 1.0, y: 2.0 };
p += Point { x: 1.0, y: 1.0 }; // p is now { x: 2.0, y: 3.0 }
For a type that owns a large buffer, implementing AddAssign directly (mutating in place) avoids the allocate-a-new-value-and-overwrite cost that p = p + other would otherwise pay through Add.
Index/IndexMut: Custom [] Access
Index lets a type support container[key] syntax. It's most useful for wrapper types around a collection where exposing the raw collection's API isn't quite what you want.
use std::ops::{Index, IndexMut};
struct Matrix {
data: Vec<f64>,
cols: usize,
}
impl Index<(usize, usize)> for Matrix {
type Output = f64;
fn index(&self, (row, col): (usize, usize)) -> &f64 {
&self.data[row * self.cols + col]
}
}
impl IndexMut<(usize, usize)> for Matrix {
fn index_mut(&mut self, (row, col): (usize, usize)) -> &mut f64 {
&mut self.data[row * self.cols + col]
}
}
let mut m = Matrix { data: vec![0.0; 9], cols: 3 };
m[(1, 2)] = 5.0;
println!("{}", m[(1, 2)]); // 5.0
This reads far better than m.get(1, 2) / m.set(1, 2, 5.0) for something that's conceptually a grid, m[(row, col)] matches how you'd write the same access in ordinary math notation.
Index::index returns a reference and has no fallible variant, an out-of-bounds index panics, the same as a Vec's []. If invalid indices are expected and should be handled rather than panicked on, expose a separate .get((row, col)) -> Option<&f64> method instead of relying on Index.
When Not to Overload an Operator
An operator carries implicit expectations from ordinary arithmetic: + should be commutative for things that look numeric, == should be cheap and side-effect-free, [] shouldn't have surprising costs. Overloading an operator to mean something unrelated to its usual meaning trades a small bit of typing for a real loss of clarity.
// Misleading: `*` here means "merge configs," not multiplication.
// A reader has to go check the impl to find out what this actually does.
let merged = config_a * config_b;
// Clearer: a named method says exactly what's happening.
let merged = config_a.merged_with(config_b);
String's impl Add<&str> for String (string concatenation) is the standard library's own example of this tension, it's widely used and accepted, but only because "adding" two pieces of text together is a familiar enough metaphor that readers don't have to guess. If you're choosing an operator because the behavior doesn't obviously map to what that symbol means elsewhere, that's the sign to use a named method instead. Newtype Pattern and From/Into Conversions covers the same caution for Deref, overload an operator because it represents the type's natural operation, not because the syntax happens to be shorter.
Quick Reference
| Operator | Trait | Watch out for |
|---|---|---|
+ - * | Add / Sub / Mul | by-value signature; add a &T impl for non-Copy types |
+= -= | AddAssign / SubAssign | separate traits, not derived from Add; mutate in place |
container[k] (read) | Index | panics on bad index; add .get() for the fallible case |
container[k] (write) | IndexMut | requires Index too; returns &mut Output |
unary -, ! | Neg / Not | only overload when the symbol's usual meaning fits |
Rule of thumb: overload an operator only when it represents the type's natural operation. If a reader would have to open the impl to learn what * means here, use a named method instead.
Key Takeaways
- Every operator is one trait method in
std::ops(Add::add,Index::index, ...). Implementing operator overloading is ordinary trait implementation, nothing more. - The default arithmetic trait signatures consume both operands by value. For non-
Copytypes, also implement the trait for&Tto avoid forcing a.clone()on every use. AddAssign/SubAssign/etc. are separate traits fromAdd/Sub, implement them directly for types where in-place mutation avoids an allocation thatx = x + ywould otherwise cost.Index/IndexMutare a good fit for wrapper types that are conceptually indexed by position (grids, matrices); they panic on invalid indices the same asVec, expose a separate fallible method if that's not acceptable.- Reach for an operator only when the behavior matches that operator's usual meaning. When it doesn't, a named method communicates the intent far better than a symbol a reader has to go look up.