The missing reference type: write-only

I find myself wishing for a write-only reference type, and I'm curious to get feedback on this idea.

| read |       | &      | shared reference
| read | write | &mut   | mutable reference
|      | write | &write | write-only reference

The use case is for working with uninitialized data safely:

// STDLIB
impl<T, A> Vec<T, A> {
  /// returns the data between len and cap
  pub fn spare_capacity_write(&write self) -> &write [T] {
    ...
  }
}

// UPSTREAM CRATE: foo
/// fills some number n bytes of dst and returns n
pub fn decompress(src: &[u8], dst: &write [u8]) -> usize {
  ... // assign values to dst, but never read from it
}

// DOWNSTREAM CRATE: bar
fn main() {
  let (src0, src1) = ...;
  let mut dst = Vec::with_capacity(...);
  // write to uninitialized data:
  let bytes_written = decompress(&src0, dst.spare_capacity_write());
  unsafe { dst.set_len(bytes_written); }
  ... // do something with the now safely initialized dst
  // now reuse dst, writing to initialized data:
  decompress(&src1, dst.as_mut_slice()); // mut references can be cast to write
  ... // do something with dst again
}

In terms of borrow checking, &write would behave exactly the same as &mut. The difference is it would be impossible to read anything from the &write, allowing us to create safe methods like spare_capacity_write. In contrast, here's how to do this same thing today with MaybeUninit:

// STDLIB
impl<T, A> Vec<T, A> {
  /// returns the data between len and cap
  pub fn spare_capacity_mut(&mut self) -> &mut [MaybeUninit<T>] {
    ...
  }
}

// UPSTREAM CRATE: foo
trait WriteableSlice<T> {
  fn into_maybe_uninit(self) -> &mut [MaybeUninit<T>];
}

impl<T> WriteableSlice<T> for &mut [T] {
  fn into_maybe_uninit(self) -> &mut [MaybeUninit<T>] {
    mem::transmute(self)
  }
}

impl<T> WriteableSlice<T> for &mut [MaybeUninit<T>] {
  fn into_maybe_uninit(self) -> &mut [MaybeUninit<T>] {
    self
  }
}

/// fills some number n bytes of dst and returns n
pub fn decompress<Dst: WriteableSlice<u8>>(src: &[u8], dst: Dst) -> usize {
  ... // assign values to dst, but never read from it
}

// DOWNSTREAM CRATE: bar
fn main() {
  let (src0, src1) = ...;
  let mut dst = Vec::with_capacity(...);
  // write to uninitialized data:
  let bytes_written = decompress(&src0, dst.spare_capacity_mut());
  unsafe { dst.set_len(bytes_written); }
  ... // do something with the now safely initialized dst
  // now reuse dst, writing to initialized data:
  decompress(&src1, dst.as_mut_slice()); // mut references can be cast to write
  ... // do something with dst again
}

Reasons I like this idea:

  • We no longer need to use traits and generics for the decompression API in foo to accept both initialized and uninitialized data.
  • The user in bar now has a simpler signature to work with.
  • It's now more guaranteed that we get a single compiled function in the assembly, rather than relying on optimization passes.
  • It's easier to assign a regular value (dst[i] = x) than a MaybeUninit (dst[i] = MaybeUninit::new(x)).

Of course, I recognize that adding a new type of reference would be a major undertaking, and I don't have a formal proposal for this. But I'm curious to hear what people think.

1 Like

...and this means that it would be impossible to assign anything to the place pointed-to be &write, if the pointed-to type has drop glue (since assignment will drop an old value, and dropping requires reading). That's the first remark one should address, I think.

1 Like

That's a good point. Maybe this can only work when the type isn't Drop, since we don't track information about which elements are initialized or not. That would still work great for my use case, but it does make the general idea less exciting.

When the type doesn't have Drop glue (String isn't Drop, but it has drop glue, because it wraps the Vec). Which complicates things further, yes.

I recently wrote a WriteOnly type for the use of the wgpu project. It is not a new primitive reference type, of course, but this doesn’t make a lot of difference other than in some lifetime/reborrowing hassle.

It indeed can only be (safely) used with Copy types which don’t do anything when dropped.

If Rust allowed making WriteOnly undroppable & unforgettable, and the memory pointed to was not initialized to start, then we could relax that constraint, since there would be exactly one write into uninitialized memory, so there is known to be no previous value needing to be dropped.

2 Likes

By the way this signature is impossible, because the function still needs to read the Vec's fields.

1 Like

These have been referred to as "out" or "uninit" parameters before, you can find some references with those terms a bit easier.

I think the main things they require for safety are basically the same as C# out parameter rules:

  • guaranteed to not be initialized on being passed into a function
  • the function cannot return on a path that doesn't have a guaranteed initialization
  • the function must track if the parameter has been initialized to allow following access within the function

While this works fine for C# as a way to describe the intended usage pattern ("if you can get this item / open this file, then use it", commonly) Rust has better options (pun intended)

Worse, as you have provided in your examples, this mostly makes sense as a variant of references, but you can put references in all sorts of places where control flow analysis can't (at least obviously) track if it has already been written to. Consider, for example, a Vec<&write T>, the only way that makes sense is if you have to remove from the collection and "consume" the reference to write to it.

In short, it seems like you could make this work, but it adds a lot of additional complexity to what's already a confusing area of Rust, for apparently only small potential gains.

3 Likes

Here's a crate that emulates this feature. uninit::out_ref - Rust

1 Like

This is often called &uninit, see e.g. The Algebra of Loans in Rust | Nadri’s musings.