Rust Life Improvement
This is the companion blog post to my eponymous Rustikon talk. The video recording and slides are also available now.
As is my tradition, I started with a musical number, this time replacing the lyrics to Deep Blue Something’s “Breakfast at Tiffany”, inspired by some recent discussions I got into:
You say that Rust is like a religion
the community is toxic
and you rather stay apart.
You say that C can be coded safely
that it is just a skill issue
still I know you just don’t care.
R: And I say “what about mem’ry unsafety?”
You say “I think I read something about it
and I recall I think that hackers quite like it”
And I say “well that’s one thing you got!”
In C you are tasked with managing mem’ry
no help from the compiler
there’s so much that can go wrong?
So what now? The hackers are all over
your systems, running over
with CVEs galore.
R: And I say “what about…”
You say that Rust is a woke mind virus,
rustaceans are all queer furries
and you rather stay apart.
You say that C can be coded safely
that one just has to get gud
still I know you just don’t care.
Beta Channel
I started out the talk by encouraging people who use Rustup to try the Beta channel. Unlike stable, one can get six weeks of performance improvements and despite thirty-five point-releases since 1.0.0, most of those didn’t fix issues that many people happened upon.
Even when one wants to be sure to only update once the first point release is likely to be out, the median release appeared roughly two weeks after the point-zero one it was based on. Besides, if more people test the beta channel, its quality is also likely to improve. It’s a win-win situation.
Cargo
Cargo has a number of tricks up its sleeve that not everyone knows (so if you already do, feel free to skip ahead). E.g. there are a number of shortcuts:
$ cargo b # build
$ cargo c # check
$ cargo d # doc
$ cargo d --open # opens docs in browser
$ cargo t # test
$ cargo r # run
$ cargo rm $CRATE # remove
besides, if one has rust programs in the examples/
subfolder, one can run
them using cargo r --example <name>
.
I also noted that cargo can strip release binaries (but doesn’t by default),
if you add the following to your project’s Cargo.toml
:
[profile.release]
strip=true
Cargo: Configuration
Cargo not only looks for the Cargo.toml
manifests, it also has its own
project- or user-wide configuration:
- project-wide:
.cargo/config.toml
- user-wide, UNIX-like (Linux, MacOS, etc.):
~/.cargo/config.toml
- user-wide, Windows:
%USERPROFILE%\.cargo\config.toml
The user configuration can be helpful to…
Add more shortcuts:
[alias]
c = "clippy"
do = "doc --open"
ex = "run --example"
rr = "run --release"
bl = "bless"
s = "semver-checks"
Have Rust compile code for the CPU in your computer (which lets the compiler use all its bells and whistles that normally are off limits in case you give that executable to a friend):
[build]
rustflags = ["-C", "target-cpu=native"]
Have Rust compile your code into a zero-install relocatable static binary
[build]
rustflags = ["-C", "target-feature=+crt-static"]
Use a shared target
folder for all your Rust projects (This is very useful if
you have multiple Rust projects with some overlap in dependencies, because build
artifacts will be shared across projects, so they will only need to be compiled
once, conserving both compile time & disk space):
[build]
target = "/home/<user>/.cargo/target"
Configure active lints for your project(s):
[lints.rust]
missing_docs = "deny"
unsafe_code = "forbid"
[lints.clippy]
dbg_macro = "warn"
There are sections for both Rust and Clippy. Speaking of which:
Clippy
This section has a few allow by default lints to try:
missing_panics_doc
, missing_errors_doc
, missing_safety_doc
If you have a function that looks like this:
pub unsafe fn unsafe_panicky_result(foo: Foo) -> Result<Bar, Error> {
match unsafe { frobnicate(&foo) } {
Foo::Amajig(bar) => Ok(bar),
Foo::Fighters(_) => panic!("at the concert");
Foo::FieFoFum => Err(Error::GiantApproaching),
}
}`
The lints will require # Errors
, # Panics
and # Safety
sections in the
function docs, respectively:
/// # Errors
/// This function returns a `GiantApproaching` error on detecting giant noises
///
/// # Panics
/// The function might panic when called at a Foo Fighters concert
///
/// # Safety
/// Callers must uphold [`frobnicate`]'s invariants'
There’s also an unnecessary_safety_doc
lint that warns on # Safety
sections
in docs of safe functions (which is likely a remnant of an unsafe function
being made safe without removing the section from the docs, which might mislead
users):
/// # Safety
///
/// This function is actually completely safe`
pub fn actually_safe_fn() { todo!() }
The multiple_crate_versions
will look at your dependencies and see if you
have multiple versions of a dependency there. For example, if you have the
following dependencies:
- mycrate 0.1.0
- rand 0.9.0
- quickcheck 1.0.0
- rand 0.8.0
The rand
crate will be compiled twice. Of course, that’s totally ok in many
cases (especially if you know that your dependencies will catch up soon-ish),
but if have bigger dependencies, your compile time may suffer. Worse, you may
end up with incompatible types, as a type from one version of a crate isn’t
necessarily compatible with the same type from another version.
So if you have long compile times, or face error messages where a type seems to be not equal to itself, this lint may help you improve things.
The non_std_lazy_statics
lint will help you to update your code if you still
have a dependency on lazy_static
or once_cell
for functionality that has
been pulled into std
. For example:
// old school lazy statics
lazy_static! { static ref FOO: Foo = Foo::new(); }
static BAR: once_cell::sync::Lazy<Foo> = once_cell::sync::Lazy::new(Foo::new);
// now in the standard library
static BAZ: std::sync::LazyLock<Foo> = std::sync::LazyLock::new(Foo::new);
The ref_option
and ref_option_ref
lints should help you avoid using
references on options as function arguments. Since an Option<&T>
is the same
size as an &Option<T>
, it’s a good idea to use the former to avoid the double
reference.
fn foo(opt_bar: &Option<Bar>) { todo!() }
fn bar(foo: &Foo) -> &Option<&Bar> { todo!() }
// use instead
fn foo(opt_bar: Option<&Bar>) { todo!() }
fn bar(foo: &Foo) -> Option<&Bar> { todo!() }
The same_name_method
lint helps you avoid any ambiguities with would later
require a turbo fish to resolve.
struct I;
impl I {
fn into_iter(self) -> Iter { Iter }
}
impl IntoIterator for I {
fn into_iter(self) -> Iter { Iter }
// ...
}
The fn_params_excessive_bools
lint will warn if you use multiple bools as
arguments in your methods. Those can easily be confused, leading to logic
errors.
fn frobnicate(is_foo: bool, is_bar: bool) { ... }
// use types to avoid confusion
enum Fooish {
Foo
NotFoo
}
Clippy looks for a clippy.toml
configuration file you may want to use in your
project:
# for non-library or unstable API projects
avoid-breaking-exported-api = false
# let's allow even less bools
max-fn-params-bools = 2
# allow certain things in tests
# (if you activate the restriction lints)
allow-dbg-in-tests = true
allow-expect-in-tests = true
allow-indexing-slicing-in-tests = true
allow-panic-in-tests = true
allow-unwrap-in-tests = true
allow-print-in-tests = true
allow-useless-vec-in-tests = true
Cargo-Semver-Checks
If you have a library, please use cargo semver-checks before cutting a release.
$ cargo semver-checks
Building optional v0.5.0 (current)
Built [ 1.586s] (current)
Parsing optional v0.5.0 (current)
Parsed [ 0.004s] (current)
Building optional v0.5.0 (baseline)
Built [ 0.306s] (baseline)
Parsing optional v0.5.0 (baseline)
Parsed [ 0.003s] (baseline)
Checking optional v0.5.0 -> v0.5.0 (no change)
Checked [ 0.005s] 148 checks: 148 pass, 0 skip
Summary no semver update required
Finished [ 10.641s] optional
Your users will be glad you did.
Cargo test
First, doctests are fast now (apart from compile_fail
ones), so if you
avoided them to keep your turnaround time low, you may want to reconsider.
Also if you have a binary crate, you can still use #[test]' by converting
your crate to a mixed crate. Put this in your
Cargo.toml`:
[lib]
name = "my_lib"
path = "src/lib.rs"
[[bin]]
name = "my_bin"
path = "src/main.rs"
Now you can test all items you have in lib.rs
and any and all modules
reachable from there.
Insta
Insta is a crate to do snapshot tests. That means it will use the debug representation or a serialization in JSON or YAML to create a “snapshot” once, then complain if the snapshot has changed. This removes the need to come up with known good values, since your tests will create them for you.
#[test]
fn snapshot_test() {
insta::assert_debug_snapshot!(my_function());
}
insta has a few tricks up its sleeve to deal with uncertainty arising from indeterminism. You can redact the output to e.g. mask randomly chosen IDs:
#[test]
fn redacted_snapshot_test() {
insta::assert_json_snapshot!(
my_function(),
{ ".id" => "[id]" }
);
}
The replacement can also be a function. I have used this with a
Mutex<HashMap<..>>
in the past to replace random IDs with sequence numbers
to ensure that equal IDs stay equal while ignoring their randomness.
Cargo Mutants
Mutation testing is a cool technique where you change your code to check your
tests. It will apply certain changes (for example replacing a +
with a -
or returning a default value instead of the function result) to your code and
see if tests fail. Those changes are called mutations (or sometimes mutants)
and if they don’t fail any tests, they are deemed “alive”.
I wrote a bit about that technique in the past and even wrote a tool to do
that as a proc macro. Unfortunately, it used specialization and as such was
nightly only, so nowadays I recommend cargo-mutants
. A typical run might look
like this:
$ cargo mutants
Found 309 mutants to test
ok Unmutated baseline in 3.0s build + 2.1s test
INFO Auto-set test timeout to 20s
MISSED src/lib.rs:1448:9: replace <impl Deserialize for Optioned<T>>::deserialize -> Result<Optioned<T>,
D::Error> with Ok(Optioned::from_iter([Default::default()])) in 0.3s build + 2.1s test
MISSED src/lib.rs:1425:9: replace <impl Hash for Optioned<T>>::hash with () in 0.3s build + 2.1s test
MISSED src/lib.rs:1202:9: replace <impl OptEq for u64>::opt_eq -> bool with false in 0.3s build + 2.1s test
TIMEOUT src/lib.rs:972:9: replace <impl From for Option<bool>>::from -> Option<bool> with Some(false) in 0.4s
build + 20.0s test
MISSED src/lib.rs:1139:9: replace <impl Noned for isize>::get_none -> isize with 0 in 0.4s build + 2.3s test
MISSED src/lib.rs:1228:14: replace == with != in <impl OptEq for i64>::opt_eq in 0.3s build + 2.1s test
MISSED src/lib.rs:1218:9: replace <impl OptEq for i16>::opt_eq -> bool with false in 0.3s build + 2.1s test
MISSED src/lib.rs:1248:9: replace <impl OptEq for f64>::opt_eq -> bool with true in 0.4s build + 2.1s test
MISSED src/lib.rs:1239:9: replace <impl OptEq for f32>::opt_eq -> bool with false in 0.4s build + 2.1s test
...
309 mutants tested in 9m 26s: 69 missed, 122 caught, 112 unviable, 6 timeouts
Unlike code coverage, mutation testing not only finds which code is run by your tests, but which code is actually tested against changes – at least as far as they can be automatically applied.
Also mutation testing can give you the information which tests cover what possible mutations, so you sometimes can remove some tests, making your test suite leaner and faster.
rust-analyzer
I just gave a few settings that may improve your experience:
# need to install the rust-src component with rustup
rust-analyzer.rustc.source = "discover"
# on auto-import, prefer importing from `prelude`
rust-analyzer.imports.preferPrelude = true
# don't look at references from tests
rust-analyzer.references.excludeTests = true
cargo sweep
If you are like me, you can get a very large target/
folder.
cargo sweep
will remove outdated build artifacts:
$ cargo sweep --time 14 # remove build artifacts older than 2 weeks
$ cargo sweep --installed # remove build artifacts from old rustcs
Pro Tip: Add a cronjob (for example every Friday on 10 AM):
0 10 * * fri sh -c "rustup update && cargo sweep --installed"
cargo wizard
This is a subcommand that will give you a TUI to configure your project, giving you a suitable Cargo.toml etc.
cargo pgo
Profile-guided optimization is a great technique to eke out that last bit of performance without needing any code changes. I didn’t go into detail on it because Aliaksandr Zaitsau did a whole talk on it and I wanted to avoid the duplication.
cargo component
This tool will allow you to run your code locally under a WASM runtime.
- Run your code in
wasm32-wasip1
(or later) - the typical subcommands (
test
,run
, etc.) work as usual - can use a target runner:
[target.wasm32-wasip1]
runner = ["wasmtime", "--dir=."]
The argument is used to allow accessing the current directory (because by default the runtime will disallow all file access). You can of course also use different directories there.
bacon
compiles and runs tests on changes
great to have in a sidebar terminal
‘nuff said.
Language: Pattern matching
Rust patterns are super powerful. You can
- destructure tuples and slices
- match integer and char ranges
- or-combine patterns with the pipe symbol, even within other patterns (note that the bindings need to have the same types). You can even use a pipe at the start of your pattern to get a nice vertical line in your code (see below)
- use guard clauses within patterns (
pattern if guard(pattern) => arm
)
match (foo, bar) {
(1, [a, b, ..]) => todo!(),
(2 ..= 4, x) if predicate(x) => frobnicate(x),
(5..8, _) => todo!(),
_ => ()
}
if let Some(1 | 23) | None = x { todo!() }
match foo {
| Foo::Bar
| Foo::Baz(Baz::Blorp | Baz::Blapp)
| Foo::Boing(_)
| Foo::Blammo(..) => todo!(),
_ => ()
}
matches!(foo, Foo::Bar)
Also patterns may appear in surprising places: Arguments in function signatures are patterns, too – and so are closure arguments:
fn frobnicate(Bar { baz, blorp }: Bar) {
let closure = |Blorp(flip, flop)| blorp(flip, flop);
}
What’s more, patterns can be used in let
and in plain assignments:
let (a, mut b, mut x) = (c, d, z);
let Some((e, f)) = foo else { return; };
(b, x) = (e, f);
As you can see, with plain let
and assignment, you need an irrefutable
pattern (that must always match by definition), otherwise you can do
let-else
.
Language: Annotations
use #[expect(..)]
instead of #[allow(..)]
, because it will warn if the
code in question is no longer linted (either because the code or clippy
changed), so the #[allow(..)]
will just linger.
#[expect(clippy::collapsible_if)
fn foo(b: bool, c: u8) [
if b {
if c < 25 {
todo!();
}
}
}
Add #[must_use]
judiciously on library APIs to help your users avoid
mistakes. There’s even a pedantic clippy::must_use_candidates
lint that you
can auto-apply to help you do it.
You can also annotate types that should always be used when returned from functions.
#[must_use]
fn we_care_for_the_result() -> Foo { todo!() }
#[must_use]
enum MyResult<T> { Ok(T), Err(crate::Error), SomethingElse }
we_care_for_the_result(); // Err: unused_must_use
returns_my_result(); // Err: unused_must_use
Traits sometimes need special handling. Tell your users what to do:
#[diagnostic::on_unimplemented(
message = "Don't `impl Fooable<{T}>` directly, `#[derive(Bar)]` on `{Self}` instead",
label = "This is the {Self}"
note = "additional context"
)]
trait Fooable<T> { .. }
Sometimes, you want internals to stay out of the compiler’s error messages:
#[diagnostic::do_not_recommend]
impl Fooable for FooInner { .. }
library: Box::leak
For &'static
, once-initialized things that don’t need to be drop
ped
let config: &'static Configuration = Box::leak(create_config());
main_entry_point(config);
The End?
That’s all I could fit in my talk, so thanks for reading this far.