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:
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:
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:
Or:
All of these are equivalent, they just define the trait bounds in different ways.
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:
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:
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:
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
:
Let's create a struct and implement the Processor
trait for it:
Now, let's write a function get_processor()
that returns a closure that uses the Processor
trait:
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:
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
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:
Let's run the code and see if it works:
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:
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:
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
:
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.
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
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?"
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!