Learn Rust Series (#44) - Higher-Ranked Trait Bounds & Lifetime Elision
Published on HivePostify by @scipio · Tue Sep 01 2026
Learn Rust Series (#44) - Higher-Ranked Trait Bounds & Lifetime Elision
What will I learn - You will learn what a higher-ranked trait bound is and how for expresses "for every lifetime"; - why closures that take references need HRTBs, and how the compiler usually adds them for you invisibly; - the three lifetime elision rules in full, so you know exactly when you can omit annotations; - when elision cannot decide and forces you to reach for an explicit 'a; - how these two features together explain most of the lifetime syntax you see in real Rust code.
Requirements - A working modern computer running macOS, Windows or Ubuntu; - An installed Rust toolchain (via rustup, from rustup.rs); - The previous forty-three episodes, especially lifetimes (episode 10), closures and the Fn traits (episode 11); - The ambition to learn systems programming from the ground up.
Difficulty - Intermediate
Curriculum (of the Learn Rust Series): - [Learn Rust Series (#1) - Introduction to Rust](https://hive.blog/hive-196387/@scipio/learn-rust-series-1-introduction-to-rust) - [Learn Rust Series (#2) - Variables, Types, Functions](https://hive.blog/hive-196387/@scipio/learn-rust-series-2-variables-types-functions) - [Learn Rust Series (#3) - Ownership & Borrowing](https://hive.blog/hive-196387/@scipio/learn-rust-series-3-ownership-borrowing) - [Learn Rust Series (#4) - Control Flow & Pattern Matching](https://hive.blog/hive-196387/@scipio/learn-rust-series-4-control-flow-pattern-matching) - [Learn Rust Series (#5) - Structs & Enums](https://hive.blog/hive-196387/@scipio/learn-rust-series-5-structs-enums) - [Learn Rust Series (#6) - Error Handling](https://hive.blog/hive-196387/@scipio/learn-rust-series-6-error-handling) - [Learn Rust Series (#7) - Collections](https://hive.blog/hive-196387/@scipio/learn-rust-series-7-collections) - [Learn Rust Series (#8) - Traits & Generics](https://hive.blog/hive-196387/@scipio/learn-rust-series-8-traits-generics) - [Learn Rust Series (#9) - Modules & Crates](https://hive.blog/hive-196387/@scipio/learn-rust-series-9-modules-crates) - [Learn Rust Series (#10) - Lifetimes](https://hive.blog/hive-196387/@scipio/learn-rust-series-10-lifetimes) - [Learn Rust Series (#11) - Closures & the Iterator Trait](https://hive.blog/hive-196387/@scipio/learn-rust-series-11-closures-the-iterator-trait) - [Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell](https://hive.blog/hive-196387/@scipio/learn-rust-series-12-smart-pointers-box-rc-refcell) - [Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex](https://hive.blog/hive-196387/@scipio/learn-rust-series-13-concurrency-threads-channels-arc-mutex) - [Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App](https://hive.blog/hive-196387/@scipio/learn-rust-series-14-mini-project-a-command-line-to-do-app) - [Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch](https://hive.blog/hive-196387/@scipio/learn-rust-series-15-trait-objects-dynamic-dispatch) - [Learn Rust Series (#16) - Static vs Dynamic Dispatch](https://hive.blog/hive-196387/@scipio/learn-rust-series-16-static-vs-dynamic-dispatch) - [Learn Rust Series (#17) - Associated Types vs Generic Parameters](https://hive.blog/hive-196387/@scipio/learn-rust-series-17-associated-types-vs-generic-parameters) - [Learn Rust Series (#18) - Operator Overloading with std::ops](https://hive.blog/hive-196387/@scipio/learn-rust-series-18-operator-overloading-with-stdops) - [Learn Rust Series (#19) - Deref, DerefMut & Deref Coercion](https://hive.blog/hive-196387/@scipio/learn-rust-series-19-deref-derefmut-deref-coercion) - [Learn Rust Series (#20) - Drop & Deterministic Destruction (RAII)](https://hive.blog/hive-196387/@scipio/learn-rust-series-20-drop-deterministic-destruction-raii) - [Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions](https://hive.blog/hive-196387/@scipio/learn-rust-series-21-from-into-tryfrom-idiomatic-conversions) - [Learn Rust Series (#22) - Deriving Common Traits](https://hive.blog/hive-196387/@scipio/learn-rust-series-22-deriving-common-traits) - [Learn Rust Series (#23) - The Orphan Rule & Trait Coherence](https://hive.blog/hive-196387/@scipio/learn-rust-series-23-the-orphan-rule-trait-coherence) - [Learn Rust Series (#24) - Blanket Implementations & the Newtype Pattern](https://hive.blog/hive-196387/@scipio/learn-rust-series-24-blanket-implementations-the-newtype-pattern) - [Learn Rust Series (#25) - Marker Traits: Sized, Send, Sync & Copy](https://hive.blog/hive-196387/@scipio/learn-rust-series-25-marker-traits-sized-send-sync-copy) - [Learn Rust Series (#26) - Const Generics: Types That Depend on Values](https://hive.blog/hive-196387/@scipio/learn-rust-series-26-const-generics-types-that-depend-on-values) - [Learn Rust Series (#27) - Generic Associated Types & Lending Iterators](https://hive.blog/hive-196387/@scipio/learn-rust-series-27-generic-associated-types-lending-iterators) - [Learn Rust Series (#28) - Sealed Traits & Designing Stable APIs](https://hive.blog/hive-196387/@scipio/learn-rust-series-28-sealed-traits-designing-stable-apis) - [Learn Rust Series (#29) - Typestate Programming: State Machines in the Type System](https://hive.blog/hive-196387/@scipio/learn-rust-series-29-typestate-programming-state-machines-in-the-type-system) - [Learn Rust Series (#30) - Mini Project: A Generic Units-of-Measure Library](https://hive.blog/hive-196387/@scipio/learn-rust-series-30-mini-project-a-generic-units-of-measure-library) - [Learn Rust Series (#31) - Move Semantics Deep Dive](https://hive.blog/hive-196387/@scipio/learn-rust-series-31-move-semantics-deep-dive) - [Learn Rust Series (#32) - Interior Mutability: Cell & RefCell](https://hive.blog/hive-196387/@scipio/learn-rust-series-32-interior-mutability-cell-refcell) - [Learn Rust Series (#33) - Rc Internals: Reference Counting & Shared Ownership](https://hive.blog/hive-196387/@scipio/learn-rust-series-33-rc-internals-reference-counting-shared-ownership) - [Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost](https://hive.blog/hive-196387/@scipio/learn-rust-series-34-arc-thread-safe-reference-counting-its-cost) - [Learn Rust Series (#35) - Weak References & Breaking Reference Cycles](https://hive.blog/hive-196387/@scipio/learn-rust-series-35-weak-references-breaking-reference-cycles) - [Learn Rust Series (#36) - Cow: Clone-on-Write for Borrow-or-Own APIs](https://hive.blog/hive-196387/@scipio/learn-rust-series-36-cow-clone-on-write-for-borrow-or-own-apis) - [Learn Rust Series (#37) - Pin & Self-Referential Structs](https://hive.blog/hive-196387/@scipio/learn-rust-series-37-pin-self-referential-structs) - [Learn Rust Series (#38) - PhantomData, Zero-Sized Types & Marker Lifetimes](https://hive.blog/hive-196387/@scipio/learn-rust-series-38-phantomdata-zero-sized-types-marker-lifetimes) - [Learn Rust Series (#39) - Variance: Covariance, Contravariance & Why It Matters](https://hive.blog/hive-196387/@scipio/learn-rust-series-39-variance-covariance-contravariance-why-it-matters) - [Learn Rust Series (#40) - Arena & Bump Allocation Patterns](https://hive.blog/hive-196387/@scipio/learn-rust-series-40-arena-bump-allocation-patterns) - [Learn Rust Series (#41) - Building Your Own Smart Pointer](https://hive.blog/hive-196387/@scipio/learn-rust-series-41-building-your-own-smart-pointer) - [Learn Rust Series (#42) - Drop Order, the Drop Check & Leak Safety](https://hive.blog/hive-196387/@scipio/learn-rust-series-42-drop-order-the-drop-check-leak-safety) - [Learn Rust Series (#43) - std::mem: swap, replace, take & forget](https://hive.blog/hive-196387/@scipio/learn-rust-series-43-stdmem-swap-replace-take-forget) - [Learn Rust Series (#44) - Higher-Ranked Trait Bounds & Lifetime Elision](https://hive.blog/hive-196387/@scipio/learn-rust-series-44-higher-ranked-trait-bounds-lifetime-elision) (this post)
Learn Rust Series (#44) - Higher-Ranked Trait Bounds & Lifetime Elision
Two small lifetime features round out this phase, and between them they explain nearly all the lifetime syntax you have been staring at (and mostly not writing) since episode 10. The first is the higher-ranked trait bound, written with for , which lets you demand that something works for every possible lifetime -- exactly what a closure that takes a reference needs. The second is lifetime elision, the small set of rules that let you leave lifetime annotations off in the common cases so your function signatures stay readable. You have leaned on both since very early in the series without ever seeing their names; putting names to them is what finally makes the last of the lifetime mystery evaporate ;-)
Last episode I closed on a deliberate cliffhanger. The mem::replace-to-consume-a-variant trick, I said, was quietly leaning on some subtle machinery about how a generic function accepts borrowed data of any lifetime. That machinery is the higher-ranked trait bound, and it is where we head today. Having said that, let us clear the homework first.
Solutions to Episode 43 Exercises
Episode 43 was the std::mem toolbox -- swap, replace, take and forget. Three exercises, and here is full, runnable code for each.
Exercise 1 asked you to use mem::swap to rotate three variables so that a gets b's value, b gets c's, and c gets a's, using exactly two swaps, printing all three before and after:
rust use std::mem;
fn main() { let (mut a, mut b, mut c) = (1, 2, 3); println!("before: {a} {b} {c}"); // 1 2 3 mem::swap(&mut a, &mut b); // a=2, b=1, c=3 mem::swap(&mut b, &mut c); // a=2, b=3, c=1 println!("after: {a} {b} {c}"); // 2 3 1 }
The trick is the order. The first swap trades a and b; the second swap trades the new b (which is really a's old value) into c. After two swaps a holds 2, b holds 3, c holds 1 -- the three-way rotation you wanted, with no temporary variable and no clone.
Exercise 2 wanted a struct with an Option field and a method that uses mem::take to pull the value out, leaving None behind, called twice to prove the second call yields None:
rust use std::mem;
struct Session { token: Option }
impl Session { fn taketoken(&mut self) -> Option { mem::take(&mut self.token) // leaves None behind } }
fn main() { let mut s = Session { token: Some(String::from("abc123")) }; println!("{:?}", s.taketoken()); // Some("abc123") println!("{:?}", s.taketoken()); // None -- already taken }
mem::take moves the value out and drops the type's default (None for Option) into the field. The first call hands you Some("abc123") and empties the slot; every call after that finds only None. This is precisely how the standard library's own Option::take works under the hood.
Exercise 3 asked you to model a traffic light as an enum (Red, Green, Yellow) and write fn next(&mut self) that uses mem::replace to consume the old state, compute the next colour from it, and write the new colour back -- without cloning or fighting the borrow checker:
rust use std::mem;
#[derive(Debug)] enum Light { Red, Green, Yellow }
impl Light { fn next(&mut self) { let new = match mem::replace(self, Light::Red) { Light::Red => Light::Green, Light::Green => Light::Yellow, Light::Yellow => Light::Red, }; self = new; } }
fn main() { let mut light = Light::Red; light.next(); println!("{:?}", light); // Green light.next(); println!("{:?}", light); // Yellow light.next(); println!("{:?}", light); // Red }
mem::replace(self, Light::Red) writes a cheap placeholder into self and hands us back the old value by value, so we own it and can match it to pick the next colour, which we then write back. A plain match self that tried to move the variant out would hit the "cannot move out of a mutable reference" wall we spent all of episode 43 dismantling. Right, homework cleared -- now the two features of the day.
The problem: a lifetime you cannot name
Start with a concrete puzzle. You want to write a function that accepts a closure taking a &str and returning a &str borrowed from that same input. What lifetime do you write for the argument? Whatever you pick, it is wrong, because the closure has to work for whatever lifetime the function decides to call it with -- including a borrow of a string the function creates on its own stack, which has a lifetime the caller cannot possibly know or name in advance.
What you actually want to say is "for all lifetimes 'a, this closure is callable with a &'a str and gives back a &'a str". That "for all lifetimes" quantifier is exactly what a higher-ranked trait bound expresses, and its syntax is for :
rust fn apply (f: F) -> usize where F: for Fn(&'a str) -> &'a str, // for ANY lifetime 'a { let owned = String::from("hello world"); f(&owned).len() // f must accept this particular short-lived borrow }
fn firstword(s: &str) -> &str { s.splitwhitespace().next().unwrapor("") }
fn main() { println!("{}", apply(firstword)); // 5 }
Read the bound out loud: "for every lifetime 'a, F is Fn(&'a str) -> &'a str". That universal quantifier is what makes the body legal. Inside apply we build a local String called owned -- a value whose lifetime is tiny, ending when apply returns -- and we call f on a borrow of it. If the bound had named a single fixed lifetime, f would have been locked to one particular caller-chosen lifetime and could not also accept our short-lived local borrow. for says the closure is flexible enough to handle any lifetime we throw at it, including this one. That is the whole idea: a higher-ranked bound is a bound that is itself generic over a lifetime.
You have been using HRTBs invisibly
Here is the part that surprises people: you have written dozens of these bounds already, and never once typed for . Whenever you have an Fn, FnMut or FnOnce bound whose arguments are references, the compiler silently wraps it in a higher-ranked lifetime for you:
rust // This bound, written the short way... fn run i32>(f: F) -> i32 { let v = vec![1, 2, 3]; f(&v) // ...is silently for Fn(&'a [i32]) -> i32 }
fn main() { println!("{}", run(|slice| slice.iter().sum())); // 6 }
The bound F: Fn(&[i32]) -> i32 desugars to F: for Fn(&'a [i32]) -> i32. That is why run can build a local Vec and pass a borrow of it to f -- the higher-ranked lifetime was there all along, inserted on your behalf. The same invisibility applies from the calling side. When you hand a closure that borrows its argument to such a function, the closure quietly satisfies the higher-ranked bound:
rust fn callonlocal Fn(&'a str) -> usize>(f: F) -> usize { let s = String::from("hello"); f(&s) // the closure must work for this local's short lifetime }
fn main() { println!("{}", callonlocal(|s| s.len())); // 5 }
So for the most part, higher-ranked trait bounds are a thing you read, not a thing you write. You will meet the for syntax far more often in a compiler error message, or in the signature of a library function you are studying, than in your own code. Recognising it -- knowing it just means "for every lifetime" and calming down accordingly -- matters more than producing it.
Where the syntax actually surfaces
There is one everyday place the for really does show up in code you read: inside the type of a stored closure. When you keep a boxed closure in a struct field, the trait object type carries the higher-ranked lifetime, whether spelled out or elided:
rust struct Trimmer { op: Box usize>, // really Box Fn(&'a str) -> usize> }
fn main() { let t = Trimmer { op: Box::new(|s| s.trim().len()), }; println!("{}", (t.op)(" hello ")); // 5 }
The field type Box usize> stores a closure that works for any input lifetime -- which is what you want, because a Trimmer should be callable on borrows of all kinds. When you eventually hit an error that reads "implementation is not general enough" or mentions for , this is the feature talking: the compiler is telling you a closure was only general enough for one lifetime where a higher-ranked bound demanded it be general over all of them. Now you know what those words mean.
The three elision rules, in full
Now the second feature, and the reason most reference-taking functions need no 'a at all. Lifetime elision is a small, fully deterministic set of rules the compiler applies to fill in lifetimes you left off. There are exactly three, and they are worth memorising because they explain every "why did this compile without annotations?" moment you have had:
- Rule 1 -- each elided lifetime in the parameters gets its own distinct lifetime. One reference parameter, one fresh lifetime; two reference parameters, two different fresh lifetimes; and so on. - Rule 2 -- if there is exactly one input lifetime (elided or not), that lifetime is assigned to every elided output lifetime. - Rule 3 -- if there are multiple input lifetimes but one of them is &self or &mut self, the lifetime of self is assigned to every elided output lifetime.
If, after applying these three, any output lifetime is still unknown, elision fails and the compiler asks you to annotate. Here are rules two and three doing their job:
Tags: #stem#stemsocial#steemstem#rust#programming