Higher-Rank Trait Bounds

Rust is known for its powerful type system, including generics, traits, and lifetimes. Giving you a robust type system while giving you some flexibility in how you use it.

If you're new to Rust, you might be a little intimidated by this code:

fn process_items<I, F>(
    items: Vec<I>,
    handler: F,
) -> impl for<'a> Fn(&'a I) -> Box<dyn Future<Output = bool> + 'static>
where
    I: Clone + Send + 'static,
    F: for<'b> Fn(&'b [I]) -> Result<bool, &'b str> + Clone + Send + 'static,
{
    // Implementation here...
}

This code consists of trait bounds, lifetimes, and Higher-Ranked Trait Bounds (HRTBs) which we'll discuss in this article.

By the end of this article, you'll completely understand what this code does and why it's so powerful. So don't get discouraged by the syntax; let's dive in!

The best way to explain a complex concept is to break it down into smaller, more digestible parts and then put them back together in the end. So let's start with the basics.

Trait bounds

You might already know about trait bounds, it's just a way to specify that a generic type must implement a certain trait. For example:

fn print<T: Display>(value: T) {
    println!("{}", value);
}

In this example, we are expecting the argument of the print function to be any type that implements the Display trait. This is a simple and common use of trait bounds in Rust.

Defining trait bounds can be done using an alternative syntax:

fn print<T>(value: T)
where
    T: Display,
{
    println!("{}", value);
}

Or:

fn print<T>(value: impl Display) {
    println!("{}", value);
}

All of these are equivalent, they just define the trait bounds in different ways.

Learn Rust by Practice

Master Rust through hands-on coding exercises and real-world examples.

Lifetimes

Lifetimes are another important concept in Rust. It's just a way to let the borrow checker know how long a reference is valid. It makes sure you are pointing to valid memory locations and avoid dangling pointers.

For example:

fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() > s2.len() {
        s1
    } else {
        s2
    }
}

In this example, we are expecting the two references s1 and s2 to have the same lifetime 'a. This makes sure that both references are valid for the same duration.

We are also annotating the return value with the same lifetime 'a which means the returned reference will be valid for the same duration as the input references.

If one of them goes out of scope before the other, the borrow checker will catch it and give you a compile-time error.

To use the code above:

fn main() {
    let s1 = String::from("hello");
    let s2 = String::from("world");
    let result = longest(&s1, &s2);
    println!("The longest string is: {}", result);
}

If you make a mistake in using the code above by having invalid references, the borrow checker will catch it and give you a compile-time error:

fn main() {
    let s1 = String::from("hello");
    let result;
    {
        let s2 = String::from("world");
        result = longest(&s1, &s2);
    }
    println!("The longest string is: {}", result);
}

In this example the s2 reference goes out of scope before the result reference. After the code block, the result is not dropped but s2 is dropped, that means result is still pointing to an invalid memory location. The borrow checker will catch this and give you a compile-time error makes our lives much easier.

Now that we have an overview about lifetimes and trait bounds, let's dive into a more complex function that uses both of them.

Example: Higher-Ranked Trait Bounds

Let's say we have a trait Processor that has a method process and returns a String:

trait Processor {
    fn process<T: Display>(&self, value: T) -> String
}

Let's create a struct and implement the Processor trait for it:

struct MyProcessor;
 
impl Processor for MyProcessor {
    fn process<T: Display>(&self, value: T) -> String {
        format!("{}", value)
    }
}

Now, let's write a function get_processor() that returns a closure that uses the Processor trait:

fn get_processor<P>(processor: P) -> impl Fn(&str) -> String
where
    P: Processor,
{
    move |value| processor.process(value)
}

We use the impl Fn(i32) -> String to define the closure that takes an i32 and returns a String. The P: Processor trait bound specifies that the processor argument must implement the Processor trait.

Now that the function is ready, we can use this in our code and get the closure:

fn main(){
    let processor = MyProcessor;
    let process_closure = get_processor(processor);
 
    let item_1 = "10".to_string();
    let item_2 = "20".to_string();
 
    println!("{}", process_closure(&item_1));
    println!("{}", process_closure(&item_2));
}

Most of the times, whenever we define a reference, the compiler will infer the lifetimes for us. But in complex scenarios, the compiler will not be able to infer them, in such cases we need to specify the lifetimes explicitly.

In the example above, the compiler can easily infer the lifetimes for us, so we don't need to specify them.

Let's run the code above:

Output Output

Since the compiler can infer the lifetimes for us, the code runs without any issues, but what if we wanted to specify the lifetimes explicitly? How would they look like?

Specifying Lifetimes Explicitly

Intuitively, you might think that we can specify the lifetimes like this:

fn get_processor<'a, P>(processor: P) -> impl Fn(&'a str) -> String
where
    P: Processor,
{
    move |value| processor.process(value)
}

Let's run the code and see if it works:

Lifetime error Lifetime error

We got a lifetime error, item_1 and item_2 do not live long enough! Why are we getting this message?

Explaining the lifetime error

Let's explore the issue and see how we can fix it.

We used the 'a lifetime specifier here:

fn get_processor<'a, P>(processor: P) -> impl Fn(&'a str) -> String
...

We have specified the lifetime 'a, but it's not used in the function arguments, instead it's used in the argument of the closure returned by the function.

This establishes a relationship between the argument of the closure and the closure itself.

This relationship ensures that the closure returned by get_processor() can only be called with references live at least as long as the closure itself. Now, let's have a look at the main function:

fn main(){
    let processor = MyProcessor;
    let process_closure = get_processor(processor);
 
    let item_1 = "10".to_string();
    let item_2 = "20".to_string();
 
    println!("{}", process_closure(&item_1));
    println!("{}", process_closure(&item_2));
}

We can see that the closure is stored in the process_closure variable, which lives until the end of the main function.

Values in Rust are dropped in reverse order, meaning item_2 is dropped, then item_1, and finally the processor. This means that the string will be dropped before the closure stored in process_closure.

Once item_2 is dropped, all the references to item_2 will be invalid, because the references are located to de-allocated memory.

The problem is that the closure expects a reference that lives as long as the processor, but the string item_2 is dropped before the processor. This is why we are getting the lifetime error.

A simple and not recommended solution is to increase the lifetime of the item_2 by moving it above the process_closure:

fn main(){
    let processor = MyProcessor;
    let item_2 = "20".to_string();
    let item_1 = "10".to_string();
 
    let process_closure = get_processor(processor);
 
    println!("{}", process_closure(&item_1));
    println!("{}", process_closure(&item_2));
}

This way, the item_2 will live until the end of the main function, and the closure will be able to use it without any issues.

But this is not a good approach, because in complex scenarios, you might not be able to move the references around, it might be critical for your function to define the closure at the top of the function.

So a better approach is making the trait bound valid for all lifetimes, this is where Higher-Ranked Trait Bounds (HRTBs) come into play.

The Solution: Higher-Ranked Trait Bounds

To solve this issue, instead of having a too accept a very restrictive lifetime (a reference that lives at least as long as the closure), we can use higher-ranked trait bounds to make the trait bound valid for all lifetimes, meaning the returned closure can be called with references of any lifetime.

fn get_processor<P>(processor: P) -> impl for<'a> Fn(&'a str) -> String
where
    P: Processor,
{
    move |value| processor.process(value)
}

In this example, we are using the for<'a> syntax to specify that the trait bound is valid for all lifetimes 'a. This means that the closure returned by get_processor() can be called with references of any lifetime.

Running the code now, we can see we don't get any compile errors:

Resolved lifetime error Resolved lifetime error

Conclusion

In many cases, the compiler can infer lifetimes for us, but in more complex scenarios, we might need to specify them explicitly.

When returning closures, it's crucial to specify the lifetimes as higher-ranked so that the closure can accept references of any lifetime.

Now that you have a better understanding of Higher-Ranked Trait Bounds, can you understand the code at the beginning of this article?"

fn process_items<I, F>(
    items: Vec<I>,
    handler: F,
) -> impl for<'a> Fn(&'a I) -> Box<dyn Future<Output = bool> + 'static>
where
    I: Clone + Send + 'static,
    F: for<'b> Fn(&'b [I]) -> Result<bool, &'b str> + Clone + Send + 'static,
{
    // Implementation here...
}

I hope you do! Thanks for reading, if you enjoyed this article, you'll also enjoy trying out the Practice section on our platform

Good luck and happy coding!

Learn Rust by Practice

Master Rust through hands-on coding exercises and real-world examples.

Check out our blog

Discover more insightful articles and stay up-to-date with the latest trends.

Subscribe to our newsletter

Get the latest updates and exclusive content delivered straight to your inbox.