Learn Rust Series (#18) - Operator Overloading with std::ops
Learn Rust Series (#18) - Operator Overloading with std::ops
What will I learn
- You will learn how operators like
+,*, and[]are really just traits living instd::ops; - how to implement
Addfor your own type, and why itsOutputis an associated type; - how to overload an operator for mixed operand types, like scaling a vector by a plain scalar;
- how
AddAssignpowers+=, and howIndexpowers[]; - when overloading reads beautifully and when it quietly turns your code into a riddle.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Rust toolchain (via rustup, from rustup.rs);
- The previous seventeen episodes, especially traits from episode 8 and associated types from episode 17;
- The ambition to learn systems programming from the ground up.
Difficulty
- Beginner
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections
- Learn Rust Series (#8) - Traits & Generics
- Learn Rust Series (#9) - Modules & Crates
- Learn Rust Series (#10) - Lifetimes
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
- Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
- Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch
- Learn Rust Series (#16) - Static vs Dynamic Dispatch
- Learn Rust Series (#17) - Associated Types vs Generic Parameters
- Learn Rust Series (#18) - Operator Overloading with std::ops (this post)
Learn Rust Series (#18) - Operator Overloading with std::ops
There is no operator magic in Rust, and I mean that quite literally. When you write a + b, the compiler does not reach for some sealed built-in adder that only the language designers are allowed to touch. It rewrites your expression into a.add(b), where add is a plain method that comes from the Add trait in std::ops. Every arithmetic operator, the indexing brackets, even negation, is a trait with a method, and the number types you have been using since episode 2 implement those traits exactly the same way you are about to. That is the whole trick, and it is a genuinely elegant piece of design: give your own type the right trait impl and it slots into the operators as if it had been part of the language all along. It makes math-heavy code read like math in stead of like a wall of method calls ;-)
Before we open the new topic, we clear last episode's homework, as always.
Solutions to Episode 17 Exercises
Episode 17 was associated types versus generic parameters: why Iterator carries an associated Item instead of being Iterator<T>, and the deciding question of whether one type could reasonably implement a trait more than one way. Three exercises, and here is each one with full code you can paste and run.
Exercise 1 asked you to give the Container trait a len method and a default is_empty built on top of it, then implement the whole trait for a Words(Vec<String>) newtype and print its length and whether it is empty:
trait Container {
type Item;
fn get(&self, i: usize) -> Option<&Self::Item>;
fn len(&self) -> usize;
fn is_empty(&self) -> bool { self.len() == 0 }
}
struct Words(Vec<String>);
impl Container for Words {
type Item = String;
fn get(&self, i: usize) -> Option<&String> { self.0.get(i) }
fn len(&self) -> usize { self.0.len() }
}
fn main() {
let w = Words(vec!["hallo".into(), "wereld".into()]);
println!("{} {} {:?}", w.len(), w.is_empty(), w.get(0)); // 2 false Some("hallo")
}
The key insight is that is_empty is a default method riding entirely on len, so an implementor writes len once and gets is_empty for free. The associated Item type is fixed to String by this single impl, and get returns Option<&Self::Item> without any extra type parameter cluttering the signature. That is the whole comfort of an associated type in one small example.
Exercise 2 wanted a Producer trait with an associated Output, implemented for two structs whose outputs differ, with produce called on each in main:
trait Producer {
type Output;
fn produce(&self) -> Self::Output;
}
struct Number;
struct Label;
impl Producer for Number {
type Output = i32;
fn produce(&self) -> i32 { 42 }
}
impl Producer for Label {
type Output = String;
fn produce(&self) -> String { String::from("ready") }
}
fn main() {
println!("{} {}", Number.produce(), Label.produce()); // 42 ready
}
Notice there is no ambiguity at either call site. Number.produce() can only return i32 and Label.produce() can only return String, because each type pins its own Output exactly once. No : i32 or : String annotation is needed anywhere, which is precisely the property an associated type gives you.
Exercise 3 was the one that is supposed to not compile: try to implement Iterator twice for one struct with two different type Item = ... lines, and read the error. Here it is, and it fails on purpose:
struct Evens { n: u32 }
impl Iterator for Evens {
type Item = u32;
fn next(&mut self) -> Option<u32> { self.n += 2; Some(self.n) }
}
// A second impl with a different Item is what we were told to try:
impl Iterator for Evens { // ERROR: conflicting implementation
type Item = String;
fn next(&mut self) -> Option<String> { None }
}
The compiler stops you with conflicting implementations of trait Iterator for type Evens. The one-sentence why: Iterator uses an associated type, and an associated type may be fixed only once per implementing type, so there is no room for a second impl that disagrees about what Item is. Had Iterator been designed as Iterator<T> with a generic parameter, Iterator<u32> and Iterator<String> would have been two different trait impls and both would be allowed. This is exactly the "exactly one versus many" distinction from last episode, and it is the perfect bridge into today, because operators lean on associated types constantly.
Operators are traits
Let us make the opening claim concrete. To use + on your own type you implement std::ops::Add. The trait has a single method, add, and an associated type Output that names the result of the addition:
use std::ops::Add;
#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec2 { x: f64, y: f64 }
impl Add for Vec2 {
type Output = Vec2;
fn add(self, other: Vec2) -> Vec2 {
Vec2 { x: self.x + other.x, y: self.y + other.y }
}
}
fn main() {
let a = Vec2 { x: 1.0, y: 2.0 };
let b = Vec2 { x: 3.0, y: 4.0 };
println!("{:?}", a + b); // Vec2 { x: 4.0, y: 6.0 }
}
Read a + b as a.add(b) and the whole thing demystifies at once. The line type Output = Vec2 says "adding two Vec2s yields a Vec2", and it is an associated type for exactly the reason we nailed down last episode: there is one natural result type for this operation, not a menu of them. It would make no sense for the same Vec2 + Vec2 to sometimes produce a Vec2 and sometimes a String, so a generic parameter would be the wrong tool and an associated type is the right one. Add::Output was one of my headline examples last time, and now you see why.
One detail that trips people up: add takes self by value, not by reference. Arithmetic operators consume their operands by default. That is fine for a tiny value type like Vec2, which is why deriving Copy on it is so common. Without Copy, a + b would move both a and b, and you could not use them afterwards. With Copy, the + silently copies the two small structs and leaves the originals intact, which is what everyone expects from arithmetic. Small, cheap, plain-old-data types are the natural candidates for operator overloading, and Copy is what keeps them ergonomic.
Sub follows the same shape
Once you have seen Add, the rest of the arithmetic family is muscle memory. Sub has the identical structure, so vector subtraction is a couple of keystrokes away:
use std::ops::Sub;
#[derive(Debug, Clone, Copy)]
struct Vec2 { x: f64, y: f64 }
impl Sub for Vec2 {
type Output = Vec2;
fn sub(self, other: Vec2) -> Vec2 {
Vec2 { x: self.x - other.x, y: self.y - other.y }
}
}
fn main() {
let a = Vec2 { x: 5.0, y: 5.0 };
let b = Vec2 { x: 1.0, y: 2.0 };
println!("{:?}", a - b); // Vec2 { x: 4.0, y: 3.0 }
}
Same trait shape, same Output, same by-value operands. Mul, Div, and Rem (the % operator) all sing from the same hymn sheet. Once you can do one you can do all of them, and your type starts to feel like a first-class number.
Overloading for mixed operand types
Here is where it gets genuinely useful, and where last episode's other lesson pays off. The right-hand side of an operator does not have to be the same type as the left. Mul is declared as Mul<Rhs = Self>, a trait with a generic parameter for the right operand that defaults to Self. That default is why Vec2 * Vec2 would work if you implemented it, but nothing stops you from choosing a different right-hand type. Implement Mul<f64> and you can scale a vector by a plain number:
use std::ops::Mul;
#[derive(Debug, Clone, Copy)]
struct Vec2 { x: f64, y: f64 }
impl Mul<f64> for Vec2 {
type Output = Vec2;
fn mul(self, scalar: f64) -> Vec2 {
Vec2 { x: self.x * scalar, y: self.y * scalar }
}
}
fn main() {
let v = Vec2 { x: 1.0, y: 2.0 };
println!("{:?}", v * 3.0); // Vec2 { x: 3.0, y: 6.0 }
}
This is the generic-parameter idea from episode 17 walking around in the real world. Because Mul<Rhs> carries a type parameter, Vec2 can implement it several times with different right-hand types, and each impl is a seperate meaning for *, chosen by the compiler based on the operand types. Vec2 * f64 scales; you could add Vec2 * Vec2 for a component-wise product or a dot product; each is a distinct impl of the same operator. Contrast that with Output, which is associated, because a given multiplication has exactly one result type. So a single operator like * uses both mechanisms at once: a generic parameter to allow many right-hand types, and an associated type to pin the one result of each. If that sentence lands cleanly, episode 17 did its job.
Small warning that follows from the by-value operands: Vec2 * f64 is not automatically the same as f64 * Vec2. Rust does not assume your operators commute. If you want 3.0 * v to work as well as v * 3.0, you have to write a second impl, impl Mul<Vec2> for f64, spelling out the flipped version. The compiler will not invent it for you, and honestly that is the correct call, because plenty of real operators are not commutative.
Composing operators reads like math
The payoff for all this ceremony is that expressions built from your type start to look like the formulas they represent. Give Vec2 both Add and Mul<f64> and a midpoint is just the average you would write on paper:
use std::ops::{Add, Mul};
#[derive(Debug, Clone, Copy)]
struct Vec2 { x: f64, y: f64 }
impl Add for Vec2 {
type Output = Vec2;
fn add(self, o: Vec2) -> Vec2 { Vec2 { x: self.x + o.x, y: self.y + o.y } }
}
impl Mul<f64> for Vec2 {
type Output = Vec2;
fn mul(self, s: f64) -> Vec2 { Vec2 { x: self.x * s, y: self.y * s } }
}
fn midpoint(a: Vec2, b: Vec2) -> Vec2 {
(a + b) * 0.5
}
fn main() {
let a = Vec2 { x: 0.0, y: 0.0 };
let b = Vec2 { x: 4.0, y: 10.0 };
println!("{:?}", midpoint(a, b)); // Vec2 { x: 2.0, y: 5.0 }
}
(a + b) * 0.5 is the entire body, and anyone who has ever computed a midpoint reads it without a heartbeat of hesitation. That is the whole argument for operator overloading in one line: when the operator's ordinary meaning applies to your type, the code becomes the notation. This is why graphics, physics, and linear-algebra crates lean on it so heavily, and why it feels so natural there.
AddAssign and the compound operators
The += operator is not Add. It is its own trait, AddAssign, and the difference is the whole point: it mutates in place rather than producing a new value:
use std::ops::AddAssign;
#[derive(Debug)]
struct Counter { value: i32 }
impl AddAssign<i32> for Counter {
fn add_assign(&mut self, rhs: i32) {
self.value += rhs;
}
}
fn main() {
let mut c = Counter { value: 0 };
c += 5;
c += 10;
println!("{c:?}"); // Counter { value: 15 }
}
Look at the signature and the story tells itself. Where add took self by value and returned a fresh value, add_assign takes &mut self and returns nothing, because its job is mutation, not construction. That is also why Counter here does not need Copy: nothing is being moved or copied, we are just poking at a field through a mutable borrow. Every arithmetic operator has a matching assignment trait, and they pair up predictably: Add/AddAssign, Sub/SubAssign, Mul/MulAssign, Div/DivAssign, and so on down the line. Implement the assignment variant when in-place update is cheaper or clearer than building a whole new value each time, which for anything larger than a couple of floats it usually is.
Index: custom subscripting
The square-bracket subscript operator comes from Index, with IndexMut handling the mutable form. You get to choose the index type, and that freedom is what lets you build genuinely ergonomic access. A grid stored as a flat vector can be indexed by a (row, col) tuple so callers never do the row * width + col arithmetic themselves:
use std::ops::Index;
struct Grid { cells: Vec<i32>, width: usize }
impl Index<(usize, usize)> for Grid {
type Output = i32;
fn index(&self, (row, col): (usize, usize)) -> &i32 {
&self.cells[row * self.width + col]
}
}
fn main() {
let g = Grid { cells: vec![1, 2, 3, 4, 5, 6], width: 3 };
println!("{}", g[(1, 2)]); // row 1, col 2 -> 6
}
Two things worth noticing. First, index returns a reference, &Self::Output, not a value. Indexing hands you a borrow into the collection rather than a copy out of it, which is exactly how Vec and HashMap behave, and now your Grid behaves the same way. Second, Output is associated again, for the by-now-familiar reason: there is one element type you get back from indexing, not a choice of several. This is the mechanism behind some_vec[3] and some_map["key"], and it is available to your own collections the moment you implement the trait. Pair it with IndexMut and g[(0, 0)] = 99 starts working too, which is one of today's exercises.
How this looks from Python and Go
A lot of you arrive at this series from Python (as I did, having taught it for years), so it is worth seeing that operator overloading is not a Rust invention. Python has had it forever, spelled with dunder methods:
class Vec2:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vec2(self.x + other.x, self.y + other.y)
def __mul__(self, scalar):
return Vec2(self.x * scalar, self.y * scalar)
def __repr__(self):
return f"Vec2({self.x}, {self.y})"
a = Vec2(1, 2)
b = Vec2(3, 4)
print(a + b) # Vec2(4, 6)
print(a * 3) # Vec2(3, 6)
__add__ is Python's add, __mul__ is its mul, and a + b desugars to a.__add__(b) in exactly the way a + b desugars to a.add(b) in Rust. The mechanism is the same idea. The difference is the checking: Python resolves this at runtime, so if you pass something without an x field into __add__, you find out when the program crashes, not when it compiles. Rust pins every operand type at compile time, so a Vec2 + Vec2 that does not typecheck simply will not build.
Go, on the other hand, deliberately has no operator overloading at all. There is no way to make + work on your own struct; you write a method and call it:
type Vec2 struct {
X, Y float64
}
func (a Vec2) Add(b Vec2) Vec2 {
return Vec2{a.X + b.X, a.Y + b.Y}
}
func main() {
a := Vec2{1, 2}
b := Vec2{3, 4}
fmt.Println(a.Add(b)) // {4 6}, there is no a + b for structs in Go
}
Go's designers left overloading out on purpose, on the grounds that it can hide surprising behaviour behind innocent-looking symbols. That is a defensible position, and it points straight at the one real danger of this whole feature, which is worth a section of its own.
When not to overload
Operator overloading is powerful, and precisely because it is powerful it is easy to abuse. The rule I hold myself to is simple: an overloaded operator must mean what a reader already expects it to mean. + on two vectors is addition, everybody nods and moves on. But the moment you make + concatenate two unrelated things, or you make << do something clever and non-obvious the way certain C++ codebases famously do, you have not written an API, you have written a puzzle, and every future reader (including you, six months from now) has to solve it before they can use your type.
So the guidance is a pair of opposites. Do overload when your type is genuinely number-like or collection-like and the operator's ordinary meaning applies cleanly: vectors, matrices, complex numbers, big integers, money, points on a grid. Do not overload to be cute, to save a few characters, or to make an operator carry a meaning nobody could guess from the symbol. When in doubt, a well-named method like a.combine(b) is clearer and kinder than a surprising a + b. Go throws the feature out entirely to dodge the risk; Rust keeps it but leans on the same taste you would apply to naming anything else. Use it where it clarifies, skip it where it obscures.
What did we actually learn?
- Operators in Rust are ordinary traits in
std::ops:a + bcompiles toa.add(b)from theAddtrait, and your own types opt in by implementing the trait, exactly as the built-in number types do. - Arithmetic operators take their operands by value, so small value types usually derive
Copyto stay ergonomic; the result type is named by an associatedOutput, because each operation has one natural result. - The right-hand operand can differ from the left:
Mul<Rhs>carries a generic parameter (defaulting toSelf), so a type can implement*several ways, one per right-hand type, whileOutputstays associated. One operator, both mechanisms from episode 17 at once. - Compound operators are separate traits:
AddAssignand friends take&mut selfand mutate in place instead of returning a new value. Index(andIndexMut) power the[]subscript, let you pick the index type, and return a reference into the collection; and the golden rule is to overload only when the operator's ordinary meaning genuinely fits your type.
The thread running through the last two episodes is that Rust's trait system is a box of deliberate trade-offs, and today you saw associated types and generic parameters cooperating inside a single operator. Next time we stay close to this corner of the language and look at how a type can quietly stand in for the thing it wraps, so that a smart pointer feels like the value inside it, but one thing at a time ;-)
Exercises
Three exercises, gentle to chewier as always. Full solutions open the next episode, so have a real go first, because typing this stuff yourself is where it actually sticks.
- Implement
Neg(fromstd::ops) forVec2so that-vflips the sign of both components. It is a unary operator, so thenegmethod takes onlyselfand returns the negatedVec2. Print-Vec2 { x: 1.0, y: -2.0 }and confirm you getVec2 { x: -1.0, y: 2.0 }. - Add a second
Mulimpl toVec2, this timeMul<Vec2>that returns anf64dot product, soa * bgivesa.x * b.x + a.y * b.y. Note thatOutputis nowf64, notVec2, and confirm bothv * 3.0(scaling) anda * b(dot product) coexist on the same type. - Give
GridanIndexMut<(usize, usize)>impl so you can writeg[(0, 0)] = 99, then read the cell back withg[(0, 0)]and print it to verify the change actually happened. Rememberindex_mutreturns&mut Self::Output, and the grid must bemut.
Veel plezier met overloaden, en tot de volgende keer! ;-)
Leave Learn Rust Series (#18) - Operator Overloading with std::ops to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Zig Series (#129) - Mini Project: Database Engine - B-Tree Index
- Learn Rust Series (#18) - Operator Overloading with std::ops
- Learn AI Series (#148) - The Economics of AI
- Learn Zig Series (#128) - Mini Project: Database Engine - Page Storage
- Learn Rust Series (#17) - Associated Types vs Generic Parameters
- Learn AI Series (#147) - AI Safety and Alignment
- Learn Zig Series (#127) - Mini Project: Search Engine - Query Parser
- Learn Rust Series (#16) - Static vs Dynamic Dispatch
- Learn AI Series (#146) - Explainability and Interpretability
- Learn Zig Series (#126) - Mini Project: Search Engine - TF-IDF