Splitting the Rust Copy Trait

9 min read Original article ↗

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 Copy means 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 Copy and Clone for 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.
  • Copy remains as the marker trait for memcopy-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 Copy types to maintain current semantics (Copy implies '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 move has a lot going for it, as it can make the moved values explicit and help describe new scoping invariants.6
  • 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 std library types.
  • If we do not blanket implement the new trait for Copy types, 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 Copy types need not implement the new trait, in line with the fact that they are expensive.
    • Copy can be essentially forgotten be almost all Rust devs that wouldn't normally need to think of whether something is 'plain old data'.

The cons of this solution:

  • This takes a well known Rust concept (the Copy trait), 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 Copy types, we get additional disadvantages:
    • A decade of documentation and learning materials becomes obsolete, as they'll be talking about Copy implying 'copy semantics'.

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 Copy and '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 all Copy types 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.

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: Deref and Drop. In practice the Rust community has been very good at not abusing these.
  • New syntax like .use will 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 values Copy, 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 for Rc.

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.


  1. and async blocks, but those are similar, so I'll just talk about closures.

  2. 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

  3. For example 'I have this Rc here, 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.

  4. 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 Copy trait if they need or want to.

  5. Unless the destination has opted into more restrictive checks.

  6. move() can mean 'no captures'.
    move(..) could mean move (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.