A `capture` trait for cheaply cloning into closures

Originally posted here:

I find that this issue comes up very very frequently, especially in UI land, and is the number one thing holding back Rust GUI libraries, and Rust GUI in general.

People come from other ecosystems typically looking to build a GUI in this new language they just learned. GUIs and games are the most visual way of seeing your code come to life. The Yew, Dioxus, Sycamore, GTK, Iced, Druid libraries all struggle with teaching beginners about why this is necessary in the first place.

This issue is personally my #1 problem with Rust.

IMO Rust is in a weird position where we don't have a garbage collector, so it's taught that you should use RC when you don't have a clear owner of data. But, there is close to zero language support for RC and is made especially apparent in GUI land. In many ways, std acts like RC doesn't even exist, even though a large amount of programs (and many/most larger libraries) use it extensively.

The Proposal:

A new trait like Copy that takes advantage of the Copy machinery under a new capture keyword. Perhaps this trait could be Capture.

Right now, if you want a value to be moved into a closure with no ceremony, it must implement the Copy trait. This is an explicit action you must take as a library developer to make your types work well in closures. This is what we do in Dioxus. Copy is a trait that signifies a value can be copied by bytes and typically is a marker that this item is "cheap" to work with.

Likewise, a Capture trait would signify that this type is cheap to Clone. To implement the trait, we could simply defer to the clone implementation. In the Rust standard library, it would only be implemented for Rc and Arc.

trait Capture {
    fn capture(&self) -> Self;
}

impl<T> Capture for Rc<T> { // and Arc
    fn capture(&self) -> Self {
        self.clone()
    }
}

Then, to take advantage of Capture, we would replace our uses of move with capture. The capture keyword is the exact same as move but with the added benefit that any types that are not normally copy can be captured using capture provided they implement the Capture trait. By default, on Rc and Arc are Capture, but developers can make any value of theirs Capture by deriving or implementing the Capture trait.

Alternatively, we could just give Capture the same "super powers" that Copy has already and have its machinery kick-in on moves.

Using Capture would look like move:

let val = Rc::new(thing);
let handler = capture |_| println!("{val}");

This is especially helpful for the many async bits of the ecosystem:

fn app(cx: Scope) -> Element {
    let val = use_ref(&cx, || None);

    // What we have to do today
    cx.spawn({
        let val = val.clone();
        async move {
            val.set(Some("thing"));    
        }
    });

    // What the future could be
    cx.spawn(async capture {
        val.set(Some("thing"));
    });
}

I personally don't see Capture any differently than Copy - teaching capture would be the same, their implementations would be similar, but the benefit to Rust would be huge (IMO).

Some Q/A:

The capture keyword is the exact same as move but with the added benefit that any types that are not normally copy can be captured using capture provided they implement the Capture trait.

But types that are not copy can already be captured by move. If you mean that types implementing Capture will be cloned instead of moved, then that would be an unprecedented change in behaviour based on whether a trait is implemented or not: normally implementing a trait only makes more things compile it doesn't change the behaviour of code that used to already compile.

If a type cannot be moved into the closure because it is used in the enclosing scope, then capture would kick in. move would take precedence over capture unless there are other borrows in scope. This is not too dissimilar to the recently added partial borrowing behavior.


I personally don't see Capture any differently than Copy - teaching capture would be the same, their implementations would be similar, but the benefit to Rust would be huge (IMO).

Copy has a clear definition, whereas Capture seems pretty arbitrary. How cheap is "cheap"? Why even have Capture at all if we have Clone?

Capture is whatever the implementor wants capture to be. Many other traits are arbitrary - notably Display Debug don't put any requirements on how the trait must be implemented. Just that you can call their Debug/Display implementations that must satisfy the trait.

The recommendation should be that types that are cheaply cloneable are capture. For std, only Arc/Rc satisfy this criteria. By default, all Copy is Capture. Library implementors would be free to implement this however they seem fit.


What if I have two things implementing Capture, and I want one of them to be cloned into the closure, and the other one moved?

Because move takes precedence over capture, the type that can be moved will be moved and the type that can be captured will be captured. If you need to take a manual clone, you still can, since the cloned item will be handled by move anyways.


If a type cannot be moved into the closure because it is used in the enclosing scope, then capture would kick in. move would take precedence over capture unless there are other borrows in scope.

I don't believe the borrow checker is currently allowed to affect behaviour, only to reject otherwise-working programs. For example, mrustc skips borrow checking entirely, on the assumption that the code has already been validated by rustc.

Invisibly invoking (an equivalent of) Clone::clone, especially based on hidden conditions that are not particularly obvious, seems like a dangerous road to start walking down.

Copy does pretty much the same thing already.

For example, this array is "silently" copied into two handlers. In fact, since it's Copy it's also Clone. We're calling Clone silently! What if this array was 100,000 elements large? You need to know that the type is Copy for it to succeed.


fn main() {
    let vals = [0; 10];
    let handler1 = move || println!("{vals:?}");
    let handler2 = move || println!("{vals:?}");

    handler2();
    handler1();
}

The Capture trait would just toss some sugar on top of cheap clones to do exactly the same optimization, but for RC types.


fn main() {
    let vals = std::rc::Rc::new([0; 10]);
    let handler1 = capture || println!("{vals:?}");
    let handler2 = capture || println!("{vals:?}");

    handler2();
    handler1();    
}

I don't think the conditions are any more hidden than the fact the Copy gets copied on move. In fact, I think capture is more obvious because it's a trait dedicated for this very purpose, rather than Copy somehow interacting with the move keyword.

Additionally, nowhere in the Copy docs does it mention the behavior that Copy types are copied using the move keyword.

In fact, since move has its own special behavior with Copy types, I don't see why Capture couldn't also interact with move in the exact same way.

3 Likes

I feel like you don’t really answer this point at all. The obvious alternative to compare to and discuss here AFAICT is that we could just make capture |…| { … } always unconditionally clone all the captured (non-Copy) values that implement Capture.

The counterpoint to this approach would be

but if cloning a Capture type actually is cheap than it shouldn’t be too bad anyways.

One could even introduce special exceptions that don’t depend on the borrow checker. For example a captured variable that’s exclusively used in the closure could be moved instead of cloned. Considering this behavior it also makes sense to add a condition to the Capture trait that is shouldn’t make a difference whether you’re cloning a value and dropping the original (potentially later) vs. using the original directly.


Well, on second thought, even for Arc this is quite the difference: When you use the original Arc, you can avoid (potentially non-cheaply) cloning the contained value on a call to Arc::make_mut, while using a clone (while the original stays alive) means you always need to clone.

This topic seems somewhat related to proposals of “eagerly dropping” values (before the end of their scope) by means of an opt-in trait into such behavior. Implementing such a thing would need behavior that depends on the borrow-checker, too, AFAICT. I don’t know if any prior discussions on “eager dropping” addressed this point; if they have, it might be relevant here.

Note that this is not only about potentially “non-obvious” behavior of code, but also about “potentially changing in the future” behavior. The borrow checker of Rust is evolving. When we get Polonius done and stabilized, it will become better at detecting “legal” Rust programs, and (I think) consequently also better at detecting cases where moving a value is allowed vs. where it isn’t. To double-check that that’s truly the case, I (or someone else) should probably try to come up with a concrete example of a move closure that’s allowed with Polonius, disallowed with the current borrow checker, but allowed again (with current borrow checker) if a manual let captured_variable = captured_vairable.clone(); is inserted before the closure.

3 Likes

I think you also want an impl for std::sync::Weak.

Now that you mention Weak, I'm seeing another important aspect/effect of unpredictable behavior of capture that demonstrates why it's undesirable. If you try to use a Weak pointer after creating, calling and dropping a capture || closure which captured the only strong pointer for that weak pointer, then the success of that operation depends on whether the strong Rc/Arc was moved into the closure (and hence dropped with it) or whether it was just cloned (and hence its original stays alive for the whole scope of the captured variable).

If improvements to the borrow checker could turn capturing by cloning into capturing by move, then that could make dereferencing of a Weak pointer that used to succeed start to fail instead.

2 Likes

I think I understand.

You're describing this setup:

    #[test]
    fn theory() {
        let val = Rc::new(String::from("asd"));
        let weak = Rc::downgrade(&val);

        // let val2 = Rc::clone(&val);
        let handler = { move || drop(val) };
        handler();

        match weak.upgrade() {
            Some(a) => println!("upgrade succeed"),
            None => println!("upgrade fail"),
        }
    }

In my proposal, I considered this case to be a "move" has the highest priority and "capture" is used when move cannot satisfy the constraints. I wouldn't necessarily say this is "undesirable" or "unpredictable" behavior if it's clearly defined that if a value can be moved without capture, it will be moved.

However, I don't work on the compiler so I couldn't gauge whether or not the heuristics are available at this point in the compilation to understand of only "move" is possible or if a capture needs to be inserted.

Intuitively, you wouldn't insert your own clone if the value could be moved, and clippy will warn you as such with the redundant clone lint.

I do think it's worth clearly defining the semantics and optimize for the case of "move has highest priority".

This is fundamentally wrong from a language perspective. Copying does not call clone. Copying just moves the value.

This is an important thing to understand about Copy: it doesn't change how moving the value works. When you call(value), the bytes of value are memcpyd from the caller's stack frame into the callee's stack frame. In other words, the value is moved. By default, moving a value invalidates the previous location of the value, because it's been moved. If a type is Copy, that says that moving the value does not invalidate the place it's been moved from, and that those bytes can still be used.

So,

is a misunderstanding of how Copy works. The value is moved the same way that any other value is moved. For the purpose of a move closure, nothing is special about Copy types.

More importantly, though,

is just wrong. Copying a value (equivalent to moving a value, remember) specifically does not call Clone::clone. Clone on a Copy type is typically implemented by copying the value (fn clone(&self) { *self }), but it doesn't have to be[1]. Implement Clone manually on a Copy type and you'll see this behavior [2].


I could see myself supporting such a feature, with a few strong caveats:

  • It should be trait Capture : Clone {}, and codegen should call Clone::clone directly; there shouldn't be a separate Capture::capture that could be implemented differently.
  • Any Capture type should always be cloned into a capture closure, never moved. The captured value is still dropped normally in the outer scope. A Capture type should be cheap enough to clone that this shouldn't matter, and it leads to more predictable behavior (such as when Arc are unique). If you need more control, you can always fall back to move closures.
  • If it uses capture || $expr syntax, then capture needs to be a keyword. However, capture is too disruptive as a keyword (e.g. Trace::capture), so something else needs to be chosen. move capture || $expr could potentially work with capture being a contextual keyword.

However, at the same time, the performance difference between +0 passing (ArcBorrow<'_, T>), +½ passing[3] (move Arc<T>), and +1 passing (clone Arc<T>) is important enough that ref count optimization is a big (ABI) concern in RCd languages (e.g. Swift). Perhaps that's more because it pervasively uses CoW semantics as well, so you want as many +½ passes as possible to optimize CoW?


  1. This actually impacts what optimizations the compiler can do, and makes automatically optimizing T: Clone code to be as efficient as the same code with T: Copy surprisingly difficult. The compiler can't just replace a Clone::clone(&t) with move t, because Clone::clone is a function call and could do anything. Instead, it has to rely on inlining to do the transformation. This is part of why you should always either #[derive(Copy, Clone)] where possible, and if you have to impl Clone for YourType manually, mark Clone::clone as #[inline] (if not #[inline(always)]; this is like the one place always is justified). ↩︎

  2. And this is why we'll never be able to see #[derive(Copy)] imply #[derive(Clone)] without some extra funky "weak impl" semantics. Perhaps that's default impl<T: Copy> Clone for T? In a different world we had impl<T: Copy> Clone for T from the beginning and this would be slightly different, as Copy would actually guarantee a noöp Clone. ↩︎

  3. An A press is an A press, you can't call it a half –T J "Henry" Yoshi

    Watch me –panenkoek ↩︎

7 Likes

I like an idea of a marker trait for cheap shallow cloning.

I have mixed feelings about the capture keyword:

  • move is not a good design to follow, because it already needs workarounds when user wants to move some values, and reference others. If users wanted to capture some variables, move few others, and reference the rest, there's no obvious syntax for that.

  • explicit capture is yet another thing for new Rust users to learn. Rust has a steep learning curve already, and this adds to the curve instead of flattening it. Consider the opposite: if Arc was cloned when needed automatically, users wouldn't need to learn a new keyword, and would get fewer ownership errors.

  • Let's keep in mind Stroustrup's Rule: For new features, people insist on loud explicit syntax. For established features, people want terse notation. In GC languages captures are always equivalent to Arc::clone, and it's working out well. I realize Rust is stricter than most languages, but types wrapped in Arc are already meant to be cloned.

So I think I'd be happy if the rule was that in closures with move, Arc is moved if possible, and cloned if it can't be moved (let's stretch move to mean you get owned values).

In terms of introducing this without breakage, it could require Capture trait to be in scope. So you keep existing behavior, unless you opt in with use std::clone::Capture;. This could be in the prelude in a later edition.

10 Likes

I want to mention two things re. GUI frameworks here:

First, I have used Druid and don't understand why it's listed above. Its design doesn't require you to clone things into closures, anywhere. I have a small Druid app and searched it for this pattern – there were none. I only found three occurrences of clone-into-async move {}-block.

Second, the gtk-rs ecosystem has a macro to help with this pattern (glib::clone!), which does captures in a more complex way than simply cloning the inputs, for a very good reason: If you clone ref-counted widget instances into event handlers of child widgets, you get a reference cycle and those parts of the UI never get deallocated, leading to very hard to spot memory leaks.

All that is to say… even disregarding the extra language surface and everything that comes with that, easy clone-into-closures would not significantly help Rust's GUI ecosystem in my opinion.

7 Likes

Working with gstreamer-rs, when I have to capture one of its Arc-like types, most of the time I need to use weakrefs to avoid cycles. So, downgrade, capture, upgrade-or-return/panic.

I like glib::clone!. Unfortunately rustfmt can't format it, which is really painful when it contains a lot of code, so I avoid it.

Note that this isn't always true. One very common way to "leak" memory in a GC'd language is to write a closure that uses a.b.c, and thus have a get captured -- keeping alive that whole tree of objects, even if that's not what you intended.

Not implicitly cloning your Arcs so a little to help avoid that, though certainly doesn't solve the whole thing.

(C# also only generates one closure capture environment per function, so you can even accidentally capture things you don't even use in the closure. But that's not a problem with GC itself.)

1 Like

I agree with @jplatte, having recently worked with egui and had no such issues.

To me it seems the underlying friction is, that Copy and Clone exist as separate entities, combined with the hard rule that move == memcpy. In another language, focused less on performance you could get away allowing users to specify their own move implementation. But this wrecks performance in a serious way in many situations, plus it has a serious implication for unsafe code. There exists the middle ground of allowing custom move implementations, which would also simplify self-referential structs and probably more, while still having memcpy where possible. There is a language that does exactly that, C++. However it comes at a huge cost, it requires further user annotation, in C++ noexcept, and bloats the implementation of many libraries, as they have to static dispatch into two separate but nearly same implementations, eg. rust/rc.rs at 297273c45b205820a4c055082c71677197a40b55 · rust-lang/rust · GitHub one with memcpy and one which calls the move implementation. This alone would break half the standard library and many other libraries. More subtle, but arguably worse, is the exception safety implication. Knowing that moving a value will be an operation that can never ever create a panic/exception simplifies writing unsafe code. And if I had to guess, nearly all unsafe Rust code relies on this property in one way or another.

From a user perspective it would have been nicer if Rc and Arc had Copy semantics, eg:

let a = Rc::from("sdf");
dbg!(a); // moves out of a
let b = a;
dbg!(b);

Alas the price of allowing anything to implement move != memcpy is so extreme that Rust decided against such path. Note this would also break the destructive move property of Rust and probably more.

A new marker Trait might seem enticing, yet they too come with a hefty complexity price tag. There is a limited amount of marker Traits that can be added to Rust before it becomes an overly complex language. Some would argue we have already crossed that border and any further addition will only make things worse.

IMO Rust is in a weird position where we don't have a garbage collector, so it's taught that you should use RC when you don't have a clear owner of data.

Is it? I've always heard, if your code is riddled with Rc and RefCell you are doing something wrong. GUIs come with complicated lifetimes, but just shoving everything into a smart pointer, certainly isn't the best solution. This 'solves' the lifetime issues as much as having the program be free of UB because stuff is freed too early, but that's the extent of added structure. One can implement a GC in Rust and have objects be tracked that way. You can also work with plain references, and actual ownership. Many reactive UIs separate state out as a read only reference, paired with messages and state update functions. Those can be trivially modeled with regular references.

Is it? I've always heard, if your code is riddled with Rc and RefCell you are doing something wrong. GUIs come with complicated lifetimes, but just shoving everything into a smart pointer, certainly isn't the best solution

There are a lot of foundational abstractions and tools that use Rc/RefCell. As it stands, there is no simple way to have two closures with mutable access to the same data, drastically reducing the usefulness of closures.

// this does not work.
let mut value = 0;
let mut incr = || value += 1;
let mut decr = || value -= 1;

incr();
decr();

In theory though, this should be entirely valid code - incr and decr don't modify value at the same time. Callbacks and closures are typically popular in languages with garbage collectors. Whenever I see complex use of closures out in the wild, it's almost always paired with a thorough use of Rc/RefCell. I don't think the situation around closures is going to improve any time soon, so in my opinion, it's better to just make working with Rc easier. The fact that we have to manually clone Rc-s seems overly verbose to me - how often do you really care about the temporary hard count to an Rc? Most of the time you can't even elucidate when a smart pointer will drop, so the specifics of the hard count seem practically irrelevant.

With a keyword like capture instead of move, we can make it obvious that an autoclone is happening while also keeping the door open for regular move to preserve the need for Weak references instead of Hard references.

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