Skip to content
academia.sh

Lesson 02 / 15

Trait Bounds

Putting a trait bound on a generic function's type parameter adds no step to the source at all — the same body written and run in both a bounded and an unbounded form gives back the exact same number in both; the bound's cost is written to the compilation, not to the run ledger, and it cannot be measured by this number.

Published Updated

In this lesson

  • Defines the trait bound syntax (single, multiple, and the where clause) and explains its purpose
  • Compares the run count of a bounded generic body against a concrete-typed body doing the same job to show that the bound adds no step to the run
  • States that a cost exists that cannot be measured in the run (a separate body generated per type) and avoids claiming it with a number
Contents

The previous lesson showed that a trait can carry a default body and that which type this body runs for can be counted in the run. But the <T: Label> syntax of a function like static_dispatch has not been explained yet — this is a trait bound placed on a generic function’s type parameter, and it is this lesson’s subject. The question stays the same: how many times did this body you wrote in the source run? But this time what’s measured is not the body — it is the condition placed around the body.

What the Bound Says

A generic function written as fn name<T>(x: &T) can accept any type; but if the body wants to call a method on x, the compiler needs to know that method exists. A trait bound provides exactly this: the syntax fn name<T: Label>(x: &T) says “whatever T is, it must implement Label.” Without the bound, a call like x.ledger() does not compile, because the compiler does not know which methods exist for T; once the bound is added, the compiler reads this information from the trait declaration.

An unbounded generic function means the compiler knows nothing about T. When fn name<T>(x: &T) is written, the compiler only allows operations common to every type on x (moving it, taking a reference) — if a call specific to Label, like x.ledger(), is written, the compiler rejects it, because all it knows is that T is a type, not that it implements Label. Unlike the type ledger in the previous course, this rejection is not a run-time behavior here — the source does not compile at all. A trait bound fills exactly this gap: from the moment T: Label is written, the compiler knows that every method Label declares can be called on x inside the body, and x.ledger() becomes something that compiles. The bound thus binds both sides at once: the calling side (“only types that implement Label can be passed to this function”) and the body side (“this body can safely call Label’s methods”).

The same bound can be written in two different places. The short form goes right next to the type parameter: fn bounded_body<T: Label>(x: &T). When the bound gets long (multiple traits, long type names), the same meaning can also be written with a where clause:

// where-form.rs — the same bound, written apart from the signature.
trait Label {
    fn name(&self) -> String;
}

struct Example;
impl Label for Example {
    fn name(&self) -> String {
        String::from("example")
    }
}

fn short<T: Label>(x: &T) -> String {
    x.name()
}

fn long<T>(x: &T) -> String
where
    T: Label,
{
    x.name()
}

fn main() {
    let e = Example;
    println!("short={} long={}", short(&e), long(&e));
}
short=example long=example

TG3. short and long mean exactly the same thing to the compiler; the where clause is a readability option, not a separate mechanism. When more than one bound is needed (something like T: Label + Clone), the short form makes the line longer, while the where form can keep the signature short by putting each bound on its own line. This lesson uses the two interchangeably; whichever is chosen has no effect at all on the run count.

Multiple bounds are joined with the + sign: T: Label + Clone means “T must implement both Label and Clone.” This is a familiar structure from the previous lesson: Label alone was a single contract; here two contracts are bound to the same type parameter at once. + establishes an intersection here, not a union: the set of types that can stand in for T is the intersection of the set of types implementing only Label and the set of types implementing only Clone. Adding a bound always narrows which types can be passed to a function, never widens it — so adding a new bound to a signature can retroactively cut off some calls, but it can never make a new call possible.

Bounds also do not change where the type parameter is constrained: the only difference between <T: Label> and where T: Label is placement, not the strength of the constraint. The same principle holds when two bounds are written together (T: Label + Clone): the compiler, while checking the body, assumes T carries the methods of both Label and Clone; if either bound were missing, a single call belonging to that trait would already make the body fail to compile.

A signature can carry more than one type parameter, and each parameter can have its own bound: in fn combine<T: Label, U: Label>(a: &T, b: &U), T and U look like they share the same bound, but to the compiler they are two independent parameters — a and b can be different concrete types, the only shared requirement is that both implement Label. Bounds are placed per parameter; one parameter’s bound does not affect another, even if the same trait name is repeated in both places.

// combine.rs — two type parameters, each independently carrying the same trait bound.
trait Label {
    fn name(&self) -> String;
}

struct Plain;
struct Custom;

impl Label for Plain {
    fn name(&self) -> String {
        String::from("plain")
    }
}
impl Label for Custom {
    fn name(&self) -> String {
        String::from("custom")
    }
}

fn combine<T: Label, U: Label>(a: &T, b: &U) -> String {
    format!("{}+{}", a.name(), b.name())
}

fn main() {
    let p = Plain;
    let c = Custom;
    println!("{}", combine(&p, &c));
    println!("{}", combine(&p, &p));
}
plain+custom
plain+plain

In the second call, T and U both resolve to the same concrete type (Plain); in the first, they resolve to two different concrete types (Plain and Custom). combine’s signature accepts both calls, because the bound only says “must implement Label,” not “must be the same type.” If we wanted to force T and U to resolve to the same concrete type, the signature would use a single parameter instead (fn combine<T: Label>(a: &T, b: &T)); having two separate parameters exists precisely to remove that requirement.

When no trait bound is written, a type parameter is not left completely empty: every T, unless stated otherwise, also carries an implicit size bound — the compiler assumes T has a fixed, known size in memory, because a generic body needs to be able to hold a value of type T locally. This implicit bound is a separate layer from explicitly written bounds like Label, and it is invisible for most generic functions; it only needs to be explicitly relaxed in a small number of advanced writings that work with types of variable size, like slices. This lesson does not use that relaxation — what matters here is knowing that alongside the bound that is visible (Label), there is always an invisible default bound as well.

The Bound’s Contribution to the Run: Zero Steps

The same job can be set up in two forms to compare the run count: one generic with a <T: Label> bound, the other a direct concrete type (Plain), an unbounded function. A third function uses more than one bound (Label + Clone) together.

// times.rs — the slice of trait Label: `ledger`, the type name, and two implementations.

use std::cell::RefCell;
use std::collections::BTreeMap;
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)
}

pub trait Label {
    fn ledger(&self) -> &Ledger;
    fn kind(&self) -> &'static str;
}

#[derive(Clone)]
pub struct Plain {
    pub ledger: Ledger,
}

impl Label for Plain {
    fn ledger(&self) -> &Ledger {
        &self.ledger
    }
    fn kind(&self) -> &'static str {
        "plain"
    }
}

pub struct Custom {
    pub ledger: Ledger,
}

impl Label for Custom {
    fn ledger(&self) -> &Ledger {
        &self.ledger
    }
    fn kind(&self) -> &'static str {
        "custom"
    }
}
// bound-body.rs — measures that a trait bound adds no step at all to the run.
mod times;
use times::{times, record, Ledger, Label, Custom, Plain};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

// The same source line is generated separately by the compiler for every
// concrete type it is called with — this is a cost the run counter does not see.
fn bounded_body<T: Label>(x: &T) -> u32 {
    let key = format!("bounded-body-{}", x.kind());
    record(x.ledger(), &key);
    times(x.ledger(), &key)
}

// The same job, unbounded: takes a concrete type directly, no type parameter.
fn concrete_body(x: &Plain) -> u32 {
    record(x.ledger(), "concrete-body");
    times(x.ledger(), "concrete-body")
}

// T must provide both Label and Clone — two bounds joined with '+'.
fn clone_and_record<T: Label + Clone>(x: &T) -> T {
    record(x.ledger(), "multi-bound-body");
    x.clone()
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
    let plain = Plain { ledger: Rc::clone(&ledger) };
    let custom = Custom { ledger: Rc::clone(&ledger) };

    let _ = bounded_body(&plain);
    let _ = bounded_body(&plain);
    let _ = bounded_body(&custom);

    let _ = concrete_body(&plain);
    let _ = concrete_body(&plain);

    let clone = clone_and_record(&plain);
    let _ = clone_and_record(&clone);

    let mut bodies: BTreeMap<&str, u32> = BTreeMap::new();
    bodies.insert("bounded-body-custom", times(&ledger, "bounded-body-custom"));
    bodies.insert("bounded-body-plain", times(&ledger, "bounded-body-plain"));
    bodies.insert("concrete-body", times(&ledger, "concrete-body"));
    bodies.insert("multi-bound-body", times(&ledger, "multi-bound-body"));

    println!("{:<22}{}", "body", "how many times");
    for (name, n) in &bodies {
        println!("{:<22}{}", name, n);
    }
}
body                  how many times
bounded-body-custom   1
bounded-body-plain    2
concrete-body         2
multi-bound-body      2

TG4. bounded-body-plain ran twice, concrete-body also ran twice — the exact same number. Both do the same job (reading the ledger and counting one row); one through a generic type parameter, the other directly through a concrete type. The trait bound adds or subtracts nothing from how many times the body is called — this shows that the bound leaves no trace at all in the run ledger. A bound is a compile-time rule: it decides which types can be passed to bounded_body, but once passed, the running code behaves as if the bound never existed.

The Interfaces, Errors and Generics course measured, for Go’s generics, that the binding was established at the call site — a generic function’s type constraint restricted which types a call could be made with, at the call site. This course is not asking that same question; what’s measured here is what the bound adds to the run — and the answer is nothing. bounded-body-custom confirms this from a second angle: the same bounded_body function can also be called with Custom (once), because Custom also implements Label; the bound decides which types are accepted, but the body runs the same way for every accepted type.

multi-bound-body falls into the same class too: called twice, ran twice. Here the T: Label + Clone bound combines two separate conditions, but the body itself is still a single record call plus a single clone operation. As the number of bounds grows (Label, then Label + Clone), the line count in the run ledger does not grow; a bound is a condition placed on the type parameter, not a step added inside the body.

All four rows fall into the every time class: none of them is “only when needed” or “never,” because every call unavoidably finds its match — there is no skipped, deferred, or unconsumed step. This is the same class as default-body-plain and overridden-body from the previous lesson: the only difference is which condition the body can be called under, not whether the body itself runs.

This equality will come up again under a different question later in the course. The Object Safety and Dynamic Dispatch lesson will compare the same Label contract, this time using &dyn Label instead of the <T: Label> bound, against a call form. There too the count comes out equal — but for a different reason than what’s seen here: bounded generics resolve the call at compile time, while the dyn form finds the same call at run time from a method table. This lesson only shows that the bound’s existence adds nothing to the run; how the two call forms differ from each other is taken up in a separate lesson.

concrete_body not needing a bound at all is also a consequence of the same principle. When a function is already bound to a single concrete type (Plain), the answer to “which types should be accepted” is already known from the start — only Plain. A trait bound exists for cases where the answer to this question can be more than one type; there is no need to even ask it in a function bound to a single type. So the difference between concrete_body and bounded_body is not a shortcoming — it is the answer to two different questions: one never asks “which type is right for this job,” the other answers that question with a bound.

The Measurement’s Limit

Having no cost in the run does not mean the bound is free. When the bounded_body function is called separately for Plain and Custom, the compiler works by a method that generates a separate body for every concrete type: there is a single fn bounded_body<T: Label> line in the source, but the resulting binary has one body for Plain and another for Custom. This generation happens during compilation and never enters the run ledger — the ledger only counts calls at run time, not how many bodies were generated at compile time. This course’s measurement method cannot show this cost; the lesson says so openly and does not try to count it. bounded-body-plain and bounded-body-custom running three times in total carries no information at all about how many separate bodies were generated behind the scenes.

This limit also exposes a general limit of the course’s measurement method: the run ledger can only count lines that are actually passed at run time. Nothing that happens at compile time — how many separate bodies were generated, in what order each type was compiled, how many candidate types a bound eliminated — can be made visible with this ledger. Throughout the course, the word “count” always means a run-time count; when a question belongs to compile time, the lesson says so openly and answers it in prose, instead of making up a number. This distinction matters for setting out from the start what the measurement does and does not cover: for the rest of the course, the sentence “this body never ran” is always an observation that belongs to run time, not a claim about whether the source exists.

Summary

  • A trait bound places the condition “must implement this trait” on a generic function’s type parameter; the short form (<T: Label>) and the where clause mean the same thing to the compiler.
  • Multiple bounds are joined with + (T: Label + Clone); the body stays a single one, running code does not grow as the bound count grows.
  • A bounded body (bounded-body-plain) and an unbounded body doing the same job (concrete-body) ran the exact same number of times every time: two each — the bound adds no step at all to the run ledger.
  • The bound’s real cost is not in the run but in the compilation: a separate body is generated for every concrete type, but this generation cannot be measured by the run counter, and the lesson does not try to.

Next Step

The bound tied a trait to the type parameter from the outside. A trait can sometimes want to carry a type within the contract itself — a type the caller never states, that the implementer decides. The next lesson compares this associated type against writing the same contract with a type parameter instead.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close