?Uninit types [exist today]. Also let's talk about DerefMove

Previous discussion: ?Uninit types?


?Uninit types exist today:

fn main() {
    let x = (vec![0], vec![0]);
    let y = x.0.into_iter();
    let z = x.1.into_iter();
    println!("{:?}, {:?}", y, z);
}

They are (currently) local to the scope they're defined in. (Ofc, async/await syntax kinda throws a wrench into "scope", because these partially uninitialized types actually get to be preserved across yield points.)

Any way we can formalize this?

Assuming ?Uninit means something along the lines of partially uninitialized, what on earth makes you think anything in that code is anything like ?Uninit?

1 Like

The x has been moved out of after the y exists. And then the x gets moved out of again at the z.

Clearly, the x is being partially deinitialized somehow: We'd expect a panic between the y and the z to do the right thing and drop the y first, then the rest of the x. But without double-freeing the y.

Why would you expect a panic? Nothing like that is being done at runtime. At worst you might expect a compile time error.

And this isn't general uninitialized types, this the compiler being able to reason about partial moves within a single function.

2 Likes

Partial moves of x are done by deconstructing it; it doesn't need to be dropped after that. I'm pretty sure you can't partially move out of something with a drop impl:

error[E0509]: cannot move out of type HasDrop, which implements the Drop trait

So x is not being 'deinitialized'; it's being moved and destructured, with each part being dropped independently of the others. In other words, the compiler doesn't need to worry about the double-free of y when it drops x, because it never drops x.

1 Like

And why not allow the compiler to reason about partial moves (or really partially initialized types in general) across function call boundaries? It's clearly sound within a function, and functions are turing-complete, so it must be sound across functions if you can encode it in function signatures somehow.

But it does need to drop x.1! And yes, you make a good point about destructuring - an ?Uninit types RFC would probably have to be !Drop (altho drop glue would be allowed). Which means it'd be fairly limited, not allowing things like "placement new"...

Well, this definition gets muddied by partial moves out of Box, and the fact that if you partially-move a value, and then move back into that part of the value, you get the same value.

The current implemention in rustc IIRC, and probably the most basic. reasonable, implementation is to store a bitfield for the initialized state of x. When you move x.0, it clears that bit (and when you move into it, it sets it). Then when evaluating the drop glue of x, it checks the bits for x.0, drops it, if set, and then does the same for x.1.

Incidentally, static initialization checking is fun. I'm wondering what would happen if a function's parameter indicates initialization, but then the function does some fun stuff with loops (something something halting problem). I can't think of any examples that might cause issues, but it wouldn't be unreasonable for them to exist.

Note that the issues also exist with intra-function checking, but for inter-function, you may need to perform the analysis merely at any point the function can return (including the end of the block), which end up making implementions overly strict, and making the construct useless in all but trivial cases.

It's worth noting that unless you rebuild and use the reconstructed type, no static analysis of partial moves is done by the rustc frontend. It just emits dynamic drop flags, and the optimizer potentially optimizes out the use of dynamic drop flags if it can prove they're always in one state or the other.

There is no current interaction with the type system. As such, this is not extending some feature that already exists, but adding a new one.

Partial moves are handled the exact same way as moving local fields.

2 Likes

It would be "extending" a feature that already exists by adding a new feature on top that uses this feature internally. As a way to expose this feature to code.

Personally, I could see it somewhat useful as alias. In lines of

let x = destructured!( (vec![0],vec![1]) );
let y = x.0.into_iter();
let z = x.1.into_iter();
//foo(x);//error, since x is not actually a tuple

And perhaps this macro can actually be implemented in current Rust by returning a type with the same fields as the original, but appending MaybeUninit to each type. However that would be problematic to use: if we take some fields then what tracks what to drop?

It would be possible to add a take! macro that panics if the field is already taken and keep in the new type some mask with information.

let x = destructured!( (vec![0],vec![1]) );
let y = take!(x.0).into_iter();
let z = take!(x.1).into_iter();

And similarly macros to get references.

I made some years ago a thread about similar issues, which received some discussion (Including @nikomatsakis and @Soni).

We just had an epiphany.

What if... &move references, and ?Uninit types, working together, to enable less-special partial moves out of boxes and newtypes?

The basic idea is that a &move reference would have the ability to change the type it refers to, allowing it to partially-initialize or partially-deinitialize the types.

(This needs more work, but... it's an idea.)

Is @alercah still around?

I would highly support any reasonable unmagicking of Box. The only question would be if this is the right way to do it.

5 Likes

The main question we have is whether it would even work.

For something like Box, it's easy to imagine: the &move reference would attach to the T on the Box, so any changes to the T on the &move also change the T on the Box. Then it's a simple matter of generating the correct Drop impl for the correct (partially initialized) types.

For something like newtypes with custom DerefMove, that forwards to more custom DerefMove... stuff gets complicated, tbh. Our original ?Uninit idea involved calling functions that change the types, but making this work with Box requires returning objects that change the types... Hmm...

We don't mind if exploring this stuff hits a dead end, like we did with our HKTs/AnyA stuff. It seems like it'd be worth looking into.

Am I understanding you correctly that you want to be able to pass partially moved structs to functions? I.e.

#[derive(Default)]
struct Foo {
    a: String,
    b: i32,
    c: i32,
}

fn main() {
    let foo = Foo::default();
    let s = foo.a;
    bar(foo);
}

fn bar(foo: Foo without _.a) {
    // only foo.b and foo.c can be used
}

This would also allow allow partially borrowing functions.

Yes, but see also the other thread(s). There should be a way to specify what the function (de)initializes.

Still trying to come up with a way to work with that and Box/DerefMove...

That should be easy with the syntax I used:

// initializes field a:
fn bar(foo: Foo without _.a) -> Foo;

// deinitializes field a:
fn bar(foo: Foo) -> Foo without _.a;

All we can think of is maybe something along the lines of

trait DerefMove: Deref where <Self as Deref>::Target: Uninit {
  fn move_out(&move self: /* ???? */) -> <Self as Deref>::Target {
  }
  fn move_in(&move self: /* ???? */, other: <Self as Deref>::Target) {
  }
}

But this seems less than ideal as the compiler would have to move_out and then move_in for partial (de)initialization. Also unsure about the Uninit bound, or even what the Uninit trait would mean, or a bunch of other stuff.

We feel like this is definitely closer to being able to solve the problem than previous proposals, tho.

(Also, might need some form of HKTs/type families for this.)

What is the purpose of this? What problem do you want to solve, or what use case do you want to enable?

1 Like

I think normally that is accomplished by having a Option<Target> field, with which you can just Option::take to implement move_out and just assign Some(other) to implement move_in. Since Option has very little or none overhead it should suffice for most cases.

We can remove those indicator bits from the struct into the types in the way @Alonso has said, which can be seen as a split_at for structs. I have toyed that idea a little here. I can see that a split_at for structs could be useful in similar cases as when it is useful for slices. But it would require to create different types for each kind of split, which seem very confusing for any normal usage.