How to Use std

:ops for Operator Overloading

Overload operators in Rust by implementing specific traits from the std::ops module on your custom types.

When operators aren't enough

You're building a geometry library. You have a Vector2 struct with x and y coordinates. You write let v3 = v1 + v2; to combine two vectors. The compiler rejects you with E0369 (binary operation + cannot be applied to type Vector2). You know the math. You know how to add the components. You just need to tell Rust that + means "add components" for your type.

That's where std::ops comes in. Rust doesn't let you change the + symbol itself. The symbol is hardcoded to call a trait method. You implement the trait, and the operator starts working. This pattern applies to +, -, *, /, [], * (dereference), and even () (function call). The module is called std::ops, but the community just calls it "operator traits."

The universal remote analogy

Think of + as a universal remote button. Pressing + always sends the same signal: "Call the add method on the left operand." The left operand decides what happens. If the left operand is an integer, it adds bits. If it's a string, it concatenates. If it's your Vector2, it calls your implementation.

You don't modify the remote. You modify the device. You implement the contract that the remote expects. Rust calls this "implementing the trait." The trait defines the method signature. Your code fills in the logic. The operator syntax is just sugar for the method call.

Minimal example: Add for Vector2

Start with the Add trait. It lives in std::ops. The trait has one associated type, Output, and one method, add.

use std::ops::Add;

/// A 2D vector for geometry calculations.
#[derive(Debug, PartialEq)]
struct Vector2 {
    x: f64,
    y: f64,
}

// Implement Add to enable the + operator.
impl Add for Vector2 {
    // Output must be a concrete type.
    // This tells the compiler what type v1 + v2 produces.
    type Output = Vector2;

    // self is moved by default. other is moved by default.
    // The signature matches the trait definition.
    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 };
    
    // This calls Vector2::add(v1, v2) under the hood.
    // v1 and v2 are moved into the function.
    let v3 = v1 + v2;
    
    println!("Result: {:?}", v3);
}

Convention aside: Derive Debug and PartialEq on types that support arithmetic. It makes testing and debugging trivial. If you implement Add, readers expect == to work for verification.

How the compiler desugars

When you write v1 + v2, the compiler performs a lookup. It checks the type of v1. It sees Vector2. It searches for an implementation of Add for Vector2. It finds your impl. It checks the signature. add takes self and other. It generates a call to that function.

The type Output tells the compiler what type v3 will be. If you mess up the types, you get E0308 (mismatched types). The compiler is strict about the return type. If Output is Vector2, the function must return Vector2. You can't return Option<Vector2> unless you change Output to Option<Vector2>.

This desugaring happens at compile time. There is zero runtime cost. The generated code is identical to calling Vector2::add(v1, v2) directly. Operator overloading in Rust is just a trait implementation with syntactic sugar.

The Rhs parameter: Mixing types

The Add trait has a generic parameter called Rhs. It defaults to Self. This means impl Add for Vector2 is shorthand for impl Add<Vector2> for Vector2. You can change Rhs to allow adding different types.

This is how 1 + 2.0 works. The integer 1 implements Add<f64>. The float 2.0 is the right-hand side. The trait allows i32 + f64 to produce f64.

You can use this for domain-specific logic. A Vector2 might support adding a scalar to scale both components.

use std::ops::Add;

/// A 2D vector that supports scalar addition.
struct Vector2 {
    x: f64,
    y: f64,
}

// Standard vector addition.
impl Add for Vector2 {
    type Output = Vector2;

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

// Scalar addition: Vector2 + f64.
// Rhs is f64, so the + operator accepts a float on the right.
impl Add<f64> for Vector2 {
    type Output = Vector2;

    fn add(self, scalar: f64) -> Vector2 {
        Vector2 {
            x: self.x + scalar,
            y: self.y + scalar,
        }
    }
}

fn main() {
    let v = Vector2 { x: 1.0, y: 2.0 };
    
    // Vector + Vector works.
    let v2 = Vector2 { x: 3.0, y: 4.0 };
    let sum_vec = v + v2;
    
    // Vector + Scalar works too.
    let sum_scalar = sum_vec + 10.0;
    
    println!("{:?}", sum_scalar);
}

Convention aside: The parameter is named Rhs for "Right Hand Side." When you implement Add<Rhs>, you're defining what happens when your type is on the left and Rhs is on the right. If you want f64 + Vector2, you need impl Add<Vector2> for f64, which you can't do because you don't own f64. You can only implement traits for types you own. This asymmetry is a common source of confusion. Stick to Vector2 + f64 unless you wrap the scalar in a newtype.

Reference combinatorics

The default Add implementation takes self by value. This moves the operands. If your type is large or you want to reuse the values, you need to implement Add for references.

Rust's type system is precise. &v1 + &v2 requires impl Add for &Vector2. v1 + &v2 requires impl Add<&Vector2> for Vector2. You can implement all four combinations.

use std::ops::Add;

/// A complex number for signal processing.
struct Complex {
    re: f64,
    im: f64,
}

// Owned + Owned.
impl Add for Complex {
    type Output = Complex;

    fn add(self, other: Complex) -> Complex {
        Complex {
            re: self.re + other.re,
            im: self.im + other.im,
        }
    }
}

// Reference + Reference.
// This allows &a + &b without cloning.
impl<'a> Add<&'a Complex> for &'a Complex {
    type Output = Complex;

    fn add(self, other: &Complex) -> Complex {
        Complex {
            re: self.re + other.re,
            im: self.im + other.im,
        }
    }
}

// Owned + Reference.
// Allows a + &b.
impl<'a> Add<&'a Complex> for Complex {
    type Output = Complex;

    fn add(self, other: &Complex) -> Complex {
        Complex {
            re: self.re + other.re,
            im: self.im + other.im,
        }
    }
}

fn main() {
    let c1 = Complex { re: 1.0, im: 2.0 };
    let c2 = Complex { re: 3.0, im: 4.0 };

    // Owned + Owned.
    let sum1 = c1 + c2;

    // Reference + Reference.
    let sum2 = &c1 + &c2;

    // Owned + Reference.
    let sum3 = c1 + &c2;

    println!("Sum: {} + {}i", sum3.re, sum3.im);
}

Implementing the reference version saves allocations. Your callers will thank you. If you only implement Add for owned values, callers must clone before adding. Cloning is expensive for large types. The reference implementation lets the compiler borrow instead of copy.

AddAssign: The mutation cousin

The += operator is not +. It uses a different trait called AddAssign. The signature is different. AddAssign takes &mut self and modifies the value in place. It returns ().

use std::ops::{Add, AddAssign};

/// A counter that tracks total additions.
struct Counter {
    value: i64,
}

// + creates a new Counter.
impl Add for Counter {
    type Output = Counter;

    fn add(self, other: Counter) -> Counter {
        Counter {
            value: self.value + other.value,
        }
    }
}

// += modifies the Counter in place.
impl AddAssign for Counter {
    fn add_assign(&mut self, other: Counter) {
        self.value += other.value;
    }
}

fn main() {
    let mut c1 = Counter { value: 10 };
    let c2 = Counter { value: 5 };

    // c1 is moved here. c1 is gone.
    let c3 = c1 + c2;
    
    // This would fail because c1 was moved.
    // c1 += c2; // E0382: use of moved value
    
    let mut c4 = Counter { value: 10 };
    let c5 = Counter { value: 5 };
    
    // c4 is mutated. c4 still exists.
    c4 += c5;
    
    println!("c4: {}", c4.value);
}

If you implement Add but try a += b, you get E0368 (cannot assign to a using +=). The compiler looks for AddAssign, not Add. They are cousins, not twins. Implement both if your type supports mutation.

Convention aside: AddAssign is essential for types used in loops. If you're summing values in a loop, += avoids creating temporary objects. It's faster and more idiomatic.

Pitfalls and errors

Chaining breaks if types don't match. a + b + c parses as (a + b) + c. If Add returns a different type than Self, the second + might fail.

For example, if Point + Point returns Result<Point, Error>, then (p1 + p2) + p3 tries to add Result and Point. That fails with E0369. You need to handle the Result explicitly. Operator chaining assumes the output type matches the input type. If your operation can fail, return a Result and document that chaining requires ? or unwrap.

Ambiguity can occur with multiple implementations. If you have impl Add for Point and impl Add for &Point, the compiler picks the most specific one. p1 + p2 uses the owned impl. &p1 + &p2 uses the reference impl. If you have conflicting impls, the compiler rejects you with E0119 (ambiguous type implementations). Keep impls distinct.

NaN behavior is inherited from the underlying type. If you add f64 components and one is NaN, the result is NaN. Rust doesn't intercept this. If you need to handle NaN, check inside your add method.

Check the trait name carefully. Add handles +. AddAssign handles +=. Sub handles -. Mul handles *. Div handles /. Rem handles %. Neg handles unary -. Not handles !. Index handles []. Deref handles *. Each operator has its own trait. Implement the one you need.

Decision matrix

Use Add when you want a + b syntax for combining two values.

Use AddAssign when you want += syntax to mutate the left operand in place.

Use Mul when the operation represents multiplication or scaling.

Use a named function like combine when the operation is domain-specific and + would be confusing to readers.

Use Add with references when cloning is expensive and you want to support &a + &b.

Use Add<Rhs> when you want to allow adding different types, like Vector + Scalar.

Pick the trait that matches the mental model. If + feels like a stretch, write a function.

Where to go next