Two Ways Not to Move
Lately I've been talking to a few people about whether it might be possible to replace the Pin
wrapper in Rust with a new Move
trait.
Pin
is one of those things that, while amazing that Rust can express the concept and semantics using just a library type, is not a lot of fun to use.
This creates friction in building out Rust's async features because currently doing so increases the opportunities for users to be exposed to Pin
.
Maybe we can just get rid of Pin
entirely?
One possible way to do this is with a trait called Move
.
This would be a trait that most types implement and indicates that you have the ability to perform a move operation on a value of that type.
It's sort of like the Send
trait, where when a type implements Send
it means values of those types can be sent to another thread.
This trait was considered somewhere in the Rust 2015 and 2018 timeframe but was rejected in favor of Pin
instead.
The main reason is that Pin
could be added in a way that meets Rust's stability guarantees.
Indeed, how to add Move
now is a huge problem that we're going to entirely ignore in this post.
I recently discovered that the version of Move
that was originally considered worked quite different from how I would have expected.
As a result, we accidentally have two Move
designs.
In this post, I want to briefly describe each one and then discuss some of the tradeoffs they make.
The Original Move
🔗
Originally Move
was a marker trait that meant values of a type that implements Move
can be moved even after taking that value's address.
That definition applies to basically all types in Rust now, so the more interesting thing to think about is what can you do with a value of a type that does not implement Move
?
You end up with two phases to a value's lifetime. There's some period in which you can move a value around, but then when you take its address the value can no longer be moved.
Futures in Rust work this way now.
When you create a future, you can move it around, send it to another thread (as long as it implements Send
), put it in a data structure, etc.
But in order to call poll
, you first have to pin the future, and once you do that no longer move the future.
If we had the Move
trait instead, the Future
trait would look like this:
trait Future { type Output; fn poll(&mut self, cx: &mut Context) -> Poll<Self::Output>; }
Notice how Pin
has just gone away?
When you call the poll
method, doing something like f.poll(cx)
, Rust automatically borrows (i.e. takes the address of) f
for you and passes that as the self
argument to poll
.
If the future does not implement Move
, the compiler would notice that you've taken the address of f
and prevent you from moving f
after that point.
When I first heard this I thought the analysis to make this work would surely be infeasible.
It turns out it's actually not too hard and can be done entirely with local reasoning.
For example, if you've received a reference to some !Move
type, you know it's pinned because someone had to take the address to give you a reference.
So you basically just need to track where the first place you borrow some owned type happens.
This lines up neatly with the kind of analysis the compiler is already doing in borrow checking.
So why don't we have this version of Move
?🔗
The main reason is that it's not backwards compatible.
Let's look at one of many examples of the way it's not backwards compatible.
Functions in Rust implement one or more of the various Fn*
traits.
Each of these have an Output
parameter that represents the return value.
For !Move
types, we want to be able to return them from a function, such as to make a constructor function called new
.
So this means we need to add ?Move
as a bound to the Output
parameter of the Fn*
traits.
But now, any type you want to have the existing behavior, where return values are movable, you'd have to add a Output: Move
bound to your type signature.
This version of Move
also struggles with projections.1
Consider the following code:
struct Slot<T: ?Move> { slot: Option<T> } impl<T: ?Move> Slot { fn fill_slot(&mut self, value: T) { self.slot = Some(value); } fn take(&mut self) -> T { self.slot.take().unwrap() } }
Is take
legal?
Strictly no, because to call take
you've exposed the address of self
, which technically means you've exposed the address of all the fields of self
, meaning slot
would no longer be moveable.
This particular case happens to be safe since we can see that there's no way to actually observe the address of slot
.
But we've now made this an interprocedural analysis instead of a local analysis.
Consider if we add the following method:
fn poll_inner(&mut self, cx: &mut Context) -> Option<Poll<T>> where T: Future { self.map(|f| f.poll(cx)) }
Now knowing whether take
is safe depends on whether poll_inner
has been called.
This particular case is probably not insurmountable.
We'd probably just go with our initial conservative observation that take
exposes the address of slot
and therefore slot
is no longer moveable.
But this also greatly limits the flexibility of the Move
trait.
The New Move
🔗
When I first started thinking about a Move
trait, I assumed its definition was that a value of a certain type could be moved if and only if the type implements Move
.2
In particular, you'd need a Move
implementation to do any of the following things:
- Pass a value to a function
- Return a value from a function3
- Use
mem::swap
,mem::replace
,mem::take
, or similar functions
Many things about this version of the trait just work in surprisingly pleasing ways.
For example, pin projection just turns into normal projection.
You don't need any annotations to say which fields can be pinned or not because this information is carried by whether the type of the field implements Move
.
Similarly, there's no ambiguity in the Slot
example before; in order to write take
you must have a Move
bound on T
.
The downside is that this doesn't naturally support the patterns where a type may move around for a while until at some point it's pinned and then remains pinned.
Instead we have to rely on API design and use one type to encapsulate the unpinned phase of a value's lifetime, and then use another type to encapsulate the pinned phase.
For Future
, we already have this, with the IntoFuture
trait being able to represent the unpinned phase and the Future
trait representing the pinned phase.
How you convert from one to the other is hard though, because you can't return a !Move
type from a function.
In stable Rust today, you could work around this using MaybeUninit
but long term we'd need to design some kind of placement new or emplace feature.
What about backwards compatibility?
This formulation doesn't have the issue of needing to add ?Move
to the Fn*::Output
types.
In fact, there aren't many places where you'd need to add the bound.
The reason is that if you aren't moving something, you are already taking a reference to it.
Reference types like &T
and &mut T
implement Move
even if T
does not.
So the only place where it really makes sense to add a ?Move
bound is if you are already taking a generic parameter by reference and don't need to move out of it.
The bigger backwards compatibility question is how you'd make Move
interoperate well with the existing Pin
wrapper and the functions that use it.
I haven't thought much about this problem, so that will have to wait for another post.
Thanks to Boats for pointing this out to me.↩
This is also the definition that Yosh Wuyts was working from in his post Ergonomic Self-Referential Types for Rust. Also, thanks for Yosh for helping me work through the ideas in this section.↩
It's interesting that in this formulation we also cannot return a !Move
type from a function, but in this case it's a feature rather than an insurmountable obstacle.↩