Rationale for move/copy/borrow syntax

Ah interesting, I imagined a more temporary-lifetime happy version.

fn foo(s: &str) -> &str { s }

fn bar(s: String) {
    // This is ok, unlike the `drop(s)` version which kills the borrow
    let _ = foo(s);
    // This is still an error
    // let _ = foo(s);
}

fn desugared_bar(s: String) {
    // This is ok, unlike the `drop(s)` version which kills the borrow
    let _ = foo(&{s});
    // This is still an error
    // let _ = foo(&{s});
}

Oh, nice, this is a way better way of describing the desugar :+1:

1 Like

OK, so you are doing heavily domain-specific stuff. Once again, that's not a good reason to expect the language to change. Rust isn't going to become Fortran (hopefully).

First and foremost, what you observed is that the library failed to abstract sufficiently. It could have implemented the necessary arithmetic traits for matrices and vectors directly (instead of implementing them for references), but it didn't.

Usually, the argument aginst doing so is that for non-trivial types, these would consume the right-hand side, whereas this is unnecessary (and it would force you to clone them every time they are used). However, that does not apply here, since the whole tree is lazily evaluated, so the involved types contain references to the actual matrix data, meaning that they should be very cheap to clone (or even Copy). Thus, this is not a language defect, this is primarily an omission in the library that could be fixed. However, again, a single & is not something people are usually concerned with.

If you still really-really cannot tolerate that poor ampersand, then what you are looking for is an EDSL, which you can implement using macros. Macros are exactly the right tool to use when you want to invent some one-off syntax that isn't in the language, because it's not general enough.

If you find yourself writing a lot of borrows that you want to get rid of, then you can write a macro (likely a procedural macro) that transforms math-looking expressions into well-typed Rust expressions by inserting borrows. You can make it transform array literal syntax into this library's Matrix type (the mat! family of macros) too, and turn indexing into Matrix::get(), or anything you please. (I'm pretty stunned as to why you didn't complain about these two operations being syntax-heavy in this library, by the way… both are noisier than taking a plain reference.)

1 Like

Thanks, very interesting. This seems like an excellent idea. It does not sacrifice any of Rust's explicitness. (I would even argue that it increases explicitness, since the caller can now express that an owned value is no longer needed.)

This would also resolve at least part of my gripes. For example, it seems that it would allow to write let b = a * a;. For a non-copy type where the multiplication operation takes borrows. But it would not allow to write let b = a * a * a;...

Does this idea have a good chance of being realized? It does not seem to figure on the road map for Rust 2024.

Fitting name :-), and the example given at the top of the issue you link to is just perfect for what I mean: "imagine never having to write &0 or let z = &u * &(&(&u.square() + &(&A * &u)) + &one); again".

I do not have the insight to comprehend the entire discussion you link to, but from a quick look these seem to be ideas that are not actively pursued anymore (likely for good reasons).

Perhaps this is just part of the trade-off of Rust. As the participants of this thread thankfully pointed out, Rust's current syntax is a good fit for a language that wants to be quite explicit about ownership on the caller side.

But in arithmetic code, when expressing a mathematical operation like D <- -conj(A) * B - C (taken from my code snippet above), Rust's care for explicit ownership seems to be more of a hindrance than a benefit.

If anything, in such an expression it would be cool to be able to explicitly relinquish ownership but at the level of the whole mathematical expression. I.e. something like:

let z = u * &(&(u.square() + &(&A * u)) + &one);

which is just the example taken from the above issue with the ampersands removed in front of u in order to express that I want to give up ownership of u. I understand this wouldn't work - just thinking aloud.

Thanks for pointing this out so clearly. I knew this of course, somehow, but thinking it through up to the consequences for the syntax is a different thing (thanks for helping me with this).

When writing that copy and immutable borrow are semantically equivalent I had “simple” code in mind - code that is not concurrent and does not store any of the given borrows for later use. Then (perhaps I am missing some fine point, but you get the idea), borrows and copies are indeed equivalent: just ways of passing on a constant as input for some immediate computation.

Now, of course, the very point of Rust is to safely cover also the non-simple cases. But this - apparently - involves a (syntactic and cognitive) price when writing simple code.

My brain is too small and smooth to follow the subtleties of the discussion here but this sounds very odd to me.

Surely if a function borrows some data it has to give that same data back to the caller. This can be done, by passing a pointer to the data, the data itself never needs to be physically moved or copied in memory. Very quick and efficient. Conversely if a function gets a copy of the data that data needs to be copied from one memory location to another, potentially a lengthy processes for large structs and arrays.

This sounds like a huge semantic difference between copy and borrow.

Am I missing a point here?

Am I struggling with the American language? In English "borrow" is a verb. I lend, you borrow. In Rust land "borrow" has become a noun. We end up talking of "borrows" rather than "loans". A function makes a "borrow" to the caller rather than a "loan". We have a "borrow checker" rather than "loan checker". All sound like the very bad English "Can you borrow me a dollar".

With "semantically equivalent" I meant that both "simple" computations (with borrows and with copies) are equivalent in terms of the result and in terms of (lack of) side effects. Under the hood, in some cases it's more efficient to pass a pointer to the data, in some cases it's more efficient to copy the data. And in principle Rust allows to express that with copy/borrow.

But modern compilers will anyway do whatever they see fit in many such cases, if the outcome is the same.

I think there's still potentially appetite, as you'll see in the comments. It was closed because nothing was happening, so the issue wasn't providing value. There's definitely never been a decision to not improve it.

Thanks, I think that this goes very much into the direction of expressing my gut feelings more clearly.

In which ways would such a language be better or worse compared to Rust? It could still be as safe as Rust I imagine (borrow checker). Would it be less explicit in a way that affects performance?

Just using modern C++ instead is not really a good option. The fundamental problem (besides its baroque complexity) is that it is very easy to write a short program that is very simple (no heap allocations, no pointers), but still segfaults because of a single & too much. And the compiler is unable to warn, even with maximum warnings enabled!

Arithmetic operators are a big blunder on the part of Rust. They never should have been by-value. Just like ==, <, > automatically reference their operands, so should have done the arithmetic operators.

The primary use case for by-value addition is string addition: String::from("foo") " + "bar". It's clumsy, highly discouraged by the community, and has way more powerful, clear and ergonomic counterpart in the form of format! macro. Even in the languages where it was once popular (Python, JS) nowadays people use either format strings or format methods, just like in Rust.

For numerics, it never made any sense. Most numeric applications have their types Copy. The ones which do arbitrary-length arithmetics, requiring heap allocation, don't benefit much from by-value operands. Addition, subtraction and negation are pretty much the only cases where you have some possibility of fitting into existing allocation (unless you overflow). Multiplication is basically guaranteed to not fit into the original allocations, unless you over-allocate heavily, and I'm not even sure it's possible to do in-place. Division is impossible to do in-place.

Something like bitwise operators (&, |, ^) on big integers could potentially benefit from by-value traits, but in practice they are almost never used in numeric code.

Worse, there is a priory no guarantee whether by-ref or by-value operations are more efficient. If you are using huge stack-allocated integers, you wouldn't want to copy them around. This means that generic code can't assume anything about the performance of implementations, so must make a guess and take a hit to ergonomics whichever way it chooses.

I doubt that the arithmetic operator overloading could be changed in current Rust, but god do I wish it were.

Thanks for sharing your opinion - very interesting.

That's true for arbitrary precision numbers and such things, but ndarray, for example, seems to utilize Rust's syntax to good effect.

If, as you suggest, arithmetic operations automatically referenced their operands - it seems to me that it would not be possible to manually avoid the creation of temporary allocations. (One could then use "template expressions" to automatically deal with this problem like in C++, but this introduces a lot of complexity.)

My bignum library ibig does some multiplications and divisions (a big number multiplied by or divided by a small number) in place.

These operators used to work by reference the way you want, but it got changed.

Yeah, and the rationale for that change was basically nonexistent. Most of the RFC is concerned with other operators, and with switching from type parameters to associated types (which was a good thing to do). The entirety of justification for by-value arithmetics is

Making these traits work by value is motivated by cases like DList concatenation, where you may want the operator to actually consume the operands in producing its output (by welding the two lists together).

which is exactly the kind of undesirable useless use case I discussed above. An earlier PR with those changes was closed after a month because the author no longer considered it reasonable.

I'm not disputing that there are some uses of the current API, of course there are. But the same functionality could be achieved via ordinary methods. So the question is, does the API carry its weight and provide enough ergonomic or generic programming benefits compared to its costs.

In my experience, no. The current desugaring of arithmetics is painful to use in generic code, and even in non-generic leads to suggestions like above to write macros in order to get readable code, wrapping the current operators. It's not reasonable that one must write 4 impls instead of 1 for every type where one wants to use +, and 2 more if you want +=. And that's just for a single operation! It's even worse when you try to write generic functions, because you need to carry al those traits in the signature, even when we're basically talking about convenience methods.

I believe by value is always more efficient if you're not going to use the value any more. The current ABI already will not do a memcpy if you're passing a large object by value.

I wish there was a way to automatically pass something by value or by reference depending on whether you're still going to need the value or not, which the compiler can figure out automatically.

On the contrary, those are the ones that benefit the most. BigInt + &BigInt can reuse the allocation of the by-value lhs. You can use mut bindings and += to get buffer reuse, but that requires turning a fundamentally functional/declarative/pure operation into an impure imperative one.

Part of the selling point of Rust is that you can get imperative performance without requiring writing imperative code (e.g. iterator combinators).

You can say that saving the allocations doesn't matter as much because the operation being done is more expensive, making the allocation relative cost lower, and yes! Extra allocation for convenience is good, actually! But Rust goes to great lengths such that (almost[1]) nothing requires allocation unless you ask for it (e.g. for the convenience it gives).

Saying roughly "Unlike Java, you can overload operators and use them on BigInt! ...Unless you want to avoid a bunch of temporary allocations, at which point you need to go back to using methods like .add(_).sub(_) etc." seems quite unfortunate.

Is Rust sometimes a bit too eager to eliminate allocations and substitute them with an excess of stack copies? Likely! But one of the major selling points of Rust is that you have control over allocation. I can all but guarantee that we'd get people doing C++ std::move like shenanigans if + required references. Writing "alloc optimal" code is a bit of a brain worm through Rust library authors. I know 'cause I do it.

I absolutely agree that the current behavior is far from great, but not to the point that forbidding by-value operations would be a preferable solution. Rather, an autoref behavior seems a good solution, though doing it for two input types simultaneously is a bit problematic; see e.g. Swift operator overload lookup woes.

Yes, the ABI will use pass-by-reference transparently for copied/moved parameters. Do what makes sense ownership-wise; let the compiler optimize it from there. Especially if stuff gets inlined; then it literally doesn't matter.

...but it unfortunately does still end up doing memcpys a lot of the time, especially for Copy values. The reason is complicated but we're working on it and most of the reasons aren't fundamental, just missed optimization.

(There are at least one somewhat fundamental limitation resulting from Copy preventing destructive moves. Combine with an analysis-escaped reference and you can't turn the last copy into a destructive move, because there might be live references. Funny potential workaround not yet recognized by the compiler: *&mut to invalidate said references.)


  1. Returning an owned unsized value (e.g. dyn Trait) essentially requires a heap allocation for the time being. Not strictly, and with the ptr_metadata APIs it'll become easier to write a StackBox, but effectively for now. ↩︎

2 Likes

Didn't I literally write it?

Again, it's not a matter of language capabilities. You want extra control - fine, write a method, case closed. The question is purely about ergonomics, and ergonomics-wise the current behaviour is bonkers. Try to write a few thousands LoC of generic arithmetic code and see what I mean.

Mea culpa, I somehow missed that quote. Though multiplication can still fit if the reused allocation is oversized (e.g. since Rust convention is typically not to shrink allocations until asked), as well as matrix operations having very different resizing behavior than BigInt style allocating numerics.

Yes, also even if your numbers are growing, allocations can be done in a way that amortizes well. When you run out of space, you wouldn't just reallocate by 1 byte or whatever, you may allocate some spare capacity so that you don't have to allocate for a while in future operations. Also a smart implementation may do interesting things, such as not store all data in one memory chunk and allocate memory incrementally.

Even if the library can only reuse the memory for additions -- well, that's something that people may care about. It's important that addition is efficient and users may be annoyed when memory is needlessly reallocated. Also there are caching benefits to reusing the same memory.

I agree that matrices are perhaps a more straightforward example. It's nice if you can add two matrices without allocating extra memory.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.