Why can't I return a mutable reference of `()`?

This code cannot compile, because it returns a reference of a temporary value.

fn return_unit_ref() -> &'static mut () {
    &mut ()
}

A unit is zero-sized, nothing will be written via the mutable reference of it, returning &mut () is theoretically sound. How to pass the compile, or am I wrong? Thanks.

I think you're misinterpreting what it is you wrote.

What the fn signature says is that it returns a mutable borrow, that lives for the entire life of the program, to a unit value.

Regardless of the type behind the borrow (i.e. it's not unique to ()), this can obviously never work when what is returned is a borrow to a stack-allocated value, which is popped as soon as the fn returns. If it were to compile, it would result in either a dangling reference, or UB, neither of which is desirable.

This is a good moment cut through the XY problem to ask: What is it you're trying to achieve with all this?

The value is zero-sized so this is in principle sound. In fact, Rust supports the almost identical empty mutable slice:

fn foo() -> &'static mut [i32] {
    &mut []
}

There just isn't a comparable special case for (). I can't immediately think of a workaround other than writing unsafe code to construct the reference (which should be fine).

Static promotion of temporaries doesn't apply for () for some reason. It does apply for empty slices, for example, but notionally, that is pretty much a special case which shouldn't normally be allowed, unless the compiler was (as it is) made aware of the special nature of ZSTs.

2 Likes

Exactly this. And as far as I'm aware, no such special support exists. That that support exists for slices is... slightly baffling, in terms of use cases. Especially so considering that slices are not resizable, at least AFAIK.
If anyone would like to explain that to me, I'm all ears (eyes?).

2 Likes

It's convenient to be able to construct an empty value in a context where a &'static reference is needed.

4 Likes

I understand it will become a dangling reference if the stack-allocated value's size is not zero, but the point is the value's size is zero.

There is a way could avoid this, that is, return a mutable reference of a static invariant. There will be no data race, because () contains no data, in other words any () is guaranteed to be unchanged. But the compiler doesn't seem to know this situation, multiple mutable reference of a variant is not allowed, it requires unsafe code.

To static check a mutable reference cannot be used twice.

struct SomeData<'a, T>{
    // many fields ...
    data: &'a mut T
}

impl<'a, T> SomeData<'a, T> {
    fn call_once(self, f: impl FnOnce(&mut T)) -> SomeData<'a, ()> {
        f(self.data);
        SomeData {
            // many fields ...
            data: &mut ()
        }
    } 
}

Here is how to construct your own perfectly good reference from scratch. [Update: but don't do this; Box::leak() is safe and just as good.]

fn mut_unit() -> &'static mut () {
    let ptr: *mut () = core::ptr::NonNull::dangling().as_ptr();
    
    // SAFETY:
    // The pointer is a nonnull, aligned pointer to a ZST.
    // All such pointers are valid; there are no requirements on the pointed-to
    // memory because the pointer refers to zero bytes of it.
    unsafe { &mut *ptr }
}

/// Run this under Miri to test
fn main() {
    let a = mut_unit();
    let b = mut_unit();
    // If there were an aliasing problem, Miri would reject this
    *a = ();
    *b = ();
}
1 Like

How about

fn return_unit_ref() -> &'static mut () {
    Box::leak(Box::new(()))
}

This can be compiled, but I worried it will really allocate the memory.

3 Likes

Whoops, I forgot about that option. :person_facepalming: That is even better, because it is pure safe code and guaranteed not to allocate:

This doesn’t actually allocate if T is zero-sized.

4 Likes

thanks :grinning:

For why &mut [] is special-cased? So you can safely do things like this:[1]

struct IterMut<'a, T> {
    slice: &'a mut [T],
}

impl<'a, T> Iterator for IterMut<'a, T> {
    type Item = &'a mut T;
    fn next(&mut self) -> Option<Self::Item> {
        // We can't get an `&'a mut T` from our shorter `&mut self` so we
        // need to get the slice "out from under self" for a moment.
        //
        // (`take` works too, but for illustration)
        let slice = std::mem::replace(&mut self.slice, &mut []);
        match slice {
            [] => None,
            [first, slice @ ..] => {
                self.slice = slice;
                Some(first)
            }
        }
    }
}

Special casing &mut for all ZSTs also came up recently.


  1. Including on no_std ↩︎

3 Likes

This example is confusing to me, why can't we get &'a mut T from &'1 self directly and must via replace function to put a empty slice at that place and take it back? What unsound thing would happen if getting a longer lived reference from a shorter lived reference is allowed (the type of the value is 'static)?

If you could get the longer lifetime out through the outer reference, you could do this:

    fn next(&mut self) -> Option<Self::Item> {
        if self.slice.len() == 0 {
            None
        } else {
            // N.b. we didn't update `self.slice`
            Some(&mut self.slice[0]) // A
        }
    }

// Elsewhere, B
let first = iter.next().unwrap();
let other_first = iter.next().unwrap();

And at point B you have two active &mut to the same element, which is instant UB. In fact, you also have that at point A already, where &mut self is active and can reach self.slice[0] and the return value is also pointing at self.slice[0], so calling next with a non-0 length is always UB -- no need to call it in a certain way.

More generally, you can never get something 'long out of a &'short mut &'long mut _, because once 'short expires, the underlying &'long mut must again have exclusive access to everything within it.

To avoid the UB, we need to "split" the borrow of the exclusive slice into the element we want to return and the rest of the slice. I used a slice pattern; another way would be split_first_mut.[1] In any case, the idea behind exclusive borrow splitting is that the multiple returned borrows must be non-overlapping, so we avoid the aliasing UB.

But we can't split the &'long mut through a &'short mut &'long mut, so we have to get ahold of the &'long mut _. That's why we used replace to get the slice "out from under self". This is the part where you need to be able to summon a &mut [] "out of nowhere" as a placeholder, as you can't move out of borrowed content (as that would leave uninitialized data behind a reference).

At that point we can split the borrow, and then put the non-overlapping remainder of the slice back.


  1. You used to need unsafe to do this borrow splitting, but slice patterns make it possible without unsafe. ↩︎

2 Likes

I got it, thanks a lot :grinning:

On one hand, having static promotion work for ZSTs would be consistent, on the other I can't really think of a situation where that would come in useful.

I didn't go through all the replies. But this one is definitely the answer to OP.


But since you're asking a question based on specific code, the first thing you should be aware of is 1414-rvalue_static_promotion - The Rust RFC Book .

For &'static mut case, it was designed as an extension.

It would be possible to extend support to &'static mut references, as long as there is the additional constraint that the referenced type is zero sized.
...
Not doing this means:

  • Relying on static and const items to create 'static references, which won't work in generics.
  • Empty-array expressions would remain special cased.
  • It would also not be possible to safely create &'static mut references to zero-sized types, though that part could also be achieved by allowing mutable references to zero-sized types in constants.

So Rust doesn't forbid you from creating &'static mut () actually, just forbids that in static promotion instead: Rust Playground

// This compiles.
fn return_unit_ref() -> &'static mut () {
    static mut A:  () =  ();
    unsafe { &mut A }
}
3 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.