Equality, Hashing & Ordering Contracts
Key Points
- Override
EqualsANDGetHashCodetogether — they're a contract:a.Equals(b) ⇒ a.GetHashCode() == b.GetHashCode(). Breaking it corrupts dictionaries and hash-sets. - Records auto-generate value equality + a stable hash code. Use them for DTOs and value objects.
IEquatable<T>avoids boxing on value types; always implement it alongsideEquals(object).IComparable<T>for natural ordering;IComparer<T>for pluggable orderings (multiple sort strategies).IComparable<T>.CompareTomust be a strict weak ordering — total, transitive, anti-symmetric.- Strings:
StringComparer.Ordinalfor keys;OrdinalIgnoreCasefor case-insensitive; never rely on default culture-sensitive comparison for identifiers.
Concepts (deep dive)
The four equality APIs
object.Equals(object?) — virtual, boxes value types
IEquatable<T>.Equals(T) — generic, no boxing
operator == — static, can be hidden by overload
ReferenceEquals(a, b) — pure reference identity
object.Equals defaults to reference equality for classes and shallow field equality for value types (slow, uses reflection). Override both Equals and GetHashCode whenever you redefine equality.
GetHashCode contract
- Equal objects must produce equal hash codes.
- Different objects may collide; just keep collisions rare.
- Must be stable for the lifetime the instance is in a hash table. Mutating fields used in the hash → corrupted dictionary.
- Use
HashCode.Combine(field1, field2, …)— handles spreading & seeding.
Records do this for you
public record Money(decimal Amount, string Currency);
// Auto: Equals, GetHashCode, ==, != by structural equality on all positional members.
For mutable types, define equality manually — records' equality on mutable members can mismatch hashes after mutation.
IEquatable<T> to avoid boxing
public readonly struct OrderId(long value) : IEquatable<OrderId>
{
public long Value { get; } = value;
public bool Equals(OrderId other) => Value == other.Value;
public override bool Equals(object? obj) => obj is OrderId o && Equals(o);
public override int GetHashCode() => Value.GetHashCode();
public static bool operator ==(OrderId a, OrderId b) => a.Equals(b);
public static bool operator !=(OrderId a, OrderId b) => !a.Equals(b);
}
Without IEquatable<T>, Dictionary<OrderId, …> boxes on every key compare.
Equality vs identity
- Identity: "are these the same instance?" →
ReferenceEquals. - Value equality: "do they represent the same value?" → records, value objects.
- Domain equality: entities are equal if their identity field matches; value objects compare all members. Don't conflate these in DDD models.
Ordering — IComparable<T> vs IComparer<T>
public record Order(DateTime Created) : IComparable<Order>
{
public int CompareTo(Order? o) => o is null ? 1 : Created.CompareTo(o.Created);
}
// Plugged-in orderings
public sealed class ByTotalDesc : IComparer<Order>
{
public int Compare(Order? x, Order? y) => (y?.Total ?? 0).CompareTo(x?.Total ?? 0);
}
CompareTo returns negative / zero / positive. Must satisfy trichotomy (exactly one is true: ab) and transitivity. Violating these breaks Sort, OrderBy, sorted collections.
EqualityComparer<T>.Default
Generic code resolves the right comparer:
It picks IEquatable<T>.Equals if available, else object.Equals. Same story for Comparer<T>.Default with IComparable<T>.
String comparison policy
StringComparer.Ordinal — byte-wise, fastest, identifier-safe
StringComparer.OrdinalIgnoreCase — case-folded ordinal
StringComparer.InvariantCulture — culture-neutral linguistic
StringComparer.CurrentCulture — user culture, locale-sensitive
Rule: identifiers, file paths, configuration keys → ordinal. Display ordering → culture. Never use default string.Equals(other) for security/identifier checks — it's ordinal but Compare defaults to current culture (Turkish-I bug).
Code: correct vs wrong
❌ Wrong: equality without hash code
public class Sku
{
public string Code { get; init; } = "";
public override bool Equals(object? o) => o is Sku s && s.Code == Code;
// GetHashCode not overridden → falls back to reference, breaks dictionaries
}
var d = new Dictionary<Sku, int>(); d[a]=1; d[b]; (b equals a) → KeyNotFoundException.
✅ Correct
public sealed class Sku : IEquatable<Sku>
{
public string Code { get; }
public Sku(string code) => Code = code;
public bool Equals(Sku? o) => o is not null && Code == o.Code;
public override bool Equals(object? o) => Equals(o as Sku);
public override int GetHashCode() => Code.GetHashCode(StringComparison.Ordinal);
}
❌ Wrong: mutable hash inputs
public class Tag { public string Name { get; set; } = ""; public override int GetHashCode() => Name.GetHashCode(); }
var s = new HashSet<Tag>(); s.Add(t); t.Name = "renamed"; s.Contains(t); // false
✅ Correct: hash on immutable fields only
Use init-only or readonly fields for any field that participates in the hash.
Design patterns for this topic
Pattern 1 — Value object as record struct
Stack-allocated, value equality, no boxing in dictionaries (compiler emits IEquatable<Money>).
Pattern 2 — Strongly-typed ID
Prevents passing a ProductId where CustomerId is expected. Implements IEquatable<> and == automatically.
Pattern 3 — Pluggable comparer registry
public static class OrderComparers
{
public static readonly IComparer<Order> ByDate = Comparer<Order>.Create((a, b) => a.Created.CompareTo(b.Created));
public static readonly IComparer<Order> ByTotalDesc = Comparer<Order>.Create((a, b) => b.Total.CompareTo(a.Total));
}
list.Sort(OrderComparers.ByDate);
Pattern 4 — IEqualityComparer<T> for keys you don't own
public sealed class CaseInsensitiveOrderComparer : IEqualityComparer<Order>
{
public bool Equals(Order? x, Order? y) => string.Equals(x?.Sku, y?.Sku, StringComparison.OrdinalIgnoreCase);
public int GetHashCode(Order o) => StringComparer.OrdinalIgnoreCase.GetHashCode(o.Sku);
}
new Dictionary<Order, int>(new CaseInsensitiveOrderComparer());
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
| Record (class) | Auto equality + hash | Heap-allocated, equality includes all positional members |
| Record struct | Value type, no GC | Larger structs become expensive to copy; equality from compiler |
Manual IEquatable<T> | Full control | More code; easy to break contract |
IEqualityComparer<T> | External equality without owning the type | Must be passed everywhere; duplicates contract |
When to use / when to avoid
- Records for immutable DTOs and value objects.
IEquatable<T>on any type you put inDictionary/HashSetkeys.- External
IEqualityComparerwhen you can't modify the type (third-party) or need multiple equality semantics. - Avoid redefining equality on entity types — DDD entities are equal by identity, not value.
Interview Q&A
Q1. Why must GetHashCode be consistent with Equals? A. Hash containers bucket by hash code; if equal objects hash differently, the lookup never visits the right bucket and Contains/TryGetValue returns false.
Q2. Why is overriding Equals without GetHashCode a compiler warning (CS0659)? A. The compiler can prove the contract is broken — keep the warning at error severity.
Q3. What does HashCode.Combine give over manual XOR? A. Spreading (avoids cluster collisions for sequential ints), per-process randomized seed (mitigates hash-flooding DoS), and tested arithmetic.
Q4. When does default struct equality become a problem? A. The default ValueType.Equals uses reflection — slow, allocates. Always implement IEquatable<T> on structs you compare or use as keys.
Q5. Why is string.Equals(other, StringComparison.Ordinal) preferred for keys? A. Linguistic comparison varies by culture (Turkish "I", German "ß"); ordinal compares code units, deterministic across locales, and faster.
Q6. What's the contract for IComparable<T>.CompareTo? A. Trichotomy (exactly one of <, =, > holds), transitivity, anti-symmetry, and consistency with Equals (sgn(a.CompareTo(b)) == -sgn(b.CompareTo(a))). Violating these can crash Sort.
Q7. When does record equality mislead? A. When a positional member is a reference type with reference equality (e.g., byte[]). The record compares by reference, not contents — surprising for "value types."
Q8. Why is Comparer<T>.Default preferred over hand-rolled comparison in generic code? A. It honors IComparable<T> if implemented, falls back to IComparable (boxing), and produces a clear InvalidOperationException if neither exists. Hand-rolling can silently miscompare.
Gotchas / common mistakes
- Mutating fields used in
GetHashCodeafter the object enters a hash container. - Implementing
Equalsbut forgetting==/!=, leaving them as reference equality. recordwithbyte[]fields — reference equality, not byte equality.IComparable<T>that returns1/-1instead ofMath.Sign(...)— fine in practice but confusing.- Using
GetHashCode().ToString()as a stable identifier — hash codes are randomized per process in modern .NET. - Ignoring
StringComparer.Ordinalfor dictionary keys — Turkish-I and locale surprises.