Part 3 · The compile-time machine II — types & traits
Reading generic signatures in the wild
The capstone. A repeatable five-step procedure, applied to real signatures from serde, tokio and axum — the ones that look like noise until they suddenly do not.
What this lesson is trying to break (2)
- Complicated signatures require knowing the library.
- You have to understand every bound to understand the function.
Everything in Part 3 has been building to this. The stated goal of this course is reading Rust, and the thing that most reliably stops people is a signature like:
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
That is not hard. It just needs a procedure.
The five steps
- Cover the bounds. Read only the name, the arguments and the return type. This tells you what the function does.
- List the type parameters. What is generic here?
- For each parameter, ask: who chooses it? The caller, or the function? (This is the 3.3 distinction and it is the step people skip.)
- Translate each
whereline into English. Each is one requirement. - Read the lifetimes as relationships between inputs and outputs.
Note what is not in the list: knowing the library. The procedure works on crates you have never seen, which is exactly what makes it a reading skill.
Worked example, fully
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
Step 1 — cover the bounds
pub fn spawn(future: F) -> JoinHandle<F::Output>Takes a future, gives back a handle to whatever that future produces.
That is the entire meaning of the function. Everything else is conditions.
Steps 2 and 3 — the parameters, and who picks them
One parameter: F.
The caller chooses it. It appears in argument position, so whatever you pass
determines F. By monomorphisation, each distinct future type you spawn produces
its own copy of spawn.
F::Output is not a second parameter — it is an associated type
(3.2) determined by F. Once F is
fixed, F::Output is fixed too. That is why it needs no separate parameter.
Step 4 — the bounds, in English
F: Future— it must be pollable. Obviously.F: Send— it may be moved to another thread. Tokio’s default runtime is work-stealing, so a task can start on one worker and resume on another.F: 'static— it must not borrow anything from the caller’s frame. The bound meaning from 2.4, not “lives forever”.spawnreturns immediately; the task may still be running after the calling function returns, so anything borrowed from that frame would dangle.F::Output: Send + 'static— the same two conditions for the result, because it travels back across threads through theJoinHandle.
Step 5 — and the practical consequence
No lifetime parameters. The 'static bounds are what replaces them, and that is
the design: rather than tracking regions across a thread boundary, spawn
forbids borrowing entirely.
Which explains the single most common error people hit with tokio:
error: `cfg` does not live long enough
= note: required because it appears within `spawn`'s `'static` boundThe fix is never to make something live longer. It is to stop borrowing:
async move to move ownership in, .clone() for cheap data, or Arc for
shared data. You could have derived all three of those fixes from step 4 alone,
without knowing anything about tokio.
Now with less scaffolding
pub fn from_str<'a, T>(s: &'a str) -> Result<T>
where
T: Deserialize<'a>,Parse a string into a T, or fail.
The caller picks T, and — crucially — nothing in the arguments determines
it. T appears only in the return type. So the caller must supply it, either
by annotation or turbofish:
let cfg: Config = serde_json::from_str(text)?; // annotation
let cfg = serde_json::from_str::<Config>(text)?; // turbofishThis is return-type polymorphism, and it is unfamiliar coming from TypeScript, where a function’s return type is fixed by its definition.
The lifetime is the interesting part. T: Deserialize<'a> where 'a is the
input string’s region means T is allowed to borrow from the input. That is
zero-copy deserialisation: a struct Config<'a> { name: &'a str } can point
directly into the JSON buffer with no allocation.
And it explains DeserializeOwned, which you will meet constantly:
pub trait DeserializeOwned: for<'de> Deserialize<'de> {}Deserialisable from a buffer of any lifetime — which in practice means “borrows nothing”. You need it whenever the deserialised value must outlive the buffer, which is most of the time.
Two lessons meeting: higher-ranked bounds from 2.4 and blanket impls from 3.2.
fn collect<B>(self) -> B
where
Self: Sized,
B: FromIterator<Self::Item>,Consume the iterator and build a B out of the items.
The caller picks B, and again it appears only in the return type — so it
comes from context:
let v: Vec<u32> = iter.collect(); // from the annotation
let s: String = chars.collect(); // same function, different B
let m: HashMap<_, _> = pairs.collect(); // and again
let v = iter.collect::<Vec<_>>(); // turbofish when there is no annotationOne function, and B can be Vec, String, HashMap, BTreeSet, or your own
type if you implement FromIterator.
The genuinely clever one:
let r: Result<Vec<u32>, ParseIntError> =
["1", "2", "x"].iter().map(|s| s.parse()).collect();Result<Vec<T>, E> implements FromIterator<Result<T, E>>, so collecting an
iterator of Results gives a single Result containing a Vec — short-circuiting
on the first error. Nothing special-cases this; it is one more FromIterator
impl.
Self: Sized is there so Iterator stays dyn-compatible
(3.3) — collect is excluded from
the vtable.
pub fn get<H, T, S>(handler: H) -> MethodRouter<S, Infallible>
where
H: Handler<T, S>,
T: 'static,
S: Clone + Send + Sync + 'static,This is axum’s route builder. T never appears in the arguments or the return
type.
Build a router that handles GET with this handler.
T is a marker for the handler’s extractor arguments, and this is one of the
cleverest tricks in the Rust web ecosystem.
axum lets you write handlers with wildly different signatures:
async fn a() -> &'static str { "hi" }
async fn b(Path(id): Path<u32>) -> String { format!("{id}") }
async fn c(State(db): State<Db>, Json(body): Json<Req>) -> Json<Res> { /* ... */ }All three are accepted by get. That works because Handler<T, S> is
implemented many times over — once for each arity and combination of extractor
types — and T is the tuple of those extractor types. It is a type-level
dispatch key, inferred from the handler’s own signature and never written by
anyone.
This is the trait solver being used as a small compile-time program, which is the framing from 0.2.
The practical payoff is being able to read the error when it goes wrong.
the trait bound ... Handler<_, _> is not satisfied almost never means what it
says. It means: one of your extractor arguments does not implement
FromRequestParts, or you put a body-consuming extractor somewhere other than
last. Knowing that saves an hour, and it comes from understanding what T is,
not from knowing axum.
Drill
Decode each in one sentence. Do not open the docs.
pub fn sort_by_key<K, F>(&mut self, f: F)
where
F: FnMut(&T) -> K,
K: Ord,Sort this slice in place, ordering elements by the key that f extracts from
each.
F: FnMut rather than Fn because the closure is called repeatedly and is
allowed to mutate captured state. K: Ord because the keys must be totally
orderable.
Note there is no K: Clone. The keys are computed on the fly and possibly
recomputed — which is exactly why sort_by_cached_key exists as a separate
method for when computing the key is expensive.
impl<T: Display + ?Sized> ToString for TEvery type that can be displayed automatically gets .to_string().
A blanket impl (3.2). ?Sized means it
covers unsized types too, so str and dyn Display are included, not just
String and i32.
The practical rule this encodes: never implement ToString yourself.
Implement Display and this gives you ToString for free. A reviewer who spots
a manual ToString impl will say exactly that.
pub fn from_utf8_lossy(v: &[u8]) -> Cow<'_, str>Interpret these bytes as UTF-8, replacing anything invalid — borrowing if possible, allocating only if it had to change something.
Cow is “clone on write”: an enum with Borrowed(&'a T) and Owned(T)
variants. If the input is already valid UTF-8, nothing is copied and you get a
borrow. If it is not, a corrected String is allocated.
The '_ (2.4) says the borrowed
case borrows from v.
Cow is a good beacon in general: this API avoids allocating in the common
case but must sometimes allocate. Seeing it in a signature tells you the author
thought about the fast path.
Go to docs.rs, pick a crate you have never used, and open any function with at least two bounds.
Apply the five steps in writing. Then check yourself against the prose documentation — which you should read after, not before.
Do this three times. It is the single best transfer exercise on this site, because it is the actual task the course exists to make possible.
Part 3 checkpoint
Out loud, no notes:
- Why is a trait not a type? (3.2)
- What is the orphan rule protecting? (3.2)
- What does monomorphisation do to build times and binary size? (3.3)
impl Traitin argument vs return position — who picks the type? (3.3)- Where does
.len()on aStringactually come from? (3.4) - What does
?desugar to, and which trait makes it work? (3.5) thiserrororanyhow— which for a library, and why? (3.5)
Next: 4.1 — Send and Sync, where the borrow
rule from Part 2 gets extended across threads and turns out to need almost
nothing new.