When + isn't enough
You're writing a 2D game engine. You have a Vector2 struct holding x and y coordinates. You want to move a player by adding a velocity vector to their position. You write new_pos = pos + vel and the compiler rejects you. Rust doesn't know how to add two Vector2 values. It knows how to add integers, floats, and strings, but your custom type is opaque to the + operator.
You can't just use + on any struct. You have to teach Rust what addition means for your type. You do this by implementing a trait from std::ops. The + operator is just syntax sugar. Under the hood, it's a method call. When you implement the trait, you unlock the operator.
Operators are just traits
Rust treats operators as regular traits. The + operator corresponds to the Add trait. The * operator corresponds to Mul. The - operator corresponds to Sub. These traits live in the std::ops module.
When you write a + b, the compiler translates that into Add::add(a, b). The operator disappears by the time the code runs. The binary contains a function call, not an operator instruction. This design gives you control. You decide whether + consumes the values, borrows them, or returns a different type. You decide the semantics.
The Add trait requires two things. You must define an associated type called Output that tells the compiler what type results from the addition. You must implement the add method that performs the operation. The Output type is flexible. Point + Point can produce a Point. Point + i32 can also produce a Point if you implement the trait with a generic parameter. The trait system handles the type checking.
The minimal implementation
Start with a simple struct. Implement Add to allow the + operator.
use std::ops::Add;
/// A 2D point with integer coordinates.
struct Point {
x: i32,
y: i32,
}
// Implement Add to enable the + operator for Point.
impl Add for Point {
// The Output type defines what the result of + is.
type Output = Point;
// The add method takes ownership of both operands.
fn add(self, other: Point) -> Point {
// Return a new Point with summed coordinates.
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
let p1 = Point { x: 1, y: 2 };
let p2 = Point { x: 3, y: 4 };
// This desugars to Add::add(p1, p2).
// p1 and p2 are moved into the function.
let p3 = p1 + p2;
println!("Result: ({}, {})", p3.x, p3.y);
}
The type Output = Point line is mandatory. The compiler needs to know the return type of the expression p1 + p2. Without it, type inference breaks. The add method signature matches the trait definition. self is the left operand. other is the right operand. Both are passed by value, which means they are moved into the function.
What the compiler actually does
When the compiler sees p1 + p2, it performs a lookup. It checks if Point implements Add. It finds the implementation. It verifies that type Output is Point. It generates code that calls Add::add(p1, p2).
The move semantics matter here. Because add takes self by value, p1 and p2 are consumed. You cannot use p1 after the addition. If you try, the compiler rejects you with E0382 (use of moved value). This matches the behavior of primitive types. 1 + 2 doesn't consume the literals, but for owned structs, the default trait implementation moves the data.
This design choice prevents accidental copies. If Point held a large buffer, moving it is cheap. Copying it would be expensive. The trait signature forces you to think about ownership. If you want to keep the original values, you need a different implementation.
Borrowing and performance
Consuming the inputs is rarely what you want in math-heavy code. You usually want to add vectors without destroying them. You need to implement Add for references.
use std::ops::Add;
struct Vector2 {
x: f64,
y: f64,
}
// Implement Add for references so inputs are borrowed, not moved.
impl Add for &Vector2 {
type Output = Vector2;
fn add(self, other: &Vector2) -> Vector2 {
Vector2 {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
let v1 = Vector2 { x: 1.0, y: 2.0 };
let v2 = Vector2 { x: 3.0, y: 4.0 };
// v1 and v2 are borrowed. They remain usable after this line.
let v3 = &v1 + &v2;
println!("Sum: ({}, {})", v3.x, v3.y);
println!("v1 is still valid: ({}, {})", v1.x, v1.y);
}
The signature impl Add for &Vector2 changes the receiver. self is now &Vector2. The method borrows the left operand. other is also a reference. The result is a new Vector2 by value. This pattern is common for numeric types. It avoids moving data while still creating a new result.
There is a convention here. When you implement Add for references, you often want to support mixed usage. &v1 + v2 might fail if you only implemented Add for &Vector2. The compiler looks for an implementation that matches the exact types. If you have &Vector2 and Vector2, you need impl Add<Vector2> for &Vector2. Implementing all combinations can get verbose. Many libraries implement Add for references and rely on auto-ref, or they implement the value version and let callers borrow explicitly. Pick the ergonomics that fit your API.
AddAssign for in-place updates
Creating a new value every time you add allocates or copies. In a tight loop, that overhead adds up. Use AddAssign for the += operator. It modifies the left operand in place.
use std::ops::AddAssign;
struct Vector2 {
x: f64,
y: f64,
}
// AddAssign modifies self in place.
impl AddAssign for Vector2 {
fn add_assign(&mut self, other: Vector2) {
self.x += other.x;
self.y += other.y;
}
}
fn main() {
let mut v1 = Vector2 { x: 1.0, y: 2.0 };
let v2 = Vector2 { x: 3.0, y: 4.0 };
// Modifies v1 directly. No new allocation.
v1 += v2;
println!("Updated: ({}, {})", v1.x, v1.y);
}
AddAssign takes &mut self. It changes the value and returns nothing. This is the performance-friendly choice for accumulators and physics updates. The community convention is to implement AddAssign whenever Add makes sense. It saves allocations and signals intent. Readers see += and know the value is being updated, not replaced.
Pitfalls and errors
Operator overloading introduces specific failure modes. The compiler catches most of them, but the errors can be opaque if you don't know what to look for.
Missing associated type. If you forget type Output, the compiler rejects the implementation with E0192 (missing associated type). The trait requires it. You cannot omit it.
error[E0192]: associated type `Output` is not set
Trait not implemented. If you use + on a type without Add, you get E0277. The error message lists the trait and the types involved.
error[E0277]: the trait bound `Point: std::ops::Add` is not satisfied
Mismatched types. If type Output says Point but add returns i32, you get E0308. The compiler compares the declared output with the actual return.
error[E0308]: mismatched types
The reference trap. Implementing Add for T but using &T + &T fails. The compiler looks for impl Add for &T. If you only have impl Add for T, the expression doesn't match. You must implement the trait for the exact types you use. This is a common source of confusion. The compiler doesn't automatically dereference or borrow to find a match. You need the right implementation.
Precedence is fixed. You cannot change operator precedence. * always binds tighter than +. If you implement Add and Mul, a + b * c will always multiply first. If your domain requires different precedence, operators are the wrong tool. Use method calls.
The social contract. Operators should feel intuitive. + should be commutative. a + b should equal b + a. If your operation isn't commutative, don't use +. Use a named method. If + does something surprising, like deleting data or making a network request, you're violating the contract. Readers expect mathematical or concatenation semantics. Break that expectation and you confuse everyone.
When to overload
Operator overloading is powerful but easy to misuse. Follow these rules to keep your code readable.
Use Add when the operation is a natural addition in your domain and the result is a new value. Use Add with references (impl Add for &T) when you want to add without consuming the inputs, which is standard for math types. Use AddAssign when you want to update a value in place for performance or clarity. Use Mul, Sub, Div, or other std::ops traits when the operation maps cleanly to the corresponding symbol. Use method calls when the operation is rare, domain-specific, or has non-standard semantics; named methods are clearer than overloaded operators in those cases. Skip operator overloading when the operation would violate commutativity or associativity expectations; readers will be misled. Reach for operators only when they make the code read like the problem domain.
The + operator is just a function call in disguise. Treat it like one. If you can't explain what a + b does in one sentence, don't overload +.