Skip to Content
Course content

196: Operator Overloading via std::ops Traits

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine we're building a simple 2D physics engine. We've got a Vector2D struct to handle positions and velocities. At first, you'll probably do what most of us do: write a few helper methods to handle the math. It feels clean because it's explicit.

struct Vector2D {
    x: f64,
    y: f64,
}

impl Vector2D {
    fn add(&self, other: &Vector2D) -> Vector2D {
        Vector2D {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

let pos = Vector2D { x: 1.0, y: 2.0 };
let vel = Vector2D { x: 0.5, y: 0.1 };
let next_pos = pos.add(&vel);

The Clutter of Named Methods

This works fine for one addition. But physics involves long chains of operations. If you're calculating a trajectory with acceleration, friction, and wind, your code quickly turns into a nesting doll of method calls. You end up with something like pos.add(&vel.add(&accel.multiply(dt))). It's visually noisy, and frankly, it's a pain to read. You spend more time tracking parentheses than thinking about the actual math.

In most languages, we'd just overload the + operator. For a long time, I thought Rust's lack of "magic" overloading was a limitation, but it's actually a design choice. Rust doesn't let you just "declare" an overload; it forces you to implement a specific trait from the std::ops module. This means when you see + in Rust, you know exactly what's happening: the Add trait is being invoked.

Bringing Intuition Back with std::ops

By implementing std::ops::Add, we can tell the compiler that our Vector2D knows how to handle the + symbol. This transforms the API from a series of method calls into something that looks like actual linear algebra.

use std::ops::Add;

impl Add for Vector2D {
    type Output = Vector2D;

    fn add(self, other: Vector2D) -> Vector2D {
        Vector2D {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

// Now we can do this:
let next_pos = pos + vel;

Notice the type Output = Vector2D; line. This is an associated type. It's necessary because adding two things doesn't always result in the same type (for example, adding a Duration to a DateTime results in a DateTime). By specifying the output, you give the compiler the type-safety it needs to optimize the resulting expression.

The Ownership Headache: Values vs. References

Here is where you'll probably hit a wall. In the example above, add(self, other: Vector2D) takes ownership of both vectors. If you try to use pos again after pos + vel, the compiler will scream at you because pos was moved into the add method.

I've seen a lot of developers struggle with this. You might be tempted to just call .clone() everywhere, but that's a lazy fix that kills performance. The professional way to handle this is to implement the trait for references as well. You can actually implement Add for &Vector2D.

impl<'a, 'b> Add<&'b Vector2D> for &'a Vector2D {
    type Output = Vector2D;

    fn add(self, other: &'b Vector2D) -> Vector2D {
        Vector2D {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

// Now we can add without losing ownership:
let next_pos = &pos + &vel;

It's a bit more boilerplate—you have to deal with lifetimes like 'a and 'b—but it's the correct trade-off. You get the syntactic beauty of operators without the runtime cost of unnecessary allocations or the frustration of "value moved" errors. If you're building a library that others will use, I strongly recommend implementing these traits for both values and references to give your users the flexibility they need.




📋 Practical Task

Implementing a Complex Number Arithmetic Engine

Create a struct called Complex that represents a complex number with re (real) and im (imaginary) fields, both as f64.

  • Implement the std::ops::Add trait for Complex so that two complex numbers can be added using the + operator.
  • Implement the std::ops::Sub trait for Complex so that they can be subtracted using the - operator.
  • To avoid ownership issues, implement both of these traits for references (&Complex) rather than just the values.
  • In your main function, create two complex numbers, add them together, and subtract one from the other, ensuring you can still use the original variables after the operations are complete.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.