the core of rust
NOTE: this is not a rust tutorial.
Every year it was an incredible challenge to fit teaching Rust into lectures since you basically need all the concepts right from the start to understand a lot of programs. I never knew how to order things. The flip side was that usually when you understand all the basic components in play lots of it just fits together. i.e. there's some point where the interwovenness turns from a barrier into something incredibly valuable and helpful. —Jana Dönszelmann
Vision
One thing I admire in a language is a strong vision. Uiua, for example, has a very strong vision: what does it take to eliminate all local named variables from a language? Zig similarly has a strong vision: explicit, simple language features, easy to cross compile, drop-in replacement for C.
Note that you don’t have to agree with a language’s vision to note that it has one. I expect most people to find Uiua unpleasant to program in. That’s fine. You are not the target audience.
There’s a famous quote by Bjarne Strousup that goes “Within C++, there is a much smaller and cleaner language struggling to get out.” Within Rust, too, there is a much smaller and cleaner language struggling to get out: one with a clear vision, goals, focus. One that is coherent, because its features cohere. This post is about that language.
Learning Rust requires learning many things at once
Rust is hard to learn. Not for lack of trying—many, many people have spent person-years on improving the diagnostics, documentation, and APIs—but because it’s complex. When people first learn the language, they are learning many different interleaving concepts:
- first class functions
- enums
- pattern matching
- generics
- traits
- references
- the borrow checker
Send
/Sync
Iterator
s
These concepts interlock. It is very hard to learn them one at a time because they interact with each other, and each affects the design of the others. Additionally, the standard library uses all of them heavily.
Let’s look at a Rust program that does something non-trivial:1
#!/usr/bin/env -S cargo -Zscript
---
package.edition = "2024"
notify = "=8.2.0"
---
use Path;
use ;
I tried to make this program as simple as possible: I used only the simplest iterator combinators, I don't touch std::mpsc
at all, I don't use async, and I don't do any complicated error handling.
Already, this program has many interleaving concepts. I'll ignore the module system and macros, which are mostly independent of the rest of the language. To understand this program, you need to know that:
recommended_watcher
andmap
take a function as an argument. In our program, that function is constructed inline as an anonymous function (closure).- Errors are handled using something called
Result
, not with exceptions or error codes. I happened to usefn main() -> Result
and?
, but you would still need to understand Result even without that, because Rust does not let you access the value inside unless you check for an error condition first. - Result takes a generic error; in our case,
notify::Error
. - Result is an data-holding enum that can be either Ok or Err, and you can check which variant it is using pattern matching.
- Iterators can be traversed either with a
for
loop or withinto_iter()
. 2
If you want to modify this program, you need to know some additional things:
println
can only print things that implement the traitsDisplay
orDebug
. As a result,Path
s cannot be printed directly.path.display()
returns a struct that borrows from the path. Sending it to another thread (e.g. through a channel) won't work, becauseevent.paths
goes out of scope when the closure passed torecommended_watcher
finishes running. You need to convert it to an owned value or passevent.paths
as a whole.- As an aside, this kind of thing encourages people to break work into "large" chunks instead of "small" chunks, which I think is often good for performance in CPU-bound programs, although as always it depends.
recommended_watcher
only accepts functions that areSend + 'static
. Small changes to this program, such as passing the current path into the closure, will give a compile error related to ownership. Fixing it requires learning themove
keyword, knowing that closures borrow their arguments by default, and the meaning of'static
.- If you are using
Rc<RefCell<String>>
, which is often recommended for beginners3, your program will need to be rewritten from scratch (either to use Arc/Mutex or to use exterior mutability).
- If you are using
This is a lot of concepts for a 20 line program. For comparison, here is an equivalent javascript program:
;
;
for path in paths
For this JS program, you need to understand:
- first class functions
- yeah that's kinda it.
I'm cheating a little here because notify
returns a list of paths and node:fs/watch
doesn't. But only a little.
Rust's core is interwoven on purpose
The previous section makes it out to seem like I'm saying all these concepts are bad. I'm not. Rather the opposite, actually. Because these language features were designed in tandem, they interplay very nicely:
- Enums without pattern matching are very painful to work with and pattern matching without enums has very odd semantics
Result
andIterator
s are impossible to implement without generics (or duck-typing, which I think of as type-erased generics)Send
/Sync
, and the preconditions toprintln
, are impossible to encode without traits—and this often comes up in other languages, for example printing a function in clojure shows something like#object[clojure.core$map 0x2e7de98a "clojure.core$map@2e7de98a"]
. In Rust it gives a compile error unless you opt-in with Debug.Send
/Sync
are only possible to enforce because the borrow checker does capture analysis for closures. Java, which is wildly committed to thread-safety by the standards of most languages, cannot verify this at compile time and so has to document synchronization concerns explicitly instead.
There are more interplays than I can easily describe in a post, and all of them are what make Rust what it is.
Rust has other excellent language features—for example the inline assembly syntax is a work of art, props to Amanieu. But they are not interwoven into the standard library in the same way, and they do not affect the way people think about writing code in the same way.
A smaller Rust
without.boats wrote a post in 2019 titled "Notes on a smaller Rust" (and a follow-up revisiting it). In a manner of speaking, that smaller Rust is the language I fell in love with when I first learned it in 2018. Rust is a lot bigger today, in many ways, and the smaller Rust is just a nostalgic rose-tinted memory. But I think it's worth studying as an example of how well orthogonal features can compose when they're designed as one cohesive whole.
If you liked this post, consider reading Two Beautiful Rust Programs by matklad.