Hashing

#305 Aug 2026

305. to_bits — Hash and Dedup Floats Without a Wrapper Crate

HashSet<f64> doesn’t compile — floats aren’t Hash or Eq. f64::to_bits turns each float into its exact u64 bit pattern, which is both.

Try to dedup a list of readings and the compiler stops you at the door:

1
2
3
4
5
use std::collections::HashSet;

// error[E0277]: the trait bound `f64: Eq`
// is not satisfied
// let seen: HashSet<f64> = HashSet::new();

f64 can’t be Eq because NaN != NaN, and it can’t be Hash because hashing requires consistent equality. The usual escape hatch is a wrapper crate like ordered-float — but for keying and dedup, std already has what you need:

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

let readings: [f64; 4] =
    [0.1 + 0.2, 0.3, 0.15, 0.3];

let mut seen = HashSet::new();
let uniq: Vec<f64> = readings
    .into_iter()
    .filter(|x| seen.insert(x.to_bits()))
    .collect();

// 0.1 + 0.2 is not bit-equal to 0.3
assert_eq!(uniq.len(), 3);

to_bits is a transmute, not a cast: (1.0f64).to_bits() is 4607182418800017408, not 1. Every distinct float maps to a distinct u64, and f64::from_bits round-trips it losslessly:

1
2
let x: f64 = 0.1 + 0.2;
assert_eq!(f64::from_bits(x.to_bits()), x);

Two things bit-equality changes, both usually what you want for keys:

  • NaN becomes equal to itself (same payload, same bits), so a NaN key is stored once instead of leaking in forever.
  • 0.0 and -0.0 compare == as floats but have different bit patterns, so they count as two keys.

Note the first assert above: 0.1 + 0.2 stays in the set alongside 0.3 because they really are different floats. to_bits doesn’t paper over float imprecision — it makes it visible. If you want tolerance-based grouping, that’s a different tool; for exact identity — memoization keys, dedup, caching — the bit pattern is the honest answer.