Your Missed Rust Class
There are two topics I struggled to grasp back when I was learning Rust:
Pin
s- Subtyping & Variance
These topics may sound scary for some people, but they are actually quite straightforward once explained. Here, I want to explain them in layman’s terms.
Pins§
Pinning in Rust ensures that an object has a stable location in memory, meaning it won’t be moved
You might wonder why this matters and how does the Pin
type achieve this?
These are excellent questions. To answer them, we need to understand what Future<T>
is hiding from us.
Anatomy of a Future<T>
§
Future<T>
is a type for representing asynchronous jobs, somewhat similar to Promise
in JavaScript.
The interesting part here is the internals of a Future<T>
. How does it determine when it’s completed, and how is its state stored?
Before answering this, let’s take a step back and look at how to obtain a Future<T>
in the first place.
You may have heard that the async
keyword is a function decorator in Rust. It transforms a function in an
interesting way. Suppose we have this:
|
|
What does async
do here? It takes your function, and wraps the return type in a Future
and moves body into an async
block, like this:
|
|
The async
block here generates an “anonymous structure” implementing the Future
trait which is defined as follows:
|
|
The poll function drives the Future<T>
forward. If the future is ready (i.e., the function has returned), it returns Poll::Ready(Output)
,
informing the runtime that the future has completed. Otherwise, it returns Poll::Pending
, which the runtime receives. In this case, the
runtime may then poll other futures to allow them to progress.
In the example above, our future will return Poll::Ready
immediately, as there are no await
points.
Let’s see an example of a more complicated future:
|
|
Let’s try to reason about it. The runtime will execute our future until it hits the first await point (opening the file).
This operation won’t return the value immediately, so the runtime polls other futures. However, after some time the runtime
polls our foo
future again, and this time the file has been opened, so the execution continues up until the second .await
point. After the read operation completed, we return our dst
array.
This seems straightforward, doesn’t it? But have you noticed a potential issue? If the read operation returns early,
the dst
array, being a stack variable, would be deallocated, and we would return garbage data.
Futures are stateful§
To solve this problem, futures are designed as “functions with state”. For any variable that crosses a .await
point,
the generated future stores it in its internal state.
Let’s have a look at the actual future generated by the compiler for the example above:
|
|
To generate this for yourself, run this:
|
|
Note: You can also use the
#[rustc_layout(debug)
attribute in conjuction with#[define_opaque(T)]
to see the layout of the future. More
There will be lots of output, search for something like {async block ..}
or {async fn body of ..}
.
Back to the topic. The generated future seems to be an enum
with 5 variants. Only two of them are of interest.
Translating it to Rust code we get this:
|
|
The first one (Suspend0
) is a variant for our first .await
. It’s pretty simple and only contains the future
generated by tokio::fs::File::open::<T>()
.
The next variant, though, is much more interesting. Two fields are already familiar to us,
as they are the variables we created: src
, representing tokio::fs::File
(notable 104 bytes, quite large)
and the dst
array of 64 bytes.
The .__awaitee
field is particularly interesting. It represents the future from the read operation, and its internals are what
matter: it’s 24 bytes on my x86_64 machine (where width of a poitnter is 8 bytes). It also contains two references: one to
src
and one to dst
(a fat pointer, hence occupying 16 bytes)
Let’s make sure that this’s true by looking at the definition if the tokio::io::util::read::Read<'_, R>
structure:
|
|
Yeap, in our future reader
and buf
fields are indeed self-references. So the lifetime in tokio::io::util::read::Read<'_, tokio::fs::File>
has to be something like 'self
, but as of now there is no way to express self-references with lifetimes.
All of this leads to the question of what can go wrong here and how can the Pin<P>
type address the issue?
Self-referential types are subtle§
You see, self-references add a bit of spiciness: it becomes unsafe to move any structure containing them.
Let’s forget about futures for now and have a look at another self-referential struct (that’s easier to reason about):
|
|
So, why is it unsafe to move such a structure? Well, I think an example here illustrates it better:
|
|
What is the output? Pay attention, as we moved (swapped) these objects.
«YOUR INTENSIVE THINKING HERE»
Obviously enough, it’s Fizz
. Wait, what? We swapped these structures, isn’t it supposed to be Buzz
?
Nope. You see, in both structures, p
contains address of the s
field, and when we swap this is what happens:
|
|
After the swap, p
s point to the address of the string before the swap happened.
Surely enough, this is unsound as it just broke our invariant: p
is not a self reference anymore. If s2
dies, s1.p
is a dangling reference, but we still assume that it’s a self-reference with the lifetime of s1
!
One simple swap (move) can break all of this.
So the question is: how to guarantee a stable location in memory of a self-referential object?
Pin<P>
and Unpin
§
To solve the problem of fragility of self-referential types, a new smart pointer and an auto trait were added: Pin<P>
and Unpin
.
The former is a type that wraps a pointer with some restrictions depending on the pointee (we’ll discuss it below),
and the latter is a trait the compiler implements for every type if it’s save to move.
But why is it a smart pointer? The reason for this is pretty simple: if Pin<T>
held the object
directly, we would need to ensure Pin<T>
itself isn’t moved, which doesn’t solve the problem.
Such a type would make little sense, and as we all know
All problems in computer science can be solved by another level of indirection
So P
in Pin<P>
stands for pointer.
To achieve its ambitious goal, Pin<P>
has the following idea in mind:
- If the pointee of
P
is something that we can freely move around, no restrictions are imposed (Pointee: Unpin
) - Otherwise, once we create
Pin<P>
, some restrictions apply: you can’t access the value mutably (because this would allow anyone to move it) , and the pointee must be dropped with thePin<P>
itself
Let’s look at an example:
|
|
If you try to compile this, you should get a pretty bizzare (on the first glance) compilation error:
|
|
If you recall the contract of Pin<P>
, it explicitly states that if we wish to pin something that isn’t Unpin
,
it must be dropped with the Pin<P>
. Regular references allow to bypass this rule, as Pin<P>
will live less than a the object.
Consider this:
|
|
If this was allowed, the contract of the Pin<P>
would be violated.
Okay, we got this, but why does the compiler suggest using Box::pin
? You see, Box<T>
is an owning pointer: the pointee
is being dropped with the box. By extension, it’ll be dropped with the pin:
|
|
In this example, the pinned variable will die when Pin
will drop the box, so the contract is upheld and everybody is happy.
However, have you noticed another suggestion from the compiler?
|
|
Looking at the documentation, we see this:
Constructs a
Pin<&mut T>
, by pinning avalue: T
locally.
What, local pinning? That’s some new terminology.
Let’s look at the definition of the macro, maybe it’s going to be more insightful:
|
|
The crucial part are braces around $value
. Shortly, these braces move $value
to the scope
of the pin, making it inaccessible for everyone else. This works since now no one except the
created pin will have access to the value, and the value’ll die alongside the pin.
If the paragraph above isn’t convincing, I encourage you to read the large comment I ommited, it has a lot of text for such a short macro :)
Conclusion§
Hopefully this wasn’t hard and even eye-opening for someone. Although the pinning topic in Rust is pretty straighforward, a lot of people struggle understanding it. With this foundation, you can explore more on your own, as I didn’t cover some topics (like pin projection). I advise you by starting from a brilliant video from Jon, the chapter on pinning from the async book (or even better, spend some time and read the whole book). The documentation of the pin module is also quite comprehensive, give it a read!
Subtyping§
Subtyping and variance in Rust are all about lifetimes and making our lives easier. There are three
“types” of variance (for types Child
and Parent
, where Parent
is the parent of Child
):
- Covariance: wherever
Parent
is expected we can passChild
- Contravariance: inverse of covariance. We can pass
Parent
instead ofChild
and be just fine - Invariance: no subtyping property. If
Child
is expected,Parent
won’t work and vice versa.
Let’s start with covariance and its definition, but before, a bit of notation:
‘a defines a region of code.
’long <: ‘short means that ’long defines a region of code that completely contains ‘short.
This is taken from then excellent chapter from The Rustonomicon which the rest of section on variance will follow. The Nomicon’s explanation, though, can be quite challenging for beginners — I know students who didn’t understand it at all
Covariance§
Immutable references are covariant over their lifetime. This means that we can “downcast” &'long T
to &'short T
.
Here is a small example from the nomicon to make this clear:
|
|
If there were no covariance, this code wouldn’t have compiled: &'static str
is not of the same type as &'world str
.
However, it compiles perfectly well without any warning, because the hello
string was “downcasted” to a shorter lifetime.
This absolutely makes sense, if we think logically: if an immutable reference lives for some long lifetime, we can
downcast it to some shorter lifetime, as the variable will still be around.
Pretty simple, huh? And that’s it. But did you notice my emphasis on immutability? Why can’t this rule be extended for mutable references as well?
Invariance§
Here is the interesting part. Mutable references are invariant over their lifetime, meaning that there is no subtyping relationship.
Before explaining anything, let’s assume that a covariance relationship exists, meaning that if there is &'long mut T
, we
can assume it’s similar to &'short mut T
. The code is again taken from the nomicon chapter:
|
|
What does happen here? Let’s first pay attention to the function signature of assign
(1). It accepts a mutable reference to
an object of type T
, and an object of that type. So, types here must be absolutely equal. Now, at (2), they differ:
we have hello
which lives longer than world
, so we downcast &'static str
to &'world str
to have two arguments of
the same type (T = &'world str
, I know it’s a bit confusing).
That’s exactly where the catastrophy happens! Shortening lifetime of a mutable reference allows us to write a value which
doesn’t live long enough. In our example, world
string is dropped at the end of its scope, though hello
lives and points
to it. Now, at (3) we end up with a dangling reference which is dereferenced to print the string…
This’s it, the entire reason why mutable references are invariant. Not so hard, isn’t it?
Contravariance§
This is my favorite part. The only source of contravariance in Rust are function pointers. Specifically, arguments of function pointers.
What does that mean? Well, let’s see. Assume we have this function
|
|
Its function pointer type is fn(&'static str)
. So, this is a function
pointer that accepts a string slice of any lifetime.
Now, why is this contravariant? If you recall, “child type” of an immutable reference is a reference that lives shorter.
This means that we are free to substitute something like fn(&'short str)
instead of fn(&'long str)
(if 'long
overlives 'short
, of course).
WHY?
Again, let’s have a look at the code
|
|
We have two functions, one takes a string slice of any lifetime, and the other one only static string slices.
Variable x
is of type fn(&'static str)
. Then we call it and reinitialize at (2)…
What happens with print_str
, is that it gets “upcasted”, from
|
|
to
|
|
Reasoning about this should eventually lead to the conclusion that this is okay, as print_str
is intended to be used
with string slices of any lifetimes (with short lifetimes, in particular). However, x
is of type fn(&'static str)
,
so we know a static string slice will always be passed to it. Therefore, print_str
will always have a 'static str
,
and as we know 'static
outlives any other lifetime, or using the nerdy notation we defined: for<'a> 'static <: 'a
.
This rule is tightly coupled with the covariance property of immutable lifetimes.
There is more to function pointers§
If you read the nomicon chapter I referenced earlier, there is a small section about covariance of function pointers. Wait, aren’t they contravariant? Well, again, function pointers are contravariant over input arguments. Covariance here, though, is about return types. The same stuff in essence, but just flipped :)
Let’s assume we have the following functions:
|
|
And here is the example code:
|
|
This is fine because the static string slice returned from get_static
will be treated as &'a str
. After all,
we already concluded that “downcasting” immutable references of a long lifetime to a shorter one is okay (and, again,
&'static
outlives anything).
Conclusion§
This section may seem like a breather at first glance, but it’s quite interesting in practice. I highly recommend reading the chapter from the nomicon, as it covered a bit more that I did here.