(no boilerplate author here) Thank you so much for linking!
When I was first getting started I learned so much from Amos and his INSANE deep-dives over at fasterthanli.me, basically he taught me Rust.
For my 3rd video I messaged him and asked if I could base it on his article and he said 'go for it' - since then we've both found ways to go full-time in our respective worlds, and we shitpost in a private discord together :-D
This video isn't really a good intro to either my channel or the WHYs of Rust, just the syntax.
Your videos (and by extension you) are awesome and have been one of the biggest motivations for me learning Rust and continuing programming. Thank you.
As a recent newcomer to Rust, I find Amos' articles an indispensable source of knowledge. His post on recursive iterators [1] has been a lifesaver while I was working on my problem. Thanks, Amos!
This is, more or less, how I learned Rust circa 2015 (might have been a couple years later). I quickly started to write programs like I would have in C but got completely thwarted by the borrow checker because I wanted pointers everywhere. This made me throw my hands up and leave for other languages.
However, 8 years later I did eventually come to love rust after learning the One Weird Trick of using indices instead of pointers.
IMvHO, Rust Ownership and Lifetime rules aren't really that hard to learn. The only trick is that a programmer cannot learn Rust exclusively by trial-and-error (which programmers love to do), trying dozens of syntax combinations to see what works. A programmer is forced to learn Rust by RTFM (which programmers hate to do), in order to understand how Rust works. That's all there's to it. It's like trying to learn to play the guitar without reading a book on guitar cords, pulling guitar strings individually at random - the music just won't happen for quite a long time, unless you're Mozart. At any rate, one can always forget Rust references (i.e., raw pointers) and use Rust reference-counted smart pointers[1], effectively writing Rust analogously to Swift.
True, but because there are many ways to satisfy the borrow checker it is very easy to arrive at needlessly complicated solutions. Idiomatic solutions on the other hand aren’t always obvious.
Isn't that one weird trick more or less defeating the whole purpose of rust's ownership/borrowing model by moving the problems one level up the ladder?
Having seen that kind of opinion stated elsewhere, it seems what most people would like is rust minus borrowing, and I feel I would get behind that too.
> what most people would like is rust minus borrowing
It's already possible. Use Rust reference-counted smart pointers[1] for shareable immutable references and internal mutability[2] for non-shareable mutable references checked at runtime instead of compile time.
> this is one of the main things Rust users don’t promote enough
This is probably because using internal mutability not to achieve a valid design goal (such as a controlled side-effect on an otherwise immutable entity), but to side-step the borrow checker for ease of programming, is not considered idiomatic Rust and even though it makes a good educational tool, it should rather not end up in production codebases.
Firstly, when using regular references, you cannot create multiple mutable references. This rule prevents subtle bugs such as data races. When using internal mutability, you still keep that protection, but it is (undesirably) delayed from compile time to runtime, potentially causing an unavoidable crash/panic.
Secondly, when using regular references, you cannot create even a single mutable reference when an immutable reference already exists. This rule prevents subtle bugs such as unexpected mutation ("from under you") of data that was passed-in as immutable. When using internal mutability, you throw away that protection, since multiple immutable reference owners can request a mutable reference (even if only one at a time).
use std::rc::Rc;
use std::cell::RefCell;
let entity1 = Rc::new(RefCell::new(42));
let /* immutable */ entity2 = entity1.clone();
*entity1.borrow_mut() += 27;
assert_ne!(*entity2.borrow_mut(), 42);
A lot of systems software, especially in C++ but perhaps less so in C, makes heavy use of indices too, for reasons unrelated to the borrow checker obviously. I always felt this should produce less friction in the adoption of Rust than it seems to since it is idiomatic for many types of C++ software.
If you're looking for something with a narrative you can watch, the RustConf 2018 closing keynote https://www.youtube.com/watch?v=aKLntZcp27M is a great talk that has an overview on "generational arenas", which are effectively "just" a vector with a custom index type that holds the "pointed-to" generation, and the stored values also store their generation inline. This allows: all objects to be stored in a contiguous block of memory (increasing the likelihood of "pointer-chasing" style of code to have the pointed to value warm in cache, significantly speeding them up), allows relocation (because you're no longer dealing with pointers, but rather offsets to the beginning of the pointer), and doesn't have the pointer invalidation problem (because the "pointer" now asserts that the pointed to value hasn't changed, and if so return an Err/None). It also has the benefit that because you're not dealing with pointers you don't need to write any unsafe Rust code, out of bound accesses are checked, and the borrow graph becomes simpler because the arena owns all of its contents, instead of having to keep track of the lifetime of borrows at compile time (the famous "fighting with the borrow checker") nor is as "expensive" as the tracking an Arc<RefCell<T>> could be.
The Entity Component System (ECS) pattern seems to side-step the Rust borrow checker entirely in order to solve the following issues, all at the same time: 1.) allow a "parent" entity to have a reference to a "related" entity, 2.) allow such a reference to a "related" entity to be mutable, 3.) allow multiple "parent" entities to reference the same "related" entity, and 4.) allow the overall system to deallocate a "related" entity at any time without invalidating the state of all the observing "parent" entities.
I have never written a game before, so Catherine West[1] might have very good reasons for choosing ECS, but I... am not so crazy about it. ECS seems to replace Rust references (raw pointers) and/or Rust smart pointers with indexes into one, large "registry" (a container: e.g., a `Vec<T>`) of entities (e.g., `struct` instances). In other words, instead of allowing a Rust pointer (a managed memory address) to keep a piece of memory alive, ECS chooses to have a "registry" keep a piece of memory alive under a particular index, managing allocation and deallocation manually.
In a sense, ECS dumps Rust memory management in favor of... writing the good, ol', data-and-function -oriented C. Quite needlessly (?), since the same (?) can probably be accomplished with Rust reference counted pointers[2], weak pointers[3] and interior mutability[4].
----- CUT HERE -----
use std::rc::{Rc, Weak};
use std::cell::RefCell;
type WeakMutEntity = Weak<RefCell<Entity>>;
struct Entity {
related: WeakMutEntity,
}
impl Entity {
fn new(related: WeakMutEntity) -> Self {
Self { related }
}
fn use_related(&mut self) {
let Some(related) = self.related.upgrade() else { return; };
related.borrow_mut().use_related();
}
}
let entity1 = Rc::new(RefCell::new(
Entity::new(/* null */ Weak::new())));
let entity2 = Rc::new(RefCell::new(
Entity::new(Rc::downgrade(&entity1))));
entity2.borrow_mut().use_related();
----- CUT HERE -----
If the above does not get the job done, the solution shouldn't be to just abandon the Rust borrow checker; the solution should be to get the Rust Gods to optimize the available pointer types syntax-wise and/or performance-wise.
The key benefits of an ECS system with large static arrays of data is (a) to avoid the speed overhead of managing memory allocation and deallocation - instead of doing it automatically or manually, these memory allocations/deallocations never happen during operations, allocated just once at startup and deallocated all at once; (b) avoid the memory overhead of having to store any metadata per each item, as your basic unit of allocation is "all items of this kind" and, very importantly, (c) ensure memory locality, that all the consecutive items are always in continuous memory in a cache-friendly manner, as you're going to repeatedly iterate over all of them.
No construction made up of any kind of pointers can achieve that, unless there's a Sufficiently Smart compiler that can magically fully eliminate these pointers.
I know this is not going to make me an expert in Rust. That'll probably take 10 years. But this is just the kind of thing I was looking for to get started with Rust. Thanks for sharing it here.
I've been writing JS for 17 (continuous) years I'd say it took me at the very least 6, probably much closer to 10, to get to what I'd call "expert" JS knowledge (and have since regressed somewhat).
I've been using other languages longer than 10 years too but not continuously enough to ever consider myself expert.
I'd say there are definitely rust experts out there, but I'd posit it's unlikely for the average dev to have gotten that far yet.
(I'm learning Rust by implementing a simple Lisp interpreter in it)
I think TFA is a great resource in terms of presenting syntax and idioms. My biggest problem with learning Rust though has been understanding how to do things when the borrow checker prohibits the solution that would be 'obvious' in C#, C++, etc. I started to make progress when I bought the O'Reilly Programming in Rust book, and then thoroughly read and re-read the chapter on memory management.
If you're already an expert in some other programming discipline (e.g. another language) I wouldn't expect it to take 10 years to become a Rust expert. I'd expect one or two if you're using it primarily, which is similar to what I'd expect for most other languages.
Feels like every language should have an introduction like this. It's frustrating that lang intros are either your very basic and trivial example, or a full-blown set of tutorials, nothing in-between.
As someone with bad eyesight, Rust has been very legible, and the sigils actually help for that (in the same way as most mathematical notations make formulae easier to parse).
It does take a little while to get accustomed to, but the investment is more than worth it.
Can someone explain to me where the feeling_lucky variables comes from in the match example? I don't get it. The variable isn't defined anywhere in the relevant scope.
This might not be a popular opinion, but - I love the idea of Rust, but the language itself seems needlessly complex to me. It looks like the authors tried to cover so many esoteric usecases that the result is more like C++ than C (which is not good in my eyes).
One example:
> `let` patterns can be used as conditions in `if`:
Anyway, this tutorial looks great, I'll give Rust another go when I have a good project for it, but to me it seems there is a great need for a simpler language with similar memory management. Rust is just too complex, imho.
> > `let` patterns can be used as conditions in `if`:
You can code without that, it's just that you are going to wish you had it after your first few programs.
> but the language itself seems needlessly complex to me
A lot of the complexity is around making the explicitness of the language bearable. If you had to be explicit without all the syntax sugar and type / lifetime inference it would be insufferable to program in.
Not sure if I disagree but your example is terrible. Let patterns in if statements greatly simplify things. The alternatives are a lot more convoluted.
You are confusing shortening with simplifying. The `if let` syntax is an unnecessary addition to the language so it is by definition an added complexity. Of course the resulting code is much easier to read and I definitely agree that it's a nice feature but I wouldn't pretend it made the language simpler, just more comfortable once you already know it.
You imply simple = better, complex = worse, but this isn't such a simple relationship.
If you keep removing redundant constructs from languages, you will end up with something pure and minimal like the lambda calculus or turing machine. But these aren't easy to program in!
Languages are an interface for humans. If the code density is too low or too high, it becomes difficult to for people reason about the programs. Concepts like readability and expressiveness are important, but end up requiring some level of complexity.
Exactly, it's a smooth trade off curve from lambda calculus all the way to Haskell with 30 language extensions enabled.
You could learn lambda calculus in an afternoon, but then spend a month writing a single complex algorithm. You could spend 5 years learning Haskell, and then write the most powerful system in a couple minutes.
There's a sweet spot somewhere between those two, and Rust is definitely near it.
Just to elaborate on the "just more comfortable once you already know it" part, I think a lot of Rust is optimized (willingly or not) for the "once you know it" use case.
Which, while it might arguably hurt adoption, is a good value proposition, since you spend a lot more time knowing the language than not knowing it.
Now, not everything is always perfect, and I agree that the `if let` is not the most useful part of the language, as it drives pointless discussions about when to use it vs match (some people prefer the esthetics of match even when an if let can be used, others prefer to use a if let whenever possible). This redundancy apart, the construct doesn't eat any mental energy once you know it. The same can't be said of many of C++ quirks (initialization rules, member-initializer list in constructors, the rule of five)
> I love the idea of Rust, but the language itself seems needlessly complex
Well, I have to agree Rust isn't one of the simplest PL-s on the planet. This is due to the fact that it is quite a modern PL and quite a versatile PL, supporting elements of functional programming, trait-oriented (conditional generics) programming, asynchronous programming, etc. and a capable standard library on top of it all. It takes, indeed, quite some time to take all of that Rust in. As a reward, you get a lot of expressiveness and the capability to discover a significant percentage (if not an overwhelming majority) of your programming mistakes at compile time as opposed to runtime (unit/integration tests or Q/A), which to me is priceless.
Nevertheless, from my experience, Rust is, at the same time, one of the most... "consistent", "predictable", "internally symmetric" PL-s I have ever seen. I consider Rust to be way easier to learn than, say, Swift.
> `let` patterns can be used as conditions in `if`
I somewhat cannot help it but feel that such an example should not be used in an introductory tutorial. `if let` is usually used to pattern-match a simple `enum`[1], not a complex `struct` that looks "dense" unless you're used to it.
> ...and quite a versatile PL, supporting elements of functional programming, trait-oriented (conditional generics) programming, asynchronous programming, etc. and a capable standard library on top of it all.
Yes, exactly! Now can I have just a safe C without all the other stuff, pretty please? :) I understand Rust is not it, but I hope someone comes up with a simple PL which is also compile-time memory safe. I think it would be an instant hit.
> Now can I have just a safe C without all the other stuff, pretty please? :)
Not sure how much we could remove from Rust while keeping the problem tractable. The borrow checker is needed for compile time memory safety sans garbage collection. For borrow checking to be tractable, one needs shared references, exclusive references, owned values, the possibility for reference counting, and the possibility for interior mutability. This mandates smart pointers, that pretty much mandate generics. These various abstractions also mandate a capable standard library, unless the language would hardcode all these abstractions, which would endanger the low-levelness of the language (since you wouldn't be able to implement your own abstractions for e.g. embedded contexts)
For the demarcation between unsafe Rust and safe Rust to work, one needs encapsulation, so field privacy.
For concurrency, the language requires a way to mark types as thread safe, which requires a way to say things about structures, so a least a weak version of traits.
A smaller version of Rust probably exists, but I don't think we could remove as much of the language as we could imagine at first without compromising safety. It also wouldn't be "just a safe C", because C itself has heaps of accidental complexity (e.g. the way function pointers are declared, integer promotions, implicit conversions, the antiquated textual inclusion compilation model that is prone to ODR violations and complicates build systems, array decay, and so on...) that would need to be removed.
> Now can I have just a safe C without all the other stuff, pretty please? :)
Hmmmmm, if you are not being forced by others into any particular Rust feature set (standard) and you are not being forced into any preexisting Rust codebase, then perhaps just forget the entire standard library, traits, async, etc. and go Bare Metal Rust[1], using only the Rust language features that you need and interfacing with external C APIs through Foreign Function Interface[2].
... is a bad idea. I've heard this from several programmers who now use Rust professionally, but don't have first-hand experience about it. Their take can be boiled down to:
Rust has a learning curve that will mean that the first version will invariably be throw-away. Developing a mental model for how to change memory management structures to please the borrow checker and properly encode ownership seems like the biggest hurdle to writing good Rust.
I believe this is the main reason Rust adoption isn't skyrocketing, but still growing. The "onboarding" for Rust is more labor intensive and therefore disincentivizes its use.
Your example is a minor but quite useful piece of syntactic sugar, which was introduced a few years in following a similar construct being successful in Swift. You can read the entire reasoning at https://rust-lang.github.io/rfcs/0160-if-let.html
I agree with the GP. "if let" is just such a weird contruct that it doesn't feel justified to be in the language. it just screams of over engineering. now "let else" is cool.
By far the biggest changes to Rust in recent times are non-lexical lifetimes (IIRC, introduced in the 2018 edition), and async functions (introduced in Nov. 2019). So if you're reading any introductory material after Nov. 2019, or anything after 2018 that isn't related to asynchronicity/futures/etc., it'll be reasonably similar to modern code.
No, introductory Rust code would not have become dated in the past few years. It's possible there would be slightly shorter ways to do some things using new standard library functions.
I get the impression that Rust revisions are (at least intended to be?) backwards compatible: [0]
Are there are good workarounds when breaking changes to occur? No idea if/how well breaking language changes can be isolated to stay within individual crates.
> I get the impression that Rust revisions are (at least intended to be?) backwards compatible
I don't think that matters at all. If I try to build a new project with an older version of Rust, the compiler will still throw errors when stumbling upon newer features.
Is there any language toolchain that isn't absolutely static capable of having not only backwards compatibility but also forwards compatibility? If I try to open an MS Office XP file in MS Office 97, it will fail. If I try to run a new linux application that uses a new syscall in an older kernel, it will fail. If I try to use structural pattern matching in Python 3.9, it will fail. How is Rust any different?
If the concern is that projects are using "too new a rustc version", then your beef is with those projects, but be advised that demanding open source projects not use newer versions of their toolchains can be a big ask that increases their workloads and they might not be amenable to cater to your usecase.
Finally, because every Rust version is backwards compatibility, there's no reason for builders and developers to not use the latest stable release as quickly as feasible, all existing projects will continue compiling.
The person you're responding to was specifically responding to your criticism that Rust does not provide a specification that implementations can target.
You're now listing languages, such as Python, that don't have a specification, so it is unclear what your criticism actually is.
By python's standards, Rust is very good: a lot fewer breaking changes (changes are tested against the entire, huge, open source ecosystem to check if they are breaking, I think this level of testing is unparalleled), the release and versioning process is extremely clear (one minor release every six weeks, patch releases to address unexpected regressions + security issues), and it also have a really well specified evolution process through the RFC process.
https://www.youtube.com/watch?v=br3GIIQeefY