Another idea for placement new

Disclaimer: This is still in the brainstorming phase, and I need to know if there are any major problems I missed. Please don't bikeshed syntax details for now :slight_smile:


I've been thinking about placement new, and I realized that we "just" need to extend move analysis with two things:

struct MyStruct {
  foo: String,
  bar: [u64; 1024],
}

fn use_my_struct(_: MyStruct) {
    todo!();
}

// THIS WORKS TODAY
let mut s = MyStruct { foo: String::new(), bar: [0; 1024] };
// move the fields out of the struct
drop(s.foo);
drop(s.bar);
// move new values into the struct
s.foo = String::from("hello world");
s.bar = [13; 1024];
use_my_struct(s);

Rust ensures that all fields are present when use_my_struct is called. What's missing is a way to create a struct instance without its fields:

let mut s: MyStruct = empty!(); // built-in macro
// move new values into the struct
s.foo = String::from("hello world");
s.bar = [13; 1024];
use_my_struct(s);

And a way to initialize it from another function; this can be solved with what I call move references:

let mut s: MyStruct = empty!();
init_my_struct(&move s);
use_my_struct(s);

fn init_my_struct(s: &move MyStruct) {
    s.foo = String::from("hello world");
    s.bar = [13; 1024];
}

What's a move reference?

A move reference is similar to a mutable reference. However, it can be uninitialized at first. Once it has been fully initialized, it turns into a normal mutable reference.

Move references need to be linear types -- they can be neither copied, nor destroyed:

{
    let mut s: MyStruct = empty!();
    let r = &move s;
} // error! `r` goes out of scope here, but it can't be dropped

{
    let mut s: MyStruct = empty!();
    let mut r = &move s;
    *r.foo = String::from("hello world");
    *r.bar = [13; 1024];
} // this is ok, because `r` is fully initialized and turned into `&mut MyStruct`

This guarantees that functions that accept a move reference and do not return it need to initialize them. We can deduce from the lifetimes which move references are initialized by a function:

// initializes a, but not b
fn f<'a, 'b>(a: &'a move X, b: &'b move Y) -> &'b Z;

a has to be initialized by the function f, as that is the only way to drop it.

How would this work with Box and Vec?

Move references can be created from raw pointers, so it's possible to allocate memory on the heap and obtain a move reference to it.

To initialize a Box<MyStruct>, we can add a function that accepts an initializer function:

impl<T> Box<T> {
    fn init(initializer: impl FnOnce(&move T)) -> Self;
}

let boxed_struct = Box::init(|mut r| {
    *r.foo = String::from("hello world");
    *r.bar = [13; 1024];
});

Here, r points to a newly created allocation on the heap. r being a move reference guarantees that it is initialized within the closure. A similar approach is possible for Vec:

impl<T> Vec<T> {
    fn init(len: usize, initializer: impl FnMut(&move T, usize)) -> Self;
}

let struct_vec = Vec::init(20, |mut r, index| {
    *r.foo = String::from("hello world");
    *r.bar = [index as u64; 1024];
});

How can we enforce that move references aren't dropped?

The problem is that values of generic types can be dropped, like here:

fn drop<T>(value: T) {}

We need to make it impossible to call drop with a move reference. This can be achieved with a Destroy auto trait that is (like Sized) implicitly added to all generics. Since T does not have a ?Destroy bound, we can't call drop with linear types. Destroy is implemented for all types except move references (and types containing move references).

How about types other than structs?

Tuples, structs and unions can be initialized by their fields. Enums can be initialized in place like this:

let mut opt: Option<MyStruct> = Some(empty!());
if let Some(value) = &move opt {
    *r.foo = String::from("hello world");
    *r.bar = [index as u64; 1024];
} else {
    unreachable!()
}

Arrays and slices can not be initialized in a loop, since the compiler couldn't prove that every index was initialized. Instead, helper methods can be added:

impl<'a, T> &'a move [T] {
    fn fill_uninit(self, value: T) where T: Clone;
    fn fill_uninit_with(self, initializer: FnMut(&'a move T, usize));
}

Custom DSTs

A problem with custom DSTs is that you can't easily create them. Move references could alleviate this problem:

struct MySuperSlice {
    info: u32,
    data: [u8],
}

// passing `<&mut MySuperSlice as Pointee>::Metadata` as first parameter
let boxed_super_slice = Box::init_unsized(1024, |mut r| {
    r.info = 30;
    r.data.fill_uninit(17);
});

Panic safety

When an initializer panics, a value may be left in an uninitialized state. However, since &move T does not implement UnwindSafe, this should never be observable.

1 Like
impl<T> Box<T> {
    fn init(initializer: impl FnOnce(&move T)) -> Self;
}

let boxed_struct = Box::init(|mut r| {
    *r.foo = String::from("hello world");
    *r.bar = [13; 1024];
});

I don't think this provides emplacement at all. Nothing here guarantees that String will be constructed in unitialized *r.foo place, rather than on the stack, and then moved there.

1 Like

I think it's already covered by view types suggestion here: https://smallcultfollowing.com/babysteps/blog/2024/06/02/the-borrow-checker-within/

I did not understand how to make an anchor to those headers, so "Step 3: View types and interprocedural borrows"

To me, the biggest open question for view types is how to accommodate “strong updates” to types. I’d like to be able to do let mut wf: {} WidgetFactory = WidgetFactory {} to create a WidgetFactory value that is completely uninitialized and then permit writing (for example) wf.counter = 0. This should update the type of wf to {counter} WidgetFactory. Basically I want to link the information found in types with the borrow checker’s notion of what is initialized, but I haven’t worked that out in detail.

1 Like

The content of the String is in a separate allocation anyway, and having 3 usize on the stack is not a big problem. But I think LLVM can easily remove the stack allocation, because the pointer to the box allocation is already available.

The main reason why Box::new([1; 1_000_000]) can't be optimized to write the array directly on the heap is that the heap application occurs after the array is created. The Box::init api fixes this.

Ok, so an initializing function would look like this:

fn init(s: {} MyStruct) -> MyStruct

I really like this idea. The only problem I see with it is that passing around owned values is typically more expensive than passing references.

FYI the name "move references" has generally been used for references you can move out from, not move in. What you described here has been discussed in the past with the name of "out references".

This does not account for implicit drops due to unwinding. I don't think you can really avoid them, so the only way to fix it is to ensure that the uninitialized place is not accessed during unwinding except for dropping those and only those fields that were initialized before it started.

3 Likes

Yes, it doesn't matter much for String. What I'm pointing out is that this proposal is in no way an alternative to placement new.

I do think gradual/partial initialization should be possible in the language. It's already possible to do let s; s = MyStruct { foo, bar }, but not let s: MyStruct; s.foo = foo; s.bar = bar;. This is actually independent of the ability to do so across function boundaries.

I've played some with a "DefaultForOverwrite" trait, which works like Default (makes a valid instance of the type) but doesn't have a semantic meaning for the "default" value (safe but unspecified state), just an expectation that it's "cheap" to make. It works but isn't particularly pretty.


Any proposal that includes adding new types should consider pinned initialization and if it beats out just a library approach. My current favorite to point at is moveit (it seems the most featureful) but there are others as well.

My current feeling is that both init->uninit refs and uninit->init refs are possible in the language. init->uninit should IMO behave exactly like a theoretical Box<_, PhantomBorrow<'a>> and have responsibility to drop or leak the value, but cannot safely reference pinned values. uninit->init should IMO act more like a C++ lvalue reference, and only exist as a property of function signatures: the function's name binding is a place that directly aliases the out place, otherwise acting as if it were just a local binding, the difference being that at normal function exit it's required to have been initialized and isn't dropped; ownership is given to the caller's place. In the event of an unwind, drops happen on local places (including the loaned one) as normal, and the caller's place hasn't been initialized.

Is any of that worth the language complexity to do? I have no idea at this point. (Necessary disclaimer: I'm a part of the Rust Project on T-opsem. However, this gives no special insight nor say in the design of new language features.)

1 Like

Generally, I don't think that beating a library implementation would be hard for a proper language feature. moveit is a very ingenious library, but frankly its api is horrible. It's better than nothing if you're stuck with implementing C++ constructors, but I'd never use it if I can avoid it. A proper feature with pretty syntax and compiler-enforced safety wouldn't have that issue.

What do you mean by "consider pinned initialization"? It seems to me that if we had &out T with reasonable semantics (ignoring the subtle details for now), then pinned inititialization would just deal with Pin<&out T> using the usual Pin API. Do you think there is some special subtle issue here?

The usual issue is what if the function panics, but the (transitive) caller doesn't know it? I.e. f calls g calls h, the latter panics, but g catches the panic with catch_unwind. Now the invariant "if h returns, the uninit place was initialized" is violated, and there is no reasonable way to pass this info to g, nevermind f.

1 Like

The invariant is not violated, because h did not return. The control flow that unwinds out of the call to h has not initialized the place. If that would lead to g returning normally without initializing it some other way, g won't compile.

Now, in current Rust you can't directly handle unwinding as control flow, and just from the signature of catch_unwind (takes a closure) the compiler can't assume that it would even call that closure.

1 Like

The apt name should be 'return reference' or 'result reference'. Using out has a slight potential to be confused with 'moving-out-of' which is exactly not allowed and because the intended semantic model is quite close to literally that of return values.

This view also relates to catch_unwind if the property that the place was initialized must be part of the function signature at the return value somehow. Since catching unwind changes that type to a Result<R> it clearly does not provide the necessary semantics for a caller to absoultely rely on initialization. And that also propagates for wrapper so that on error no suitable 'value' proving initialization is returned, which forces any wrapper to explicitly handle it by fallback initialization (which it probably can't do very well, the owning pointer to the uninitialized slot was moved with the call—the inverse of the old problem of trying to replace utilizing an FnOnce(T) -> T).

That said, I don't think the view works out practically. The optimal semantic fit might be have multiple disjoint return values however this may run into much worse issues than colored functions where a lot of previously written code is not composable with these new function types (include core primitives, including catch_unwind ironically).

1 Like

One thing I didn't see mentioned in the OP at all is Result/?.

To me, the trickiest part of all this is making *p = foo()?; be in-place, and I have no idea how to make that happen.

(Just having APIs for Box<MU<T>> + T -> Box<T> kinds of things is much easier.)

Of course, there's the C++ approach of just ignoring it and requiring "constructors" to be infallible or panic. Rust would be a little better, since instead of C++'s

System sys = {};
auto result = sys.init();

Rust could have

let sys;
let result = System::init(&mut sys);

which looks very similar, but differs in that instead of System having an intrinsic default "null state," init gets (has) to assign the valid-but-unspecified state to the provided return reference before returning an err result.

It's of course still far from ideal — it'd be preferable to not have to make a default value to fill at all — but it's marginally better than always creating a default to then overwrite, even in the success case.

A different view of the problem space that seems like it could almost work is instead:

impl System {
    fn init(this: &return MaybeUninit<Self>) -> Result<&move Self, SystemInitError>;
}

// used as:
let sys;
let sys = System::init(&mut sys)?;

where &return T is the uninit->init reference and &move T is the init->uninit reference.

C# combines out params with bool-returning Try* functions, IIRC, and optionally has strict null checks as part of the compiler. It could be interesting to see how they handle it. (My understanding of newer C# features like strict null checking is merely hearsay.)

One radical way to solve this would to take a cue from Swift. My understanding is that Result/Option over there has a special ABI for returns, where the flag is returned in a separate register.

As I don't know swift I don't know if they have niche optimisation, but the way I see it for Rust, we could have such a special ABI only when the niche isn't optimised.

This would also make Result/Option special. But I think you could extend this to any 2 variant enum, potentially even to more than 2 variants. That would remove the specialness.

A question thst remains is how to handle Result<Option<T>, E> though. In that case you could collapse the outer enum into 3 states in that dedicated register (when the niches aren't optimised).

So I think in place construction is doable for results, but would require the Rust ABI to not just be a thin wrapper around LLVM C ABI. And it would be a lot of work.

My opinion is that this kind of magic just shouldn't be supported.

The relatively simple and reliable way to do in-place initialization is to take an explicit pointer to the uninitialized place as a parameter. I.e. on stable the function would take place: &mut MaybeUninit<T>, and with a proper language support it would be place: &in T. The called function can now initialize the place in whatever way it sees fit: it can pass it around to other functions, split into smaller places, store it in structs, pass to FFI calls. Once the function guarantees that the place is initialized, it must use some out of band signalling to pass this information upwards. So basically it should return some special "initialization token", which also automatically handles issues of panic safety, since unwinding will not create those tokens or pass them to callers. And finally, once the original caller gets that "initialized" signal, it can safely call assume_init on that uninitialized place.

That's simple, reliable, flexible and maximally composable, working just the same in any context (generators, async). Fallible initialization should be handled in an ordinary way, with initializers returning Result, where the initialization token is included only inside the Ok variant.

Yes, it won't support the whimsical *p = f(); syntax, but who needs that, really? The hope of people proposing such syntax is usually that inplace initialization could somehow be magically supported for arbitrary function calls, but an arbitrary function can handle initialization in arbitrary ways, which can't conform to any extra rules without extra effort. At best, maybe we could force the return value to always be generated inplace in the final location. But a lot of huge, possibly self-referential data could be passed around in the function before the returned value is produced. There is no hope to defeat those copies in general, and they defeat the purpose of in-place initialization.

Basically, at best we could hope for some variant of NRVO, and it's just fundamentally not flexible or reliable enough for all use cases, even if we could somehow make it better than C++.

I had a sketch of safe delayed initialization written for a while. Unfortunately, there is a small but crucial bit of compiler magic that I currently don't know how to reasonably implement. Perhaps I should publish the write-up anyway.

1 Like

The problem is not the ABI. The problem is that when you write *p = foo()?; (p being an out reference), it doesn't compile because p isn't initialized in the error case. There's no way to make initializing functions fallible under this proposal.

Maybe initializers need to return the value – this is more similar to placement by return. Then the challenge is make an ABI that guarantees that the return value is placed directly into the allocation, without first putting it on the stack.

Any proposal which starts with "we add linear types" is imho immediately impossible. The core issue is panic safety: values are automatically dropped during unwinding. How do you reconcile it with linear types? Do you forbid panicking while values of those types exist? But most things in Rust can panic, so how would you handle it in code? See The Pain Of Real Linear Types in Rust.

Since that post was written, we also have an extra complication: async. Values which are held over .await points become part of the future struct, and that struct can be dropped anywhere anytime. Do undroppable values mean that some futures become undroppable? The async design & ecosystem aren't ready for that, even if it could be benefical.

Excluding panics is also a non-starter. There is a lot written about possible no_panic annotation or NoPanic effect, although I couldn't find any definitive reference. In short, the subset of Rust which defintely doesn't panic is so small it can't be used for anything interesting. OOM panics, arithmetics operations panic, collection indexing panics, I/O panics. Maybe very rarely, but it's very hard to formally remove from functions.

That could only work if &'a move X is invariant w.r.t. 'a, unlike &'a X or &'a mut X, which are covariant. That sounds a bit confusing, but perhaps possible.

UnwindSafe is a safe trait, so it can't be relied on for memory safety. In particular, one can always wrap &move T in AssertUnwindSafe.

That's unsound, because Clone or initializer may panic in the middle of the slice, and you won't know which part is left uninitialized. This kind of initialization can only work for Copy types.

1 Like

I can't remember how it was called, but there is a crate that implements inplace (probably pinned too?) initialization with this pattern. It requires marking the tokens with an invariant existential lifetime, like the one used in GhostCell, in order to guarantee that the returned token is the correct one.

Makes sense. Two issues I see with a pure-library approach:

  • It can't take ownership of a place, since &move references are currently not a thing. This means that it likely requires boxing. Stack places could be only handled via &mut T, so no ownership semantics. Although perhaps it could handle owned stack places the same way moveit does?
  • There is no safe way to do partial initialization of structs via library-only approach, since there is no way to be generic over the set of fields at the library level.

EDIT: moveit does something similar with its stack slots [1].

Maybe they aren't "proper" linear types, but panic safety isn't a problem in practice. A function accepting a move reference (or out reference) must have initialized it by the time it returns, but only if it returns. If it panicks, aborts, or loops indefinitely, the uninitialized value can never be observed. Move references can't be moved in catch_unwind's closure, because it has no ?Destroy bound.

This would mean that the future itself would become !Destroy, so the type system would prevent you from dropping the future. Maybe async runtimes won't support ?Destroy futures at all, so you'll get an error whenever you try to spawn a future holding a move reference across an .await. In practice, I don't think initializers ever need to be async, so I don't see a problem here.

You're right. The actual reason why it won't work is that catch_unwind implicitly requires F: Destroy.

Again, this is not observable as far as I can tell. The only potential issue is a double free, but I'd argue to just leak the entire slice if initializing it panics. Or do you have an example where this would cause UB?