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 thewhereclause 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.