TL;DR:
Rust has the opportunity to significantly improve its ergonomics by targeting one of its core usability issues: passing values across boundaries.
Specifically, the ability to opt into 'copy semantics' for non-Copy user types would solve a host of issues without interfering with lower-level code and letting users opt into ergonomics ~on par with garbage collected languages.
Copy & Move semantics
As a reminder, in Rust when a value is assigned, moved into a closure 1 , or passed to a function:
- If it has 'copy semantics': the value is copied, and the copy is used.
No user code is run, the value being
Copymeans that the compiler can literally copy and paste (memcopy) the raw value without side effects (like, say, an allocation). The type is 'plain old data'. - If it has 'move semantics': the value is not copied and is 'used up' instead.
This post will not try to establish more context than this, but if you have used Rust for a while this will all sound familiar. See
CopyandClonefor more info.
As a full time Rust developer writing code across the stack2, I enjoy the control and guarantees the language offers. But. There's set of related issues that wears down on Rust's utility in a lot of code:
- Moving things into closures.
- Passing values to functions.
- Accessing fields.
Example
struct Post { author: Username, } fn main() { let post = Post { username: todo!() }; lookup(post.username); // ^ Compile error. // This often gets in the way! } fn lookup(value: &Username) -> User { ... }
The above are small, but frequent, pain-points for code that simply does not need to care about, say, an Rc::clone. This is a genuine productivity sink. I have forgotten a meaningless .clone() many times, leading to slow downs in my iteration speed as the compiler churns. Even when iteration speed is not the bottleneck, extra annotations can often go from useful guardrails to obscuring fog over the logic they are sprinkled on.
Countless times, small paper-cuts shave off slivers of productivity even for experienced Rust devs when the language should just get out of the way.
Yet, those clones do count in other contexts. We also have mental models of them—for example, they help maintain useful scoping invariants3—on top of the usual performance considerations. Rust can't just turn all of that onto its head, it's part of what makes it great!
A solution of hopefully small compromises
Desired properties:
- Rust keeps working basically as is for everyone who doesn't want this new feature.
- Rust doesn't significantly increase in complexity overall.4
- Rust becomes more friendly to higher-level code, particularly to contexts where values are promiscuously shared.
- Rust doesn't split into 'dialects'. By default, any code you copy-paste should work.5
Proposed solution:
- Introduce a new trait which opts a type into 'copy semantics', deferring to a clone implementation which is understood to be cheap.
Copyremains as the marker trait formemcopy-able 'plain old data'.- DO NOT implement this new trait for standard library types like
Rc. This is an important point! This is not about making all of Rust more 'ergonomic' for a high-level definition of ergonomic. - Optionally, blanket-implement the new trait for
Copytypes to maintain current semantics (Copyimplies 'copy semantics'). - (Separately, introduce lightweight cloning syntax like the
move(rc.clone()) || drop(rc)proposal if desired.)
The trait would look something like:
/// Invariants: /// - The clone has ~no side effects, like for an Rc. /// - The clone may be skipped. /// NOTE: unsafe code should not rely on a clone (not) happening. trait CopySemantics: Clone {} // A blanket impl is the simplest option, but we could also // use editions to improve the semantics of the trait by // disentangling it from `Copy` types. impl<T: Copy> CopySemantics for T {}
The pros of this solution:
- Code dealing with standard types and existing low-level code is not opted into something undesirable or gains 'new degrees of freedom'.
- Low level Rustacean don't even need to know this is a thing.
- By default, code remains explicit.
- No new syntax or overloading of a keyword.
- By and large, the places that really need promiscuous cloning can use their own types, this allows them to opt into much more ergonomic syntax.
- The cloning issue is resolved separately, by a more targeted and generalizable solution.
- The extension of
movehas a lot going for it, as it can make the moved values explicit and help describe new scoping invariants.6
- The extension of
- We can distinguish between, say: 'this is a ref-counted value that is promiscuously shared' and 'this is a cyclic data-structure where we carefully manage clones to avoid leaks'.
- If we find that all this implicit cloning is fine, we can later enable it for
stdlibrary types. - If we do not blanket implement the new trait for
Copytypes, we get additional benefits:- More types can be
Copy(currently they are 'plain old data', but invisibly copying them would be a cause of logic foot-guns). - Very large
Copytypes need not implement the new trait, in line with the fact that they are expensive. Copycan be essentially forgotten be almost all Rust devs that wouldn't normally need to think of whether something is 'plain old data'.
- More types can be
The cons of this solution:
- This takes a well known Rust concept (the
Copytrait), and essentially splits it in two. - Relies on the community not misusing the new trait to maintain code-quality.
- Arbitrary code can run on things like fields access.
This is a big one and the main reason not to do it. If this were to proliferate widely then it could increase the cognitive overhead of thinking about Rust code.
I would go as far as saying that expecting the community to not grossly misuse this is essential to moving forward with the proposal. - If we do not blanket implement the new trait for
Copytypes, we get additional disadvantages:- A decade of documentation and learning materials becomes obsolete, as they'll be talking about
Copyimplying 'copy semantics'.
- A decade of documentation and learning materials becomes obsolete, as they'll be talking about
Alternatives:
- We could make which types use 'copy semantics' a property of the code, rather than the type.
What this would look like is something like an
#[autoclone]attribute that tells the compiler 'in this code, just implicitly clone these types' (where 'these types' is either enumerated in the attribute or from a marker trait).
This has the big issue of causing Rust to potentially diverge into dialects, where in some codebases the same types behave differently. It would also require a magic incantation for high level users of Rust to start working. Still, 'autoclone is a property of the code' does seem in principle 'correct'. [source] - Niko Matsakis has a couple of blog posts on a proposal very similar to the one in this post. The main differences are:
- He recommends disentangling
Copyand 'copy semantics'. I am still a bit wary of making the accumulated resources on Rust subtly wrong, but it might not matter in practice since almost allCopytypes would still follow 'copy semantics'. - He recommends implementing the new trait on
Rcs and similar. This is something that can be done late backward-compatibly, so I think we should hold off on it (and probably never do it). It is also useful to have a type to use for potentially recursive types to avoid accidental leaks from promiscuous cloning.
- He recommends disentangling
Assorted closing notes
- This or similar proposals might be sufficient for ergonomic garbage collection in Rust. A large point of garbage collection is not thinking about it, so you want to avoid
.clone()s everywhere. - There are similar precedents for silently-run user code in Rust:
DerefandDrop. In practice the Rust community has been very good at not abusing these. - New syntax like
.usewill not be enough for high level programming like frontend UI work. The cost of needing to remember it and associated recompilation time when you don't is just too high and isn't addressed. Frameworks will keep investing in ad-hoc solutions to make their valuesCopy, and places where the issue is not existential will just deal. It would also interfere with the current scoping mental model, so it should not be enabled forRc.
Conclusions
Rust could become significantly more ergonomic in high-level code by allowing users to opt into 'copy semantics' for their types, which would then behave like current Copy types.
By allowing this, but not using it in the standard library and core crates, we avoid permeating the ecosystem with the pattern, while letting higher level crates reap the benefits where they are most plentiful.
and async blocks, but those are similar, so I'll just talk about closures.↩
Including in what might be one of the most complex Rust browser frontends in the world, though mostly by virtue of not there being many of them. :D↩
For example 'I have this
Rchere, but I haven't cloned it before passing it into the closure, and I am using it below, and it compiles, so it is not used in the closure'. Lots of small invariants like this add up to code that is simpler to grok.↩As in: a beginner would not have an extra thing to learn, which I think is true for the proposed solution. A beginner would still have to learn about 'copy' and 'move' semantics, but wouldn't need to care about the, now deprecated, connection to the Copy trait. They can separately learn about the
Copytrait if they need or want to.↩Unless the destination has opted into more restrictive checks.↩
move()can mean 'no captures'.move(..)could meanmove(move everything).move(a)could mean 'capture by move exactly a'move(ref a)could mean 'capture by reference a'move(a, ..)could mean 'capture by move at least a'move(a, ref ..)could mean 'capture a by move, capture by reference everything else'.
Though not all of these need be implemented.↩