Postfix Macros and `let place`
Postfix macros is the feature proposal that would
allow something.macro!(x, y, z). It’s been stalled for a long time on some design issues; in this
blog post I’m exploring an idea that could answer these issues.
The obvious way to make the feature work is to say that in <expr>.macro!(), the macro gets the
tokens for <expr> and does what it wants with them.
This however allows macros to break the so-called “no-backtracking rule” (coined by Tyler Mandry
IIRC): in x.is_some().while! { ... }, reading the while makes us realize that the is_some()
call wasn’t just a boolean value, it was an expression to be evaluated every loop. So we sort of
have to go back and re-read the beginning of the line. For purposes of reducing surprise and code
legibility, we’d like to avoid that.
Hence the question that the feature stalled on: can we design postfix macros that always respect the
no-backtracking rule? We would need to somehow evaluate <expr> once and pass the result to the
macro instead of passing <expr> itself. Apart from that I’ll assume that we want maximal
expressiveness.
This post is centrally about places and the implicit operations that surround them; check out my recent blog post on the topic for an overview of that vocabulary.
Partial Place Evaluation
To get the obvious out of the way: we can’t just desugar <expr>.method() to let x = <expr>;
x.method(); that may give entirely the wrong behavior, e.g.:
struct Foo { count: Option<u32> }
impl Foo {
fn take_count(&mut self) -> Option<u32> {
// That's fine
self.count.take()
// That creates a copy
// let tmp = self.count;
// tmp.take() // modifies the copy instead of the original
}
}
In technical terms, that’s because the LHS of a method call is a place expression. Storing
<expr> in a temporary adds an incorrect place-to-value coercion. The same applies to postfix
macros.
I think that the behavior we ideally want is to pre-evaluate all temporaries (that arise from value-to-place coercion), and pass whatever remains of the expression as-is to the macro. I’ll call that “partial place evaluation”.
Some examples:
let x: Foo = ...;
x.field.macro!()
// becomes (there are no temporaries)
macro!(x.field)
impl .. { fn method(&self) -> Foo { .. } }
x.method().field.macro!()
// becomes
let mut tmp = x.method();
macro!(tmp.field)
Looks easy enough, except for autoderef1.
let x: Box<Foo> = ...;
x.field.macro!()
Depending on the contents of macro!(), this may need to expand to a call to deref or deref_mut:
let tmp = Box::deref(&x);
macro!((*tmp).field)
// or
let tmp = Box::deref_mut(&mut x);
macro!((*tmp).field)
At this point it’s hopefully clear that no simple syntactic transformation will give us what we want.
Place aliases, aka let place
What we’re trying to express is “compute a place once and use it many times”.
let place is an idea I’ve seen floating around2 which expresses exactly that:
let place p = <expr>; causes <expr> to be evaluated as a place,
and then p to become an alias for the place in question.
In particular, this does not cause a place-to-value coercion.3
let place p = x.field; // no place-to-value, so this does not try to move out of the place
something(&p);
something_else(p); // now this moves out
// would be identical to:
something(&x.field);
something_else(x.field); // now this moves out
let place p = x.method().field;
something(&p);
// would be identical to:
let tmp = x.method();
something(&tmp.field);
This is exactly what we need for postfix macros: <expr>.macro!() would become (using a match to
make the temporary lifetimes work as they should 🤞):
match <expr> {
place p => macro!(p),
}
This would have the effect I propose above: any side-effects are evaluated early, and then we can do what we want with the resulting place.
One of my litmus tests of expressivity for postfix macros is this write! macro, which ends up
working pretty straighforwardly:
macro_rules! write {
($self:self, $val:expr) => ({
$self = $val; // assign to the place
&mut $self // borrow it mutably
})
}
let mut x; // borrowck understands that `write!` initializes the place!
let _ = x.write!(Some(42)).take();
// desugars to:
let _ = match x {
place p => write!(p, Some(42)).take(),
};
// desugars to:
let _ = write!(x, Some(42)).take();
// desugars to:
let _ = {
x = Some(42);
(&mut x).take()
};
let place and custom autoderef
The hard question is still autoderef1 :
let mut x: Box<Foo> = ...;
let place p = x.field; // should this use `deref` or `deref_mut`?
something(&p);
something_else(&mut p); // causes `deref_mut` to be called above
For that to work, we infer for each place alias whether it is used by-ref, by-ref-mut or by-move
(like closure captures I think), and propagate this information to its declaration so that we can
know which Deref variant to call 4.
let place isn’t too powerful
Turns out let place is a rather simple feature when we play with it:
// Place aliases can't be reassigned:
let place p = x.field;
// Warning, this assigns to `x.field` here! that's what we want place aliases to do
// but it's admittedly surprising.
p = x.other_field;
// You can't end the scope of a place alias by hand:
let place p = x.field;
drop(p); // oops you moved out of `x.field`
// `p` is still usable here, e.g. you can assign to it
// Place aliases can't be conditional.
let place p = if foo() { // value-to-place happens at the assignment
x.field // place-to-value happens here
} else {
x.other_field
};
// This won't mutate either of the fields, `p` is fresh from a value-to-place coercion. I propose
// that this should just be an error to avoid sadness.
do_something(&mut p);
In particular it’s easy to statically know what each place alias is an alias for.
The caveat is that all of those are surprising if you think of p as a variable. This is definitely
not a beginners feature.
let place doesn’t need to exist in MIR
The big question that let place raises is what this even means in the operational semantics of
Rust. Do we need a new notion of “place alias” in MiniRust?
I think not. The reason is that the “store intermediate values in temporaries” happens automatically
when we lower to MIR. All place coercions and such are explicit, and MIR place expressions do not cause
side-effects. So whenever we lower a let place p to MIR, we can record what mir::Place p
stands for and substitute it wherever it’s used.
To ensure that the original place doesn’t get used while the alias is live, we insert a fake borrow
where the let place is taken and fake reads when it’s referenced. That’s already a trick we use
in MIR lowering for exactly this purpose5.
So the only difficulty seems to be the mutability inference mentioned in previous section. The rest
of typechecking let place is straighforward: let place p = <expr>; makes a place with the same
type as <expr>, and then it behaves pretty much like a local variable.
All in all this is looking like a much simpler feature that I expected when I started playing with it.
let place is fun
I kinda of want it just because it’s cute. It makes explicit something implicit in a rather elegant way. Here are some fun things I discovered about it.
To start with, it kind of subsumes binding modes in patterns: if let Some(ref x) = ... is the same
thing as if let Some(place p) = ... && let x = &p. One could even use place x instead of x in
patterns everywhere and let autoref set the right binding mode! That’s a funky alternative to match
ergonomics.
We can also use it to explain this one weird corner case of borrow-checking. This code is rejected by the borrow-checker, can you tell why?
let mut x: &[_] = &[[0, 1]];
let y: &[_] = &[];
let _ = x[0][{x = y; 1}];
// ^^^^ value is immutable in indexing expression
What’s happening is that we do the first bound-check on x before we evaluate the second index
expression. So we can’t have that expression invalidate the bound-check on pain of UB. We can use
let place to explain the situation via a desugaring:
x[0][{x = y; 1}]
// desugars to:
let place p = x[0]; // bounds check happens here
p[{x = y; 1}]
// desugars to:
let place p = x[0];
let index = {x = y; 1}; // x modified here
p[index] // but place alias used again here
Can this be used to explain closure captures? I don’t think so because closures really do carry borrows of places, not just places. It does feel like a related kind of magic though.
Conclusion
I started out writing this blog post not knowing where it would lead, and I’m stoked of how clean
this proposal ended up looking. I kinda want let place even independently from postfix macros. The
one weird thing about let place is this “mutability inference” for autoderef, hopefully that’s an
acceptable complication.
I’m most looking forward to everyone’s feedback on this; let place is rather fresh and I wanna
know if I missed anything important (or anything fun!).
-
Well
Boxdoesn’t actually useDeref/DerefMutbecause it’s built into the borrow-checker, but that’s the easiest type to use for illustration so forgive me. ↩ ↩2 -
I can’t find references to that idea apart from this thread, so maybe I’m the one who came up with it. I can find this that uses the same syntax but for a different purpose. ↩
-
In a way
let placecreates a sort of magic reference to the place: you can move out of it (if allowed), mutate it (if allowed), get shared access to it (if allowed). The “magic” part is that the permissions that the magic reference requires are inferred from how the reference is used, instead of declared up front like for&,&mutand proposed extensions like&pin mut,&ownand&uninit. ↩ -
You might thing this gets more complicated with custom places and field projections, but actually for those we have no choice but to call the appropriate
PlaceOperationtrait method only when we know what operation is being done on the place, so there’s no need to infer anything. The question of how to represent this in MIR may get a bit more tricky though. ↩ -
See the indexing example below, which I took from the doc on fake borrows. ↩