When people compare numbers, they usually expect a simple rule: same value means equal.
For floating-point numbers (Half, float, and double), there are actually two useful notions of equality in .NET:
- IEEE comparison semantics, used by
== - equivalence semantics for .NET objects, used by
Equals
At first sight this looks inconsistent. In practice, each one solves a different problem.
#Quick behavior matrix
For Half, double, and float, these are the important edge cases:
| Case | == | Equals | CompareTo |
|---|
NaN vs NaN | false | true | 0 |
+0.0 vs -0.0 | true | true | 0 |
+Infinity vs +Infinity | true | true | 0 |
-Infinity vs +Infinity | false | false | < 0 |
NaN vs finite number | false (and != is true) | false | NaN.CompareTo(x) < 0 |
== follows the floating-point comparison rules from IEEE 754 / IEC 60559. Equals is designed to support .NET equality contracts used by collections and dictionaries.
#Why NaN == NaN is false but NaN.Equals(NaN) is true
NaN means "Not a Number". It is a special IEEE 754 value used when an operation is undefined, for example a division by 0 (0.0 / 0.0).
IEEE 754 treats NaN as unordered in comparisons. That implies:
x == y is false if either side is NaNx < y, x > y, x <= y, x >= y are all false if either side is NaNx != y is true if either side is NaN
So this is expected:
C#
double x = double.NaN;
Console.WriteLine(x == x); // False
Console.WriteLine(x != x); // True
Console.WriteLine(x < 0); // False
Console.WriteLine(x >= 0); // False
To test whether a value is NaN, use the type-specific APIs:
C#
Half h = Half.NaN;
float f = float.NaN;
double d = double.NaN;
Console.WriteLine(Half.IsNaN(h)); // True
Console.WriteLine(float.IsNaN(f)); // True
Console.WriteLine(double.IsNaN(d)); // True
But .NET also needs an equality notion that works for hash-based containers.
If Equals were IEEE-style for NaN, this would break reflexivity (x.Equals(x) should be true), and keys containing NaN would behave poorly in Dictionary / HashSet.
That is why Half.Equals, Double.Equals, and Single.Equals special-case NaN and return true when both values are NaN.
In runtime source, the implementation is effectively:
C#
public bool Equals(double obj)
{
if (obj == m_value)
return true;
return IsNaN(obj) && IsNaN(m_value);
}
Half follows the same idea. In runtime source:
C#
public bool Equals(Half other)
{
return _value == other._value
|| AreZero(this, other)
|| (IsNaN(this) && IsNaN(other));
}
#+0.0 and -0.0: equal, but not identical bits
IEEE 754 has signed zero. +0.0 and -0.0 compare equal, so both == and Equals return true:
C#
double pz = +0.0;
double nz = -0.0;
Console.WriteLine(pz == nz); // True
Console.WriteLine(pz.Equals(nz)); // True
However, the sign still matters for some operations:
C#
Console.WriteLine(1.0 / +0.0); // +Infinity
Console.WriteLine(1.0 / -0.0); // -Infinity
So "equal" does not always mean "interchangeable in every expression".
Note: NaN values are also signed. Methods such as float.IsNegative return true for -NaN.
#CompareTo gives ordering, including NaN handling
Relational operators with NaN are intentionally awkward because NaN is unordered. For sorting, .NET exposes an ordering via CompareTo.
For Half, double, and float:
- NaN compares equal to NaN
- NaN compares less than non-NaN values
This behavior is explicitly encoded in CompareTo implementations in runtime source.
#Hash codes are normalized for NaN and signed zero
Another subtle but important point: GetHashCode() intentionally canonicalizes values.
In Half.GetHashCode, Double.GetHashCode, and Single.GetHashCode, .NET ensures:
- all NaN bit patterns produce the same hash code
+0.0 and -0.0 produce the same hash code
This is required for consistency with Equals when values are used as keys.
#Precision edge case: values that look equal may not be equal
A separate source of confusion is representability, not NaN rules.
Many decimal values are not exactly representable in binary floating-point, so direct equality often fails:
C#
Console.WriteLine(0.1 + 0.2 == 0.3); // False
decimal solves this specific issue because it stores a base-10 scaled integer, not a base-2 fraction. Values such as 0.1m, 0.2m, and 0.3m are exactly representable, so this comparison is true:
C#
Console.WriteLine(0.1m + 0.2m == 0.3m); // True
decimal precision is about 28 to 29 significant decimal digits. Internally, it uses a 96-bit integer plus a scaling factor (0 to 28 decimal places) and a sign.
Important limits:
decimal is not arbitrary precision. It still has finite range and can overflow.- Operations that produce more than 28 to 29 significant digits are rounded.
decimal is about exact decimal representation, not IEEE floating-point behavior. It has no NaN or Infinity values.
For numeric algorithms, compare with tolerance (absolute + relative), not with Double.Epsilon:
C#
static bool NearlyEqual(double a, double b, double relTol = 1e-12, double absTol = 1e-15)
{
if (a == b)
return true; // handles infinities and signed zero equality
if (double.IsNaN(a) || double.IsNaN(b))
return false;
if (double.IsInfinity(a) || double.IsInfinity(b))
return false;
double diff = Math.Abs(a - b);
double scale = Math.Max(Math.Abs(a), Math.Abs(b));
return diff <= Math.Max(absTol, relTol * scale);
}
#Practical guidance
Use this mental model:
- Use
== when you explicitly want IEEE comparison semantics. - Use
Equals when you need .NET equality semantics (especially in collections). - Use
Half.IsNaN, double.IsNaN, or float.IsNaN to test for NaN. - Use tolerance-based comparison for computed floating-point results.
- Use
decimal when you need exact base-10 fractions (for example, money), and when 28 to 29 significant digits are enough.
#Conclusion
Both == and Equals are correct.
They answer different questions:
==: "Are these values equal under IEEE floating-point comparison rules?"Equals: "Should these two .NET values be considered equal for object equality contracts?"
Once you separate those two intents, the edge cases around NaN, signed zero, and infinities become predictable.
#Additional resources
Do you have a question or a suggestion about this post? Contact me!