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:
async fn hello() -> String {
String::from("Hello world")
}
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:
fn hello() -> impl Future<Output = String> {
async move {
String::from("Hello world")
}
}
The async
block here generates an “anonymous structure” implementing the Future
trait which is defined as follows:
pub trait Future {
type Output;
// Ignore `Pin` for now
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T),
Pending,
}
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 Poll::Pending
is what 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:
use tokio::{io::AsyncReadExt, fs::File}; // feature flags: ['fs', 'io-util']
async fn foo() -> [u8; 64] {
let mut src = File::open("foo.txt")
.await
.expect("damn would you create `foo.txt` for me 🥺😭??");
let mut dst = [0u8; 64];
let _ = src.read(&mut dst).await;
dst
}
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”. Any variable that crosses a .await
boundary will be stored
in futures internal state.
Let’s have a look at the real future generated by the compiler for the example above!
print-type-size type: `{async fn body of foo()}`: 200 bytes, alignment: 8 bytes
print-type-size discriminant: 1 bytes
print-type-size variant `Unresumed`: 0 bytes
print-type-size variant `Suspend0`: 87 bytes
print-type-size padding: 7 bytes
print-type-size local `.__awaitee`: 80 bytes, alignment: 8 bytes, type: {async fn body of tokio::fs::File::open<&str>()}
print-type-size variant `Suspend1`: 199 bytes
print-type-size padding: 7 bytes
print-type-size local `.src`: 104 bytes, alignment: 8 bytes
print-type-size local `.__awaitee`: 24 bytes, type: tokio::io::util::read::Read<'_, tokio::fs::File>
print-type-size local `.dst`: 64 bytes
print-type-size variant `Returned`: 0 bytes
print-type-size variant `Panicked`: 0 bytes
To generate this for yourself, run this:
$ cargo +nightly rustc -- -Zprint-type-sizes
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 ..}
.
The generated future seems to be an enum
with 5 variants (only two of them interest us)
Translating it to Rust code we get this:
enum FooFuture {
Unresumed,
// the first .await point
Suspend0 {
// the `FileOpenFuture` type is made up, but you get the idea
file: tokio::fs::FileOpenFuture
},
// the second .await point
Suspend1 {
src: tokio::fs::File,
dst: [u8; 64],
__awaitee: tokio::io::util::read::Read<'_, tokio::fs::File>
// Pay attention to the lifetime!
},
Returned,
Panicked
}
Suspend0
is a variant that represents the first .await
point. 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 in size on my x86_64 machine (where width of a pointer 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:
// in our case `R = tokio::fs::File`
pub struct Read<'a, R: ?Sized> {
reader: &'a mut R,
buf: &'a mut [u8],
// Make this future `!Unpin` for compatibility with async trait methods.
#[pin]
_pin: PhantomPinned,
}
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.
Again: .__awaitee
field internally contains two references to src
and dst
fields in the same enum variant.
These are self-references, meaning that they point to the same structure.
Can you foresee any problems with such references?
Self-referential types are subtle§
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):
struct SelfReferential {
s: String,
// `p` should point to `s`
p: *const String
}
impl SelfReferential {
pub fn new(s: String) -> Self {
Self { s, p: std::ptr::null() }
}
fn init_self_ref(&mut self) {
let self_ref = &raw const self.s;
self.p = self_ref;
}
}
So, why is it unsafe to move such a structure? Well, I think an example here illustrates it better:
fn main() {
let mut s1 = SelfReferential::new(String::from("Fizz"));
s1.init_self_ref();
let mut s2 = SelfReferential::new(String::from("Buzz"));
s2.init_self_ref();
std::mem::swap(&mut s1, &mut s2);
println!("{}", unsafe { &*s1.p })
}
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:
For simplicity, pretend that addr_of(s1.s) = 0xA and addr_of(s2.s) = 0xB;
Before swap:
+--------------------------+
|s1 |
+--------------------------+
|s = "Fizz", address = 0xA |
|p -> 0xA |
+--------------------------+
+--------------------------+
|s2 |
+--------------------------+
|s = "Buzz", address = 0xB |
|p -> 0xB |
+--------------------------+
After swap:
+--------------------------+
|s1 |
+--------------------------+
|s = "Buzz", address = 0xA |
|p -> 0xB |
+--------------------------+
+--------------------------+
|s2 |
+--------------------------+
|s = "Fizz", address = 0xB |
|p -> 0xA |
+--------------------------+
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:
use std::{marker::PhantomPinned, pin::Pin};
struct YouCantMoveMe {
// `PhantomPinned` is a marker structure
// that doesn't implement `Unpin`.
// By extension, neither does this
// structure.
_ph: PhantomPinned
}
impl YouCantMoveMe {
fn new() -> Self {
Self {
_ph: PhantomPinned
}
}
}
fn main() {
let x = 42;
let pin = Pin::new(&mut x);
// Pin::set works for any pointee types,
// regardless of whether or not it's okay
// to move. This is because the value
// that is potentially unsafe to move
// gets dropped first, and only then
// we write another value
pin.set(43);
let youcantmoveme = YouCantMoveMe::new();
let pin = Pin::new(&mut youcantmoveme);
}
If you try to compile this, you should get a pretty bizzare (on the first glance) compilation error:
error[E0277]: `PhantomPinned` cannot be unpinned
--> src/main.rs:21:24
|
21 | let pin = Pin::new(&mut youcantmoveme);
| -------- ^^^^^^^^^^^^^^^^^^ within `YouCantMoveMe`, the trait `Unpin` is not implemented for `PhantomPinned`
| |
| required by a bound introduced by this call
|
= note: consider using the `pin!` macro
consider using `Box::pin` if you need to access the pinned value outside of the current scope
note: required because it appears within the type `YouCantMoveMe`
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:
fn main() {
let youcantmoveme = YouCantMoveMe::new();
{
let pin = Pin::new(&mut youcantmoveme);
// Although `pin` dies here
// `youcantmoveme` will be dropped later.
// `pin` is not happy. (;⌣̀_⌣́)
}
}
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:
fn main() {
let youcantmoveme = YouCantMoveMe::new();
{
let pin: Pin<Box<YouCantMoveMe>> = Box::pin(youcantmoveme);
// `youcantmoveme` was actually moved,
// but that's fine since we didn't pin it yet
// and now it dies with the pin itself
}
}
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?
note: consider using the `pin!` macro
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:
pub macro pin($value:expr $(,)?) {
// <a large interesting comment goes here, read it youself...>
$crate::pin::Pin::<&mut _> { __pointer: &mut { $value } }
}
The crucial part here 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. Sometimes a
tricky macro hack woulnd’t hurt, you know.
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 projections). 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 the 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
(as long as 'long <: 'short
).
Here is a small example from the nomicon to make this clear:
// Parent = &'large T
// Child = &'short T
// Note: debug expects two parameters with the *same* lifetime
fn debug<'a>(a: &'a str, b: &'a str) {
println!("a = {a:?} b = {b:?}");
}
fn main() {
let hello: &'static str = "hello";
{ // 'world begins here
let world = String::from("world");
let world = &world; // 'world has a shorter lifetime than 'static
debug(hello, world);
}
}
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:
fn assign<T>(input: &mut T, val: T) { // (1)
*input = val;
}
fn main() {
let mut hello: &'static str = "hello";
{ // 'world begins here
let world = String::from("world");
assign(&mut hello, &world); // (2)
}
println!("{hello}"); // (3)
}
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
fn print_static_str<'a>(input: &'static str) {
println!("{}", input);
}
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
// Parent = fn(&'large T)
// Child = fn(&'short T)
fn print_str<'a>(input: &'a str) {
println!("{}", input);
}
fn print_static_str(input: &'static str) {
println!("{}", input);
}
fn main() {
let mut x: fn(&'static str) = print_static_str; // (1)
x("hello!")
x = print_str; // (2) Compiles
// Type of `x` is still fn(&'static str)
}
We have two functions, one takes a string slice of any lifetime, and the other one takes only static string slices.
Variable x
is of type fn(&'static str)
. We then call it and reinitialize at (2)…
What happens with print_str
, is that it gets “upcasted”, from
for<'a> fn(&'a str) // `for<'a>` means for any lifetime
to
fn(&'static str)
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 that 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 doesn’t work with 'static
lifetimes only, but with any lifetimes, as long as the long one contains a shorter one.
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:
// Parent = fn() -> &'large T
// Child = fn() -> &'short T
fn get_static() -> &'static str;
// and
fn get_str<'a>() -> &'a str;
// pretend that we can write such a signature...
And here is the example code:
fn main() {
let mut x: for <'a> fn() -> &'a str = get_str;
x = get_static;
}
This is sound 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.