Skip to content
academia.sh

Lesson 06 / 15

Operator Overloading

Every use of an operator (like +) is a trait call hiding behind it; a three-term sum calls it twice, a five-term sum four times — the operator's short syntax does not change the call count, it only changes whether the call is visible in the source, and this number can only be known from the run ledger.

Published Updated

In this lesson

  • Shows that using an operator corresponds to a method call on a trait in the standard library
  • Verifies by running it that in a chained operator expression the call count is one less than the term count
  • Explains that the operator's call site is not visible in the source, so the call count can only be known by counting
Contents

The previous lesson showed that a body with no line written in the source still runs. This lesson looks at the opposite kind of invisibility: the body is written in the source, but the line that calls it is not. When a + b is written, a call is made, but there is no line carrying the name add anywhere in the source — the call is hidden behind the + operator. The Object-Oriented Java course already introduced operator overloading; what was measured there was which type made the decision, what’s measured here is how many times an operator’s body runs.

Every body measured across this course’s five lessons could be referred to by a name in the source: name, a bounded function, produce, static_dispatch/dynamic_dispatch, eq. In operator overloading this naming disappears — only + appears in the source, the word add never appears anywhere. This final lesson carries the course’s measurement axis to its most invisible application: to count how many times a body ran, you first have to recognize the line that calls it in the source, and with operators this recognition step lies not in the source itself, but in the language’s syntax rule.

An Operator Is a Trait Call

In Rust, the + operator is not a fixed behavior, it is a call on the std::ops::Add trait. If a type wants to be addable with +, it has to implement Add; if it does not, + does not compile. The a + b syntax is expanded by the compiler into Add::add(a, b) — the shortcut visible in the source carries an ordinary method call behind it.

This expansion is not in the same category as the derive from the previous lesson. A derived body was never written in the source at all; the add body here is fully written, sitting line by line inside the impl Add for Length block. What’s invisible is not the body, it is the line that calls the body — the + sign does not say it carries a call, it just places two values side by side. This is the same even for the primitive integer and floating-point types: when 3 + 4 is written, the same pattern runs under the hood, only for a primitive type like i32 this call is resolved through the compiler’s own built-in rules, via the standard library’s Add implementation. For a user-defined type like Length, opening the same path means implementing Add exactly.

// times.rs — slice: the record/times ledger, Length and the Add implementation.

use std::cell::RefCell;
use std::collections::BTreeMap;
use std::ops::Add;
use std::rc::Rc;

pub type Ledger = Rc<RefCell<BTreeMap<String, u32>>>;

pub fn record(ledger: &Ledger, name: &str) {
    *ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}

pub fn times(ledger: &Ledger, name: &str) -> u32 {
    *ledger.borrow().get(name).unwrap_or(&0)
}

#[derive(Clone)]
pub struct Length {
    pub value: i32,
    pub ledger: Ledger,
}

impl Add for Length {
    type Output = Length;
    fn add(self, other: Length) -> Length {
        record(&self.ledger, "operator-body");
        Length {
            value: self.value + other.value,
            ledger: Rc::clone(&self.ledger),
        }
    }
}

The impl Add for Length block carries a notation familiar from the previous lesson: the line type Output = Length; is the associated type covered in the Associated Types lesson — Add itself carries the sum’s result type within itself as a type. This lesson does not look at Output, it looks at how many times the add body is called.

The add method’s signature carries an observation of its own: fn add(self, other: Length) -> Length. The parameter takes self itself (not &self) — the addition operation consumes the two Length values, it does not borrow them. This is the ownership and move rule built in the Ownership and the Memory Model course showing up again here: once a + b is written, neither a nor b can be used after that expression, because both were moved into add. This is exactly why a is cloned first in the a.clone() + b syntax below — since a will be used again in the second sum (sum + a), it must not be consumed in the first sum.

Three Terms, Two Calls

// operator.rs — every use of an operator is a call; a three-term sum calls it twice.
mod times;
use times::{times, Ledger, Length};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

fn build(ledger: &Ledger, value: i32) -> Length {
    Length { value, ledger: Rc::clone(ledger) }
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
    let a = build(&ledger, 3);
    let b = build(&ledger, 4);

    // Three terms, two '+' calls: (a.clone() + b) + a
    let sum = a.clone() + b;
    let sum = sum + a;
    println!(
        "three-term-sum={} operator-body={}",
        sum.value,
        times(&ledger, "operator-body")
    );

    // Five terms, four '+' calls - the same principle verified with a separate ledger.
    let ledger2: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
    let five: Vec<Length> = (1..=5).map(|d| build(&ledger2, d)).collect();
    let mut running_total = five[0].clone();
    for term in five.into_iter().skip(1) {
        running_total = running_total + term;
    }
    println!(
        "five-term-sum={} operator-body={}",
        running_total.value,
        times(&ledger2, "operator-body")
    );
}
three-term-sum=10 operator-body=2
five-term-sum=15 operator-body=4

TG12. The first measurement sums three terms (a clone of a, b, a itself), and operator-body ran twice. The + sign also appeared twice in the source — a.clone() + b and adding the result to a — and this appearance lines up exactly with how many times the body was called. This row falls into the every time class: every use of the operator unavoidably corresponds to a call. The second measurement confirms the same principle with a separate ledger: when five terms are summed, the + written once in the source ran four times. The two measurements together show a general rule: a sum of n terms runs the operator body n minus one times — the first term is already in hand without needing any call, every remaining term requires one call.

This rule comes from the structure of addition itself: summing two terms is one call, adding a third term is a second call, adding a fourth is a third call. In the five-term loop this accumulation starts from five[0] — zero calls so far, since no addition has happened yet — and every new term adds one call, completing at four calls. The loop body in the source is a single line (running_total = running_total + term;), but what matters is not how many times this line was written, it is how many times it ran — a line written once in the source repeats itself four times through the loop. This is an extra observation carried by the Operator Overloading lesson: how many times a call is written in the source and how many times it runs at run time can be different numbers, and a loop is the most common tool that pulls these two apart.

The Object-Oriented Java course measured operator overloading’s which type made the decision — whether the declared type or the runtime type of the operands decides which implementation is called for an operator. This course is not asking that question again; what’s measured here is how many times an already-determined implementation’s body runs. In Rust this determination already happens at compile time, read from Length‘s concrete type — the determination itself is not this lesson’s subject.

The Syntax Is a Shortcut, the Call Is the Same

That the + syntax is a shortcut can also be verified by calling Add::add explicitly — if the two notations expand to the same call, the trace both leave in the run ledger should be the same.

// operator-equivalent.rs — the '+' syntax and the Add::add call run the same number of times.
mod times;
use times::{times, Ledger, Length};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::ops::Add;
use std::rc::Rc;

fn build(ledger: &Ledger, value: i32) -> Length {
    Length { value, ledger: Rc::clone(ledger) }
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
    let a = build(&ledger, 5);
    let b = build(&ledger, 6);

    // Two notations, the same call: one carrying '+', the other calling Add::add explicitly.
    let via_syntax = a.clone() + b.clone();
    let explicit = Add::add(a, b);

    println!("via_syntax={} explicit={}", via_syntax.value, explicit.value);
    println!("operator-body={}", times(&ledger, "operator-body"));
}
via_syntax=11 explicit=11
operator-body=2

TG13. a.clone() + b.clone() is one call, Add::add(a, b) is one call — both trigger the same add body, and operator-body runs twice. This confirms that the + sign does not add any new behavior on the compiler’s part, it is only a way of shortening a call that already exists. Add::add(a, b) carries the trait’s name and method explicitly; a + b carries the same call but hides the name. Choosing between the two has no effect at all on the run count, it only affects the readability of the source.

Add is not alone; the std::ops module carries a similar trait for every binary operator — Sub for subtraction, Mul for multiplication, Index for indexing access. All rest on the same principle: a sign expands into a trait call. This lesson only measured Add, but the counting method stays the same for all of them: every use is a call, and when chained, the call count is one less than the term count.

This generalization is not an assumption — it comes from the same pattern being repeated across the other traits, just like Add. Sub declares a single method named sub, Mul one named mul, Index one named index, and each hides the same way behind its own sign (-, *, []). A type can also overload more than one operator — Length could have implemented both Add and Sub if it wanted, each sitting in an independent impl block, and neither would affect the other’s run count. This lesson focused only on Add because the course’s measurement kernel already carried it; the principle stays the same for the remaining operators.

The Measurement’s Limit

An operator body is an ordinary method, but where it is called is invisible in the source. In a.clone() + b, the word add does not appear anywhere — the call hides behind the + sign. A reader can answer “how many calls are in this expression” by counting the signs: every + is a call. But as a chain grows longer, especially when produced inside a loop or an iterator chain, counting the + signs in the source alone may not be enough either — how many times the loop ran is also information you need to know. In the five-term loop above, only one + is written in the source (running_total = running_total + term;), but since this line runs four times inside the loop, the total call count is four. The number of signs in the source and the run-time call count diverge here: how many times it ran can only be known by counting, only by the run ledger — not by reading the source.

This is the final form of the question this course has asked from the start: how many times did this body you wrote in the source run? In operator overloading this question shows up in its most invisible form, because the call itself carries no name — only a sign. Of the sixteen bodies measured across the course, the last one also stood behind this sign.

This invisibility confirms, from the other direction, an observation from the course’s first lesson. The Rust Design Goals lesson had shown that HashSet::insert and Vec::push do the same job (adding an element) but fall into different classes when you look at their signatures — the class could be determined by reading the source. In operator overloading, the class (every time) can still be read from the source too — + is always a call — but the count cannot be read from the source, because every + sign in the source can run more than once if it is inside a loop. Throughout the course, the distinction “class is read from the source, count comes from the run” is seen here in its clearest form.

These six lessons asked the same question in six different settings. Trait Definition and Implementation showed that a default body written in the source runs for one type and never for another. Trait Bounds showed that a condition placed around that body does not touch the run count at all. Associated Types showed that two different notations of the same contract leave the run count equal, the difference being in what the caller writes. Object Safety and Dynamic Dispatch showed that whether the call is resolved at compile time or at run time does not change the count either. Derived Traits showed that a body can run without ever being written in the source, but that run cannot be counted. And this lesson showed that a written body’s call can be invisible in the source. Together, all six show that the question “how many times did it run” is independent of visibility in the source: sometimes a visible body does not run, sometimes an invisible call does.

Summary

  • Using an operator (like +) corresponds to a method call (Add) in the standard library; the shortcut visible in the source carries an ordinary call behind it.
  • The operator body (operator-body) falls into the every-time class: it runs twice in a three-term sum, four times in a five-term sum — the call count is always one less than the term count.
  • The operator’s syntactic brevity does not change the call count; calling Length::add by hand runs the same number of times as writing +.
  • Since the call site is not visible in the source (only a sign is visible), how many times it ran can only be known by counting with the run ledger, not by reading the source.

This general rule — n terms, n minus one calls — holds not just for Add, but for every notation that chains binary operators. If the chain’s length is written once in the source and repeated with a loop (as in the five-term example above), the only way to know the call count in advance is knowing how many times the loop will run; counting the operator signs in the source alone is not enough. This is the final example of a distinction the course has carried from the start: the source says what will run, the run says how many times it ran, and the two do not carry the same information.

Next Step

This course measured traits, bounds, associated types, dispatch forms, derived bodies, and operator calls by asking when a body runs — every time, only when needed, or never. The next topic carries the same question to places where a body can fail: how is a value’s absence expressed in the type, and does a body that handles that absence run every time, or only when needed?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close