Hacker Newsnew | past | comments | ask | show | jobs | submitlogin
Easy Mode Rust (llogiq.github.io)
68 points by thunderbong on April 5, 2024 | hide | past | favorite | 53 comments


The self-modifying code mentioned in the Macros section could be automated in an IDE! That is, your IDE should be able to show you the expansion of a macro at that specific place. This feature would help me much more to understand what's going on than navigation to the macro source.

I'm not sure if there are any IDEs supporting (temporary) macro expansion already. The output showing all macros expanded is usually too verbose.


Rustrover (IntelliJ) can do some of this. You can use quick actions to expand a macro inline in the IDE.


As can rust-analyzer, btw. It's very useful sometimes.


How would you interface with rust-analyzer to make it do this? As I understand it rust analyzer acts like an LSP to your IDE, which in turn doesn’t let you just make raw calls to rust analyzer


In addition to the already mentioned Command Palette, macro expansion is also available as a quick fix, either by clicking the light bulb icon when it appears, or triggering it via hotkey (Ctrl+. by default).


In VSCode it’s just a command away (on the command palette).


Intellij can also expand macro inline, But iirc you have to enable it in advanced settings.


For that matter it would be nice to expand any function call in place. That way you could view a full call stack in a single view.


    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.


I don’t think I’ve ever seen code of this shape in general, ever. Lists are homogeneous in their semantics, aka items stand for the same semantic thing. This is in contrast to tuples. Both lists and tuples exist in both Rust and Python as well was a bunch of other languages.

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)


I've seen it in Python. Not in Rust, because I haven't worked with Rust.


This:

> 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.


> It's just too easy to forget to await on a function returning a Future/Promise.

Is there not a way with linting configurations to bump this warning to a "compile time error" (unless marked ignore or something)?


Yes now a days every sensible project comes with a linter by default that will tell you when you are using a promise wrong.


That seems extreme. In both JS and Rust, it’s not unusual to not await the promise/future immediately and just deal with the returned promise directly. Presumably that’s why neither language awaits for you automatically. I would be pretty annoyed if the linter yelled at me every time I did that.


I think it would be helpful if Rust had a much more verbose mode. Nothing changed, just that symbols and operators have an alternative mcuh more verbose syntax that will make it easier for someone without a Rust background to read Rust code.

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.


What's an example of what this would look like?


Love the Dylan adaptation. I don't think computer filk [1] is very common these days – seems like it was more of a thing in the "good old days" when hackers still roamed free on the Usenet. Or maybe that's just nostalgia.

[1] https://en.wikipedia.org/wiki/Filk_music


I was looking at what Mojo is doing for the first time in half a year or so the other day. It seems to be coming along nicely as an "easier to use Rust with first class Python and MLIR interop". At first glance, it looks like what this article is proposing is rather similar in spirit. E.g. in Mojo when a function wants ownership, but the caller doesn't give it, the value automatically gets copied without requiring additional annotations. The annotation is needed for transferring ownership instead, etc...

https://docs.modular.com/mojo/manual/values/


Most of this stuff isn't addressing what makes Rust harder than, say, Go or C#. The pattern matching makes it if anything easier, since pattern matched dispatch is way easier to read than a bunch of complicated if/else blocks.

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 find this very interesting:

> [...], 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?


It annoys me to no end that the community has cargo-culted around tokio. Async should also have probably defaulted to single-threaded. Does work-stealing offer that much of a performance advantage over simpler thread per core?


100% agree it’s a huge mistake to water down Rust’s `enum` and `match` when teaching Rust.

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.


Cool cheatsheet. As someone new to Rust, what are the benefits versus Go, C++, and C?


A sibling comment already mentioned the type system as a whole, but I wish to highlight one specific feature: Rust has algebraic data types.

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.


> I honestly can't see a modern, programmer-friendly language not having proper discriminated union types in 2024

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?


`std::variant` is very awkward to use, and has design compromises because it doesn't have language support. Besides, the ability to write something like `std::variant` as a pure library type first requires you to have a very complex type system, more complex than Rust's, and certainly more complex than a "simple" language such as C or Go would ever consider adopting.

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.


Right, I'm not trying to argue that std::variant is better than native language support. By definition, a language construct will always be easier to write and read.

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.


You did ask more generally:

> 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.


Rust's language-level support for pattern matching (including exhaustiveness checking) is very nice. Most languages with sum types have this feature. It's hard to imagine one without the other. I haven't used `std::variant` much, but I remember finding it unergonomic. Real-world C++ code uses `std::variant` way less often than Rust/functional code uses sum types. Probably, for that reason

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.


I agree, the lack of pattern matching is a bummer. You should retry std::variant with the lambda overload trick though. It's kind of okay in terms of syntax.

    using MyDiscreminatedUnion = std::variant<MyType1, MyType2, MyType3>;

    MyDiscreminatedUnion var = MyType2{};

    std::visit(overload {
        [](MyType1 t1) {...},
        [](MyType2 t2) {...},
        [](auto t) {...},
   }, var);


You can even do better with something like:

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) {...}
);


Sibling doesn't really cover the benefits vs. Go, so here's my attempt at a list.

* 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.


I'd like to elaborate on the "fancier type system". It's not all academic. There are obvious practical advantages:

- 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.


> No more null pointer errors at runtime

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.


Good summary. This also illustrates why the Go compiler is much faster than the Rust compiler: it does a lot less work, pushing problems down to run time.


Rust can:

- 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.)


Annoyingly said library ecosystem with the standard build system and package manager becomes a pain to deal with when you're trying to do something like add packages made in the language to distros, requiring a bunch of hacks to do things like just have Cargo not try to reach online to get all the dependencies. Also stuff like the way feature flags are used cause a combinatorial explosion of packages just so you can have every single variant packaged, because it's the only way to be sure that software can be reliably compiled.

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.


> becomes a pain to deal with when you're trying to do something like add packages made in the language to distros, requiring a bunch of hacks

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.


In this particular case, I think it's distros who make life hard for themselves by trying to force a square peg into a round hole. Cargo supports vendoring quite well, so, in my opinion, distros should simply vendor all dependencies of a Rust application into its package together with Cargo.lock file.

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.


I don't really know what distro package managers are offering here.


How do the distro packages work? Are they trying to provide dependencies as pre-built binaries? I didn't know Cargo could consume binaries like that.


I can't speak for other distros, but at least in Fedora what happens is that library code is distributed in various devel packages, where the base package for, say, "futures-io" contains the actual code. So that's the "rust-futures-io-devel.noarch". After this, you get various "subpackages" for each feature. These are mostly there so that you can declare in a package that you need certain features, these packages are fully virtual, it seems, even though they all claim ownership of the relevant Cargo.toml in the local registry.

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.


I installed a few rust binaries the other day, like wasm-tools and the typst compiler. I installed them from cargo. Each program probably had 30-50 dependencies which were downloaded and compiled from cargo.

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?


Rust does not have offsetof (the real deal, not some pointer-based hack in a third-party crate) and using FFI to call C or C++ practically requires bindgen which is not stable, not part of the standard library, and tricky to configure in a portable way. Rust also doesn't have a stable ABI yet though slow progress is being made.

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.



For those kind of applications zig can do a more close feel to C while removing some of the pains such as error handling, matching, null checks, slices, etc.


I don’t know Go, compared to C and C++:

* 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.


Recording of the Rust Nation UK talk mentioned in the article:

https://www.youtube.com/watch?v=33FG6O3qejM


Thanks!


Saving this article to point to next time someone asks me yet again "why not rust". I think one could explain the entirety of C in fewer words than this "easy mode" rust.


> I think one could explain the entirety of C in fewer words than this "easy mode" rust.

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".


Basejumping is so much easier if you don't have to fold a parachute beforehand.




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: