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
copycan already be captured bymove. If you mean that types implementingCapturewill 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.