[Pre-RFC] safe Uninit Types

The Rust already supports safe uninit types. It supports not with MaybeUninit<T>, but with type-check: type-checker grants no use before initializing!

let a : i32; // a : uninit i32;
a = 2;       // a : init   i32;

I propose to extend this - extend type-checking to grant no read (instead of "no use") before initializing.

Next is described here - is a realization of this extended rule.

Safe Uninit Types is a "easy to implement" extension, which is extendable and flexible in use.

(J) Rust allow to write safe uninitialized variables for each types. We add uninit before type-name to indicate that this type is uninitialized:

UninitializedType:  uninit TypeNoBounds
TypeNoBounds:       ... | ReferenceType | UninitializedType | ...

Most important: Uninitialized variable after initialization ("dropping" Uninitialization), is no longer uninit!

let a :  i32;
// a : uninit i32;

a = 7;
// a : i32; uninit is "dropped"/initialized

Now we add more abilities to Uninitialized Types!

(J1) Uninitialized variable is movable: uninitialization is "moved" by move from sender to receiver.

let a :  i32;
// a : uninit i32;

let b = a;
// b : uninit i32;
// a : i32; // not longer uninit, but moved

let c :  &i32;
// c : uninit &i32; // uninitialized reference

let d = c;
// d : uninit &i32;
// c : &i32; // not longer uninit, but moved

(J2) Referential Uninitialization is a bit complicated.

(1) Uninitialization is "moved" by move from sender to inside receiver (reference)

(2) Inner-Uninitialized Reference is always "exclusive", regardless if it mutable or not (till drop).

(3) Inner-Uninitialized dereferenceble Variable is always at least once write-only, regardless if it mutable or not

(4) Inner-Uninitialized Reference is forbidden to move (after initialization, reference is not longer inner-Uninitialized).

(5) Inner-Uninitialized Reference is forbidden to drop (after initialization, reference is not longer inner-Uninitialized).

let a :  i32;
// a : uninit i32;

let b = &a;
// b : & uninit i32; // initialized reference to uninitialized variable
// a : i32; // not longer uninit, but exclusive borrowed!

let c = &b;
// b : && uninit i32; 
// b : & i32; // not longer uninit, but exclusive borrowed!

**c = 7;
// c : && i32; // uninit is "dropped"/initialized
// now reference 'c' could be dropped

drop(c);
drop(b);
// a == 7

Note: a : uninit & i32 is an uninitialized variable with referential type, but b : & uninit i32 is initialized reference to uninitialized variable

(I+) Partial Uninitialized Types - we also add partiality next to uninit - Partial Uninitialization for Product Types (Structs and Tuples first of all)

UninitializedType:  PartialUninit TypeNoBounds
PartialUninit:  uninit Partiality?

And we could write:

struct S4 {a : i32, b : i32, c : i32, d : i32}

let s_bd  : uninit.{a, c} S4 = S4 {b: 7, d: 9, ..uninit};

If we also use extension (E), then we could also write uninitialization for tuples and more flexible for Structs

let s_bd : uninit.{c} S4 = S4 {a: 3, b: 7, uninit c, d: 9};

let t_1  : uninit.{1} (i32, u16, f64, f32) = (8, uninit, 4, 9.0);

let t_3  : (i32, uninit u16, uninit f64, f32) = (5, uninit, uninit, 9.0f32);

Now we could create Self-Referential Types:

struct SR <T>{
    val : T,
    lnk : & T, // reference to val
}

let x = SR {val : 5, uninit lnk };
    // x : uninit.{lnk} SR<i32>

x.lnk = & x.val;
    // x : SR<i32>;

(J3) Uninitialized Parameters are similar to Referential Uninitialization

(1) Uninitialization must be written explicitly at Type

(2) If Uninitialized Parameter is a not-reference, then it behaves same as Uninitialized variable.

(3) If Uninitialized Parameter is an inner-uninitialized reference, then it behaves same as inner-uninitialized reference.

(4) If Uninitialized Parameter is an inner-uninitialized reference, then uninitialization must be moved or initialization must happens before return or together with return.

struct S4 {a : i32, b : i32, c : i32, d : i32}

impl S4 {
    fn init_a(self : & unit.{a} Self) {
        *self.a = 5;
    }
}

If we also use extension (B), we could write more specific sub-type:

impl S4 {
    fn init_a(self : & unit.{a} Self.{a}) {
        *self.a = 5;
    }
}

(J4) Uninitialized arguments, again easy: uninitialization of argument and parameter must match! It is error if not.

struct S4 {a : i32, b : i32, c : i32, d : i32}

let s : uninit.{a} S4 = S4 { uninit a, b : 7, c : 33, d : 4};

s.init_a();

P.S.

Note: Safe Uninit Types(J) is not a part of "Partial Types" proposal, but I've described how we could extend Uninit Types to Partial Uninit Types with (I) and even further to Partial of Partial Uninit Types (with (I + B) or (J + B) or (I + J + B)).

More details about partial types are proposed here Partial Types (v3) and it is discussed here (Pre? RFC) Partial types and partial mutability.

This is very similar to MaybeUninit<&i32> vs &MaybeUninit<i32>, so I'm surprised to not see any mention of it in your post.

A good pre-RFC will discuss existing approaches to the scenario, what the gaps are, and how the proposal makes it better.

Basically,

is essentially "please put typestate back".

4 Likes

@scottmcm Thank you!

Sure, uninit and MaybeUninit has a lot of similarities. But they has also a difference.

(Pro uninit)

  • MaybeUninit is fully unsafe for use, but unit is fully safe
  • MaybeUninit is uninitialized OR initialized, but uninit is always uninitialized
  • MaybeUninit could be Cloned and Copied, but uninit could neither of them
  • for MaybeUninit we could manually set as initialized (assume_init()), but uninit has no such option
  • dropping a MaybeUninit does nothing, dropping of uninit (except initialized reference to uninitialized variable) drop variable or show "dead code".
  • MaybeUninit has no type-checker support, but uninit has

(Pro MaybeUninit)

  • MaybeUninit support Arrays and Vectors, Slices and Ranges nicely, but uninit has limited capabilities for those types
  • MaybeUninit is already supported by Rust, but uninit is just an idea

(another behavour)

  • With MaybeUninit we could have (for Option<T>) Option<MaybeUninit<T>>, but for uninit we could have either uninit Option<T>(fully uninitialized) or uninit.{Some} Option<T>.{Some}(partly uninitialized), but Option<uninit T> is impossible and nonsensical

I didn't mention, but MaybeUninit and uninit could coexist and do not contradict each other.

So, we could have and uninit MaybeUninit<&i32> and uninit &MaybeUninit<i32>. Maybe it is possible to has uninit.{value} MaybeUninit<&i32> if partial Unions are supported.

This is not true. For example, MaybeUninit::uninit() is safe, as is MaybeUninit::write.

So what does uninit do to make it more safe, and how does it do that without the same overhead that Option<T> uses to make it safe?

3 Likes
  1. Rust already supports safe uninit types. It supports not with MaybeUninit<T>, but with type-check: type-checker grants no use before initializing!
let a : i32; // a : uninit i32;
a = 2;       // a : init   i32;

I just propose to extend this - extend type-checking to grant no read (instead of "no use") before initializing.

The whole my proposal - just a realization of this extended rule.

  1. MaybeUninit<T> has a lot of footguns and "minimal" type-checker support
pub struct Foo {
    name: String,
    list: Vec<u8>,
}

let foo = {
    let mut uninit: MaybeUninit<Foo> = MaybeUninit::uninit();
    let ptr = uninit.as_mut_ptr();
    unsafe { addr_of_mut!((*ptr).name).write("Bob".to_string()); }
    unsafe { addr_of_mut!((*ptr).list).write(vec![0, 1, 2]); }
    unsafe { uninit.assume_init() }
};

is it better than next?

pub struct Foo {
    name: String,
    list: Vec<u8>,
}

let foo : Foo;                // foo : uninit Foo;
foo.name = "Bob".to_string(); // foo : uninit.{list} Foo;
foo.list = vec![0, 1, 2]);    // foo : Foo;
3 Likes

Your full proposal is much more complicated in that you're (appear to be) allowing talking about uninit types more generally, e.g. I can write some fn(uninit Foo). And even without, there are the odd semantics that allow interacting with uninitialized bindings in ways other than to initialize them.

For the specific, limited case of piecewise/partial initialization, that's a known desire point and "just" a matter of someone putting in the work to implement it.

We actually already support piecewise/partial deinitialization, so it really is "just" a matter of allowing the constructing direction as well as the destructing.

Unfortunately there's a not insignificant amount of implementation work hiding behind that "just."

1 Like

I almost agree, that moving uninit variable is a dead code that produces another dead code with no useful application and we could forbid moving.

But fn(& uninit Foo) is a delegated initialization and could be suitable for async functions and not only.

Yes, this is a second useful case.

Cool joke! :joy: :innocent: :sweat_smile:

I didn't say that it requires insignificant amount of work.

My "just" means, that rules I described are not my wishes for uninit types, but they are a requirement for grant not reading before initialization.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.