ENOSUCHBLOG

Programming, philosophy, pedaling.


Things I hate about Rust, redux

Mar 10, 2022     Tags: programming, rant, rust    

This post is at least a year old.

A picture of Ferris, the Rust mascot, with a poorly drawn "angry" expression

Two years ago, I wrote a post with a handful of grievances about Rust, a language that I then (and still) consider my favorite compiled language.

In the two years since I’ve gone from considering myself familiar with Rust, to comfortable in it, to thinking in Rust even when writing in other languages (sometimes to my detriment). So, like two years ago, this post should be read from a place of love for Rust, and not a cheap attempt to knock it.

IntoIterator is too overloaded

Here is how the IntoIterator docs explain the trait:

Conversion into an Iterator.

By implementing IntoIterator for a type, you define how it will be converted to an iterator. This is common for types which describe a collection of some kind.

If that sounds extremely generic to you, it’s because it is! Here are just a few of the ways IntoIterator is used in the wild, using a generic Container<T> for motivation:

Each of these can be a useful iterator to have, which is why container types frequently have multiple Item-variant IntoIterator implementations. Those implementations are, in turn, occasionally (optionally!) disambiguated with aliases: iter_mut(), drain()2, &c.

The downside is comprehension: absent of context, an into_iter() could be doing any of the above3, leaving it to me (or any other poor soul) to read further into the iterator’s consumer to determine what’s actually going on. It’s never ambiguous (only one selection is possible at compile time!), but it can be difficult to rapidly comprehend in the manner that Rust otherwise facilitates.

IntoIterator is already firmly baked into Rust’s core, so it’s probably too late to devolve it into the half dozen traits that it conceptually covers. But if I could turn back time:

It’s difficult to write “high-assurance” Rust

Rust’s safety is a sort of inverted Faustian bargain: in exchange for a small amount of control over memory layout, we get complete spatial and temporal memory safety, automatic memory management without a garbage collector, and zero-cost abstractions that let us take full advantage of our optimizing compilers.

As such, when I say that “high-assurance” Rust is difficult, I don’t mean Safe Rust. What I mean is that we’ve made a trade: in exchange for all of this safety, we’ve accepted a certain amount of mandatory invariant enforcement — the Rust standard library will panic when an invariant would produce unsafety, and community maintained libraries will use panic!, assert!, and the like to trade the occasional uncontrolled program termination for slightly better programming ergonomics (fewer Options and Results).

Invariant enforcement is a good thing and, by and large, both Rust’s internal and community uses of panics are judicious: by convention, panicking functions tend to have either (1) a non-panicking Result or Option alternative, or (2) failure conditions that are environmental in a way that mandates program termination anyways (e.g., stack exhaustion).

The end result: the Rust standard library and ecosystem are full of panics that almost never occur, panics that are only specified informally (i.e., in human-readable documentation). But “almost never” isn’t always good enough: it’s sometimes nice to have the assurance that no code being executed can possibly panic.

To the best of my knowledge, there are only imperfect solutions to this:

In sum, it’s very difficult to write provably non-panicking code in Rust in 2022. Avoiding explicit panics in first-party code is perfectly possible (and even ergonomic!); it’s the panics embedded in third-party dependencies and runtime code that are nearly impossible to track.

I have some ideas for improving this, ones that are outside the scope of this gripe-fest. Maybe another time.

Integration tests feel bolted on

Integration tests are one of Cargo’s more oblique features: in addition to hosting your tests in-tree (i.e., in a mod tests in each foo.rs file), you can also create a parallel tests/ tree for tests whose scope reaches beyond the unit level.

In other words, if your source tree looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
src/
├── kbs2
│   ├── agent.rs
│   ├── backend.rs
│   ├── command.rs
│   ├── config.rs
│   ├── generator.rs
│   ├── input.rs
│   ├── mod.rs
│   ├── record.rs
│   ├── session.rs
│   └── util.rs
└── main.rs
tests/
├── common
│   └── mod.rs
├── test_kbs2_init.rs
└── test_kbs2.rs

…then your cargo test output might look something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
william@janus kbs2 [0:0] integration-tests $ cargo test
   Compiling kbs2 v0.6.0-rc.1 (/home/william/devel/self/kbs2)
    Finished test [unoptimized + debuginfo] target(s) in 4.25s
     Running unittests (target/debug/deps/kbs2-2dd9eb541b527992)

running XX tests
test kbs2::backend::tests::test_ragelib_create_keypair ... ok
test kbs2::config::tests::test_initialize_wrapped ... ok
test kbs2::backend::tests::test_ragelib_create_wrapped_keypair ... ok
test kbs2::backend::tests::test_ragelib_rewrap_keyfile ... ok

test result: ok. XX passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.75s

     Running tests/test_kbs2.rs (target/debug/deps/test_kbs2-4f1d8387af33e18c)

running 3 tests
test test_kbs2_version ... ok
test test_kbs2_help ... ok
test test_kbs2_completions ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

     Running tests/test_kbs2_init.rs (target/debug/deps/test_kbs2_init-d890a2d5d4f7537d)

running 1 test
test test_kbs2_init ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

This is a fantastic feature: you don’t need to do anything special to do integration testing on a Rust codebase!

Except…

These are trivial quality-of-developer-life things, each of which has a very good reason for not being different8. But they’re still a drag!

Bonus: cargo install is too eager

cargo install is the main interface for installing user-facing executables from the crates ecosystem. Because it’s built right into the Rust toolchain, lots of projects list cargo install $FOO as a recommended installation technique. So far, so good.

What’s not so good is how cargo install chooses to do builds. Unlike cargo build, cargo install ignores Cargo.lock by default, meaning that a different but “compatible” (per SemVer) version might be selected for the final compiled product.

There are (at least) two problems with this:

  1. It violates some of the (perhaps incorrectly) presumed consistency of telling users to run cargo install to install your program: each user may have a slightly different dependency tree depending on when they ran cargo install. Debugging small compatibility errors then becomes an exercise in frustration, as users and maintainers determine the relevant differences in their dependency trees.

  2. More perniciously: cargo’s interpretation of semantic versioning diverges from the normal interpretation:

The former behavior can be frustrating, but is ultimately justifiable in an ecosystem that largely respects semantic versioning: it almost always makes sense to install foo 1.2.4 instead of foo 1.2.3. When a package misbehaves (i.e., fails to follow SemVer) or this behavior simply isn’t desired for whatever reason, cargo install --locked provides an escape hatch (albeit not a default one).

The latter behavior is, in my opinion, unjustifiable: it’s inconsistent with the compatibility standards established by SemVer and otherwise respected by Cargo (and the overwhelming majority of crates in the ecosystem), and directly interferes with any attempts to use pre-releases (as well as release candidates, betas, &c.) in a stable manner in programs that ordinary users are expected to install.

The umbrella issue for this has been open since 2019, and is tracked here. Prominent projects that have had cargo install failures due to it include (in no particular order):

Wrapup and honorable(?) mentions

At the end of the day, Rust is still my preferred compiled language and development ecosystem. I see the increase in visible problems as a function of my increased familiarity with the language, not as insurmountable flaws — after all, similar problems exist in just about every language (and packaging ecosystem).

I didn’t want to bloat this post with too many grievances, so here’s a smattering of other (more minor?) things that I’ve noticed over the years:


  1. i.e. the same as Iterator::cloned or Iterator::copied, except Container<T>: ?Iterator

  2. Vec::drain() isn’t actually an alias for a consuming IntoIterator, for some reason — they appear to have completely separate types. I’m not sure why that is. 

  3. Or even something totally different! An IntoIterator could choose to produce Item = String for a Vec<u32>; there’s nothing stopping it. 

  4. i.e., dependencies and the standard/core runtime. 

  5. Itself built on dont_panic

  6. This is my favorite part of this pile of hacks: it displays the error message by embedding it as the symbol name for the unresolved function. Evil! 

  7. This is, admittedly, not a problem in many testing scenarios: if you’re building a binary, then your integration tests should be testing that binary instead of the interfaces beneath it. But there are legitimate scenarios (e.g., comparing a computed result to a constant within the private API). 

  8. For example, doing dead code detection across all integration test crates would probably be a mess and is certainly an abstraction violation. 

  9. I didn’t mention this as a separate problem, but it’s just as serious of one: it’s not clear whether SemVer should include MSRV changes, and so plenty have projects have caused transitive cargo install failures by bumping their MSRV in a minor release. 


Discussions: Reddit Twitter