#179 Jun 2026

179. Iterator::max_by_key — Find the Best Element Without a Manual Fold

Finding the “best” item in a collection — longest string, heaviest order, latest timestamp — is one of those tasks where a hand-rolled fold keeps showing up. Iterator::max_by_key does the same job in one call, and min_by_key is right there next to it.

The fold you keep writing

You have a slice of strings and want the longest one. The DIY version looks something like this:

1
2
3
4
5
6
7
8
9
let words = ["pear", "raspberry", "fig", "kiwi"];

let longest = words.iter().fold(None, |best, w| match best {
    None => Some(w),
    Some(b) if w.len() > b.len() => Some(w),
    other => other,
});

assert_eq!(longest, Some(&"raspberry"));

That’s a lot of code for a one-liner concept. max_by_key collapses the whole pattern:

1
2
3
let words = ["pear", "raspberry", "fig", "kiwi"];
let longest = words.iter().max_by_key(|w| w.len());
assert_eq!(longest, Some(&"raspberry"));

You hand it a closure that extracts the key — the thing you want to maximise — and it returns the item that produced the largest key.

Works on anything Ord

The key doesn’t have to be a number. It just has to implement Ord, which means strings, tuples, dates, your own types — anything totally ordered:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#[derive(Debug, PartialEq)]
struct Order { id: u32, total_cents: u64 }

let orders = vec![
    Order { id: 1, total_cents: 1299 },
    Order { id: 2, total_cents: 4500 },
    Order { id: 3, total_cents: 800 },
];

let biggest = orders.iter().max_by_key(|o| o.total_cents);
assert_eq!(biggest, Some(&Order { id: 2, total_cents: 4500 }));

Tuples are where this really shines — you get multi-key sorting for free, because (A, B): Ord compares lexicographically:

1
2
3
4
5
let words = ["pear", "fig", "kiwi", "lime"];

// Longest word, then alphabetically last among ties.
let pick = words.iter().max_by_key(|w| (w.len(), *w));
assert_eq!(pick, Some(&"pear"));

Same trick with min_by_key — the entire _by_key family follows the same shape.

The tie-break rule, and why it matters

When two elements produce equal keys, max_by_key returns the last one and min_by_key returns the first. That asymmetry is in the docs and it bites people:

1
2
3
4
5
6
7
let nums = [3, 1, 4, 1, 5, 9, 2, 6, 5];

let max = nums.iter().max_by_key(|&&n| n);
let min = nums.iter().min_by_key(|&&n| n);

assert_eq!(max, Some(&9));
assert_eq!(min, Some(&1)); // the first 1, not the second

If you need a specific tie-break — “longest word, but earliest in the list when tied” — encode it in the key itself with a tuple, instead of relying on iteration order.

Floats need min_by / max_by

f32 and f64 don’t implement Ord (because NaN), so max_by_key won’t compile if your key is a float. Reach for max_by and pass a comparator instead:

1
2
3
4
5
6
let measurements = [1.2_f64, 3.4, 0.5, 2.8];

let biggest = measurements.iter()
    .max_by(|a, b| a.partial_cmp(b).unwrap());

assert_eq!(biggest, Some(&3.4));

Or wrap your floats in something like ordered_float::OrderedFloat and stay with max_by_key.

Takeaway

Any time you find yourself folding to track the “best so far,” check whether max_by_key or min_by_key says it in one line. The closure extracts the score; the iterator returns the winner. For ties you control the rule by shaping the key.

#178 Jun 2026

178. Ord::clamp — Stop Writing min(max, max(min, x))

Bounding a value between two limits is one of those tiny operations where everyone hand-rolls a confusing nest of min/max calls. Ord::clamp is the single-call version, and it works on anything that’s Ord or PartialOrd — not just numbers.

You’ve seen this somewhere in every codebase:

1
2
let volume = raw.max(0).min(100);          // OK
let other  = std::cmp::min(100, std::cmp::max(0, raw)); // worse

Both work. Both make you stop and squint to figure out which bound is which. clamp says exactly what it does:

1
let volume = raw.clamp(0, 100);

It’s defined for Ord on integers, and as f32::clamp / f64::clamp for floats (which need PartialOrd because of NaN). Same shape on all of them:

1
2
3
4
5
6
assert_eq!((-5_i32).clamp(0, 10), 0);
assert_eq!(7_i32.clamp(0, 10), 7);
assert_eq!(99_i32.clamp(0, 10), 10);

assert_eq!(2.5_f64.clamp(0.0, 1.0), 1.0);
assert_eq!((-0.3_f64).clamp(0.0, 1.0), 0.0);

It’s not just for numbers. Anything Ord works — char, &str, String, your own types:

1
2
assert_eq!('z'.clamp('a', 'f'), 'f');
assert_eq!("zebra".clamp("apple", "mango"), "mango");

One gotcha: clamp panics if min > max. That’s deliberate — a backwards range is almost always a bug, and silently returning either bound would hide it. If your bounds come from user input or config, validate them once at the boundary:

1
2
3
4
5
6
fn safe_clamp(x: i32, lo: i32, hi: i32) -> i32 {
    let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) };
    x.clamp(lo, hi)
}

assert_eq!(safe_clamp(5, 10, 0), 5); // bounds got swapped, still works

For floats there’s one more wrinkle: f64::clamp propagates NaN if the input is NaN, but panics if either bound is NaN. So x.clamp(0.0, 1.0) is safe as long as your bounds are real numbers — which they always should be.

#177 Jun 2026

177. BTreeMap::range — Iterate a Sorted Map by Key Range

You have a sorted map keyed by time, port number, or version, and you want every entry between two keys. Filtering on iter() walks the whole map — BTreeMap::range walks just the slice you asked for.

The filter-everything pattern

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::collections::BTreeMap;

let mut map = BTreeMap::new();
map.insert(1, "a");
map.insert(3, "c");
map.insert(5, "e");
map.insert(7, "g");

// Walk every entry, skip the ones you don't want.
let hits: Vec<_> = map.iter().filter(|(k, _)| (3..7).contains(*k)).collect();
assert_eq!(hits, vec![(&3, &"c"), (&5, &"e")]);

Correct, but with a million entries you still traversed all of them. BTreeMap::range takes a RangeBounds and seeks straight to the start key, iterating only through the requested window:

1
2
3
4
5
6
7
use std::collections::BTreeMap;

let map: BTreeMap<i32, &str> =
    BTreeMap::from([(1, "a"), (3, "c"), (5, "e"), (7, "g")]);

let hits: Vec<_> = map.range(3..7).collect();
assert_eq!(hits, vec![(&3, &"c"), (&5, &"e")]);

Same result, log-N lookup to the first key, in-order iteration from there.

All the usual range syntax works

Any RangeBounds<K> is fair game — .., a..b, a..=b, ..b, a..:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::collections::BTreeMap;

let map: BTreeMap<i32, &str> =
    BTreeMap::from([(1, "a"), (3, "c"), (5, "e"), (7, "g")]);

let inclusive: Vec<_> = map.range(3..=7).collect();
assert_eq!(inclusive, vec![(&3, &"c"), (&5, &"e"), (&7, &"g")]);

let from_five: Vec<_> = map.range(5..).collect();
assert_eq!(from_five, vec![(&5, &"e"), (&7, &"g")]);

let up_to_five: Vec<_> = map.range(..5).collect();
assert_eq!(up_to_five, vec![(&1, &"a"), (&3, &"c")]);

For an exclusive start (rare but it happens), reach for Bound::Excluded:

1
2
3
4
5
6
7
8
use std::collections::BTreeMap;
use std::ops::Bound::{Excluded, Unbounded};

let map: BTreeMap<i32, &str> =
    BTreeMap::from([(1, "a"), (3, "c"), (5, "e"), (7, "g")]);

let after_three: Vec<_> = map.range((Excluded(3), Unbounded)).collect();
assert_eq!(after_three, vec![(&5, &"e"), (&7, &"g")]);

Mutate in place with range_mut

range_mut gives you &mut V for each entry in the window — keys stay immutable (they have to, the map’s invariant depends on them):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::collections::BTreeMap;

let mut scores: BTreeMap<i32, i32> =
    BTreeMap::from([(1, 10), (3, 10), (5, 10), (7, 10)]);

for (_, v) in scores.range_mut(3..=5) {
    *v += 1;
}

assert_eq!(scores[&1], 10);
assert_eq!(scores[&3], 11);
assert_eq!(scores[&5], 11);
assert_eq!(scores[&7], 10);

The same trick works on BTreeSet::range.

Takeaway

When you find yourself filtering a BTreeMap by a range of keys, swap the filter for range. You get O(log n) seek plus O(k) iteration over just the matches, and the code reads more like the intent.

#176 Jun 2026

176. Option::as_deref — Stop Writing .as_ref().map(|s| s.as_str())

You have an Option<String>. The function next door takes Option<&str>. The chain you keep typing — .as_ref().map(|s| s.as_str()) — has a one-method replacement.

The pattern that keeps showing up

Borrowing the inside of an Option<String> looks like this:

1
2
3
4
let owned: Option<String> = Some("hello".to_string());

// Compare against a literal.
assert_eq!(owned.as_ref().map(|s| s.as_str()), Some("hello"));

as_ref() turns Option<String> into Option<&String>, then map reaches inside to pull out &str. Two methods, one closure, all just to borrow.

Option::as_deref collapses the whole thing:

1
2
let owned: Option<String> = Some("hello".to_string());
assert_eq!(owned.as_deref(), Some("hello"));

Same result, one call, no closure.

Why it works

as_deref is defined for any Option<T> where T: Deref. It calls Deref::deref on the inner value, so you get Option<&T::Target>:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let s: Option<String> = Some("hi".to_string());
let v: Option<Vec<i32>> = Some(vec![1, 2, 3]);
let b: Option<Box<i32>> = Some(Box::new(7));

let s_ref: Option<&str> = s.as_deref();      // String -> str
let v_ref: Option<&[i32]> = v.as_deref();    // Vec<T> -> [T]
let b_ref: Option<&i32> = b.as_deref();      // Box<T> -> T

assert_eq!(s_ref, Some("hi"));
assert_eq!(v_ref, Some(&[1, 2, 3][..]));
assert_eq!(b_ref, Some(&7));

Anything that derefs works, including your own types — implement Deref and as_deref follows for free.

Where it actually saves you

The most common use is feeding a borrowed view into a function that wants the unsized form:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fn greet(name: Option<&str>) -> String {
    match name {
        Some(n) => format!("hi {n}"),
        None => "hi stranger".to_string(),
    }
}

let stored: Option<String> = Some("alice".to_string());
assert_eq!(greet(stored.as_deref()), "hi alice");

let missing: Option<String> = None;
assert_eq!(greet(missing.as_deref()), "hi stranger");

The stored value stays untouched — as_deref only borrows.

There’s also as_deref_mut for the &mut version, and Result::as_deref / as_deref_mut for the same trick on Result:

1
2
3
let r: Result<String, ()> = Ok("ok".to_string());
let borrowed: Result<&str, &()> = r.as_deref();
assert_eq!(borrowed, Ok("ok"));

Takeaway

Whenever you catch yourself typing .as_ref().map(|x| x.as_str()) or .as_ref().map(|x| &**x), reach for .as_deref(). One method, no closure, and it works on anything that derefs.

#175 Jun 2026

175. PathBuf::push — When an Absolute Argument Wipes Your Base Path

base.push(user_input) looks like string concatenation for paths. It isn’t — if user_input is absolute, the original base is gone.

The footgun

PathBuf::push reads almost like += for paths. Most of the time, it behaves that way:

1
2
3
4
5
6
use std::path::PathBuf;

let mut p = PathBuf::from("/home/alice");
p.push("docs");
p.push("notes.txt");
assert_eq!(p, PathBuf::from("/home/alice/docs/notes.txt"));

But the moment the pushed component is absolute, push throws the existing buffer away and starts over:

1
2
3
4
5
use std::path::PathBuf;

let mut p = PathBuf::from("/home/alice");
p.push("/etc/passwd");
assert_eq!(p, PathBuf::from("/etc/passwd"));

That’s not a bug. The docs spell it out: “if path is absolute, it replaces the current path.” It mirrors how cd /etc/passwd works in a shell. The catch is that when one half of push is user input, the cd-like behavior turns into a path-traversal vector.

Why this bites

The most common shape of the bug:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
use std::path::PathBuf;

fn user_file(home: &str, requested: &str) -> PathBuf {
    let mut p = PathBuf::from(home);
    p.push(requested);
    p
}

assert_eq!(
    user_file("/srv/users/alice", "avatar.png"),
    PathBuf::from("/srv/users/alice/avatar.png"),
);

// An absolute `requested` silently escapes the sandbox.
assert_eq!(
    user_file("/srv/users/alice", "/etc/passwd"),
    PathBuf::from("/etc/passwd"),
);

No panic, no error, no warning. The function just hands back a path that points somewhere else entirely.

The fix

Reject absolute components before joining. Path::is_absolute and Path::has_root are the two checks you need:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
use std::path::{Path, PathBuf};

fn safe_join(base: &Path, requested: &str) -> Option<PathBuf> {
    let segment = Path::new(requested);
    if segment.is_absolute() || segment.has_root() {
        return None;
    }
    Some(base.join(segment))
}

assert_eq!(
    safe_join(Path::new("/srv/users/alice"), "avatar.png"),
    Some(PathBuf::from("/srv/users/alice/avatar.png")),
);
assert_eq!(safe_join(Path::new("/srv/users/alice"), "/etc/passwd"), None);

has_root matters on Windows too — \windows\system32 has a root but no drive prefix, and push will replace the non-prefix portion of your buffer with it. is_absolute alone misses that case on Windows.

For full sandbox enforcement you also want to canonicalize and check the result is still under base.. components can still escape — but stopping the absolute-path case is the cheap first line of defence.

Takeaway

PathBuf::push is not string concatenation. Treat any component you didn’t write yourself as suspect and gate it through is_absolute / has_root before letting it near your buffer.

#174 May 2026

174. iter::repeat_with — Build N Fresh Things When vec![] Won't Clone

vec![Mutex::new(0); 10] — won’t compile. Mutex isn’t Clone. iter::repeat_with(|| Mutex::new(0)).take(10).collect() builds ten fresh ones instead.

The vec![x; n] trap

The vec![x; n] macro is the obvious way to build a Vec of n copies. It works by cloning x n times:

1
2
let zeros: Vec<u32> = vec![0; 5];
assert_eq!(zeros, vec![0, 0, 0, 0, 0]);

That’s fine for u32. It falls apart the moment you want something that isn’t Clone, or something where cloning gives you the wrong behavior — a counter, a channel endpoint, an Arc<Mutex<...>> you wanted to be separate locks:

1
2
3
use std::sync::Mutex;
// error[E0277]: the trait `Clone` is not implemented for `Mutex<u32>`
// let locks = vec![Mutex::new(0); 10];

The fix: iter::repeat_with

iter::repeat_with(f) takes a closure and yields f() every time the iterator is polled. Combine with take(n).collect() and you’ve got a builder that calls the closure exactly n times — no Clone required:

1
2
3
4
5
use std::iter;
use std::sync::Mutex;

let locks: Vec<Mutex<u32>> = iter::repeat_with(|| Mutex::new(0)).take(10).collect();
assert_eq!(locks.len(), 10);

Each Mutex is freshly constructed. They’re independent locks, not ten handles to the same one.

Stateful closures work too

Because the closure is FnMut, it can capture and mutate state — handy for counters, RNG-like generators, or anything where each element depends on the previous call:

1
2
3
4
5
use std::iter;

let mut n = 0;
let counts: Vec<u32> = iter::repeat_with(|| { n += 1; n }).take(5).collect();
assert_eq!(counts, vec![1, 2, 3, 4, 5]);

Try that with vec![...; 5] and you’d get five copies of the same number.

The function-pointer form

When the closure is just a constructor call, you can pass the function directly — no || needed:

1
2
3
4
5
use std::iter;

let buffers: Vec<Vec<i32>> = iter::repeat_with(Vec::new).take(3).collect();
assert_eq!(buffers.len(), 3);
assert!(buffers.iter().all(|b| b.is_empty()));

Vec::new here is a zero-argument function pointer, exactly what repeat_with wants.

When to reach for it

Any time you’d write a for loop to push n fresh items into a Vec, or any time vec![x; n] rejects you because the element isn’t Clone. It’s also the idiomatic way to seed parallel structures: repeat_with(|| Arc::new(Mutex::new(...))).take(workers).collect() gives each worker its own lock.

Available since Rust 1.28, lives in core::iter, no allocation overhead beyond the Vec itself.

#173 May 2026

173. NonZeroU32 and Friends — Encode an Invariant and Shrink Option for Free

Option<u32> is 8 bytes. Option<NonZeroU32> is 4 bytes. Same information, half the size — and the compiler enforces “this can’t be zero” for you.

The problem

You have an ID, a port number, or a child-process exit code. It’s logically a u32, but 0 is meaningless or sentinel-only. So you reach for Option<u32>:

1
struct Handle { id: Option<u32> }

That’s now 8 bytes: 4 for the u32 and 4 more for the discriminant telling you which variant you’re in. Even worse, “no ID yet” and “ID is zero” are two distinct states the compiler can’t tell apart for you.

The fix: NonZero*

std::num::NonZeroU32 (and its siblings NonZeroI64, NonZeroUsize, etc.) is a u32 that’s guaranteed at the type level to never be zero. Constructors return Option so you can’t accidentally build an invalid one:

1
2
3
4
5
use std::num::NonZeroU32;

let port = NonZeroU32::new(8080).expect("non-zero");
assert_eq!(port.get(), 8080);
assert!(NonZeroU32::new(0).is_none());

Because zero is now “impossible,” the compiler reuses that bit pattern as the None discriminant — this is the niche optimization:

1
2
3
4
5
6
7
use std::mem::size_of;
use std::num::NonZeroU32;

assert_eq!(size_of::<u32>(), 4);
assert_eq!(size_of::<Option<u32>>(), 8);          // discriminant + payload
assert_eq!(size_of::<NonZeroU32>(), 4);
assert_eq!(size_of::<Option<NonZeroU32>>(), 4);   // free!

Wrap it in a newtype

The real win is making invalid states unrepresentable. Stop passing raw u32 around and use a newtype:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::num::NonZeroU32;

#[derive(Copy, Clone, Debug)]
struct UserId(NonZeroU32);

impl UserId {
    fn new(raw: u32) -> Option<Self> {
        NonZeroU32::new(raw).map(UserId)
    }
    fn get(self) -> u32 {
        self.0.get()
    }
}

let alice = UserId::new(42).unwrap();
assert!(UserId::new(0).is_none());
assert_eq!(alice.get(), 42);

Now Option<UserId> is 4 bytes, Vec<Option<UserId>> is half the memory it would otherwise be, and “this u32 is a real user id, not a placeholder” is checked at construction, not at every call site.

When to reach for it

Any u32/u64/usize where zero is invalid: database row IDs, file descriptors, generation counters, capacity-like values, lengths that must be at least one. The generic NonZero<T> form also exists for cleaner code: NonZero<u32> reads the same and works in const contexts since Rust 1.79.

172. #[track_caller] — Point the Panic at the Caller, Not Your Helper

You wrap an assert in a helper to clean up your tests. Now every failure points at the helper’s source line instead of the test that called it. #[track_caller] fixes that with a single line of code.

The problem: panics blame the helper

Say you’ve factored out a custom check used across many tests:

1
2
3
4
5
6
7
8
fn assert_positive(x: i32) {
    assert!(x > 0, "expected positive, got {x}");
}

#[test]
fn it_works() {
    assert_positive(-3); // panics
}

The panic message looks like this:

1
2
thread 'it_works' panicked at src/lib.rs:2:5:
expected positive, got -3

src/lib.rs:2 is the line inside assert_positive. Every test that uses this helper points at the same spot. Useless.

The fix: one attribute

Put #[track_caller] on the helper and the reported location becomes whichever call site invoked it:

1
2
3
4
5
6
7
8
9
#[track_caller]
fn assert_positive(x: i32) {
    assert!(x > 0, "expected positive, got {x}");
}

#[test]
fn it_works() {
    assert_positive(-3); // panics, blames THIS line
}

Now the panic points at the test’s call, exactly like a built-in assert! does. That’s because assert!, unwrap, expect, Vec::index, and friends are all themselves #[track_caller].

How it works

The attribute makes the compiler thread the caller’s Location through the function. You can grab it explicitly with core::panic::Location::caller():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::panic::Location;

#[track_caller]
fn where_am_i() -> &'static Location<'static> {
    Location::caller()
}

fn main() {
    let loc = where_am_i();
    assert_eq!(loc.line(), 11); // the call site, not the fn body
}

The attribute propagates through wrappers — mark every layer between the panic and the public API, otherwise the chain breaks at the first un-annotated function and the location resets to that frame.

When to reach for it

Any time you wrap panic!, assert!, unwrap, or expect behind a helper that callers will treat as a primitive: test assertions, domain-specific unwraps, invariant checks. The cost is zero at runtime in optimized builds — the location is baked in at compile time.

171. assert_matches! — A Test Failure That Actually Tells You What Went Wrong

assert!(matches!(x, Foo::Bar)) panics with assertion failed: matches!(x, Foo::Bar) and zero hint about what x actually was. Rust 1.96 stabilises assert_matches!, which prints the offending value for you.

The old way leaves you guessing

The classic assert!(matches!(...)) combo has been in tests forever, but its failure message is useless:

1
2
3
4
5
6
#[derive(Debug)]
enum Status { Ok, Pending, Failed(u32) }

let s = Status::Failed(42);
assert!(matches!(s, Status::Ok));
// panic: assertion failed: matches!(s, Status :: Ok)

When this fires in CI, you get the pattern back but not the value. Was it Pending? Failed? With what code? You either rerun with dbg! or eyeball the test setup.

assert_matches! includes the value

assert_matches! lives in core (and std) as of 1.96. It checks the same way, but on failure it prints the Debug representation of what you handed it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::assert_matches::assert_matches;

#[derive(Debug)]
enum Status { Ok, Pending, Failed(u32) }

let s = Status::Failed(42);
assert_matches!(s, Status::Ok);
// panic: assertion `left matches right` failed
//   left: Failed(42)
//  right: Status::Ok

Same one-liner, real diagnostic. No extra dbg!, no rerun.

Pattern guards still work

Because it’s a real pattern position, you get bindings and guards too — useful when you want “some variant with a value in a range”:

1
2
3
4
use std::assert_matches::assert_matches;

let n: Result<i32, &str> = Ok(7);
assert_matches!(n, Ok(x) if x > 0);

The guard is checked just like in a match arm, and the panic message still shows you the value if it fails.

Heads up: not in the prelude

Unlike assert! and assert_eq!, assert_matches! is not in the prelude — too many third-party crates (notably the assert_matches crate) already export the same name. Import it explicitly:

1
2
3
use std::assert_matches::assert_matches;
// or, in no_std:
// use core::assert_matches::assert_matches;

There’s also debug_assert_matches! for the same trick that compiles away in release builds.

Stabilised in Rust 1.96 (May 2026). Delete one third-party dependency from your dev-dependencies today.

#170 May 2026

170. Ordering::then_with — Chain Comparators for Multi-Key Sorts

Sorting by name, then by age, then by id ends in a nested if a == b { ... } ladder. Ordering::then_with flattens the whole thing into one expression.

The pain: a manual tie-break ladder grows ugly fast.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
items.sort_by(|a, b| {
    let ord = a.last.cmp(&b.last);
    if ord != std::cmp::Ordering::Equal {
        ord
    } else {
        let ord = a.first.cmp(&b.first);
        if ord != std::cmp::Ordering::Equal {
            ord
        } else {
            a.id.cmp(&b.id)
        }
    }
});

Ordering has two combinators built for exactly this:

  • then(other) — eager: returns self if it’s not Equal, otherwise other.
  • then_with(|| other) — lazy: only computes the next comparison if the previous was Equal.

Prefer then_with whenever the next cmp does real work (string comparison, derived keys), so you don’t pay for it on every pair.

1
2
3
4
5
items.sort_by(|a, b| {
    a.last.cmp(&b.last)
        .then_with(|| a.first.cmp(&b.first))
        .then_with(|| a.id.cmp(&b.id))
});

Reads top-to-bottom in priority order, no nesting, no early-return. Mixing ascending and descending is a one-character change — wrap a field in Reverse:

1
2
3
4
5
6
7
8
use std::cmp::Reverse;

// Last name ascending, then age DESCENDING, then id ascending.
items.sort_by(|a, b| {
    a.last.cmp(&b.last)
        .then_with(|| Reverse(a.age).cmp(&Reverse(b.age)))
        .then_with(|| a.id.cmp(&b.id))
});

The same pattern works in any Ord/PartialOrd impl — then_with is how a hand-written cmp stays readable.