Easy Mode Rust
llogiq.github.io
llogiq.github.io
I'm not sure if there are any IDEs supporting (temporary) macro expansion already. The output showing all macros expanded is usually too verbose.
for item in items.iter() {
if predicate(item) {
items.push(modify(item));
}
}
This is not a good idea in Python either. You are better off creating a new list in which you accumulate all items, both modified and unmodified.After you’re done with the above iteration, what are you going to do with the items list? It’ll contain a mix of semantically different things (all original entries as well as filtered and modified entries).
For example, say items originally is a list of file names. We want to only process image files (the predicate), and want to normalize paths beforehand (modify). Makes sense. But it requires a separate results list. Processed results cannot go at the end of the original list.
(The idiomatic way would be to process outright and not needlessly allocate and populate new lists, but let’s put that aside)
> add .await after every function call, and then remove it again wherever the compiler complains
Should be something Rust (and JS runtimes!) should somehow check/complain. It's just too easy to forget to await on a function returning a Future/Promise. Rust at least will complain if the returning types don't match some expected value and hint a helpful "help: consider `await`ing on the `Future`". Typescript, well tslint, has "no-floating-promises" which is also helpful.
Is there not a way with linting configurations to bump this warning to a "compile time error" (unless marked ignore or something)?
A script / compiler could go from one to the other and back again without any changes having occurred.
The syntax is good once you have figured it all out its it is faster to type.
Rust is not garbage collected. It's a systems language, so it doesn't ship or run inside a big runtime. Rust's RAII and lifetime system does an admirable job of making most of the headaches and unsafe aspects of manual memory management fade into the background... until you throw in async.
After having used Rust for almost two years, I can confidently say that async and its interactions with memory are the hard part. (Edit: Making async code properly clean up after itself is also the hard part, unless you use a better async runtime than tokio.)
There are two paths with async. One is to use Arc<> everywhere, at which point Rust degrades into a more verbose version of Go but without automatic handling of circular references like you get with a good GC. You still get a thinner runtime and deterministic execution, which are benefits of a non-GC language at runtime, but you lose a lot of the efficiency and ergonomic benefits. You also end up with circular Arc<> references very easily if you have a task that owns two Arc<> classes that own tasks that own each other etc.
The second path with async is to use a scoped / structured concurrency library that lets tasks have lifetimes associated with them. Unfortunately those all come with not-entirely-safe warnings when used with tokio, and the underspecification of async in the standard library means the ecosystem has fixated on the runtime with nonexistent structured concurrency support (tokio) as the standard against which all other async runtimes link. You could use a superior async runtime like smol, but then you'd have to vendor/fork all your libraries.
TL;DR: async is still half-baked. The rest of Rust is actually easier than C++ and can be almost as easy as a language like C# once you grok lifetimes.
Edit:
IMHO tokio made a massive mistake in avoiding lifetimes associated with tasks and structured concurrency, probably on the speculation that this would be more confusing. It's not. It's more confusing not to have this, since you basically lose the entire lifetime system and its benefits in multithreaded async code. You also lose RAII in async code.
Rust also made a massive mistake in not more fully specifying async within the standard library and structuring it to be either runtime-included or runtime-agnostic. Until this happens async is half-baked and broken.
> [...], I can confidently say that async and its interactions with memory are the hard part. (Edit: Making async code properly clean up after itself is also the hard part, unless you use a better async runtime than tokio.)
I would love to read more about this, are there any specific areas or things I might search for?
If anything, these concepts (algebraic data types and pattern matching) are by far the highest “ROI” features to learn of all: they’re super easy to learn, intuitive to read and write, immediately useful, and unlock a level of rich, expressive, inherently robust code not possible in many other languages.
However, I do agree with the author that the borrow checker is the first area of “friction” for most, and so suggesting more liberal use of cloning is a completely fair strategy to make the learning curve less steep for beginners.
- Do the things C and C++ can do.
- Without the memory corruption issues those languages are infamous for.
- With the conveniences you'd expect of any post-internet language. (A library ecosystem that's unified around a standard build system and package manager, an async IO story, UTF-8 strings, etc.)
I feel that this could have been provided even without having Cargo and crates repeat the mistakes of both Maven and NPM.
At least the async IO is nice enough even if it does rely on a bunch of sometimes uncontrollable heap allocation. I'd prefer CSP personally, but it could be worse. Although with that you also couldn't avoid allocations.
So to be fair, I was incorrect about it being a combinatorial explosion, since I was under the impression that each combination of features would be a package, but this makes a lot more sense. It's still a quite foreign way of packaging software, though. Although I'm glad that at least Cargo can be operated offline and from official repos.
Do fedora and apt try to mirror all of the packages from crates.io? Are they kept up to date? Is this a manual process, where a human picks and chooses some packages and hopes nothing is missing, or is it a live mirror? If it’s done by hand, what are the chances all the dependencies for some given project will even be available?
It may cause some amount of duplication across all packages, but the final amount is arguably will be quite small when measured in MB. Also new release of an upstream crate may cause several updates of downstream packages even if downstream apps did not release new versions, but distros are not known for quick updates either way, so it should not be a big issue.
The package managers in distros are pretty awful for a language like rust though. They are designed for dynamically linked C code, not a language like rust where small, developer published libraries are the norm and there’s no dynamic linking. Distro package managers also don’t support rust’s feature flags well (C programs with compile time config often has the same problem).
Apt, rpm and friends’ biggest problem is they’re awful for developers. If I write a program or library for people to use, now I’m expected to test and keep up-to-date packages (or at a minimum build instructions) for like, 6 different operating systems. “On Debian, apt install packages X and Y. Z is also needed but it’s out of date so install that from source. On Ubuntu it’s nearly the same but library Z is usable in apt. On redhat everything is available but named differently. And gentoo. And arch. And nixos. And FreeBSD pkg. Also here’s the configure script. And CMake, visual studio project files, Xcode project files, homebrew and a windows installer too.
What version of rust is even available on Debian and redhat? Is it 2 months old or 2 years old? Do my rust project’s dependencies work on that version of rustc? Are they available in apt? Urgh just kill me.
Cargo means I can just ship my project in the form I use while developing. Users get all the latest packages, chosen by me, no matter their OS. And I know their build environment is sane. Hate on cargo if you want, but cargo, npm and friends are the only sane way to ship cross platform code.
If you can write pure Rust in a single library/binary these aren't major issues but as a drop-in replacement for C/C++ in many of the areas where those languages are heavily used today, the edges can be surprisingly sharp.
* Rust offers memory safety without a GC. You may or may not want a GC. If you don't, then Rust is the better option.
* More broadly, Rust has a C++-like focus on providing zero cost abstractions. Go is generally happy to accept a small runtime cost for abstraction.
* Rust can generate small WASM targets because it doesn't need to bundle a runtime. Go can target WASM too, but you either need to accept much larger object sizes or use TinyGo, which doesn't implement all Go language features (although it's pretty close now that generics support has arrived).
* Rust code generally runs faster (although a lot depends on whether you're writing the kind of code where a GC is a net positive or a net negative for performance).
* Rust has a fancier type system that's much more able to express invariants. If your happy place is a place where the type system proves that your code is correct, then Rust will make you much happier than Go. The Go type system (and the culture around Go more generally) tends not to favor elaborate abstractions built on types.
* Rust has fully-featured macros, if that's your bag.
I think there are also some disadvantages of Rust compared to Go, but I've only attempted to list the advantages here.
- No more bugs where a value that shouldn't be mutated is accidentally mutated in another place.
- No more bugs with writing to a closed channel or file.
- No more bugs with forgetting to close a file.
- No more null pointer errors at runtime.
- No more runtime reflection errors.
Compared to Go, safe Rust provides all these benefits and more.
This is the most glaring thing IMO. Go repeating the billion dollar null pointer mistake is inexcusable IMO. There’s zero reasons for a language designed in the last 20 years to have this problem. This alone is enough for me to want to stay away from Go.
The term sounds academical, but I honestly can't see a modern, programmer-friendly language not having proper discriminated union types in 2024. Go's lack of sum types is not simplicity. It's a glaring omission, forcing programmers to rely on "idioms" like using tuples to return errors. Having only product types (structs) is literally like trying to do arithmetic with only multiplication, without addition.
Does that really need to be part of the language though, or as long as you can code it, or have it in the standard library, it's fine?
What can Rust's unions do that std::variant cannot?
UPDATE: also, some advantages coming from the interaction of enums with other Rust language features:
- Because Rust doesn't have a stable ABI, the compiler is free to agressively optimize the layout of enums. The size of an enum is often equal to (rather than greater than) the size of the largest variant, if the variant has some "impossible" bit values (like null pointers and non-UTF8 chars) that can be used for storing the tag.
- Because Rust traits can (and must) be implemented outside of the type definition, you can implement a trait directly for an enum and then use that enum as a polymorhpic "trait object". In C++, if you want to polymorphically use an `std::variant` as a subclass of something, you need to define an ackward wrapper class that inherits from the parent.
using MyDiscreminatedUnion = std::variant<MyType1, MyType2, MyType3>;
MyDiscreminatedUnion var = MyType2{};
std::visit(overload {
[](MyType1 t1) {...},
[](MyType2 t2) {...},
[](auto t) {...},
}, var);namespace StlHelpers {
template<class... Ts> struct overload : Ts... { using Ts::operator()...; };
template<class... Ts> overload(Ts...) -> overload<Ts...>;
template<class var_t, class... Func> auto visit(var_t & variant, Func &&... funcs)
{
return std::visit(overload{ funcs... }, variant);
}}
And then
StlHelpers::visit(var,
[](MyType1 t1) {...},
[](MyType2 t2) {...},
[](auto t) {...}
);C++ is going to have pattern matching "any year now", and it's going to make `std::variant` more ergonomic to use, but on the other hand any pattern matching feature will have to make design compromises in order to support `std::variant` and other mutually-incompatible variant-like library types of which C++ has a bunch of (pointers, unions, `std::optional`, `std::any`, `std::expected`, did I miss any?) Add to that all the zillions of third-party variant-like types (including boost::variant) that exist in the wild because the code base predates C++17 and/or doesn't want to use C++17 features for whatever reason. All this complexity could've been avoided had the language just supported real sum types from the start. Or at least from C++11 up or whatever.
As a sibling commenter noted, algebraic data types and pattern matching with compile-time exhaustiveness checking go hand in hand; I meant to mention the latter in my original comment, but left it out because I consider the latter almost implied by the former.
All I'm saying is that the current state of std::variant makes it okay enough to use type safe discriminated unions.
Visit + overload is not that far away from pattern matching in terms of readability, clang does warn on non exhaustive switch cases, etc.
> Does that really need to be part of the language though, or as long as you can code it, or have it in the standard library, it's fine?
And my answer is, yes. I don't consider `std::variant` a proper replacement, more like a crutch that may even be worse than not having anything at all, because its existence can be used as an argument against introducing language-level sum types in the future.
* safe by default — for example all array accesses are checked by default. You can do unchecked access for speed when you need, and you can check for safety in C and C++ if you want, but I feel nowadays we really should be using “safe by default”.
* much easier to parallelize. I had basically given up on multithreading in C++ and believed it was almost impossible to do well. In Rust, in my experience, if parallel code compiles it works correctly. This is because the borrow checker stops you ever mutating anything in two threads at once at compile time.
You really want to skip over the ruleset for undefined behaviour, otherwise "easy mode rust" becomes as long as the just the first chapter of the first book of "a quick intro of undefined behaviour in c".