Rationale for move/copy/borrow syntax

Hi,

I recently started to learn Rust, coming from (nowadays) mostly Python and C/C++. As someone who enjoyed assembly coding in the good old times, I am excited to see a language gaining traction that combines being close to the machine with powerful abstractions and safety. It feels like a breeze of fresh air!

My excitement notwithstanding, I keep wondering about some design decisions of basic Rust syntax. I know that this aspect of the language went through a long phase of iterations and experimenting, so I’m confident that good decisions were made. However, I would like to understand. I cannot answer the following by searching and reading existing discussions and documents. Perhaps someone here would be so kind to enlighten me or to point to relevant sources?


In programming in general it seems more common for function parameters to be “observed” than to be “mutated” or “consumed”. Indeed, in pure functional programming values never get modified. Even in an imperative language it seems a good idea to consider “observing” the default mode of argument passing.

However, in Rust, the syntax for borrowing is more unwieldy than the one for moving.

Rust allows to pass an argument to a function in four ways:

  • move
  • copy
  • immutable borrow
  • mutable borrow

Copy and move have the same syntax and I understand that under the hood they are the same operation and the only difference is whether the memory that has been copied is allowed to be used further as a value or not.

However, semantics seems more important than implememtation, and from this point of view an immutable borrow and a copy are the same: in both cases a value is passed on and the original owner may continue to use it unchanged. How this is implemented under the hood is anyway the choice of an optimizing compiler.

In this light, would it have been possible to base the syntax of Rust on the concepts “observe, consume, and mutate” as proposed in this Reddit post? It proposes an alternative syntax

observe(b);
consume(^b);
mutate(&b);

to replace Rust’s

observe(&b);
consume(b);
mutate(&mut b);

In response, one may argue that this proposal will give let a = b the meaning of borrowing b instead of moving it into a. However, let is special anyway, so perhaps a satisfactory solution could be found.

Naively, the above alternative syntax seems quite attractive. It would be not only more concise and clearer, but also more expressive. With today’s Rust, one library for 2d vectors may choose to make its vector type “copy” and the basic arithmetic operations (dot product, vector addition, etc.) to take their arguments by copy. But another library for big vectors will reasonably choose to make them non-copy and the basic arithmetic operations to take their arguments by immutable borrow.

I haven’t tried, but it seems difficult to write a generic function that will do some vector arithmetics and will work when parametrized with either a “copy” 2d vector or a ‘non-copy’ big vector.

I guess that I must be missing something important, or otherwise Rust would already use something similar to the above alternative syntax...

It is not. A borrow conceptually passes a pointer; this means that an immutable borrow cannot be used together with a mutable borrow, whereas a copy of a value can co-exist with a mutable reference to the original instance. Passing a reference/pointer vs. making a copy cannot merely be an optimization choice due to things like Rc which absolutely do count on the number of pointers that exist to a particular memory location.

I'm not sure what you are trying to do exactly, but if you want to write e.g. a dot product that works with both Vec<f32> and [f32; N] for example, then you can just take a slice or an Iterator<Item = f32>.

How?

This is at best highly subjective. I don't find it any more intuitive, attractive, concise, or clearer at all. & is well-known from C and C++ as the address-of operator, and mut denoting mutability is clear enough. The ^ would add no value here, it's just another magic symbol.

2 Likes

I think some syntactic choices are just arbitrary. Given a variable name foo, or in general any place-expression such as bar.baz or qux[42], in Rust, passing this expression to a function, or pattern matching against a variable name without ref or ref mut modifier will indeed move by default, and it also seems like a reasonable design idea to have it create a shared borrow by default instead, requiring explicit syntax for the move. But it’s not trivial.

If you are proposing to change just function calls, but keep let the same, I would find that fairly inconsistent though. Where to draw the line? If let a = foo; moves, does let b = (bar, baz); move, too? Or borrow? Then how about let c = Struct(qux);? Constructors of tuple-structs are essentially functions though…

Now, what do we do with value expressions? Borrowing a value expression has no real advantages over moving it – why would I want to write f(^String::new()), even though moving the newly constructed string has no downsides? In generic contexts where you should have moved but borrowing makes your code type-check successfully, too (at least up to borrow-checking), borrowing can have disadvantages though, as it introduces lifetime dependencies and you could run into dangling (hence unusable) references. Will let x = (1 + 2, 3 + 4); create a tuple of two references now? But those integers would only be temporary values…

Long story short: as far as I can tell it’s easy to take a narrow view on function calls alone, but it’s a lot harder to come up with a complete and coherent picture for alternative syntactic choices here. But I don’t want to keep anyone from imagining. I’d be interested in hearing more complete details about hypothetical alternative syntax. Of course it’s clear that such discussions are hypothetical, since Rust won’t ever change in such fundamental ways :slight_smile:

One thing to also consider is that in Rust, method call syntax is often used. Your example makes it look like there’s always a clear syntactic distinction between “observe/consume/mutate”, but really, in many cases it looks like this instead

b.observe();
b.consume();
b.mutate();

and then, even for standalone functions, or in non-self arguments, there’s also the common case where b is already a reference, where we would have

observe(b);
consume(*b); // only works for `Copy` types
mutate(b); // only works in the `b: &mut …` case

Final consideration: Rust uses a nicely consistent syntax between types and values. A value x: T gives &x of type &T and &mut x of type &mut T. Breaking this property has some downsides in my opinion; and I don’t see any good way of making “borrowed access” the default on the type-level, too (but maybe I’m just not creative enough?)

22 Likes

Keep in mind that while “good decisions were made” they are not the best possible evah!

Usually after you weed out few “obviously awful” decisions you are left with a few “not too anwful” ones and very often the actual decision picked is very subjective.

Note that problem that you have described have nothing at all to do with the syntax and would remain no matter what syntax would you choose.

I think this is the key observation here: foo(T) and foo(&T) are doing more-or-less what they would do in C/C++ (yes, in C/C++ foo(&T) will use pointer, not reference, but semantic is the same) while foo(&mut T) is obvious extension to that.

This basically means that someone who comes from C/C++ world already knows how two out of three forms would work and, more importantly, the forms that exist both in C++ and Rust work the same.

Now look on this:

Here we have one forms which acts the same as in C/C++ (last one), but, more importantly the first form does different things in C/C++ and Rust.

The last part, more-or-less automatically disqualifies the offer.

Rust is often called, jokingly, “ML in a C++ trench-coat” and that's only half-joke: making things easy to grok for users of C++ was much more important than picking some kind of syntax close to platonic ideal.

8 Likes

It does not seem so to me. One is quite often asking a function to update and array, structure, list or whatever and expecting the modified thing back. Perhaps that is because of my long experience of C like languages. Makes me wonder what the statistics are for "observed", "mutated", "consumed" parameters are. Anyone one up to analysing a bunch of Rust code to find out?

Probably a good idea to be as "functional" as possible. I don't know about being the default though. That might depend on the statistics we get from actual code.

Certainly more concise. You example saves all of 3 characters out of 33. Is that worth worrying about? Personally I don't think extreme concision (word?) is what a programming language should strive for. It cannot be more expressive, at least your example says exactly the same thing in both cases. "clear and "attractive" are very personal judgments.

Personally I feel that given that mutability is a "red flag", we like functional programming right? then that "mut" standing out there is a good thing.

Also personally, reusing ^ for this would annoy me. As would any other special character probably. I'm not even keen on the use of & for references but there is not much better to choose from.

Thank you all very much for your very interesting (and almost immediate) replies that shed light on the problem from many sides.

This gives me a lot to think through and experiment with.

Further reasons why “move” being the default can be worth it: Move semantics are among Rust’s most powerful tool, so it’s worth encouraging them to be used whenever possible.


As an upgrade over C++, they replace a weirdly explicit std::move(…) annotation that you ought to sprinkle throughout your code for performance reasons, and then prey to god that you haven’t missed any expensive implicit deep copies after all. (Seriously, that’s one of the weirdest-seeming aspects of C++ to me; anyone please enlighten me how you actually deal with this in practice, is there something fundamental that I’ve missed?)


Compared to functional languages, move semantics allow significant performance improvements. On the one hand, good ownership semantics allows avoiding a garbage collector, on the other hand, moving owned values allows for mutation without side-effects! Since mutating in-place is more run-time efficient than creating new values (especially when allocations are involved), this is clearly a performance win. But how can mutation be without side effects? In pure[1] functional programming, values are often immutable only as a means to avoid side-effects, but modifying values is only inevitably considered a side-effect in the first place since all values are always hopelessly shared in such “typical” high level programming languages. The whole language would usually operated with a garbage collected heap containing every single value there exists, and every mention of a value goes through some implicit references. Blink once, and your value might have become aliased from somewhere else while your eyes were closed.

But mutation does not need to have side effects. If all mutable access is unaliased, via unique ownership or unique borrows, then there is no side-effect. When the value was owned, the effect is obviously local, and when the value was mutably borrowed, we clearly indicated the mutation in our function signature; the mutation was not a side effect, but the main effect. Functional languages go out of their way to re-model something quite similar to (but harder to use than) Rust’s mutable references as “functional references” aka “lenses”, essentially proving the argument that such mutation really is pure.

(Of course, Rust has/allows true side-effects, too; mainly in the form of IO and interior mutability, but those are typically only used if you need them.)

Back from mutation to moves; those are even more pure, since moves in Rust do not even require any local mutability. A variables can be moved into when initialized, and then moved out of again (once). The value of the variable is immutable; it never changes, it merely becomes inaccessible eventually, but one cannot witness any changes, just compiler errors, if one tries to access it after a move.


  1. which means “without side effects” ↩︎

6 Likes

That one is easy. C++ exists since 1985, std::move exists since 2011, I'n pretty sure if Rust would need to add something as fundamental as move semantic to the language in year 2035 or 2040 it would have to do something weird, too.

1 Like

Taking this train of thought to its logical conclusion, you end up with something similar to C++ rvalue reference rules. Wherever you have a temporary value known not to be used again (or use std::move to say you're not going to use an lvalue again), the rvalue reference (move) overload applies.

Applied a Rust-ish world like described by the OP, an rvalue would move by default without annotation but if that doesn't work also allow pass-by-ref. You'd essentially get C++'s move rules except with destructive moves, meaning the language handles preventing access to and suppressing the destructors of moved-from values rather than leaving it to be handled by user code.

Correct use of std::move (or std::forward in templates because T&& means something completely different in templates, of course) is thankfully one of the easier things to lint for. If you use such a linting tool, it gives you Rust-like affine type information, allowing you to identify where the last use of a value is that should've been std::moved, as well as when you've incorrectly potentially used a value after std::moveing it.

std::move is a strong promise[1] that you never use the value again (except to delete it), so it does generally get underused. For user types where it's truly important, you can IIRC mark the copy constructor as explicit or decide to delete the copy constructor and provide separate clone/clone_from methods. (But this is extremely rare.)


  1. It's not quite on-pain-of-UB, but the general interpretation is that it's essentially that. Being moved from leaves a type in a (user implementation-defined) state with the only requirement being it's safe to call methods without preconditions. (In practice, they're usually left in a default initialized state or swapped with the value from the place being moved to.) For STL types, that's an actual meaningful guarantee, but for user types without such strict/formal specification, "hasn't been moved from" is an allowable and typically assumed precondition on everything but constructors and destructors. ↩︎

2 Likes

This section really jumped out at me. An immutable borrow and a copy are very different, as when you borrow something both the borrower and the borrowee know they're working with the same underlying value. The caller can also only use the borrowed item in a borrowed manner so long as that borrow lasts (which may be longer than the function call). Additionally, a borrowed &T is an entirely different type than a T (with different lifetime limitations, trait implementations, and all sorts of other considerations), so what the callee can do is very different as well.

That is, borrows in Rust do have semantic meaning, and a lot of it. I think you'll find that whether something is borrowed or not has a lot of significance in Rust :slight_smile:.

4 Likes

C++ has another variant, where (for historical reasons), the default is copying but moving needs extra effort.

This is broadly considered an annoyance by people writing modern C++. Needing foo(std::move(bar)) to move bar is a pain, and is particularly awkward because (IIRC) wrapping something that makes a temporary in std::move is bad.

Moving is a great default in Rust because the compiler checks it for you. Thus if moving works, it's the best option, and that's the property you want for the shortest thing. The compiler can always guide you to the other possibilities if moving doesn't work in a particular case.

4 Likes

Also, a borrow is literally one extra character. This is already fully "oh come on!" territory to me.

4 Likes

Well… C++ went to great pains to remove that one character.

Probably because Pascal (the big language back then, not something extra-special and niche like today) could do that and C couldn't.

Looking back… that was a mistake. All these special and then extra-special and then extra-extra-special rules… just not worth it.

2 Likes

If you tear through abstraction levels, a moved pass-by-value reference-to-Copy or a moved pass-by-reference Copy value are potentially indistinguishable. Actually, Rust's current pass-by-reference ABI for moves allows the receiving function to mutate the referenced value directly, so there is a difference, but it's possible that the ABI could be changed such that Copy values are pointee-copied instead, and no difference remains.

... except there still is a difference, because of address uniqueness guarantees meaning the pointee still has to do the copy if there's any chance that you observe the address in any way.

So TL;DR: they're similar until you get into the weeds.

It certainly was the case that doing return std::move(value); was extremely bad, as that prevented copy/move elision from kicking in, and in fact IIRC forced a copy to be done. (Moves/copies are directly observable in C++, since they call into user code, so the allowance to elide the move/copy operation must be written directly into the language semantics.) By my understanding, NRVO copy/move elision only applies when the operand is a name of an object (i.e. not the std::move of it). IIRC this was fixed (or at least improved) in C++20, as C++20 adjust the rules for move-eligible return operand expressions. Or perhaps it's C++23, which treats all move-eligible return operands as xvalues.

Either way, it was said that return std::move is no longer a pessimization, at least. Knowing how accurate that is or how/if it relates to copy elision of function arguments is beyond my domain. (As far as I can see, function argument GCE applies to prvalues (temporaries), and no nonmandatory copy elision is provided for non-prvalues such as the xvalue produced by std::move.)

1 Like

Ah, linters. Makes sense. Why the standard compilers wouldn't come with reasonable lints included and enabled, or why tutorials and references wouldn't teach about such linters, I don't know, but in true C++ fashion, at least there exists some way to do things the right way and that ought to be enough?

“Use a linter!” – Would the same apply to straightforward variable initialization analysis? IIIRC, my last C++ usage (in a university project) was bitten by my failing to understand what syntax to use to make sure a value of a user-defined class was actually properly initialized/constructed.

2 Likes

Why was that a problem? There are just 18 types of initializers, they all are listed in the standard… Rust doesn't even have a standard.

8 Likes

Even more so if we get shared mutability + Copy.

But it's true, my reply was tinted by my experience that "borrowed or owned" is a concern a couple of magnitudes more often than "Copy or not", in Rust.

1 Like

I'm doing mostly scientific computations that often feature quite complicated arithmetic expressions with terms that are not simple numbers but matrices, vectors, quantum-mechanical operators, etc.

Here is a piece of C++ code from one old project of mine:

void Rgf::add_slice(const Slice_ham &sh)
{
    const Sparse_mat &h_hop = sh.get_h_hop();
    const Sparse_mat &h_onslice = sh.get_h_onslice();

    temp = gf_slice * h_hop;
    gf_slice = -f::conjugateTranspose(h_hop) * temp - h_onslice;
    invertmatrix(&gf_slice);

    temp = h_hop * gf_slice;
    gf_comb = temp2 = gf_comb * temp;
}

In the above code, the variables are all dense or sparse matrices. temp is actually a pre-allocated working space. Note that in this code no unnecessary copies or temporaries are made. Each line that performes matrix arithmetics is mapped to a single BLAS call thanks to a C++ template library called flens.

The flens library uses a technique named "expression templates" that allows to optimize arithmetic expressions at compile time. Seems like a perfect fit for Rust and indeed this technique works also in Rust, but it seems that it requires prefixing every variable in an expression with an ampersand, see for example here:

1 Like

It occured to me that if one ignores the suggested change of syntax, the outcome is a lot like autoref'ing Copy types (or maybe "discarding ownership"). On the caller side, anyway; not sure what was intended on the callee side.

This has been known as the "Eye of Sauron" in the past: Tracking issue for experiments around coercions, generics, and Copy type ergonomics ¡ Issue #44619 ¡ rust-lang/rust ¡ GitHub

I'm a huge fan of discarding ownership, but only in the form where if it looks like it's moving it behaves in the caller like it's moving.

So if foo takes a reference (to a non-copy type), then foo(bar); foo(bar); would still fail to compile, because it would behave more like { let temp = foo(&bar); drop(bar); temp }; { let temp = foo(&bar); drop(bar); temp }; -- and thus to re-use a (non-Copy) value you'd still & it.

1 Like