Just call clone (or alias)
Continuing my series on ergonomic ref-counting, I want to explore another idea, one that I’m calling “just call clone (or alias)”. This proposal specializes the and methods so that, in a new edition, the compiler will (1) remove redundant or unnecessary calls (with a lint); and (2) automatically capture clones or aliases in closures where needed. The goal of this proposal is to simplify the user’s mental model: whenever you see an error like “use of moved value”, the fix is always the same: just call (or , if applicable). This model is aiming for the balance of “low-level enough for a Kernel, usable enough for a GUI” that I described earlier. It’s also making a statement, which is that the key property we want to preserve is that you can always find where new aliases might be created – but that it’s ok if the fine-grained details around exactly when the alias is created is a bit subtle. Consider this future: Because this is a future, this takes ownership of and . Because is a borrowed reference, this will be an error unless those values are (which they presumably are not). Under this proposal, capturing aliases or clones in a closure/future would result in capturing an alias or clone of the place. So this future would be desugared like so (using explicit capture clause strawman notation ): Now, this result is inefficient – there are now two aliases/clones. So the next part of the proposal is that the compiler would, in newer Rust editions, apply a new transformat called the last-use transformation . This transformation would identify calls to or that are not needed to satisfy the borrow checker and remove them. This code would therefore become: The last-use transformation would apply beyond closures. Given an example like this one, which clones even though is never used later: the user would get a warning like so 1 : and the code would be transformed so that it simply does a move: The goal of this proposal is that, when you get an error about a use of moved value, or moving borrowed content, the fix is always the same: you just call (or ). It doesn’t matter whether that error occurs in the regular function body or in a closure or in a future, the compiler will insert the clones/aliases needed to ensure future users of that same place have access to it (and no more than that). I believe this will be helpful for new users. Early in their Rust journey new users are often sprinkling calls to clone as well as sigils like in more-or-less at random as they try to develop a firm mental model – this is where the “keep calm and call clone” joke comes from. This approach breaks down around closures and futures today. Under this proposal, it will work, but users will also benefit from warnings indicating unnecessary clones, which I think will help them to understand where clone is really needed . But the real question is how this works for experienced users . I’ve been thinking about this a lot! I think this approach fits pretty squarely in the classic Bjarne Stroustrup definition of a zero-cost abstraction: “What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.” The first half is clearly satisfied. If you don’t call or , this proposal has no impact on your life. The key point is the second half: earlier versions of this proposal were more simplistic, and would sometimes result in redundant or unnecessary clones and aliases. Upon reflection, I decided that this was a non-starter. The only way this proposal works is if experienced users know there is no performance advantage to using the more explicit form .This is precisely what we have with, say, iterators, and I think it works out very well. I believe this proposal hits that mark, but I’d like to hear if there are things I’m overlooking. I think most users would expect that changing to just is fine, as long as the code keeps compiling. But in fact nothing requires that to be the case. Under this proposal, APIs that make significant in unusual ways would be more annoying to use in the new Rust edition and I expect ultimately wind up getting changed so that “significant clones” have another name. I think this is a good thing. I think I’ve covered the key points. Let me dive into some of the details here with a FAQ. I get it, I’ve been throwing a lot of things out there. Let me begin by recapping the motivation as I see it: I then proposed a set of three changes to address these issues, authored in individual blog posts: Let’s look at the impact of each set of changes by walking through the “Cloudflare example”, which originated in this excellent blog post by the Dioxus folks : As the original blog post put it: Working on this codebase was demoralizing. We could think of no better way to architect things - we needed listeners for basically everything that filtered their updates based on the state of the app. You could say “lol get gud,” but the engineers on this team were the sharpest people I’ve ever worked with. Cloudflare is all-in on Rust. They’re willing to throw money at codebases like this. Nuclear fusion won’t be solved with Rust if this is how sharing state works. Applying the trait and explicit capture clauses makes for a modest improvement. You can now clearly see that the calls to are calls, and you don’t have the awkward and variables. However, the code is still pretty verbose: Applying the Just Call Clone proposal removes a lot of boilerplate and, I think, captures the intent of the code very well. It also retains quite a bit of explicitness, in that searching for calls to reveals all the places that aliases will be created. However, it does introduce a bit of subtlety, since (e.g.) the call to will actually occur when the future is created and not when it is awaited : There is no question that Just Call Clone makes closure/future desugaring more subtle. Looking at task 1: this gets desugared to a call to when the future is created (not when it is awaited ). Using the explicit form: I can definitely imagine people getting confused at first – “but that call to looks like its inside the future (or closure), how come it’s occuring earlier?” Yet, the code really seems to preserve what is most important: when I search the codebase for calls to , I will find that an alias is creating for this task. And for the vast majority of real-world examples, the distinction of whether an alias is creating when the task is spawned versus when it executes doesn’t matter. Look at this code: the important thing is that is called with an alias of , so will stay alive as long as is executing. It doesn’t really matter how the “plumbing” worked. Yeah, good point, those kind of examples have more room for confusion. Like look at this: In this example, there is code that uses with an alias, but only under . So what happens? I would assume that indeed the future will capture an alias of , in just the same way that this future will move , even though the relevant code is dead: Yep! I am thinking of something like this: Examples that show some edge cased: In the relevant cases, non-move closures will already just capture by shared reference. This means that later attempts to use that variable will generally succeed: This future does not need to take ownership of to create an alias, so it will just capture a reference to . That means that later uses of can still compile, no problem. If this had been a move closure, however, that code above would currently not compile. There is an edge case where you might get an error, which is when you are moving : In that case, you can make this an closure and/or use an explicit capture clause: Yep! We would during codegen identify candidate calls to or . After borrow check has executed, we would examine each of the callsites and check the borrow check information to decide: If the answer to both questions is no, then we will replace the call with a move of the original place. Here are some examples: In the past, I’ve talked about the last-use transformation as an optimization – but I’m changing terminology here. This is because, typically, an optimization is supposed to be unobservable to users except through measurements of execution time (or though UB), and that is clearly not the case here. The transformation would be a mechanical transformation performed by the compiler in a deterministic fashion. I think yes, but in a limited way. In other words I would expect to be transformed in the same way (replaced with ), and the same would apply to more levels of intermediate usage. This would kind of “fall out” from the MIR-based optimization technique I imagine. It doesn’t have to be this way, we could be more particular about the syntax that people wrote, but I think that would be surprising. On the other hand, you could still fool it e.g. like so The way I imagine it, no. The transformation would be local to a function body. This means that one could write a method like so that “hides” the clone in a way that it will never be transformed away (this is an important capability for edition transformations!): Potentially, yes! Consider this example, written using explicit capture clause notation and written assuming we add an trait: The precise timing when values are dropped can be important – when all senders have dropped, the will start returning when you call . Before that, it will block waiting for more messages, since those handles could still be used. So, in , when will the sender aliases be fully dropped? The answer depends on whether we do the last-use transformation or not: Most of the time, running destructors earlier is a good thing. That means lower peak memory usage, faster responsiveness. But in extreme cases it could lead to bugs – a typical example is a where the guard is being used to protect some external resource. This is what editions are for! We have in fact done a very similar transformation before, in Rust 2021. RFC 2229 changed destructor timing around closures and it was, by and large, a non-event. The desire for edition compatibility is in fact one of the reasons I want to make this a last-use transformation and not some kind of optimization . There is no UB in any of these examples, it’s just that to understand what Rust code does around clones/aliases is a bit more complex than it used to be, because the compiler will do automatic transformation to those calls. The fact that this transformation is local to a function means we can decide on a call-by-call basis whether it should follow the older edition rules (where it will always occur) or the newer rules (where it may be transformed into a move). In theory, yes, improvements to borrow-checker precision like Polonius could mean that we identify more opportunities to apply the last-use transformation. This is something we can phase in over an edition. It’s a bit of a pain, but I think we can live with it – and I’m unconvinced it will be important in practice. For example, when thinking about the improvements I expect under Polonius, I was not able to come up with a realistic example that would be impacted. This last-use transformation is guaranteed not to produce code that would fail the borrow check. However, it can affect the correctness of unsafe code: Note though that, in this case, there would be a lint identifying that the call to will be transformed to just . We could also detect simple examples like this one and report a stronger deny-by-default lint, as we often do when we see guaranteed UB. When I originally had this idea, I called it “use-use-everywhere” and, instead of writing or , I imagined writing . This made sense to me because a keyword seemed like a stronger signal that this was impacting closure desugaring. However, I’ve changed my mind for a few reasons. First, Santiago Pastorino gave strong pushback that was going to be a stumbling block for new learners. They now have to see this keyword and try to understand what it means – in contrast, if they see method calls, they will likely not even notice something strange is going on. The second reason though was TC who argued, in the lang-team meeting, that all the arguments for why it should be ergonomic to clone a ref-counted value in a closure applied equally well to , depending on the needs of your application. I completely agree. As I mentioned earlier, this also [addresses the concern I’ve heard with the trait], which is that there are things you want to ergonomically clone but which don’t correspond to “aliases”. True. In general I think that (and ) are fundamental enough to how Rust is used that it’s ok to special case them. Perhaps we’ll identify other similar methods in the future, or generalize this mechanism, but for now I think we can focus on these two cases. One point that I’ve raised from time-to-time is that I would like a solution that gives the compiler more room to optimize ref-counting to avoid incrementing ref-counts in cases where it is obvious that those ref-counts are not needed. An example might be a function like this: This function requires ownership of an alias to a ref-counted value but it doesn’t actually do anything but read from it. A caller like this one… …doesn’t really need to increment the reference count, since the caller will be holding a reference the entire time. I often write code like this using a : so that the caller can do – this then allows the callee to write in the case that it wants to take ownership. I’ve basically decided to punt on adressing this problem. I think folks that are very performance sensitive can use and the rest of us can sometimes have an extra ref-count increment, but either way, the semantics for users are clear enough and (frankly) good enough. Surprisingly to me, doesn’t have a dedicated lint for unnecessary clones. This particular example does get a lint, but it’s a lint about taking an argument by value and then not consuming it. If you rewrite the example to create locally, clippy does not complain . ↩︎ I believe our goal should be to focus first on a design that is “low-level enough for a Kernel, usable enough for a GUI” . The key part here is the word enough . We need to make sure that low-level details are exposed, but only those that truly matter. And we need to make sure that it’s ergonomic to use, but it doesn’t have to be as nice as TypeScript (though that would be great). Rust’s current approach to fails both groups of users; calls to are not explicit enough for kernels and low-level software: when you see , you don’t know that is creating a new alias or an entirely distinct value, and you don’t have any clue what it will cost at runtime. There’s a reason much of the community recommends writing instead. calls to , particularly in closures, are a major ergonomic pain point , this has been a clear consensus since we first started talking about this issue. First, we introduce the trait (originally called ) . The trait introduces a new method that is equivalent to but indicates that this will be creating a second alias of the same underlying value. Second, we introduce explicit capture clauses , which lighten the syntactic load of capturing a clone or alias, make it possible to declare up-front the full set of values captured by a closure/future, and will support other kinds of handy transformations (e.g., capturing the result of or ). Finally, we introduce the just call clone proposal described in this post. This modifies closure desugaring to recognize clones/aliases and also applies the last-use transformation to replace calls to clone/alias with moves where possible. If there is an explicit capture clause , use that. Else: For non- closures/futures, no changes, so Categorize usage of each place and pick the “weakest option” that is available: by ref For closures/futures, we would change Categorize usage of each place and decide whether to capture that place… by clone , there is at least one call or and all other usage of requires only a shared ref (reads) by move , if there are no calls to or or if there are usages of that require ownership or a mutable reference Capture by clone/alias when a place is only used via shared references, and at least one of those is a clone or alias. For the purposes of this, accessing a “prefix place” or a “suffix place” is also considered an access to . Will this place be accessed later? Will some reference potentially referencing this place be accessed later? Without the transformation, there are two aliases: the original and the one being held by the future. So the receiver will only start returning when has finished and the task has completed. With the transformation, the call to is removed, and so there is only one alias – , which is moved into the future, and dropped once the spawned task completes. This could well be earlier than in the previous code, which had to wait until both and the new task completed. Surprisingly to me, doesn’t have a dedicated lint for unnecessary clones. This particular example does get a lint, but it’s a lint about taking an argument by value and then not consuming it. If you rewrite the example to create locally, clippy does not complain . ↩︎