Strictly speaking, Rust doesn't support overloaded functions. Function overloading is when you define the same function but with different arguments, and the language selects the correct one based on the argument type. In this case, it's two different implementations of a trait for two different types. That said, it's close enough that this isn't really an issue, more of just a technical note, since this is a series trying to get into details.
> I can't find an explanation in the Rust documentation but I expect the reason is that someone could implement another trait that provides .into_iter() on whatever the x is in for y in x, thus resulting in a compiler error because there would be two candidate implementations.
Yep, I'm not sure that there is an official explanation anywhere else, but this is exactly what I would assume as well. This ensures that the correct implementation is called. This is also one of the reasons why adding a trait implementation isn't considered a breaking change, even if it could create a compiler error, because you can always expand it yourself to explicitly select the correct choice. Of course, these situations are usually treated more carefully then they have to be, because breakage isn't fun, even if it's technically allowed.
> But wait, you say, I'm doing exactly this in the first program, and indeed you are.
It's not the same, as the next paragraphs explain.
> We are able to examine the function and realize it's safe, but because the compiler wants to use local reasoning, it's not able to do so.
> I can't find an explanation in the Rust documentation but I expect the reason is that someone could implement another trait that provides .into_iter() on whatever the x is in for y in x, thus resulting in a compiler error because there would be two candidate implementations.
Because nothing in Rust is identifier-based. Unlike python, all syntax magic (even the ? operator) relies on traits defined by `core`.
for x in y {
desugars to
let mut iter = IntoIterator::into_iter(y);
while let Some(x) = y.next() {
and
x?
desugars to
match x {
Ok(x) => x,
Err(e) => {
return Err(From::from(e));
}
}
and
x + y
desugars to
core::ops::Add::add(x, y)
etc.
All of those traits are expected to live explicitly in the core crate at well known paths. Otherwise you'd be writing methods with absolutely no idea how the language would interact with it. And if you had a Set type implement `add`, it'd have to accept exactly 2 arguments to be compatible with the language's `add` or something equally as unergonomic.
It's traits all the way down! There'd be no explanation needed because it'd be antithetical and contradictory to traits to begin with. Once one understands how traits are intended to be used, the explanation for why there aren't identifier based resolution semantics becomes obvious.
Several of those de-sugarings are wrong. The book explains how Rust's `for` is actually de-sugared, and that ? is a Try operator, so unsurprisingly it de-sugars to a use of Try::branch and that gets you a ControlFlow which is then pattern matched.
Also, they don't need to live "at well known paths". Instead they're marked as "langitems", with the magic attribute lang:
#[lang = "add_assign"] // Signifies that this trait is what the compiler wanted to make the += operator work in the language.
Interesting, I didn't knew about the for loop and the ops desugars to something in core, I though it was deal in the compiler, but makes sense since you can implement the ops::* traits for your custom types.
It's a bit annoying that there is a standard desugaring of all of this, but there's no actual way of implementing this sugaring yourself. I guess you could have a token macro (or whatever they're called) at the top scope and do it yourself?
Anyway, my conclusion is that in order to learn Rust you can't use any syntactic sugar the language provides, those are 'expert features'. There's no way I'd be able to figure out why this happens. Do rust-analyzer provide a way of desugaring these constructs?
> Strictly speaking, Rust doesn't support overloaded functions. Function overloading is when you define the same function but with different arguments, and the language selects the correct one based on the argument type. In this case, it's two different implementations of a trait for two different types.
You're right that there is no function overload in this article, just some implicit derefs.
I would argue however that Rust has overloaded functions (function resolution based on argument types) due to how it handles trait resolution with implicit inference. Rust may not have syntax sugar to easily define overloads and people generally try to avoid them, but using argument-based dispatch is extremely common. The most famous example is probably `MyType::from(...)`, but any single-method generic trait using the generics for the method arguments is equivalent to function overloading. There are also other techniques. Using nightly features you can get far enough so a consumer can use native function call syntax.
Re: function overloading, Rust doesn't support overloaded functions in the same manner as Java or C++, which is important to emphasize to people coming from those languages.
However, if you then show those people that this is legal Rust:
let addr1 = Ipv4Addr::from([13, 12, 11, 10]);
let addr2 = Ipv4Addr::from(218893066);
...that also runs the risk of confusing them, because this is indistinguishable from function overloading, because it basically is function overloading.
I'm not actually sure what to call this, because unlike Java-style overloading, Rust isn't ad-hoc; everything it's doing is fundamentally integrated into the type system. But unlike strict parametric polymorphism (and like Java), both of the above functions have totally distinct implementations.
My understanding is that "ad-hoc" in this context means that you are not defining a single generic implementation but adding individual impls for specific types. There's no fundamental difference between Java's overloading and Rust's implicitly inferred trait implementations in my opinion.
Rust's implementation is more orthogonal, so specifying which impl you want explicitly does not require special syntax. It's also based on traits so you'd have to use a non-idiomatic style if you wanted to use overloading as pervasively as in Java. But are those really such big differences? See my reply to the original where I post an example using Rust nightly that is very close to overloading in other languages.
The first code snippet, which is as simple as it gets, perfectly illustrates why Rust is extremely annoying to work with. I understand why you need the into_iter bit and why the borrow checker complains about it, but the fact that even the simplest "for x in y" loop already makes you wrestle the compiler is just poor ergonomics.
You opted to use features of std::vector that are documented to be unsafe (notably ::data()). This is the actual C++ translation of the opening code in TFA:
#include <vector>
#include <iostream>
int main() {
std::vector<int> vec = {1, 2, 3};
for (auto const & i : vec) {
std::cout << i << std::endl;
}
}
It is possible to use C++ to write unsafe code! Amazing! Some people want a language where this is not possible! Great!
> This is straightforward: we create a vector containing the values [1, 2], then iterate over it and print each element, and then finally print out the length of the vector. This is the kind of code people write every day.
The C++ code I provided does essentially this (I omitted printing the length, since it is so trivial), and is "the kind of code people write every day".
The fact that Rust requires you to consider move semantics for such simple code is precisely one of the central points of the article.
The C++ code implements the intended goal, not the problem TFA is trying to illustrate.
Changing between:
for (auto i : vec)
and
for (auto & i : vec)
has essentially no bearing on what the author is trying to show. If they were focused on how move semantics are always important, they would not use an integer type.
You are not fighting the C++ compiler or showing why the C++ compiler might be annyoing. You are introducing a bug by poorly using a library (which has nothing to do with writing and compiling C++). Ergonomics I believe are fine?
I'm struggling hard trying to understand what or if your comment has anything to do with GP's comment. Perhaps you wanted to tell that the Rust compiler might have stopped you from producing a buggy program, but again, it has nothing to do with GP's comment.
I think 0xdeafbeef is roughly recreating the first code snippet from the article (which is one of the things diath is complaining about) in C++ to show that the compiler should produce an error or else undefined behavior could occur on resize.
The compiler isn't protecting them from anything in this particular example, we can all see this is valid code. It's provably valid code, just not provable by the Rust compiler.
Literally nothing in your comment is correct. In the Rust snippet vector is moved into the loop, and thus freed.
There are situations where you hit the limits of the borrow checker, plenty of them. This is not one of them. Again, the borrow checker is not even involved in the original snippet.
> In the Rust snippet vector is moved into the loop, and thus freed.
Do you see any code in the snippet that requires it to be? This could be a simple read-only borrow because the actual logic of the program requires only that, and the value could live on happily after the loop. Literally nothing in this snippet requires anything else, you've just sort of assumed that the way Rust does it is the only thing that makes sense.
It's not about what Rust currently does, really, it's about what it ought to do.
It’s important that a for loop takes ownership of the vec, because it’s the only way you can call things inside the loop body that require the element itself to be moved.
If you don’t want the loop to take ownership of the vec, there’s literally a one character change: put a & before the thing you’re iterating (ie. for x in &y). That borrows the vec instead of moving it.
You seem to want rust to decide for itself whether to borrow or own the contents, but that way lies madness… it will be really hard to reason about what more complicated code is doing, and changes would have very non-local effects on how the compiler decides to use your code.
For me, move-semantics-by-default is the key idea that rust got right, and it’s a very simple concept. It’s not intuitive, but it’s the key idea behind all of rust’s benefits for memory management and preventing concurrency bugs. “Learn one simple but non-intuitive thing and you get these huge benefits” is a tradeoff I’m very much willing to make, personally.
> You seem to want rust to decide for itself whether to borrow or own the contents, but that way lies madness…
Most of what Rust does already feels like madness, like the concept of implicit moves, etc., but I understand your point. I don't think the reasoning really makes sense in terms of actual logic, but as I wrote in another comment: It's possible that I've misunderstood the sales pitch of Rust trying to be GC-less GCd language.
> For me, move-semantics-by-default is the key idea that rust got right, and it’s a very simple concept. It’s not intuitive, but it’s the key idea behind all of rust’s benefits for memory management and preventing concurrency bugs. “Learn one simple but non-intuitive thing and you get these huge benefits” is a tradeoff I’m very much willing to make, personally.
I can respect that and seen this way (where we accept that we're simply going to have unintuitive and incorrect rejections of programs) it does make a lot more sense.
C++ “move” semantics are quite complicated. That said, those C++ semantics are much better at handling some edge cases in systems software that Rust largely pretends don’t exist. It is a tradeoff. C++ is much uglier but also much better at handling cases where ownership and lifetimes are intrinsically ambiguous in a moved-from context because hardware has implicit ownership exogenous to the code.
The equivalent of the C++ move in Rust are the function take/replace (like mem::replace ajd option::take).
And it is fully memory safe.
You can build all the ownership you want by using raw pointers in Rust. And there is nothing wrong with a specific problem requiring unsafe because the problem cannot be taught to the borrow checker. But there is a point in your stack of abstractions where you can expose a safe and ergonomic API.
If you have a concrete example I would love to get a crack at it.
> Remind me again how move, copy and clone works in C++ /s
Sarcasm, but it’s worth outlining… C++ “move semantics” are (1) precisely the opposite of rust, and (2) not move semantics at all.
- Rust doesn’t let you override what happens during a move, it’s just a memcpy
- C++ has an rvalue reference (&&) constructor, which lets you override how a thing is moved
- Rust doesn’t let you use the moved-from value
- C++ absolutely has no problem letting you used a value after wrapping it in std::move (which is really just a cast to an rvalue reference)
- Rust uses moves to allow simple memcpy’ing of values that track resources (heap space, etc) by simply making sure nobody can access the source, and not calling Drop on it.
- C++ requires you to write logic in your move constructor that “pillages” the moved-from value (for instance in std::string it has to set the source string’s pointer to nullptr and its length to 0.) This has the consequence of making the moved-from value still “valid”
For copies:
- Rust’s Copy is just “memcpy, but you can still use the original value”. Basically any type that doesn’t track some resource that gets freed on Drop. Rust simply doesn’t let you implement Copy for things that track other resources, like heap pointers.
- C++’s copy happens implicitly when you pass something by value, and you get to write arbitrary code to make it work (like copying a string will malloc a new place on the heap and copy the buffer over)
- Rust has an entirely different concept, Clone, which lets you write arbitrarily code to duplicate managed resources (analogous to how you’d use a C++ copy constructor)
- C++ has nothing to help you distinguish “deep copy that makes new copies of resources” from “dumb copy that is just a memcpy”… if your type has an expensive concept of deep copying, callers will (perhaps inadvertently) use it every time they pass your type by value.
IMO C++’s “move” still letting you touch the moved-from value is what made me realize how much C++ had lost the plot when C++11 came out. Rust’s semantics here are basically what happens when you look at what C++ was trying to do, and learn from its mistakes.
Are you suggesting Rust should automatically insert the borrow annotation because it is able to see that a borrow is sufficient? That would be quite unintuitive and make it ambiguous whether a for loop is borrowing or consuming the iterator without reviewing the body. I'd strongly argue that it should unambiguously do either one or the other and not try and read the author's mind.
Yes, I'm suggesting it should do the right thing for the code the loop is actually trying to execute. I personally think this is exactly what Rust and its users have signed up for. I might be mistaken about that, but I think it's in line with the more general view that Rust is attempting to be as close as it can get to a language that reads like it has a garbage collector without having one.
> the more general view that Rust is attempting to be as close as it can get to a language that reads like it has a garbage collector without having one.
I've used Rust a fair amount, and I've never seen that expressed as a goal.
A couple of general principles followed by Rust are to prefer explicit code over implicit conversions and to support local reasoning. Those are both present here: the borrow needs to be made explicitly, rather than implicitly based on code later on.
The code says to call into_iter and consume the iteratee, so rust does that. If you want a reference, use the &, just like in zig/c/c++/etc. You are saying an even more extreme version of "If there's a way what I wrote could possibly be interpreted that could compile, it should do that" ignoring the fact that there's almost assuredly _many_ ways that your code can be interpreted that could compile.
Slowing down type resolution/compilation (by making every unannotated loop a generic T|&T) and adding more syntax (since rust would need new annotations to explicitly specify borrow/take in for loops), in order to save a single character that matches the behavior of most other related languages and is perfectly clearly explained by the compiler, is maybe a bad move. Considering compile time and complicated syntax are two of the biggest things people who actually write rust complain about.
depends on what `into_iter` does in your type impl
ive always felt that there is something a bit strange about a keyword reaching into the stdlib (rust is not the only language that does this), especially if it's only for sugaring.
The alternative is duck-typing, hard-coding a method name, like Python’s __dunder__ methods. That’s just as strange. And for Rust, definitely unacceptable.
I think C++’s range-based for loops work this way, basically hard-coding .begin() and .end(). Oh, and only working on array-like things due to hard-coding ++ as the operator to go to the next item, so it’s less powerful than Rust.
It’s not a stable part of Rust, but you can provide your own definitions of lang items, not using even libcore. But they will need to be quite similar to the official one as the compiler will pretty much hard-code aspects of its definition.
An alternative view of it is that the standard library is reaching into the compiler. This feels just as accurate as the other way round. The two can’t reasonably be separated. A good fraction of Rust’s standard library is stuff that needs to be there, couldn’t be anywhere else.
This is quite literally a skill issue, no offense.
'wrestle the compiler' implies you think you know better; this is usually not the case and the compiler is here to tell you about it. It's annoying to be bad at tracking ownership and guess what: most people are. The ones who aren't have decades of experience in C/C++ and employ much the same techniques that Rust guides you towards. If you really know better, there are ways to get around the compiler. They're verbose and marked unsafe to 1) discourage you from doing that and 2) warn others to be extra careful here.
If this is all unnecessary for you - and I want to underscore I agree it should be in most software development work - stick to GC languages. With some elbow grease they can be made as performant as low level languages if you write the critical parts in a way you'd have to do it in Rust and will be free to write the rest in a way which doesn't require years of experience tracking ownership manually. (Note it won't hurt to be tracking ownership anyway, it's just much less of an issue if you have to put a weakref somewhere once a couple years vs be aware at all times of what owns what.)
> 'wrestle the compiler' implies you think you know better; this is usually not the case and the compiler is here to tell you about it.
Well, yes and no. The way type systems work to soundly guarantee that some program property P holds is by guaranteeing some stronger property Q, such that Q => P. This is because type systems generally enforce what we call "inductive invariant", i.e. a property that is preserved by all program statements [1], while most interesting program properties are not inductive. To give an example, suppose we're interested in the property that a program produces an even number; an inductive invariant that implies that property is one that makes sure that the outcome of all computations in the language are even. A program that satisfies the latter property obviously satisfies the former, but the converse isn't true.
Similarly, the way Rust guarantees that all programs don't have, say, use-after-free, is by enforcing a stronger property around ownership. So all safe Rust programs don't have use-after-free, but many programs that don't have use-after-free don't satisfy the stronger ownership property. This means that sometimes (and this is true for all sound type systems) you have to "wrestle" the compiler, which insists on the stronger property, even though you know that the weaker property -- the one you're interested in -- holds. In other words, sometimes you do know better than the compiler.
That is not to say that the approach where the compiler enforces stronger invariants is always right or always wrong, or even right or wrong most of the time, but that "wrestling the compiler" is something that even the most skilled programmers confront from time to time.
[1]: This is because inductive invariants are compositional, i.e. they hold for some composition of program terms t and s iff they hold for t and s and their composition operator, and type systems want to be compositional.
> It's annoying to be bad at tracking ownership and guess what: most people are. The ones who aren't have decades of experience in C/C++ and employ much the same techniques that Rust guides you towards.
You wouldn't need to do that here, in SPARK, or Oberon, or just about any other memory safe language. This is unique to Rust, and their model - and it absolutely is not required for safety. It's not a skill issue. It's a language design problem.
SPARK has had it before Rust existed. However, gnatprove doesn't require you the programmer to change anything. The compiler does the work to ensure safety, not you.
Oberon is similar. The typesolver will determine if something is safe, without the need for explicitly borrowing anything.
It doesn't allow those operations to occur at the same time. If you can't meet compile time guarantees, then it does not compile.
Pointers aren't the same as in C. A pointer has an explicity type, not just a size. A pointer cannot change where it is located, whilst in scope anywhere else.
If you then make your pointer local, then it will get cloned. As there's no concept of a void pointer, every type supports cloning, and so your thread-local variable will have nothing to do with the parent any longer.
So if you try to grab a local reference, to something in another thread, you'll get a copy, or a compile time error if you don't copy it.
If you try to modify something you're looping over, it won't compile at all.
However, in all of this, there's no extra syntax. The compiler can deal with what is permitted. The programmer can just write.
This is multithreaded (by compiler switch):
module example610a;
type
Vector = array * of integer;
var
i, n: integer;
a: Vector;
begin
a := new Vector(n);
for i := 0 to len(a) - 1 do
write("a[",i:2,"]: "); read(a[i])
end;
writeln;
for i := 0 to len(a) - 1 do
write(a[i]:3);
end;
writeln;
end example610a.
No, GP can just not use Rust, they don't have to use GC languages to have something that makes sense and doesn't force you to always have a debate with the compiler about even simple things.
If they used Odin (or Zig) they could've looped through that dynamic array no problem, in fact:
package example
import "core:fmt"
main :: proc() {
xs: [dynamic]int
append(&xs, 1, 2)
for x in xs {
fmt.println(x)
}
fmt.println(len(xs))
}
It is ridiculous that Rust complains even about the simple for loop and to say that this somehow comes down to "Well, everyone would do it this way if they cared about memory safety" is just not really true or valuable input, it sounds like what someone would say if their only systems programming experience came from Rust and they post-rationalized everything they've seen in Rust as being how you have to do it.
My tips to people who maybe feel like Rust seems a bit overwrought:
Look for something else, check out Odin or Zig, they've got tons of ways of dealing with memory that simply sidestep everything that Rust is about (because inherently Rust and everything that uses RAII has a broken model of how resources should be managed).
I learned Odin just by reading its Overview page (https://odin-lang.org/docs/overview/) and trying stuff out (nowadays there are also good videos about Odin on YouTube), then found myself productively writing code after a weekend. Now I create 3D engines using just Odin (and we in fact use only a subset of what is on that Overview page). Things can be simple, straight forward and more about the thing you're solving than the language you're using.
I think it is important to note that in 59nadir's example, the reason Rust gives an error and Odin doesn't is not memory safety. Rust uses move semantics by default in a loop while Odin appears to use copy semantics by default. I don't really know Odin, but it seems like it is a language that doesn't have RAII. In which case, copy semantics are fine for Odin, but in Rust they could result in a lot of extra allocations if your vector was holding RAII heap allocating objects. Obviously that means you would need to be careful about how to use pointers in Odin, but the choice of moving or copying by default for a loop has nothing to do with this. For reference:
Odin (from documentation):
for x in some_array { // copy semantics
for &x in some_array { // reference semantics
// no move semantics? (could be wrong on this)
Rust:
for x in vec.iter_ref().copied() { // bytewise copy semantics (only for POD types)
for x in vec.iter_ref().cloned() { // RAII copy semantics
for x in &vec { // reference semantics
for x in vec { // move semantics
C++:
for (auto x : vec) { // copy semantics
for (auto &x : vec) { // reference semantics
for (auto &&x : vec) { // move semantics
And why do you think that bug is relevant in the case of a loop that prints the elements of a container? We can all see and verify at a glance that the code is valid, it's just not provably valid by the Rust compiler.
I feel like these posts trying to show possible memory issues with re-allocated dynamic arrays are missing the point: There is no code changing the underlying array, there is no risk of any kind of use-after-free error. This is exactly the kind of case where all of this jumping through hoops shouldn't be needed.
Ok, so we've established that the loop can be verified as not changing the container in any way, what makes you believe this shouldn't be obvious to the Rust compiler?
When code that modifies the container is added, it should be understood and then correctly errored about, I don't get why this is such a crazy concept to people.
The point here is that you pay the cost for an error that can't happen. It's just a micro example of a much more general issue that boils down to:
The Rust compiler does a lot to find and mitigate bugs, it's amazing, but it also rejects completely valid programs and solutions because it simply isn't good enough (and it's a difficult enough problem where I'm prepared to say it will never be good enough). You can either pay that cost constantly and for certain problems be dealing with it (a lot) for no gain whatsoever (because the bugs it was trying to prevent weren't actual issues or are in fact imperatives because the thing you're doing requires them) or you can choose not to.
I don't think it's particularly useful to make excuses for the compiler not understanding very basic things in simple examples and indirectly argue that it would be too complicated to see what the loop is doing and act accordingly. Rust already signed up for a very complicated compiler that does all kinds of crazy things in order to mitigate bugs; this type of introspection would increase the accuracy of it a lot.
> Ok, so we've established that the loop can be verified as not changing the container in any way, what makes you believe this shouldn't be obvious to the Rust compiler?
I would be quite happy for the Rust compiler to be able to perform more powerful analysis and make writing code easier. What I object to, and I think that quite small Zig code snippet highlights, is that dealing with those shortcomings
I make custom 3D engines and I can tell you that it would not be a net benefit for us to use Rust. That's why I added "for certain problems" as a qualifier; there are use cases where Rust would be a net negative.
There are also plenty of use cases where Rust is actually useful and provides guarantees about things that you want guarantees about.
> You can either pay that cost constantly and for certain problems be dealing with it (a lot) for no gain whatsoever (because the bugs it was trying to prevent weren't actual issues or are in fact imperatives because the thing you're doing requires them) or you can choose not to.
Alternatively, you can use Rust so much these limitations become second nature, and thus don't make them in the first place.
> I don't think it's particularly useful to make excuses for the compiler not understanding very basic things in simple examples and indirectly argue that it would be too complicated to see what the loop is doing and act accordingly.
Great idea, until it stops working. It runs into the paraphrased quote: "Any sufficiently complicated borrow checker is indistinguishable from Dark Magic".
First you say, well, the compiler should be sufficiently smart to figure out case A1 should work, then you add that, but then arises another case A2 that the compiler is sufficiently smart to figure out and so on.
However, you add a bunch of these "sufficiently smart" borrow rules, and you'll end up with a mess. A1 and A2 don't work if A432 is applied, but do work if A49324 is given if the A4 and A2 are satisfied.
The harder the borrow checker is to understand, the more difficult it is to construct a mental model that's useful.
In summary: while I'm not against improving the borrow checker, but the problem is that it needs to be balanced with the opportunity cost of understanding how it approximately works.
In bulk, i.e. not one-by-one as is implied and most used with RAII. RAII works best for a one-by-one use case and in well designed, performant systems the one-by-one use case is either irrelevant, rare or an anti-pattern.
Rust, in many ways, is a terrible first systems programming language.
To program a system is to engage with how the real devices of a computer work, and very little of their operation is exposed via Rust or even can be exposed. The space of all possible valid/safe Rust programs is tiny compare to the space of all useful machine behaviours.
The world of "safe Rust" is a very distorted image of the real machine.
> Rust, in many ways, is a terrible first systems programming language.
Contrariwise, Rust in, in many way, an awesome first systems programming language. Because it tells you and forces you to consider all the issues upfront.
For instance in 59nadir's example, what if the vector is a vector of heap-allocated objects, and the loop frees them? In Rust this makes essentially no difference, because at iteration you tell the compiler whether the vector is borrowed or moved and the rest of the lifecycle falls out of that regardless of what's in the vector: with a borrowing iteration, you simply could not free the contents. The vector generally works and is used the same whether its contents are copiable or not.
A lot of idiomatic systems code is intrinsically memory unsafe. The hardware owns direct references to objects in your address space and completely disregards the ownership semantics of your programming language. It is the same reason immediately destroying moved-from objects can be problematic: it isn’t sufficient to statically verify that the code no longer references that memory. Hardware can and sometimes does hold references to moved-from objects such that deferred destruction is required for correctness.
How is someone supposed to learn idiomatic systems programming in a language that struggles to express basic elements of systems programming? Having no GC is necessary but not sufficient to be a usable systems language but it feels like some in the Rust community are tacitly defining it that way. Being a systems programmer means being comfortable with handling ambiguous object ownership and lifetimes. Some performance and scalability engineering essentially requires this, regardless of the language you use.
None of these "issues" are systems issues, they're memory safety issues. If you think systems programming is about memory saftey, then you're demonstrating the problem.
Eg., some drivers cannot be memory safe, because memory is arranged outside of the driver to be picked up "at the right time, in the right place" and so on.
Statically-provable memory saftey is, ironically, quite a bad property to have for a systems programming language, as it prevents actually controlling the devices of the machine. This is, of course, why rust has "unsafe" and why anything actually systems-level is going to have a fair amount of it.
The operation of machine devices isnt memory safe -- memory saftey is a static property of a program's source code, that prevents describing the full behaviour of devices correctly.
Yes, touching hardware directly often requires memory unsafety. Rust allows that, but encourages you to come up with an abstraction that can be used safely and thereby minimize the amount of surface area which has to do unsafe things. You still have to manually assert / verify the correctness of that wrapper, obviously.
> This is, of course, why rust has "unsafe" and why anything actually systems-level is going to have a fair amount of it.
There are entire kernels written in Rust with less than 10% unsafe code. The standard library is less than 3% unsafe, last I checked. People overestimate how much "unsafe" is actually required and therefore they underestimate how much value Rust provides. Minimizing the amount of code doing unsafe things is good practice no matter what programming language you use, Rust just pushes hard in that direction.
> For instance in 59nadir's example, what if the vector is a vector of heap-allocated objects, and the loop frees them?
But the loop doesn't free them. This is trivial for us to see and honestly shouldn't be difficult for Rust to figure out either. Once you've adopted overwrought tools they should be designed to handle these types of issues, otherwise you're just shuffling an esoteric burden onto the user in a shape that doesn't match the code that was written.
With less complicated languages we take on the more general burden of making sure things make sense (pinky-promise, etc.) and that is one that we've signed up for, so we take care in the places that have actually been identified, but they need to be found manually; that's the tradeoff. The argument I'm making is that Rust really ought to be smarter about this, there is no real reason it shouldn't be able to understand what the loop does and treat the iteration portion accordingly, but it's difficult to make overcomplicated things because they are exactly that.
I doubt that most Rust users feel this lack of basic introspection as to what is happening in the loop makes sense once you actually ask them, and I'd bet money most of them feel that Rust ought to understand the loop (though in reading these posts I realize that there are actual humans that don't seem to understand the issue as well, when it's as simple as just reading the code in front of them and actually taking into account what it does).
YMMV, naturally, but I've found that some embedded devices have really excellent hardware abstraction layers in Rust that wrap the majority of the device's functionality in an effectively zero-overhead layer. Timers? GPIO? Serial protocols? Interrupts? It's all there.
That systems languages have to establish (1) memory saftey, (2) statically; (3) via a highly specific kind of type system given in Rust; and (4) with limited inference -- suggests a lack of imagination.
The space of all possible robust systems languages is vastly larger than Rust.
It's specific choices force confronting the need to statically prove memory saftey via a cumbersome type system very early -- this is not a divine command upon language design.
sure, rust is not the final answer to eliminating memory safety bugs from systems programming. but what are the alternatives, that aren't even more onerous and/or limited in scope (ats, frama-c, proofs)?
My preference is to have better dynamic models of devices (eg., how many memory cells does ram have, how do these work dynamically, etc.) and line them up with well-defined input/output boundaries of programmes. Kinda "better fuzzing".
I mean, can we run a program in a well-defined "debugging operating system" in a VM, with simulated "debugging devices" and so on?
I dont know much about that idea, and the degree to which that vision is viable. However it's increasingly how the most robust software is tested -- by "massive-scale simulation". My guess is it isnt a major part of, say, academic study because its building tools over years rather than writing one-off papers over months.
However, if we had this "debuggable device environment", i'd say it'd be vastly more powerful than Rust's static guarantees and allow for a kind of "fearless systems programming" without each loop becoming a sudoku puzzle.
This is yet another issue with Rust, nowhere in my post have I mentioned C++, I made no effort comparing the two languages, I just pointed out poor developer ergonomics in Rust and you're instigating a language flame war as if you took valid criticism as a personal attack; you can do better than that.
I don't think it's poor developer ergonomics. The compiler tells you "Hey, try adding &x at this position".
It's unfamiliarity with Rust's type system and syntax sugars.
<HYPERBOLE>
I'll take 1000 compiler errors over a single non-deterministic bug that only happens on ARM at 6 o'clock on Blue Moon when Mercury is in Orion (it's UB).
And I'd ritually sacrifice my first unborn werekid to the Dark Compiler Gods for a compiler error, that actually suggests a correct fix.
My 2¢, it’s perfectly reasonable to bring up other languages in defense of criticism, because it explains why these decisions were made in the first place. GP literally said that rust isn’t a good fit for you if you’re in a position to use a GC. The comparison to C++ is important because it’s one of very, very few contemporary languages that also doesn’t require a GC/refcounting everywhere. So it’s useful to compare to how C++ does it.
Yet another issue with people who criticize rust: they don’t want anyone to defend rust, and complain loudly about anyone defending rust as being a literal problem with the language. You can do better than that.
I'm starting to think Zig's strategy to memory management is generally friendlier to a developer. If a function needs to allocate memory, it must take an allocator as a parameter. If it needs scratch space to perform computation, it can use that allocator to create an arena for itself, then free it up before it returns (defer). If it returns a pointer, the caller should assume the object was allocated using that allocator, and becomes the owner. It may still be unclear what happens to a pointer if it's passed as a parameter to another function, but I'd normally consider that a borrow.
It's a lot of assumptions, and if you trip, Rust will yell at you much more often than Zig; and it will likely be right to do so. But in all seriousness, I'm tired of the yelling, and find Zig much more pleasant.
A 20 page document on how to use basic variables, function calls and methods. Except for the threading paragraph, which is hard in any language, this is all complexity and refactoring pain that Rust hoists onto every programmer every day, for relatively modest benefits, somewhat improved performance and memory usage vs the garbage collected/ref-counted version of the same code.
Essentially, you wouldn't and shouldn't make that tradeoff for anything other than system programming.
> This is what you should be doing when working with C/C++
I genuinely wonder if you actually have ever written c/c++, there is plenty of code that is perfectly valid and safe (mostly involving multiple pointers to mutable memory being alive) that the borrow check cannot accept because it has to draw a line to things it can prove are correct.
It's like saying that the only valid math is the one that an automated theorem prover can prove, it's not even close to being true.
> I genuinely wonder if you actually have ever written c/c++
I have; enough for one lifetime if you ask me. It was hunting use after delete which made me stop.
I kinda agree with you, with the caveat that both can be true. If you want to write safe-ish C++ you’ll use defensive containers from the start and watch iterators like a hawk. You can also take a more cavalier approach and live with the consequences (which might not happen and then you win big).
Rust wants you to basically not use references unless your data fits the one writer xor many readers model (painfully including struct members, recursively) and the cavalier approach is very strongly discouraged on the language level. This forces you towards safety, but I also agree with everyone else who say that isn’t how computers actually work. The impedance mismatch is an engineering tradeoff to make.
I see why you're saying that, and i almost entirely agree with you. However, i would say that if all you're doing is glueing calls to third party systems (like what most backend code is), then you won't fall into complex lifetime problems anyway, and the experience will remain quite pleasant.
Another point, is that the rust ecosystem is absolutely insanely good ( i've recently worked with uniffi and wasmbindgen, and those are 5 years ahead of anything else i've seen..)
I really like the text. Giving more light to the memory management of rust will help me understand more of the language. I still think some concepts of rust are over verbose but I slowly understand the hype around rust. I myself use C or C++ but I will „borrow“ some of the rust ideas to make my code even more robust
I'm coming from C++ now I don't want to use C++ anymore. When C++ was still my primary language I always frustrated with some of its feature like non-destructive move, copy by default and dangling references then I found Rust fixed all of those problems. At the beginning I very frustrated with Rust because the borrow checker prevent me from doing what I usually do in C++ but I keep going.
I think this really depends on the type of software you develop. For some types of systems code, non-destructive moves are a killer feature because ownership and lifetimes can be intrinsically unknowable at compile-time even in a moved-from context. In these cases, a destructive move could introduce a use-after-free that the compiler can’t see whereas a deferred destruction is idiomatic and behaves the way you would expect.
This is less than ideal even in C++, though it addresses some cases perfectly. There is an additional concept of “relocatable” types that lives in the middle ground, which is closer to the Rust concept, but that is not currently part of the language though you can hack your own traits. I don’t think any language currently handles the scope of move semantics cases properly.
been banging my head against this same stuff trying to learn rust - honestly memory rules make me miss how easy c feels sometimes, but i'm sticking with it cuz i want fewer bugs
This is a good post! A few comments:
> Function Overloads
Strictly speaking, Rust doesn't support overloaded functions. Function overloading is when you define the same function but with different arguments, and the language selects the correct one based on the argument type. In this case, it's two different implementations of a trait for two different types. That said, it's close enough that this isn't really an issue, more of just a technical note, since this is a series trying to get into details.
> I can't find an explanation in the Rust documentation but I expect the reason is that someone could implement another trait that provides .into_iter() on whatever the x is in for y in x, thus resulting in a compiler error because there would be two candidate implementations.
Yep, I'm not sure that there is an official explanation anywhere else, but this is exactly what I would assume as well. This ensures that the correct implementation is called. This is also one of the reasons why adding a trait implementation isn't considered a breaking change, even if it could create a compiler error, because you can always expand it yourself to explicitly select the correct choice. Of course, these situations are usually treated more carefully then they have to be, because breakage isn't fun, even if it's technically allowed.
> But wait, you say, I'm doing exactly this in the first program, and indeed you are.
It's not the same, as the next paragraphs explain.
> We are able to examine the function and realize it's safe, but because the compiler wants to use local reasoning, it's not able to do so.
This is a super important point!
> I can't find an explanation in the Rust documentation but I expect the reason is that someone could implement another trait that provides .into_iter() on whatever the x is in for y in x, thus resulting in a compiler error because there would be two candidate implementations.
Because nothing in Rust is identifier-based. Unlike python, all syntax magic (even the ? operator) relies on traits defined by `core`.
desugars to and desugars to and desugars to etc.All of those traits are expected to live explicitly in the core crate at well known paths. Otherwise you'd be writing methods with absolutely no idea how the language would interact with it. And if you had a Set type implement `add`, it'd have to accept exactly 2 arguments to be compatible with the language's `add` or something equally as unergonomic.
It's traits all the way down! There'd be no explanation needed because it'd be antithetical and contradictory to traits to begin with. Once one understands how traits are intended to be used, the explanation for why there aren't identifier based resolution semantics becomes obvious.
Several of those de-sugarings are wrong. The book explains how Rust's `for` is actually de-sugared, and that ? is a Try operator, so unsurprisingly it de-sugars to a use of Try::branch and that gets you a ControlFlow which is then pattern matched.
Also, they don't need to live "at well known paths". Instead they're marked as "langitems", with the magic attribute lang:
#[lang = "add_assign"] // Signifies that this trait is what the compiler wanted to make the += operator work in the language.
Other than what other people said, the question is to why that desugaring is chosen, not that it's because of a specific one. That is, why
instead of That could have been done, but it wasn't.> Otherwise you'd be writing methods with absolutely no idea how the language would interact with it.
Right, this is what's guessed, because it makes the most sense.
Interesting, I didn't knew about the for loop and the ops desugars to something in core, I though it was deal in the compiler, but makes sense since you can implement the ops::* traits for your custom types.
Swift has overloading and its own version of traits. None of what you said is actually required.
It's a bit annoying that there is a standard desugaring of all of this, but there's no actual way of implementing this sugaring yourself. I guess you could have a token macro (or whatever they're called) at the top scope and do it yourself?
Anyway, my conclusion is that in order to learn Rust you can't use any syntactic sugar the language provides, those are 'expert features'. There's no way I'd be able to figure out why this happens. Do rust-analyzer provide a way of desugaring these constructs?
> there's no actual way of implementing this sugaring yourself.
This is the common case in languages with sugar.
Heck, you could consider all of Rust sugar for MIR...
> There's no way I'd be able to figure out why this happens.
You don't generally need to, but most of this is documented:
https://doc.rust-lang.org/stable/std/iter/index.html#for-loo...
https://doc.rust-lang.org/stable/std/ops/trait.Add.html#impl...
?'s desugaring isn't documented because the desugaring is subject to change.
> Strictly speaking, Rust doesn't support overloaded functions. Function overloading is when you define the same function but with different arguments, and the language selects the correct one based on the argument type. In this case, it's two different implementations of a trait for two different types.
You're right that there is no function overload in this article, just some implicit derefs.
I would argue however that Rust has overloaded functions (function resolution based on argument types) due to how it handles trait resolution with implicit inference. Rust may not have syntax sugar to easily define overloads and people generally try to avoid them, but using argument-based dispatch is extremely common. The most famous example is probably `MyType::from(...)`, but any single-method generic trait using the generics for the method arguments is equivalent to function overloading. There are also other techniques. Using nightly features you can get far enough so a consumer can use native function call syntax.
Overload on nightly: https://play.rust-lang.org/?version=nightly&mode=debug&editi...
Overload on stable: https://play.rust-lang.org/?version=stable&mode=debug&editio...
The mechanism and syntax may be different from overloading in C++ or Java, but as a user the result is the same and it causes the same pain points.
> The mechanism and syntax may be different from overloading
That's all I'm saying.
> as a user the result is the same
That's why I said I didn't think the inaccuracy is the worst detail.
Re: function overloading, Rust doesn't support overloaded functions in the same manner as Java or C++, which is important to emphasize to people coming from those languages.
However, if you then show those people that this is legal Rust:
...that also runs the risk of confusing them, because this is indistinguishable from function overloading, because it basically is function overloading.I'm not actually sure what to call this, because unlike Java-style overloading, Rust isn't ad-hoc; everything it's doing is fundamentally integrated into the type system. But unlike strict parametric polymorphism (and like Java), both of the above functions have totally distinct implementations.
For sure, that's why I don't think it's the biggest deal.
> I'm not actually sure what to call this,
Just regular old name resolution. But that's not super satisfying either.
> I'm not actually sure what to call this
I'd call it "Making ad-hoc polymorphism less ad hoc".
My understanding is that "ad-hoc" in this context means that you are not defining a single generic implementation but adding individual impls for specific types. There's no fundamental difference between Java's overloading and Rust's implicitly inferred trait implementations in my opinion.
Rust's implementation is more orthogonal, so specifying which impl you want explicitly does not require special syntax. It's also based on traits so you'd have to use a non-idiomatic style if you wanted to use overloading as pervasively as in Java. But are those really such big differences? See my reply to the original where I post an example using Rust nightly that is very close to overloading in other languages.
They were referencing the title of a specific paper, which invented type classes: https://dl.acm.org/doi/10.1145/75277.75283
Thank you, I did not know this paper and totally missed the reference.
This is pretty good.
A useful way to think about this:
- All data in Rust has exactly one owner.
- If you need some kind of multiple ownership, you have to make the owner be a reference-counted cell, such as Rc or Arc.
- All data can be accessed by one reader/writer, or N readers, but not both at the same time.
- There is both compile time and run time machinery to strictly enforce this.
Once you get that, you can see what the borrow checker is trying to do for you.
The first code snippet, which is as simple as it gets, perfectly illustrates why Rust is extremely annoying to work with. I understand why you need the into_iter bit and why the borrow checker complains about it, but the fact that even the simplest "for x in y" loop already makes you wrestle the compiler is just poor ergonomics.
#include <vector> #include <iostream>
./a.out Initial capacity: 3 Initial data address: 0x517d2b0 Pointer before push_back: 0x517d2b0 Value via pointer before push_back: 1Pushing back 4...
New capacity: 6 New data address: 0x517d6e0
Attempting to access data via the old pointer... Old pointer value: 0x517d2b0 Read from dangling pointer (UB): 20861
You opted to use features of std::vector that are documented to be unsafe (notably ::data()). This is the actual C++ translation of the opening code in TFA:
It is possible to use C++ to write unsafe code! Amazing! Some people want a language where this is not possible! Great!> This is the actual C++ translation of the opening code in TFA:
No, it isn't: this is iterating over references, not moving. This is equivalent to
in Rust. Note the &, just like in your C++.The purpose of the first code example in TFA:
> This is straightforward: we create a vector containing the values [1, 2], then iterate over it and print each element, and then finally print out the length of the vector. This is the kind of code people write every day.
The C++ code I provided does essentially this (I omitted printing the length, since it is so trivial), and is "the kind of code people write every day".
The fact that Rust requires you to consider move semantics for such simple code is precisely one of the central points of the article.
"C++ code that implements the problem, but in a different way" is not "the actual C++ translation of the opening code in TFA."
The C++ code implements the intended goal, not the problem TFA is trying to illustrate.
Changing between:
and has essentially no bearing on what the author is trying to show. If they were focused on how move semantics are always important, they would not use an integer type.You are not fighting the C++ compiler or showing why the C++ compiler might be annyoing. You are introducing a bug by poorly using a library (which has nothing to do with writing and compiling C++). Ergonomics I believe are fine?
I'm struggling hard trying to understand what or if your comment has anything to do with GP's comment. Perhaps you wanted to tell that the Rust compiler might have stopped you from producing a buggy program, but again, it has nothing to do with GP's comment.
I think 0xdeafbeef is roughly recreating the first code snippet from the article (which is one of the things diath is complaining about) in C++ to show that the compiler should produce an error or else undefined behavior could occur on resize.
> I understand why you need the into_iter bit and why the borrow checker complains about it
The borrow checker is not really involved in the first snippet (in fact the solution involves borrowing). The compiler literally just prevents a UAF.
The compiler isn't protecting them from anything in this particular example, we can all see this is valid code. It's provably valid code, just not provable by the Rust compiler.
Literally nothing in your comment is correct. In the Rust snippet vector is moved into the loop, and thus freed.
There are situations where you hit the limits of the borrow checker, plenty of them. This is not one of them. Again, the borrow checker is not even involved in the original snippet.
> In the Rust snippet vector is moved into the loop, and thus freed.
Do you see any code in the snippet that requires it to be? This could be a simple read-only borrow because the actual logic of the program requires only that, and the value could live on happily after the loop. Literally nothing in this snippet requires anything else, you've just sort of assumed that the way Rust does it is the only thing that makes sense.
It's not about what Rust currently does, really, it's about what it ought to do.
It’s important that a for loop takes ownership of the vec, because it’s the only way you can call things inside the loop body that require the element itself to be moved.
If you don’t want the loop to take ownership of the vec, there’s literally a one character change: put a & before the thing you’re iterating (ie. for x in &y). That borrows the vec instead of moving it.
You seem to want rust to decide for itself whether to borrow or own the contents, but that way lies madness… it will be really hard to reason about what more complicated code is doing, and changes would have very non-local effects on how the compiler decides to use your code.
For me, move-semantics-by-default is the key idea that rust got right, and it’s a very simple concept. It’s not intuitive, but it’s the key idea behind all of rust’s benefits for memory management and preventing concurrency bugs. “Learn one simple but non-intuitive thing and you get these huge benefits” is a tradeoff I’m very much willing to make, personally.
> You seem to want rust to decide for itself whether to borrow or own the contents, but that way lies madness…
Most of what Rust does already feels like madness, like the concept of implicit moves, etc., but I understand your point. I don't think the reasoning really makes sense in terms of actual logic, but as I wrote in another comment: It's possible that I've misunderstood the sales pitch of Rust trying to be GC-less GCd language.
> For me, move-semantics-by-default is the key idea that rust got right, and it’s a very simple concept. It’s not intuitive, but it’s the key idea behind all of rust’s benefits for memory management and preventing concurrency bugs. “Learn one simple but non-intuitive thing and you get these huge benefits” is a tradeoff I’m very much willing to make, personally.
I can respect that and seen this way (where we accept that we're simply going to have unintuitive and incorrect rejections of programs) it does make a lot more sense.
I find move by default refreshingly simple. I don't even understand what is so hard to understand about move.
A move is a simple memcopy + the certainty that the source is unreachable.
This is important if the memcopied object holds resources.
Any new type is move only by default (no copy or clone). This is so that you can opt in willingly.
Making a move type also copy is not an API/ABI breaking change compared to removing copy (or clone).
And it's the same for clone not being the default. With clone you get to run custom code for cloning instead of a memcopy.
Remind me again how move, copy and clone works in C++ /s
C++ “move” semantics are quite complicated. That said, those C++ semantics are much better at handling some edge cases in systems software that Rust largely pretends don’t exist. It is a tradeoff. C++ is much uglier but also much better at handling cases where ownership and lifetimes are intrinsically ambiguous in a moved-from context because hardware has implicit ownership exogenous to the code.
The equivalent of the C++ move in Rust are the function take/replace (like mem::replace ajd option::take).
And it is fully memory safe.
You can build all the ownership you want by using raw pointers in Rust. And there is nothing wrong with a specific problem requiring unsafe because the problem cannot be taught to the borrow checker. But there is a point in your stack of abstractions where you can expose a safe and ergonomic API.
If you have a concrete example I would love to get a crack at it.
> Remind me again how move, copy and clone works in C++ /s
Sarcasm, but it’s worth outlining… C++ “move semantics” are (1) precisely the opposite of rust, and (2) not move semantics at all.
- Rust doesn’t let you override what happens during a move, it’s just a memcpy
- C++ has an rvalue reference (&&) constructor, which lets you override how a thing is moved
- Rust doesn’t let you use the moved-from value
- C++ absolutely has no problem letting you used a value after wrapping it in std::move (which is really just a cast to an rvalue reference)
- Rust uses moves to allow simple memcpy’ing of values that track resources (heap space, etc) by simply making sure nobody can access the source, and not calling Drop on it.
- C++ requires you to write logic in your move constructor that “pillages” the moved-from value (for instance in std::string it has to set the source string’s pointer to nullptr and its length to 0.) This has the consequence of making the moved-from value still “valid”
For copies:
- Rust’s Copy is just “memcpy, but you can still use the original value”. Basically any type that doesn’t track some resource that gets freed on Drop. Rust simply doesn’t let you implement Copy for things that track other resources, like heap pointers.
- C++’s copy happens implicitly when you pass something by value, and you get to write arbitrary code to make it work (like copying a string will malloc a new place on the heap and copy the buffer over)
- Rust has an entirely different concept, Clone, which lets you write arbitrarily code to duplicate managed resources (analogous to how you’d use a C++ copy constructor)
- C++ has nothing to help you distinguish “deep copy that makes new copies of resources” from “dumb copy that is just a memcpy”… if your type has an expensive concept of deep copying, callers will (perhaps inadvertently) use it every time they pass your type by value.
IMO C++’s “move” still letting you touch the moved-from value is what made me realize how much C++ had lost the plot when C++11 came out. Rust’s semantics here are basically what happens when you look at what C++ was trying to do, and learn from its mistakes.
I wouldn't have said it better or more thoroughly.
Are you suggesting Rust should automatically insert the borrow annotation because it is able to see that a borrow is sufficient? That would be quite unintuitive and make it ambiguous whether a for loop is borrowing or consuming the iterator without reviewing the body. I'd strongly argue that it should unambiguously do either one or the other and not try and read the author's mind.
Yes, I'm suggesting it should do the right thing for the code the loop is actually trying to execute. I personally think this is exactly what Rust and its users have signed up for. I might be mistaken about that, but I think it's in line with the more general view that Rust is attempting to be as close as it can get to a language that reads like it has a garbage collector without having one.
> the more general view that Rust is attempting to be as close as it can get to a language that reads like it has a garbage collector without having one.
I've used Rust a fair amount, and I've never seen that expressed as a goal.
A couple of general principles followed by Rust are to prefer explicit code over implicit conversions and to support local reasoning. Those are both present here: the borrow needs to be made explicitly, rather than implicitly based on code later on.
This is not at all what we signed up for. The explicit-ness is the point.
The code says to call into_iter and consume the iteratee, so rust does that. If you want a reference, use the &, just like in zig/c/c++/etc. You are saying an even more extreme version of "If there's a way what I wrote could possibly be interpreted that could compile, it should do that" ignoring the fact that there's almost assuredly _many_ ways that your code can be interpreted that could compile.
Slowing down type resolution/compilation (by making every unannotated loop a generic T|&T) and adding more syntax (since rust would need new annotations to explicitly specify borrow/take in for loops), in order to save a single character that matches the behavior of most other related languages and is perfectly clearly explained by the compiler, is maybe a bad move. Considering compile time and complicated syntax are two of the biggest things people who actually write rust complain about.
i think really the problem is not the compiler so much but that you need to be thinking about what `for` desugars to.
The desugaring is an explanation of what's happening under the hood, but you don't need to think about that. Your mental model can simply be:
depends on what `into_iter` does in your type impl
ive always felt that there is something a bit strange about a keyword reaching into the stdlib (rust is not the only language that does this), especially if it's only for sugaring.
The alternative is duck-typing, hard-coding a method name, like Python’s __dunder__ methods. That’s just as strange. And for Rust, definitely unacceptable.
I think C++’s range-based for loops work this way, basically hard-coding .begin() and .end(). Oh, and only working on array-like things due to hard-coding ++ as the operator to go to the next item, so it’s less powerful than Rust.
It’s not a stable part of Rust, but you can provide your own definitions of lang items, not using even libcore. But they will need to be quite similar to the official one as the compiler will pretty much hard-code aspects of its definition.
An alternative view of it is that the standard library is reaching into the compiler. This feels just as accurate as the other way round. The two can’t reasonably be separated. A good fraction of Rust’s standard library is stuff that needs to be there, couldn’t be anywhere else.
> wrestle the compiler
This is quite literally a skill issue, no offense.
'wrestle the compiler' implies you think you know better; this is usually not the case and the compiler is here to tell you about it. It's annoying to be bad at tracking ownership and guess what: most people are. The ones who aren't have decades of experience in C/C++ and employ much the same techniques that Rust guides you towards. If you really know better, there are ways to get around the compiler. They're verbose and marked unsafe to 1) discourage you from doing that and 2) warn others to be extra careful here.
If this is all unnecessary for you - and I want to underscore I agree it should be in most software development work - stick to GC languages. With some elbow grease they can be made as performant as low level languages if you write the critical parts in a way you'd have to do it in Rust and will be free to write the rest in a way which doesn't require years of experience tracking ownership manually. (Note it won't hurt to be tracking ownership anyway, it's just much less of an issue if you have to put a weakref somewhere once a couple years vs be aware at all times of what owns what.)
> 'wrestle the compiler' implies you think you know better; this is usually not the case and the compiler is here to tell you about it.
Well, yes and no. The way type systems work to soundly guarantee that some program property P holds is by guaranteeing some stronger property Q, such that Q => P. This is because type systems generally enforce what we call "inductive invariant", i.e. a property that is preserved by all program statements [1], while most interesting program properties are not inductive. To give an example, suppose we're interested in the property that a program produces an even number; an inductive invariant that implies that property is one that makes sure that the outcome of all computations in the language are even. A program that satisfies the latter property obviously satisfies the former, but the converse isn't true.
Similarly, the way Rust guarantees that all programs don't have, say, use-after-free, is by enforcing a stronger property around ownership. So all safe Rust programs don't have use-after-free, but many programs that don't have use-after-free don't satisfy the stronger ownership property. This means that sometimes (and this is true for all sound type systems) you have to "wrestle" the compiler, which insists on the stronger property, even though you know that the weaker property -- the one you're interested in -- holds. In other words, sometimes you do know better than the compiler.
That is not to say that the approach where the compiler enforces stronger invariants is always right or always wrong, or even right or wrong most of the time, but that "wrestling the compiler" is something that even the most skilled programmers confront from time to time.
[1]: This is because inductive invariants are compositional, i.e. they hold for some composition of program terms t and s iff they hold for t and s and their composition operator, and type systems want to be compositional.
> It's annoying to be bad at tracking ownership and guess what: most people are. The ones who aren't have decades of experience in C/C++ and employ much the same techniques that Rust guides you towards.
You wouldn't need to do that here, in SPARK, or Oberon, or just about any other memory safe language. This is unique to Rust, and their model - and it absolutely is not required for safety. It's not a skill issue. It's a language design problem.
doesn't spark do something inspired by rust to get safe dynamic memory allocation? https://docs.adacore.com/spark2014-docs/html/ug/en/source/ac...
what does oberon do?
SPARK has had it before Rust existed. However, gnatprove doesn't require you the programmer to change anything. The compiler does the work to ensure safety, not you.
Oberon is similar. The typesolver will determine if something is safe, without the need for explicitly borrowing anything.
> Oberon is similar. The typesolver will determine if something is safe, without the need for explicitly borrowing anything.
so what does oberon do to prevent you from resizing a possibly reallocating array while holding a reference into it?
It doesn't allow those operations to occur at the same time. If you can't meet compile time guarantees, then it does not compile.
Pointers aren't the same as in C. A pointer has an explicity type, not just a size. A pointer cannot change where it is located, whilst in scope anywhere else.
If you then make your pointer local, then it will get cloned. As there's no concept of a void pointer, every type supports cloning, and so your thread-local variable will have nothing to do with the parent any longer.
So if you try to grab a local reference, to something in another thread, you'll get a copy, or a compile time error if you don't copy it.
If you try to modify something you're looping over, it won't compile at all.
However, in all of this, there's no extra syntax. The compiler can deal with what is permitted. The programmer can just write.
This is multithreaded (by compiler switch):
No, GP can just not use Rust, they don't have to use GC languages to have something that makes sense and doesn't force you to always have a debate with the compiler about even simple things.
If they used Odin (or Zig) they could've looped through that dynamic array no problem, in fact:
It is ridiculous that Rust complains even about the simple for loop and to say that this somehow comes down to "Well, everyone would do it this way if they cared about memory safety" is just not really true or valuable input, it sounds like what someone would say if their only systems programming experience came from Rust and they post-rationalized everything they've seen in Rust as being how you have to do it.My tips to people who maybe feel like Rust seems a bit overwrought:
Look for something else, check out Odin or Zig, they've got tons of ways of dealing with memory that simply sidestep everything that Rust is about (because inherently Rust and everything that uses RAII has a broken model of how resources should be managed).
I learned Odin just by reading its Overview page (https://odin-lang.org/docs/overview/) and trying stuff out (nowadays there are also good videos about Odin on YouTube), then found myself productively writing code after a weekend. Now I create 3D engines using just Odin (and we in fact use only a subset of what is on that Overview page). Things can be simple, straight forward and more about the thing you're solving than the language you're using.
I dunno; I've never tried Zig before, and it wasn't hard to check whether this kind of bug was easy to have:
I think it is important to note that in 59nadir's example, the reason Rust gives an error and Odin doesn't is not memory safety. Rust uses move semantics by default in a loop while Odin appears to use copy semantics by default. I don't really know Odin, but it seems like it is a language that doesn't have RAII. In which case, copy semantics are fine for Odin, but in Rust they could result in a lot of extra allocations if your vector was holding RAII heap allocating objects. Obviously that means you would need to be careful about how to use pointers in Odin, but the choice of moving or copying by default for a loop has nothing to do with this. For reference:
Odin (from documentation):
Rust: C++:And why do you think that bug is relevant in the case of a loop that prints the elements of a container? We can all see and verify at a glance that the code is valid, it's just not provably valid by the Rust compiler.
I feel like these posts trying to show possible memory issues with re-allocated dynamic arrays are missing the point: There is no code changing the underlying array, there is no risk of any kind of use-after-free error. This is exactly the kind of case where all of this jumping through hoops shouldn't be needed.
> There is no code changing the underlying array, there is no risk of any kind of use-after-free error.
There is none of this code, until there is.
Ok, so we've established that the loop can be verified as not changing the container in any way, what makes you believe this shouldn't be obvious to the Rust compiler?
When code that modifies the container is added, it should be understood and then correctly errored about, I don't get why this is such a crazy concept to people.
The point here is that you pay the cost for an error that can't happen. It's just a micro example of a much more general issue that boils down to:
The Rust compiler does a lot to find and mitigate bugs, it's amazing, but it also rejects completely valid programs and solutions because it simply isn't good enough (and it's a difficult enough problem where I'm prepared to say it will never be good enough). You can either pay that cost constantly and for certain problems be dealing with it (a lot) for no gain whatsoever (because the bugs it was trying to prevent weren't actual issues or are in fact imperatives because the thing you're doing requires them) or you can choose not to.
I don't think it's particularly useful to make excuses for the compiler not understanding very basic things in simple examples and indirectly argue that it would be too complicated to see what the loop is doing and act accordingly. Rust already signed up for a very complicated compiler that does all kinds of crazy things in order to mitigate bugs; this type of introspection would increase the accuracy of it a lot.
> Ok, so we've established that the loop can be verified as not changing the container in any way, what makes you believe this shouldn't be obvious to the Rust compiler?
I would be quite happy for the Rust compiler to be able to perform more powerful analysis and make writing code easier. What I object to, and I think that quite small Zig code snippet highlights, is that dealing with those shortcomings
> for no gain whatsoever
is also plainly wrong.
I make custom 3D engines and I can tell you that it would not be a net benefit for us to use Rust. That's why I added "for certain problems" as a qualifier; there are use cases where Rust would be a net negative.
There are also plenty of use cases where Rust is actually useful and provides guarantees about things that you want guarantees about.
> You can either pay that cost constantly and for certain problems be dealing with it (a lot) for no gain whatsoever (because the bugs it was trying to prevent weren't actual issues or are in fact imperatives because the thing you're doing requires them) or you can choose not to.
Alternatively, you can use Rust so much these limitations become second nature, and thus don't make them in the first place.
> I don't think it's particularly useful to make excuses for the compiler not understanding very basic things in simple examples and indirectly argue that it would be too complicated to see what the loop is doing and act accordingly.
Great idea, until it stops working. It runs into the paraphrased quote: "Any sufficiently complicated borrow checker is indistinguishable from Dark Magic".
First you say, well, the compiler should be sufficiently smart to figure out case A1 should work, then you add that, but then arises another case A2 that the compiler is sufficiently smart to figure out and so on.
However, you add a bunch of these "sufficiently smart" borrow rules, and you'll end up with a mess. A1 and A2 don't work if A432 is applied, but do work if A49324 is given if the A4 and A2 are satisfied.
The harder the borrow checker is to understand, the more difficult it is to construct a mental model that's useful.
In summary: while I'm not against improving the borrow checker, but the problem is that it needs to be balanced with the opportunity cost of understanding how it approximately works.
For anyone curious about Odin and graphics, it seems to work really well:
https://gist.github.com/NotKyon/6dbd5e4234bce967f7350457c1e9...
https://www.youtube.com/watch?v=gp_ECHhEDiA
And how should resources be managed?
In bulk, i.e. not one-by-one as is implied and most used with RAII. RAII works best for a one-by-one use case and in well designed, performant systems the one-by-one use case is either irrelevant, rare or an anti-pattern.
if you want bulk, you can use arrays, vecs, arenas, etc.
Rust, in many ways, is a terrible first systems programming language.
To program a system is to engage with how the real devices of a computer work, and very little of their operation is exposed via Rust or even can be exposed. The space of all possible valid/safe Rust programs is tiny compare to the space of all useful machine behaviours.
The world of "safe Rust" is a very distorted image of the real machine.
> Rust, in many ways, is a terrible first systems programming language.
Contrariwise, Rust in, in many way, an awesome first systems programming language. Because it tells you and forces you to consider all the issues upfront.
For instance in 59nadir's example, what if the vector is a vector of heap-allocated objects, and the loop frees them? In Rust this makes essentially no difference, because at iteration you tell the compiler whether the vector is borrowed or moved and the rest of the lifecycle falls out of that regardless of what's in the vector: with a borrowing iteration, you simply could not free the contents. The vector generally works and is used the same whether its contents are copiable or not.
A lot of idiomatic systems code is intrinsically memory unsafe. The hardware owns direct references to objects in your address space and completely disregards the ownership semantics of your programming language. It is the same reason immediately destroying moved-from objects can be problematic: it isn’t sufficient to statically verify that the code no longer references that memory. Hardware can and sometimes does hold references to moved-from objects such that deferred destruction is required for correctness.
How is someone supposed to learn idiomatic systems programming in a language that struggles to express basic elements of systems programming? Having no GC is necessary but not sufficient to be a usable systems language but it feels like some in the Rust community are tacitly defining it that way. Being a systems programmer means being comfortable with handling ambiguous object ownership and lifetimes. Some performance and scalability engineering essentially requires this, regardless of the language you use.
None of these "issues" are systems issues, they're memory safety issues. If you think systems programming is about memory saftey, then you're demonstrating the problem.
Eg., some drivers cannot be memory safe, because memory is arranged outside of the driver to be picked up "at the right time, in the right place" and so on.
Statically-provable memory saftey is, ironically, quite a bad property to have for a systems programming language, as it prevents actually controlling the devices of the machine. This is, of course, why rust has "unsafe" and why anything actually systems-level is going to have a fair amount of it.
The operation of machine devices isnt memory safe -- memory saftey is a static property of a program's source code, that prevents describing the full behaviour of devices correctly.
Water is wet.
Yes, touching hardware directly often requires memory unsafety. Rust allows that, but encourages you to come up with an abstraction that can be used safely and thereby minimize the amount of surface area which has to do unsafe things. You still have to manually assert / verify the correctness of that wrapper, obviously.
> This is, of course, why rust has "unsafe" and why anything actually systems-level is going to have a fair amount of it.
There are entire kernels written in Rust with less than 10% unsafe code. The standard library is less than 3% unsafe, last I checked. People overestimate how much "unsafe" is actually required and therefore they underestimate how much value Rust provides. Minimizing the amount of code doing unsafe things is good practice no matter what programming language you use, Rust just pushes hard in that direction.
> For instance in 59nadir's example, what if the vector is a vector of heap-allocated objects, and the loop frees them?
But the loop doesn't free them. This is trivial for us to see and honestly shouldn't be difficult for Rust to figure out either. Once you've adopted overwrought tools they should be designed to handle these types of issues, otherwise you're just shuffling an esoteric burden onto the user in a shape that doesn't match the code that was written.
With less complicated languages we take on the more general burden of making sure things make sense (pinky-promise, etc.) and that is one that we've signed up for, so we take care in the places that have actually been identified, but they need to be found manually; that's the tradeoff. The argument I'm making is that Rust really ought to be smarter about this, there is no real reason it shouldn't be able to understand what the loop does and treat the iteration portion accordingly, but it's difficult to make overcomplicated things because they are exactly that.
I doubt that most Rust users feel this lack of basic introspection as to what is happening in the loop makes sense once you actually ask them, and I'd bet money most of them feel that Rust ought to understand the loop (though in reading these posts I realize that there are actual humans that don't seem to understand the issue as well, when it's as simple as just reading the code in front of them and actually taking into account what it does).
> But the loop doesn't free them.
What if it did free them in a function you don't directly control?
> forces you to consider all the issues upfront.
Ever wonder why we do not train pilots in 737s as their first planes? Plenty of complex issues do NOT, in fact, need to be considered upfront.
YMMV, naturally, but I've found that some embedded devices have really excellent hardware abstraction layers in Rust that wrap the majority of the device's functionality in an effectively zero-overhead layer. Timers? GPIO? Serial protocols? Interrupts? It's all there.
- https://docs.rs/atsamd-hal/
- https://docs.rs/rp2040-hal/
That systems languages have to establish (1) memory saftey, (2) statically; (3) via a highly specific kind of type system given in Rust; and (4) with limited inference -- suggests a lack of imagination.
The space of all possible robust systems languages is vastly larger than Rust.
It's specific choices force confronting the need to statically prove memory saftey via a cumbersome type system very early -- this is not a divine command upon language design.
sure, rust is not the final answer to eliminating memory safety bugs from systems programming. but what are the alternatives, that aren't even more onerous and/or limited in scope (ats, frama-c, proofs)?
My preference is to have better dynamic models of devices (eg., how many memory cells does ram have, how do these work dynamically, etc.) and line them up with well-defined input/output boundaries of programmes. Kinda "better fuzzing".
I mean, can we run a program in a well-defined "debugging operating system" in a VM, with simulated "debugging devices" and so on?
I dont know much about that idea, and the degree to which that vision is viable. However it's increasingly how the most robust software is tested -- by "massive-scale simulation". My guess is it isnt a major part of, say, academic study because its building tools over years rather than writing one-off papers over months.
However, if we had this "debuggable device environment", i'd say it'd be vastly more powerful than Rust's static guarantees and allow for a kind of "fearless systems programming" without each loop becoming a sudoku puzzle.
> The space of all possible robust systems languages is vastly larger than Rust.
The space of all possible CVE is also vastly larger outside of Rust as well.
My biggest takeaway from Rust isn't that it's better C++. But that it's extremely fast (no runtime limited GC) and less footgunny Java.
This is yet another issue with Rust, nowhere in my post have I mentioned C++, I made no effort comparing the two languages, I just pointed out poor developer ergonomics in Rust and you're instigating a language flame war as if you took valid criticism as a personal attack; you can do better than that.
> poor developer ergonomics
I don't think it's poor developer ergonomics. The compiler tells you "Hey, try adding &x at this position".
It's unfamiliarity with Rust's type system and syntax sugars.
<HYPERBOLE>
I'll take 1000 compiler errors over a single non-deterministic bug that only happens on ARM at 6 o'clock on Blue Moon when Mercury is in Orion (it's UB).
And I'd ritually sacrifice my first unborn werekid to the Dark Compiler Gods for a compiler error, that actually suggests a correct fix.
</HYPERBOLE>
My 2¢, it’s perfectly reasonable to bring up other languages in defense of criticism, because it explains why these decisions were made in the first place. GP literally said that rust isn’t a good fit for you if you’re in a position to use a GC. The comparison to C++ is important because it’s one of very, very few contemporary languages that also doesn’t require a GC/refcounting everywhere. So it’s useful to compare to how C++ does it.
Yet another issue with people who criticize rust: they don’t want anyone to defend rust, and complain loudly about anyone defending rust as being a literal problem with the language. You can do better than that.
I'm starting to think Zig's strategy to memory management is generally friendlier to a developer. If a function needs to allocate memory, it must take an allocator as a parameter. If it needs scratch space to perform computation, it can use that allocator to create an arena for itself, then free it up before it returns (defer). If it returns a pointer, the caller should assume the object was allocated using that allocator, and becomes the owner. It may still be unclear what happens to a pointer if it's passed as a parameter to another function, but I'd normally consider that a borrow.
It's a lot of assumptions, and if you trip, Rust will yell at you much more often than Zig; and it will likely be right to do so. But in all seriousness, I'm tired of the yelling, and find Zig much more pleasant.
A 20 page document on how to use basic variables, function calls and methods. Except for the threading paragraph, which is hard in any language, this is all complexity and refactoring pain that Rust hoists onto every programmer every day, for relatively modest benefits, somewhat improved performance and memory usage vs the garbage collected/ref-counted version of the same code.
Essentially, you wouldn't and shouldn't make that tradeoff for anything other than system programming.
> this is all complexity and refactoring pain that Rust hoists onto every programmer every day
This is what you should be doing when working with C/C++, except there is no compiler to call you names there if you don’t.
If you’re saying ‘use a GC language unless requirements are strict about it’, yeah hard to disagree.
> This is what you should be doing when working with C/C++
I genuinely wonder if you actually have ever written c/c++, there is plenty of code that is perfectly valid and safe (mostly involving multiple pointers to mutable memory being alive) that the borrow check cannot accept because it has to draw a line to things it can prove are correct.
It's like saying that the only valid math is the one that an automated theorem prover can prove, it's not even close to being true.
> I genuinely wonder if you actually have ever written c/c++
I have; enough for one lifetime if you ask me. It was hunting use after delete which made me stop.
I kinda agree with you, with the caveat that both can be true. If you want to write safe-ish C++ you’ll use defensive containers from the start and watch iterators like a hawk. You can also take a more cavalier approach and live with the consequences (which might not happen and then you win big).
Rust wants you to basically not use references unless your data fits the one writer xor many readers model (painfully including struct members, recursively) and the cavalier approach is very strongly discouraged on the language level. This forces you towards safety, but I also agree with everyone else who say that isn’t how computers actually work. The impedance mismatch is an engineering tradeoff to make.
I see why you're saying that, and i almost entirely agree with you. However, i would say that if all you're doing is glueing calls to third party systems (like what most backend code is), then you won't fall into complex lifetime problems anyway, and the experience will remain quite pleasant.
Another point, is that the rust ecosystem is absolutely insanely good ( i've recently worked with uniffi and wasmbindgen, and those are 5 years ahead of anything else i've seen..)
I really like the text. Giving more light to the memory management of rust will help me understand more of the language. I still think some concepts of rust are over verbose but I slowly understand the hype around rust. I myself use C or C++ but I will „borrow“ some of the rust ideas to make my code even more robust
I'm coming from C++ now I don't want to use C++ anymore. When C++ was still my primary language I always frustrated with some of its feature like non-destructive move, copy by default and dangling references then I found Rust fixed all of those problems. At the beginning I very frustrated with Rust because the borrow checker prevent me from doing what I usually do in C++ but I keep going.
I think this really depends on the type of software you develop. For some types of systems code, non-destructive moves are a killer feature because ownership and lifetimes can be intrinsically unknowable at compile-time even in a moved-from context. In these cases, a destructive move could introduce a use-after-free that the compiler can’t see whereas a deferred destruction is idiomatic and behaves the way you would expect.
This is less than ideal even in C++, though it addresses some cases perfectly. There is an additional concept of “relocatable” types that lives in the middle ground, which is closer to the Rust concept, but that is not currently part of the language though you can hack your own traits. I don’t think any language currently handles the scope of move semantics cases properly.
been banging my head against this same stuff trying to learn rust - honestly memory rules make me miss how easy c feels sometimes, but i'm sticking with it cuz i want fewer bugs
All of the same rules exist in C, they're just tracked manually inside the programmer's head and you don't find out about mistakes until much later.
"If we change .set_value() to take a &self instead of a &self"
guess it's "instead of a &mut self"
This is brilliantly written n
Im sure it is. I don’t know how to program but read most of part 4 when it appeared here last.
> So why does this result in a move and why does replacing x with &x fix it
Precisely the sort of question I do not want to waste time on.
[dead]
[dead]