Placing functions
25 points by ThinkChaos
25 points by ThinkChaos
This is pretty cool, it outlines the problem well as always coming down to “sometimes we want a constructor function to be able to take a pointer telling it where to construct, and sometimes not”. I actually think it might be best to express at the ABI level like it already partially is; it’d just come down to “if a function returns a value, you have one version that returns a value and one version that gets given a pointer to stick the value into” and it’s the compiler’s job to decide which to use. Buuuuuut I confess I haven’t really thought about how the compiler should decide which to use… let alone how to influence its decision.
I’d honestly really like numbers on how much overhead we pay with out-of-place construction. It doesn’t sound fun to benchmark though.
It’s not just performance, it’s that in Rust you can’t prevent stack overflow with the normal idiomatic way of heap allocating! Box::new(T { ... })
makes the T
on the stack and then moves it to the heap. It’s a very weird gap for a systems language to have. In practice people hope the optimizer gets rid of it or use some crates that wrap macro magic that uses MaybeUninit.
I do worry about what introducing placement construction might imply in terms of language changes though.
One of the things that makes Rust’s memory/data model so much simpler than C++ is that it strategically avoided growing a zoo of initialisation systems.
I hope that a rugged solution can be found that’s either (a) implemented in the language itself (perhaps via a macro) or (b) is implemented in such a way that it might be trivially lowered to existing language features for the sake of tractability and minimisation of complexity.
I hear you but I think placement new is outside the C++ initialization madness, it’s deciding where the object is allocated not whether fields are initialized.
But the two are inextricably linked! You can’t effectively implement placement new without introducing concepts of partial-initialisation.
By definition placement new means, at minimum, ‘not putting the entire allocation on the stack first’, and so you’ve got to treat the creation and initialisation of each field as temporally distinct events, between which anything could happen, like a panic. What happens to the partially-initialised data structure then?
If everything is either a stack allocation or a bit copy (as it is today), the answer is obvious - the panicking thread’s stack is no longer accessible, and a bit copy is atomic (for the sake of the abstract machine), so the partially-initialised place ceases to be observable. That just isn’t true for placement new though, and it’s one of the reasons that C++ has acquired so many non-trivial forms of initialisation.
Okay on further reflection, this is definitely a deeper rabbit hole than I thought because rust doesn’t have a defined ABI, so even if you say it’s going to be field order, there is no defined memory layout order unless you use repr(C)
. But I think one easy out could be to make it so that placement is only safe if you use a version that zeroes all the memory on failure. There could be an unsafe variant that doesn’t do that, for times when you know for sure you will overwrite the memory again before looking at it.
I think you’re confused about how placement new works, there is the exact same amount of potential for partial values as exists currently for structs (none).
Today in Rust you write:
let foo: MyFoo = MyFoo { ... }
Inside the braces you initialize every field, so you know MyFoo must be complete. If any field is initialized to an expression that panics or short circuits due to the ?
operator then the MyFoo never comes into existence at all. Now the compiler may have started writing into the stack memory that backs the MyFoo before the panic occurred, but that’s irrelevant because you will never actually get access to the MyFoo object.
With placement new it works exactly the same way (made up syntax):
let foo: &mut MyFoo = place(address) MyFoo { ... }
You still have to specify every field in the braces, and if any expression panics or early returns you never get access to the reference. The memory may have been partially written to, but that’s fine, because you never got a reference (which would implicitly promise a complete object). At best you can copy bytes out or do unsafe casting/dereferencing on the memory. Which you could also have done with the stack memory!
Also even in C++, if a constructor throws an exception than the placement operator new propagates it, you don’t get the returned pointer then either.
Edit: or are you saying the fact that the bytes may have been modified in a buffer you have a pointer to is enough to be a UB concern, because you want to define which bytes were set regardless of compiler optimization level? I guess the modified stack bytes are only observable with unsafe, but these would be observable without. I guess you’d have to define for placement new that fields are written in order. Or define place() as unsafe. Don’t Drop and side effects already require the order to be defined?
Placing is trivial in C++ b/c of 3 features:
The problem for Rust is that it’s impossible to write std::vector<T>::emplace_back
because all of these things get in your way. The language has MaybeUninit<T>
, but you can’t call new
for any T in Rust, because there is no one trait that covers all T
, and you can’t express such a trait, because you would need variadics. And unlike the borrow checker, there is no unsafe
equivalent for the trait system, no way to opt in to being okay with monomorphization errors.
You don’t actually need variadics at all!
The Fn
traits have an alternative (nightly-only, for now) syntax that treats function arguments as tuples. So it’s quite possible to abstract over N-ary constructors in this way, albeit with a small amount of extra syntax jiggery pokery.
That aside, I don’t see this as a downside. That Rust doesn’t let you arbitrarily construct values without knowing the client type (or, at least, having a good sense of what that type looks like) is wonderful and is an enabler of so many other safety and API design features.
Without learning the syntax it’s not obvious to me why tuples would help, you notoriously can’t write a trait for any length tuple, or a function that takes any length tuple. That’s why Rust projects have macros to generate the same thing with 10 different lengths like it’s C++98.
I think having a value that represents the place alleviates the variadics: you can use a lambda/fn that takes the place, instead of forwarding the arguments to the code that owns the place and constructs the value. Being generic on the constructed type is enough.
You could implement emplace_back
that takes a lambda, but the ergonomics are worse. vec.emplace_back(3)
vs something like vec.emplace_back(|place| u32(place) { 3 })
. This is a beef I have with a lot of Rust APIs though, thread locals have this kind of pattern too and you end up with deeply nested code.
Yes definitely. I was thinking more of the Rust desugaring can target that, and the surface syntax stays tidy.