Placing Arguments
— 2025-08-13
Introduction
In my last post I introduced placing functions, which are functions that can “return” values without copying them. This is useful not just because it’s more efficient (fewer copies), but also because it guarantees addresses remain stable - which is something we need for types that have internal borrows (self-referential types). Here is a little reminder of what placing functions look like:
#[placing] // ← 1. Marks a function as "placing".
fn new_cat(age: u8) -> Cat { // ← 2. Has a logical return type of `Cat`.
Cat { age } // ← 3. Constructs `Cat` in the caller's frame.
}
As you can see this is just regular Rust code, with the only difference being
the added #[placing]
(placeholder) notation. The main idea behind placing
functions is backwards-compatibility, meaning: we should be able to
progressively add #[placing]
notations to the entire stdlib, just like we’re
doing with const. Which is important, because we don’t want to add yet another
axis to the language, locking out address-sensitive types from being used with
existing traits and functions. We’re already suffering through the bifurcation
Pin
has introduced 1, and I don’t
think we should go through this again for self-referential types.
And no, unfortunately the “pin ergonomics” experiment won’t do anything to resolve this problem. I think it’s a shame we’re not taking this problem more seriously, because this kind of incompatibility affects the language in ways that extend far beyond syntax and conveniences.
Placing Functions
It’s cool to have ideals and visions for how thing should be, but we do always need to square that with reality. And while placing functions seem like they will work without a hitch (fallible placing functions post tbd), it’s placing arguments which seem like they’re going to be a problem. To recap what placing arguments are about, the idea is that we would be able to write the following definition, and things would just work without any copies:
impl<T> Box<T> {
// `Box::new` here takes type `T` and
// constructs it in-place on the heap
fn new(x: #[placing] T) -> Self { ... }
}
In the post I made the following assertion (in bold even, because it’s important):
As a core design constraint: invoking a function that takes placing arguments should be no different from a regular function. This is needed to keep APIs backwards-compatible.
Cool idea, just one small hitch: we can’t actually do that. And that is because placing arguments fundamentally encode callback semantics. Olivier Faure provided this example to prove why:
let x = Box::new({
return 0;
12
});
To preserve compatibility, we need to preserve order of execution. In Rust today
if you call this, the expression in Box::new
will be evaluated before
Box::new
is called. In this case that means that this will return before
Box::new
has a chance to allocate.
If we applied placing semantics, it would fundamentally need to change the
ordering. Before we can place, the place has to be created. In this case that
place is on the heap, which means interacting with the allocator, which in Rust
can panic. That means that code which looks like it should return
before it
calls the function, would actually panic. And that’s bad!
To fully push this point home, Taylor Cramer recently provided an example which
showed how the execution ordering also causes issues with the borrow checker. In this example we would need to hold &vec
as part of the expression for vec.len
, while also holding &mut vec
for vec.push
. And that can’t work; not even with view types and abstract fields 2:
Both Vec::len
and Vec::push
need to access the vector’s “length”. Virtualizing fields / partial borrows won’t change that.
vec.push(make_big_thing(vec.len()));
// ^^^^^^^^ ^^^^^^^
// | &mut vec | &vec
Closures
The only way I see us making sense of this is by actually requiring that users
use closures here. That would make the ordering far clearer, and preventing any
surprises about ordering or borrows. Ralf Jung made the observation that
FnOnce
for these kinds of scenarios actually perfectly encodes the semantics
we want, and I agree. Consider this example:
vec.push(|| make_big_thing(vec.len()));
This would give the following error:
error[E0502]: cannot borrow `vec` as mutable because it is also borrowed as immutable
--> src/main.rs:50:9
|
47 | vec.push(|| make_big_thing(vec.len()));
| --- immutable borrow occurs here
...
47 | vec.push(|| make_big_thing(vec.len()));
| ^^^ mutable borrow occurs here
There is nothing specific to placing functions about this error, just like there’s nothing special about solving it:
let len = vec.len();
vec.push(|| make_big_thing(len);
But we can’t just change the signature of Vec::push
to take closures. Instead
we would need to introduce a new method, e.g. Vec::push_with
that can take a
closure and place its outputs:
impl<T> Vec<T> {
pub fn push_with<F>(&mut self, f: F)
where
F: #[placing] FnOnce() -> T;
}
And this points us to a rather important challenge with these APIs, because for
all intents and purposes this sets us down the path of deprecating Vec::push
.
The Vec::push_with
method is more efficient than Vec::push
, and beyond some
compat reasons there will be no reason to keep using Vec::push
. So people will
naturally begin treating Vec::push
as legacy, even if we don’t outright mark
it as such.
Edition-dependent path resolution
I’m a firm believer that the obvious choice for something should be the right
choice 3. It feels bad to scold people for using Box::new
, telling the that they
should be using Box::new_with
instead. Or instead of calling
Hashmap::insert
, they should be calling Hashmap::insert_with
. And so on.
While not quite the same, you can see this same idea of “the default way should be the right way” reflected in the safety practice of poka-yoke. Or in Hollnagel’s distinction between Safety 1 and Safety 2.
My preferred way of resolving this would be to have a notion of
edition-dependent paths: imports and symbols that resolve differently
depending on which edition you’re compiling on. For example on edition 2024
and below, people would see both Vec::push
and Vec::push_with
:
impl<T> Vec<T> {
pub fn push(&mut self, item: T);
pub fn push_with(&mut self, f: impl #[placing] FnOnce() -> T);
}
But on edition 2024 + 1
we would be able to deprecate Vec::push
and rename it to
something else (e.g. push_without
), and have Vec::push_with
take its place:
impl<T> Vec<T> {
// Is called `push_with` on editions 2024 and below
pub fn push(&mut self, f: impl #[placing] FnOnce() -> T);
// Is called `push` on editions 2024 and below
#[deprecated(note = "please use `push` instead")]
pub fn push_without(&mut self, item: T);
}
Under the hood these functions would still resolve to the same symbols after accounting for editions. Conceptually what this needs is a stable, edition-independent identifier that libraries can map back onto in edition-specific ways:
stable identifier | edition 2024 | edition 2024 + 1 |
---|---|---|
::std::vec::Vec::push | Vec::push | Vec::push_without † |
::std::vec::Vec::push_with | Vec::push_with † | Vec::push |
†: These names are for illustrative purposes only; they are not concrete proposals.
And we could even go further with this, where in a hypothetical edition 2024 + 2
we might remove the old API entirely, so you would only ever be able to use the new API:
impl<T> Vec<T> {
// Is called `push_with` on editions 2024 and below;
// `push_without` is no longer available.
pub fn push(&mut self, f: impl #[placing] FnOnce() -> T);
}
The main reason why we don’t have a system like this already is because it would be a lot of work and there are guaranteed to be edge cases that make this harder than you’d assume it is. The basic design I’m describing here is something people have thought of before, and that’s not the reason it hasn’t happened yet.
However useful edition-dependent path resolution would be in the long run, we don’t need it out of the gate. We can begin by adding placing variants of existing methods to the stdlib. It’s probably enough to proceed with, knowing edition-dependent path resolution might be possible in the future.
Thanks to Alice Ryhl for reviewing this post.