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 bymove
. If you mean that types implementingCapture
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.