When working with numeric types in programming, we generally assume that numbers behave in ways that are predictable and consistent. For instance, integers and rational numbers follow clear mathematical rules: if a == b, then b == a, and if a == b and b == c, then a == c. This property, known as equivalence (a reflexive, symmetric, and transitive relation), is fundamental to how we compare numbers.
However, floating-point numbers (e.g., f32 and f64 in Rust) behave differently. They follow a different set of rules that are not always intuitive, especially when dealing with certain edge cases. In Rust, this difference is encoded in the type system through the distinction between the PartialEq and Eq traits.
Partial Equivalence in Floating-Point Numbers
Floating-point numbers, which are typically used to represent real numbers, can represent certain values that have unintuitive or non-standard behaviors. The most prominent of these are NaN (Not a Number), positive and negative infinities, and signed zeroes (+0.0 and -0.0). These special cases do not adhere to the same mathematical properties as normal numbers, leading to behavior that is quite different from integer or fixed-point arithmetic.
To understand this better, consider the value NaN. According to the IEEE 754 floating-point standard (which Rust’s f32 and f64 types follow), NaN is not equal to any value, including itself. In other words:
let x: f32 = std::f32::NAN;
assert!(x != x); // This will pass, because NaN is not equal to itself.
This violates the property of reflexivity, which is one of the key rules of equivalence. Reflexivity states that every value must be equal to itself (a == a). For floating-point numbers, this property is violated because of NaN, making the comparison between floating-point numbers partially valid, not fully.
PartialEq vs Eq in Rust
Rust’s type system encodes this behavior by making a distinction between partial equivalence and full equivalence. The PartialEq trait is implemented for types where equivalence is only partial—meaning that certain values do not behave as expected when compared. In contrast, the Eq trait is for types that have full equivalence properties.
Here’s the distinction:
- PartialEq: Allows for partial comparison where equivalence might not always hold. This trait is used by types like
f32andf64, where comparisons can fail to meet the full requirements of an equivalence relation (e.g., NaN). - Eq: Requires full equivalence, meaning that the type satisfies reflexivity, symmetry, and transitivity in all cases. Types like integers (
i32,u64, etc.) implement this trait because they always behave as expected in comparisons.
In Rust, floating-point types only implement the PartialEq trait, not the Eq trait. This is because the semantics of floating-point numbers, especially with NaN and signed zeroes, cannot guarantee full equivalence. Here’s a comparison:
// Integer comparison
let a: i32 = 5;
let b: i32 = 5;
assert!(a == b); // Reflexive, symmetric, and transitive. Eq is implemented.
// Floating-point comparison
let x: f32 = 5.0;
let y: f32 = 5.0;
assert!(x == y); // Seems fine for normal numbers...
let nan: f32 = std::f32::NAN;
assert!(nan != nan); // ...but NaN breaks equivalence. Only PartialEq is implemented.
This distinction is more than just a theoretical concept. Rust uses this trait system to enforce safe and predictable behavior, ensuring that certain comparisons between floating-point values are treated with caution. You cannot, for instance, expect HashSet or HashMap to work with f32 or f64 as keys without a workaround because they require types to implement Eq (and Hash), which floating-point numbers do not.
The Design of Floating-Point Types in Rust
The choice to only implement PartialEq for f32 and f64 is by design, and it reflects the realities of floating-point arithmetic. Because floating-point numbers can represent edge cases like NaN and infinities, they do not always obey the rules of mathematical equivalence. This is a known limitation of floating-point types across all programming languages that follow the IEEE 754 standard, not just Rust.
In contrast, integers and other numeric types (like i32, u64, etc.) implement both PartialEq and Eq because they do not have the same edge cases that violate reflexivity. All integers are equal to themselves, and comparisons between them behave as expected.
Handling Floating-Point Comparisons in Rust
When dealing with floating-point numbers in Rust, it’s important to be aware of these nuances. For most applications, standard comparisons will work fine, but if you’re working with values that could be NaN or infinities, you’ll need to take special care. There are a few techniques that can help:
- Checking for NaN: Rust provides methods like
is_nan()to check if a value is NaN before performing comparisons.
let x: f32 = std::f32::NAN; if x.is_nan() { println!("Value is NaN");
2. Using Ordered Comparisons: If you want to compare floating-point numbers and ignore NaN, Rust provides the total_cmp() method that gives a total ordering, treating NaN as a special case.
let a: f32 = 3.14;
let b: f32 = std::f32::NAN;
assert!(a.total_cmp(&b) != std::cmp::Ordering::Equal); // NaN is treated separately.
3. Working Around Eq and Hash: When you need to use floating-point types in collections that require Eq and Hash (like HashMap), you may need to wrap or convert the floats into types that can safely handle equality, such as through a newtype or specialized library crates.
Best Practices for Working with Floating-Point Numbers
Given the nuanced behavior of floating-point numbers, particularly in Rust, it’s important to follow best practices to ensure your code behaves as expected when dealing with floating-point comparisons and operations. Here are a few strategies to keep in mind:
1. Be Aware of NaN Propagation
NaN values can propagate through calculations in unexpected ways, and they can silently render an entire computation invalid. For instance, any arithmetic operation involving NaN will result in NaN:
let a: f32 = 5.0;
let b: f32 = std::f32::NAN;
let c = a + b;
assert!(c.is_nan()); // Any operation with NaN results in NaN
Always check for NaN when it’s possible that invalid input or results might produce it.
2. Use is_nan() to Handle NaN Comparisons Explicitly
Since NaN is not equal to itself, a simple equality check will not suffice to detect NaN values. You should use the is_nan() method, provided by Rust, to explicitly check for NaN values:
let x: f32 = std::f32::NAN;
if x.is_nan() {
println!("The value is NaN.");
}
3. Use total_cmp() for Total Ordering of Floats
Rust provides the total_cmp() method on floating-point numbers for cases where you need to perform comparisons that treat NaN in a well-defined manner. This method gives a total ordering by considering all values, including NaN and infinities.
let a: f32 = 3.14;
let b: f32 = std::f32::NAN;
assert!(a.total_cmp(&b) != std::cmp::Ordering::Equal); // Uses total ordering
The total_cmp() method ensures that comparisons between floating-point numbers always produce an ordering, even when NaN or infinities are involved, making it useful for cases where predictable sorting or ordering is needed.
4. Avoid Using Floats as Keys in HashMaps
Since floating-point types do not implement Eq, they cannot be used directly as keys in collections like HashMap or HashSet. These collections require types that implement both Eq and Hash. If you need to use floats as keys, consider:
- Using Wrappers: Wrapping the floating-point type in a newtype or specialized wrapper that handles comparison in a way that fits your use case.
- Using Crates: There are crates available, like
ordered-float, that provideEqandOrdimplementations for floating-point types by internally usingtotal_cmp()for comparisons. This allows floating-point numbers to be used safely in hash-based collections.


