Aside – some ramblings about `&move` references

Continuing the discussion from Problems with Attempts at Type Erasure using Boxes:

There are two things to observe when talking about &move references:

  • An addition to the language / grammar / syntax, so as to improve otherwise quite cumbersome ergonomics. There have been discussions about &own / &move references in some github issues and on IRLO about these additions (FIXME: add links), and indeed discussing about language changes does not fit here on URLO, and we might end up rehearsing what was said in such places, so if somebody has something to mention regarding this topic / aspect of &move / &own references, then I suggest that they do so there and not here.

  • Certain semantics / an API. In the remainder of my post (and I hope, this thread), I (we) will be talking about this aspect.

The semantics of &move / &own references

When dicussing about this, calling them &own rather than &move may already make the whole thing a bit clearer: the very point of &own references is about not having to move stuff (in the sense of performing memcpys) around!!.

  • The reason to be calling them &move thus stems from "move semantics" being kind of a way of saying "ownership semantics", and from the practical aspect of move already being a keyword, which makes &move a retro-compatible language addition. But since this aspect of the discussion is out of topic here, I'll stick to &own from now on.

So, the main thing to observe is that the semantics of &'_ own T references would be very similar to those of a Box<T>, in the sense that it would be a pointer which:

  • points to a valid and well-aligned instance of type T;
    in a unique / unaliased manner;

    • (behaving like a &'_ mut T up until this point)
  • calls ptr::drop_in_place::<T>(), i.e., drops the pointee T, when it is itself (the owning pointer) dropped.

All in all, it's a an owning pointer to a T / a pointer which owns its pointee / the instance of type T it points to.

  • The reason for this design very similar to Box is that Box is not always available (::alloc-less crates such as #![no_std] embedded environments) or may be deemed too expensive (performance-critical hot functions) to use when what the caller wanted was "simply" a basic layer of indirection (such as Box) but without heap-allocating (thus such as &mut) but which does retain ownership semantics (such as … err … &own T).

Since it does not directly allocate the storage for T, a &'storage own T, contrary to a Box<T>, does not free that storage either.

That is, whilst a Box<T>, when dropped, drops the T instance and then frees the backing storage / memory, an &'_ own T, when dropped, only drops the T instance, since it was only borrowing the backing storage. Hence why it is still tied to a lifetime '_, which is the span of the borrow over that backing storage:

#[language_sugar = &'a own T]
pub
struct RefOwn<'a, T : ?Sized> /* = */ (
    &'a mut T,
);

/// Or a full new-type
type Slot<T> = MaybeUninit<T>;

pub
fn new_in<'storage, T> (
    storage: &'storage mut Slot<T>,
    value: T,
) -> &'storage own T
{
    let ptr: &'storage mut T = storage.write(value);

    impl<'storage, T : ?Sized> Drop for RefOwn<'storage, T> {
        fn drop (self: &'_ mut RefOwn<'storage, T>)
        {
            unsafe { <*mut T>:::drop_in_place(&mut *self.0) }
        }
    }

    RefOwn::<'storage, T>(ptr)
}
  • Note that one possible way to achieve all this would be by deriving some dummy Allocator API out of a &'storage mut MaybeUninit<T> (we could call it StackAllocator<'storage>), and then saying &'storage own T has the semantics of Box<T, StackAllocator<'storage>>. This maybe would be doable, but having a "false" allocator (e.g., free() would be kind of a no-op; and the allocator would work as singleton non-Copy / non-borrowable instance) seems: not everything must be expressed exactly in terms of Box!

And, indeed, this whole API has already been implemented in a third-party library (disclaimer, written by me), as the StackBox<'storage, T> abstraction:

Why?

I believe the answer is very similar to why one would be using Box to begin with: because indirection adds a whole new set of type-erasure-based capabilities, such as Unsize coercions:

  • array-to-slice (erasure of the statically-known length):
    PtrTo<[T; N]> can coerce to PtrTo<[T]>,

  • dyn Traits (erasure of the statically-known method dispatch):
    PtrTo<impl Trait> can coerce to PtrTo<dyn Trait>

Notice how APIs dealing with the left-hand-side of these coercions (arrays, polymorphism over concrete trait implementors) need to do so with generics, which leads to code bloat and, if used as methods of another Trait, to making Trait not be dyn-safe / dyn-compatible, or to making those methods not be callable on a dyn Trait:

  • #[dyn_safe(true)] // Error, `.zeroize()` method is generic and thus not dyn-safe
    trait Zeroize {
        fn zeroize<const N: usize>(&self, buf: &mut [u8; N]);
    }
    

    :x:

  • #[dyn_safe(true)] // OK
    trait Zeroize {
    -   fn zeroize<const N: usize>(&self, buf: &mut [u8; N]);
    +   fn zeroize                (&self, buf: &mut [u8   ]);
    }
    

    :white_check_mark:

As well as:

  • #[dyn_safe(true)] // Error, `.scoped_block_on()` method is generic and thus not dyn-safe
    trait ScopedExecutor<'scope> {
        fn scoped_block_on (
            self: &'_ Self,
            fut: impl 'scope + Future<Output = ()>,
        );
    }
    

    :x:

  • #[dyn_safe(true)] // OK
    trait ScopedExecutor<'scope> {
        fn scoped_block_on (
            self: &'_ Self,
    -       fut: impl 'scope + Future<Output = ()>,
    +       fut: Pin<&'scope mut dyn Future<Output = ()>>,
        );
    }
    

    :white_check_mark:

While &mut is still pretty handy to enable these coercions, it does have the issue of not allowing owning APIs / interacting with owned unsized types such as dyn Traits or slices without ::alloc (e.g., try to write a dyn-safe trait with a drop_all method which drops all the elems of an input array or slice, without using allocations such as Vec: the least bad thing to do is to take a slice of [Option<T>] and .take() them all, or to take a &mut dyn Iterator<Item = T>, and both things suffer from there occuring n dynamic branches, where n is the length of the slice).

// Basic example
#[dyn_safe(true)]
trait Runner {
    fn run (&mut self, f: &move dyn FnOnce());
}

While some of these examples are already handled by another possible feature of the language, unsized_fn_params, which is basically "magic implicit &move on function inputs so as to avoid input owned dyn Traits"), the issue with something being magical and, for instance, preventing access to the lifetime of the &move handle is that it prevents further tweaks and optimisations, such as returning owned dyn Traits and other unsized types:

fn mk_dyn_fn_once<'s> (
    //                    imaginary short-hand for
    //                    an inlined existential type
    //                    vvvvvvvvvvvvvvvvvvvvvvvvv
    storage: &'s mut Slot<#[existential] impl Sized>,
) -> &'s move dyn FnOnce()
{
    new_in(storage, move || { … })
}

so that a caller that wants to stack-allocate themselves would just:

let mut storage = Slot::uninit();
let f = mk_dyn_fn_once(&mut storage);
// They can drop / call `f` until the point where `storage` is dropped.
// They cannot, for instance, return `f`.

and for someone still wanting to "return" the f to yet another caller, they'd just pass along the requirement of providing the storage:

fn middleware<'s> (storage: &'s mut Slot<#[existential] impl Sized>)
  -> Option<&'s move dyn FnOnce()>
{
    let f = mk_dyn_fn_once(storage);
    if some_cond() { f(); None } else { Some(f) }
}

And so on and so forth.


Another nifty usage of this &move would be for non extern "Rust" functions that would like to avoid unnecessary copies of big Sized types that they pass by value: without &own pointers, one cannot have all of the three following things:

  • ownership / move semantics for the input;

  • passing the value by pointer / through indirection to avoid a memcpy if the size of the type is big (a "subcase", we could say, of supporting non-Sized types);

  • not using a Box.

16 Likes

As a relatively new Rust user I wish I even understood one line of what is written above.

I do hope it's not a suggestion to add even more mind bendingly complex syntax to Rust, like &move/&own or whatever.

I already cannot fathom what any of the code snippets above are actually supposed to do!

:frowning:

3 Likes

@ZiCog I'm also a relatively new Rustacean (less than 2yrs on this forum, maybe less than 3yrs total).

I do feel like this is a "missing part" of the ownership story, and I think I get most of it.

@Yandros can correct me if I'm wrong, but the most basic rule is this:

let x = String::from("x");
use_x_ref(&x); // Can still use x after this call completes

//use_x_move(x); // Moves x, can't use it if uncommented

use_x_ref_move(&move x); // Also "moves" x but no memcpy involved! Can't use x after this.
2 Likes

Unfortunately I'm pretty sure that slot() is dropped when the statement containing it ends, not when the block containing that statement ends.

2 Likes

True, I forgot to hoist the slot declaration :sweat_smile: – fixed it in the post :slightly_smiling_face:

1 Like

Yep, that would be the idea if we also had the language sugar :slightly_smiling_face:

  • Further considerations could even go as far as imagining a &move coercion taking place when the expected type is known (e.g., imagine use_x_ref_move was a non-generic function taking a &move String (or a &move dyn Display for a more realistic approach)), so as to even be able to write use_x_ref_move(x) – although this particular sugar is exactly what unsized_fn_params is all about; but that's kind of the idea of &move: to understand the otherwise "too" magical sugar of unsized_fn_params.

    • Careful with excessive sugar

      There is one thing that scares me with having too much sugar for these things, which is to end up with counter-intuitive / non-obvious code. The current proposal of unsized_rvalues, for instance, does mention the case of:

      // assuming `Copy` would no longer require `Sized` 🥴
      fn foo (buf: [u8])
      {
          let buf2 = buf; // <- is this a `Copy` or a move?
      }
      

      whereas:

      fn foo (x: &move [u8])
      {
          let copied = *x; // : [u8] – would require `alloca`
          let moved = x; // : &move [u8]
      }
      

Yeah, my post does go quite quickly / poorly over each of these notions :sweat_smile:: I lacked the time to go onto a fully detailed explanation, and thus rely on knowledge / experimentation made about current limitations of the language. I believe almost each sentence or paragraph of my post ought to be accompanied with a 2-3 code snippets each, to better illustrate the notions at hand.

It is very likely that I, "eventually", publish a series of blog posts about this very topic, but properly illustrated and everything.

TL,DR of the semantics involved:

&move dyn Trait would be like Box<dyn Trait>, but supported in no_std or very performance sensitive scenarios (no heap allocation involved under the hood).

  • All the rest of my previous post was thus "just" discussing why and how that would be possible, from a technical point of view :nerd_face:

Avoiding feature creep and language complexity is indeed paramount for a language to remain usable, and you are completely right that this kind of additions should not be undertaken lightly.

Some comments about the syntax proposal

Well, I guess it was impossible not to talk about the elephant in the room :grinning_face_with_smiling_eyes:

For now, you should rest assured, these are mostly my random ramblings, and we are far from having that added to the language. The very first thing is to feature this kind of APIs in a third-party library, such as

and see if people end up finding it useful but for its horrendous ergonomics. If so, then it can be a good hint for the language missing something right now.

But, for context, the unsized_fn_params initiative is very real and very likely to end up in the language, from what I see.

This is motivated by being able to write something such as:

fn foo (f: dyn FnOnce())
{
    let my_fun = f;
    my_fun();
}

let f = {
    let s = String::from("Hello, World!");
    move /* s */ || {
        drop(s);
    }
};
foo(f);

This has the two following advantages:

  • it solves the "pass dyn Trait by 'value' without using Box" problem :fire:

    • At least for function parameters, which would already cover an important part of the surface area.
  • "simple" to grasp: "you can now skip Box when dealing with dyn Traits … sometimes".

It does have the drawback of very quickly hitting use cases that are out of scope of the proposal, such as, mainly, that of returning dyn Trait.

  • It also has the drawback of making it harder to justify, when teaching, that handling non-Sized types requires indirection, when that layer of indeed necessary indirection is completely invisible in the code! The rule would, for instance, become: non-Sized types need to be behind a pointer / layer of indirection to be usable, except for the very case of a function's input parameter, in which case some magical sugar will allow you to pass such things without "any" indirection…

  • For instance, it is not clear whether that let my_fun = f; line would be supported: if it were, then it would be inconsistent with let my_fun: dyn FnOnce() = || { … }; not working (because outside of the scope of the sugar), and if it weren't supported, then the whole code pattern would be incosistent with the impl FnOnce() syntax it tries so hard to mimic.

The way I see it, these things would be tackled by having &move:

- fn foo (f: dyn FnOnce())
+ fn foo (f: &move dyn FnOnce())
  {
      let my_fun = f;
      my_fun();
  }
  
  let f = {
      let s = String::from("Hello, World!");
      move /* s */ || {
          drop(s);
      }
  };
- foo(f);
+ foo(&move f);
  • this suddenly becomes way less magical, we do have the necessary indirection very much visible!

  • it's consistent with everything else in the language: we could, for instance, write anywhere let my_fun: &move dyn FnOnce() = &move || { … }; and it would work.

I'd be curious to hear @H2CO3 thoughts on all this since they're one of the main advocates against feature creep –which I very much respect– and at the same time they're also an advocate of favoring explicit syntax over sugar: I'd guess that &move could thus be seen as a "lesser evil" compared to unsized_fn_params :upside_down_face:

5 Likes

Hm, I think the main hurdle with &own is figuring out how to properly handle DerefOwn. The other semantics seem to fall out of

  • &own T owns a T
    • mutable, covariant in T, drops T
  • doesn't own the backing allocation
  • has a lifetime

However doing like this makes it very hard to define a sensible DerefOwn. Ideally we'd have a signature like

fn deref_own(&own self) -> Self::Target;

But this runs afoul for types that implement Drop. Namely, when do we call the drop glue? At the end of deref_own? But that would likely invalidate the returned reference... (For example, how would you implement DerefOwn for Box) Most discussions I've seen don't properly address this issue. The two solutions I've thought of

  1. adding a new method to Drop, drop_after_deref_own, which get's called after deref_own if deref_own was called
  2. deref_own is always called before drop (if it wasn't already called).
    But both of these have subtle issues around unsafe code.
4 Likes

Typically I see

fn deref_move(&move self) -> &move Self::Target;

but yes, this runs into thorny issues around typestate (has it been moved from) / the borrowck eyepatch (#[may_dangle]), as seen with Box.

For Box (and other eyepatched containers), the answer is that Drop runs when the Box goes out of scope, but that the recursive drop glue doesn't drop the inner value, as its flagged (dynamically or statically) as already dropped/moved from.

A fun example:

#![allow(unused)]

struct Fun<T> {
    fun: T,
    noise: NoisyDrop,
}

struct NoisyDrop(&'static str);

impl Drop for NoisyDrop {
    fn drop(&mut self) {
        println!("{}", self.0);
    }
}

fn main() {
    let fun: Fun<&_>;
    {
        let noisy = NoisyDrop("inner drop");
        fun = Fun {
            fun: &noisy,
            noise: NoisyDrop("outer drop"),
        };
        println!("inner scope end");
        // println!("inner drop");
    }
    println!("outer scope end");
    // println!("outer drop");
}

This isn't IRLO, so I'll refrain from going on about how I think DerefMove could interact with the borrowck eyepatch. (Opening an irlo thread would be nice, though!)

moveit, a crate showing a potential way to expose C++ "move construction" to Rust, also offers a StackBox/&move T.

The author notes in the article that

(There’s a crate called stackbox that provides similar StackBox/Slot types, although it is implemented slightly differently and does not provide the pinning guarantees we need.)

so I'm curious enough to ask: from your end, what's the difference between stackbox::StackBox and moveit::StackBox, especially w.r.t. (potentially stack) pinning?

moveit provides a DerefMove, that also requires you manually "OuterDrop" the container after the moving reference lifetime ends, to drop the container without dropping the (now moved from) inner value.


And personally, when I read StackBox I usually think more "Box<?dyn T, [MaybeUninit<u8>; N]>" than "Box<?dyn T, &mut [MaybeUninit<u8>; N]>" (to abuse storage-generic Box syntax). That's mostly (I assume) because I'm firmly invested in the idea that the value of &move T is not that it can own an indirect value on the stack, but that it's fully agnostic as to the location of the value it owns.

Although to be completely fair, the Rust stack already isn't really stack memory anyway once you get into async...

2 Likes

I think this is the core of the problem.

Once the drop flags need to be maintained interprocedurally, it gets really hard to find something that's better enough than just passing a &mut Option<_> to be worth the scariness of adding another reference type as a first-class thing.

They don't, though? At least in the way I understand &move.

There's no interprocedural state involved with &move. You can only take a &move of a place if you have rights to move from it (by definition) and are in charge of dropping it (or handing it off to someone else to own it). Creating the &move "just" delegates the responsibility of calling Drop::drop to whomever owns the &move.

All of the tracking of when things are dropped is still done within a single function, with the same drop flags as before. Calling a function with a &move argument is the same as giving it the value by move, "just" enforcing a pass-by-pointer ABI at the language level.

The typestateësque behavior only comes into play with DerefMove, not &move on its own, and my position is that &move is still useful even without language support for DerefMove (thus the library-level StackBox polyfill). I also believe that DerefMove could be made to work exclusively within the existing borrowck eyepatch, thus generalizing Box's special moving deref to a regular library functionality.

I'm not a domain expert here, though, and this still isn't IRLO, so I'll leave it at that for now. I should write some of my thoughts up, though...

To anyone that hasn't, I suggest reading the aforelinked article. It's quite informative, including about &move/StackBox references.

3 Likes

Another potential use case for &own is improving compatibility with C API. I have a library which wraps Rust code and I have issues with wrapping methods which take self. Currently I have to ptr::read value from input pointer, call self method on read value on this value and manually erase memory behind the input pointer (value contains secret data). With &own pointers it would be as simple as casting *mut T to &own T and calling a consuming method on it, no need for the read dance.

2 Likes

even better:

extern "C" {
    fn destroy_mytype(ptr: Option<&own MyType>);
}

#[no_mangle]
pub unsafe extern "C" fn destroy_mytype2(ptr: Option<&own MyType2>) {
    drop(ptr); // all that's needed, or we could just have an empty function body
}
1 Like

What's the issue with the second point? Ideally it would treated like the compiler treats Box

Thanks for summoning me @Yandros! I refrained from replying to this so far because I did not yet have time to read and fully understand the issues at hand. I'll soon try to do so and write up what I think about the trade-offs here.

1 Like

Yes, that's what I meant :stuck_out_tongue_closed_eyes:

I'd say it's anything that wants to be like-deref move. I.e. IndexMove, AsMove (like AsRef). But that's getting off topic.

I agree that &move could be useful without DerefMove, but significantly less so. You can't extract a &move T from anythimg but a stack location. I.e. there isn't a canonical way to convert &move Box<T> to &move T.

The issue here is if you are writting unsafe code, and you manually "deref_own" something. Then how do you call drop, but not deref_own a second time? Maybe a new intrinsic, but there may be other subtle issues in this vein that I'm just not seeing.

Another problem is that the second method may be less efficient than the first for some data structures.

1 Like

In my opinion a DerefMove-free implementation of &move references would still – magically – support Box as well as structs/enums without a Drop implementation. The same way that currently you can move out of *b when b: Box<T> or you can move out of x.0 and x.1 when x: (S, T). So you’ll be able to do &move Box<T> -> &move T as well as &move (S, T) -> (&move S, &move T). But… the lifetimes differ. You can’t have a function fn (&'a move Box<T>) -> &'a move T. Instead, if have a local variable foo: &'a move Box<T>, then &move **foo can be of type &'b move T only as long as 'b is not longer than the scope of foo (and also 'a: 'b).

This way the situation isn’t quite as bad as

in the sense that the target of the reference can be a location on the heap. However the lifetime is always bound to a stack location because we’ll never have drop flags anywhere else but on the stack. (Ignoring async fn.)

1 Like

Another potential use case for &own:

enum RigorousOption<T: RigorousIterator> {
    Some(&own T, T::Item),
    None,
}

trait RigorousIterator {
    type Item;
    fn next(&own self) -> RigorousOption<Self>;
}

Such iterator would enforce at compile time that next can not be called after it yielded None. The same pattern can be applied to generators and coroutines. In theory we can encode the same restriction with self, but it's not guaranteed that compiler will remove needless memcpys, so code reliant on such iterator may have abysmal performance (also there are porbably some issues with ergonomics of such types in the current version of the language).

3 Likes

Can't you solve this by preventing user code from directly calling deref_own? Kinda like we already do with Drop::drop

It's true that it allows the implementation to do more things, but I can't think of an example where that actually matters

1 Like

Not what I meant... lets say your dealing with raw pointers and you call deref_own (or dome unsafe variant that works with taw pointers), how do you correctly drop the value afterwards. You can't call drop_in_place, because that would call deref_own again. So we would need a new intrinsic. Now would we need a new intrinsic like this for all things like DerefOwn (IndexOwn, AsOwn, etc) or not? Or maybe the simple solution is to not call deref_own in drop_in_place. But then that would make it very difficult/impossible to backwards compatibly implement DerefOwn for any existing type because for example: Box's drop_in_place would only free the backing memory without dropping T. There also may be other subtle issues that I'm not aware of related to this sort of type state.

This seems to be getting off topic, if you want to discuss this further, please open a thread on internals and ping me.

1 Like

This discussion is pretty interesting to me.

So, if I may simplify a little bit, my understanding of the core of &own T is that it:

  • Owns the value of type T
  • Does not own the location where the value of type T is stored, in effect pulling apart those 2 things (which are sort of conflated today)
  • moving an &own T does not incur a memcpy performance penalty, which can be significant for large Copy values
  • Everything else discussed here fans out from the previous points, including the difficulties that arise in implementing this.

Please correct me if I'm wrong on any of these points.

4 Likes