Understanding Partial Equivalence in Rust’s Floating-Point Types

When working with numeric types in programming, we generally assume that numbers behave in ways that are predictable and consistent. For…

Understanding Partial Equivalence in Rust’s Floating-Point Types

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 f32 and f64, 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:

  1. 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 provide Eq and Ord implementations for floating-point types by internally using total_cmp() for comparisons. This allows floating-point numbers to be used safely in hash-based collections.
use ordered_float::OrderedFloat; 
use std::collections::HashMap; 
 
let mut map: HashMap<OrderedFloat<f32>, &str> = HashMap::new(); 
map.insert(OrderedFloat(3.14), "pi");

In this example, OrderedFloat wraps around the f32 type and provides the necessary implementations for Eq, allowing it to be used as a key in HashMap.

5. Be Mindful of Precision and Rounding

Floating-point arithmetic is prone to rounding errors and loss of precision. Always be aware of the limitations of f32 and f64 when working with very large or very small numbers, as precision can degrade. For example:

let x: f32 = 1.0 / 3.0; 
println!("{}", x); // This will not print an exact value of 1/3

When precision is critical, consider alternative types such as arbitrary-precision libraries like num-bigfloat or fixed-point arithmetic libraries.

6. Careful Comparison of Floating-Point Numbers

Direct equality comparisons (==) between floating-point numbers can be unreliable due to precision issues. Instead of comparing for exact equality, it's often better to compare whether two floating-point numbers are close to each other within some small tolerance (epsilon):

let a: f32 = 0.1 + 0.2; 
let b: f32 = 0.3; 
let epsilon: f32 = 1e-7; 
 
if (a - b).abs() < epsilon { 
    println!("a and b are approximately equal."); 
}

This method helps avoid problems due to small rounding errors that are inherent in floating-point calculations.

Tolerances in Floating-Point Comparisons in Rust

Rust introduces a concept of tolerances to handle comparisons between floating-point values, accounting for potential precision errors. These tolerances are defined as constants, f32::EPSILON and f64::EPSILON, which represent the smallest difference between two distinct floating-point numbers. This approach is particularly useful when dealing with the inherent limitations of floating-point arithmetic, where small rounding errors can occur.

When comparing floating-point numbers, directly checking for equality (==) can lead to unexpected results due to these tiny inaccuracies. Instead, Rust allows you to compare values by considering whether the difference between them is smaller than the defined epsilon. This method ensures that the comparison is more reliable and reflects how floating-point numbers are actually represented in memory.

Here’s an example to illustrate this in practice:

fn main() { 
    let result: f32 = 0.1 + 0.1; 
    let desired: f32 = 0.2; 
    let absolute_difference = (desired - result).abs(); 
    assert!(absolute_difference <= f32::EPSILON); 
}

In this example, 0.1 + 0.1 may not be exactly equal to 0.2 due to floating-point precision issues. By calculating the absolute difference between result and desired, and checking whether this difference is less than or equal to f32::EPSILON, Rust ensures that the comparison allows for small rounding errors, making it more robust. This technique aligns with how floating-point numbers behave internally in Rust.

How Floating-Point Numbers are Represented at a Bit Level

Floating-point numbers, such as f32 and f64 in Rust, follow the IEEE 754 standard for binary floating-point arithmetic. This standard is used widely across programming languages and defines a specific format for representing real numbers in binary form. Understanding how these numbers are stored at the bit level is crucial for grasping the behavior of floating-point types, especially when dealing with edge cases like NaN and infinities.

The IEEE 754 Floating-Point Format

The IEEE 754 standard specifies two primary formats for floating-point numbers:

  • f32 (32-bit floating-point): Single-precision, 32-bit.
  • f64 (64-bit floating-point): Double-precision, 64-bit.

These floating-point numbers are divided into three main components:

  1. Sign bit (1 bit): Determines whether the number is positive or negative.
  2. Exponent: Encodes the exponent, which shifts the decimal point (in binary) to the correct place.
  3. Mantissa (or Significand): Encodes the significant digits of the number.

Each component has a specific number of bits allocated, depending on whether the number is f32 or f64.

Structure of f32 (Single-Precision)

For a 32-bit floating-point number (f32), the structure consists of 1 sign bit, 8 exponent bits, and 23 mantissa bits. The sign bit determines the sign of the number (0 for positive, 1 for negative), while the exponent encodes the exponent using a bias of 127, and the mantissa encodes the fractional part of the number (the significant digits).

For example, to represent the number 20.0 in f32, we first convert 20.0 to binary, which gives 10100 in base 2. In scientific notation, this becomes 1.0101 x 2^4.

Since 20.0 is positive, the sign bit is 0. The exponent is 4, but the stored exponent uses a bias of 127, making the stored value 4 + 127 = 131, or 10000011 in binary. The mantissa, which represents the fractional part 1.0101, is stored without the leading 1 (which is implicit in IEEE 754). The mantissa becomes 01010000000000000000000.

Thus, the complete 32-bit representation of 20.0 in memory is 0 10000011 01010000000000000000000. In hexadecimal, this is 0x41A00000.

Structure of f64 (Double-Precision)

The structure for a 64-bit floating-point number (f64) is similar but with more precision. It consists of 1 sign bit, 11 exponent bits, and 52 mantissa bits. The exponent uses a bias of 1023. For 20.0, the process is similar, but the exponent is now stored as 4 + 1023 = 1027, or 10000000011 in binary. The mantissa still encodes the fractional part, this time with more bits for precision: 0101000000000000000000000000000000000000000000000000.

The 64-bit representation of 20.0 in memory becomes 0 10000000011 0101000000000000000000000000000000000000000000000000, which in hexadecimal is 0x4034000000000000.

Special Floating-Point Values

Certain values in floating-point arithmetic have special meanings, and they are represented uniquely in the IEEE 754 format.

NaN (Not a Number) occurs when the exponent bits are all set to 1 and the mantissa contains any non-zero value. The sign bit is irrelevant, meaning NaN can be either positive or negative. For example, NaN in f32 is represented as 0 11111111 10000000000000000000000, which in hexadecimal is 0x7FC00000.

Infinity is represented by setting the exponent to all 1s and the mantissa to 0. Positive infinity has a sign bit of 0, while negative infinity has a sign bit of 1. For example, positive infinity in f32 is 0 11111111 00000000000000000000000, which in hexadecimal is 0x7F800000.

Zero has a special representation, too. Both +0.0 and -0.0 are valid floating-point numbers. They differ only in their sign bit: positive zero is represented as 0 00000000 00000000000000000000000 (in hexadecimal, 0x00000000), while negative zero is represented as 1 00000000 00000000000000000000000 (in hexadecimal, 0x80000000).

Implications of Bit-Level Representation

The bit-level representation of floating-point numbers explains some of the unintuitive behaviors that arise in floating-point arithmetic. For instance, NaN is not equal to any value, even itself, because its representation involves a non-zero mantissa combined with an all-ones exponent. Similarly, the limited number of bits for the mantissa results in precision issues, especially when trying to represent numbers like 0.1, which cannot be precisely represented in binary.

Signed zeroes (+0.0 and -0.0) may seem identical but have different internal representations. While they compare as equal in most operations, they behave differently in certain situations, such as division (1.0 / +0.0 yields positive infinity, while 1.0 / -0.0 yields negative infinity).

🚀 Explore More by Luis Soares

📚 Learning Hub: Expand your knowledge in various tech domains, including Rust, Software Development, Cloud Computing, Cyber Security, Blockchain, and Linux, through my extensive resource collection:

  • Hands-On Tutorials with GitHub Repos: Gain practical skills across different technologies with step-by-step tutorials, complemented by dedicated GitHub repositories. Access Tutorials
  • In-Depth Guides & Articles: Deep dive into core concepts of Rust, Software Development, Cloud Computing, and more, with detailed guides and articles filled with practical examples. Read More
  • E-Books Collection: Enhance your understanding of various tech fields with a series of e-Books, including titles like “Mastering Rust Ownership” and “Application Security Guide” Download eBook
  • Project Showcases: Discover a range of fully functional projects across different domains, such as an API Gateway, Blockchain Network, Cyber Security Tools, Cloud Services, and more. View Projects
  • LinkedIn Newsletter: Stay ahead in the fast-evolving tech landscape with regular updates and insights on Rust, Software Development, and emerging technologies by subscribing to my newsletter on LinkedIn. Subscribe Here

🔗 Connect with Me:

  • Medium: Read my articles on Medium and give claps if you find them helpful. It motivates me to keep writing and sharing Rust content. Follow on Medium
  • LinkedIn: Join my professional network for more insightful discussions and updates. Connect on LinkedIn
  • Twitter: Follow me on Twitter for quick updates and thoughts on Rust programming. Follow on Twitter

Wanna talk? Leave a comment or drop me a message!

All the best,

Luis Soares
luis.soares@linux.com

Lead Software Engineer | Blockchain & ZKP Protocol Engineer | 🦀 Rust | Web3 | Solidity | Golang | Cryptography | Author

Read more