sort_by_key sounds like it computes the key once per element. It doesn’t — it calls your closure at every comparison, so an n-element sort can pay for that key O(n log n) times. If the key is expensive, sort_by_cached_key is the fix you’ve been looking for.
The trap
The signature reads nicely: “sort by this key.” The implementation, less so — the closure fires on every comparison, not once per element:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| use std::cell::Cell;
let mut items = vec!["banana", "fig", "apple", "cherry", "date"];
let calls = Cell::new(0);
items.sort_by_key(|s| {
calls.set(calls.get() + 1);
// Pretend this is a heavy computation: allocating, hashing,
// parsing, calling a regex, opening a file, etc.
s.to_string()
});
// 5 elements, but the key ran way more than 5 times.
assert!(calls.get() > items.len());
assert_eq!(items, ["apple", "banana", "cherry", "date", "fig"]);
|
For identity keys that cost a pointer-deref, nobody cares. For anything that allocates — .to_string(), .to_lowercase(), format!(...), a regex capture, a trimmed-and-lowered filename — the cost compounds quickly. I’ve seen a profile where 80% of total runtime was the key closure being called 40,000 times to sort 2,000 items.
The fix
slice::sort_by_cached_key runs your closure exactly once per element, stashes the results in a scratch buffer, then sorts against the cache. This is the Schwartzian transform, wrapped up in a method call:
1
2
3
4
5
6
7
8
9
10
11
12
13
| use std::cell::Cell;
let mut items = vec!["banana", "fig", "apple", "cherry", "date"];
let calls = Cell::new(0);
items.sort_by_cached_key(|s| {
calls.set(calls.get() + 1);
s.to_string()
});
// Exactly one call per element — no matter how big the slice is.
assert_eq!(calls.get(), items.len());
assert_eq!(items, ["apple", "banana", "cherry", "date", "fig"]);
|
Same result, linear key-function calls. The memory trade is a Vec<(K, usize)> the size of the slice — cheap next to the cost of re-running an allocating closure on every compare.
When to reach for which
The rule is about where your time goes, not how fancy the key looks:
1
2
3
4
5
6
7
8
9
10
| let mut nums = vec![5u32, 2, 8, 1, 9, 3];
// Trivial key: sort_by_key is fine (and avoids the scratch alloc).
nums.sort_by_key(|n| *n);
assert_eq!(nums, [1, 2, 3, 5, 8, 9]);
// Expensive key: sort_by_cached_key wins.
let mut files = vec!["Cargo.TOML", "src/MAIN.rs", "README.md", "build.RS"];
files.sort_by_cached_key(|path| path.to_lowercase());
assert_eq!(files, ["build.RS", "Cargo.TOML", "README.md", "src/MAIN.rs"]);
|
Use sort_by_key for cheap, Copy-ish keys. Use sort_by_cached_key the moment your closure allocates, hashes, parses, or otherwise does real work — it’s the difference between O(n log n) and O(n) calls to that closure.