Equality, GetHashCode and hashed collections
What == and Equals compare for classes, strings and boxed values, and why Equals without GetHashCode breaks every HashSet.
After this lesson you can
- Predict what == and Equals return for two instances of a class and for two strings
- Override Equals and GetHashCode together so a type works as a dictionary key
- Explain why a key must not change while it sits in a HashSet or Dictionary
C# has two questions that sound alike: are these the same object?
(reference equality) and do these hold the same value? (value
equality). Which one == and Equals answer depends on the type.
- For a class that overrides nothing, both
==andEqualscompare references. Twonew Point(1, 2)objects are different objects, so they are not equal. stringoverloads==and overridesEqualsto compare characters.- Operators are chosen at compile time from the static type. Cast
two strings to
objectand==becomes the reference comparison onobject, whileEquals, a virtual method, still runs the string version.
public class Point{ public int X, Y; public Point(int x, int y) { X = x; Y = y; }} public static class Solution{ public static string Describe() { var p1 = new Point(1, 2); var p2 = new Point(1, 2); string a = "hello"; string b = new string(new[] { 'h', 'e', 'l', 'l', 'o' }); object oa = a, ob = b; return $"points: =={p1 == p2} Equals={p1.Equals(p2)} | " + $"strings: =={a == b} object=={oa == ob} Equals={oa.Equals(ob)}"; }}The string b is built at runtime from a char array, so it is a
different object from the literal a even though the characters match.
That is why object== says False.
Equals and GetHashCode are a pair
HashSet<T> and Dictionary<TKey, TValue> do not compare every
element. They compute GetHashCode(), jump to the bucket for that
number, and only then call Equals on the few items in it. The
contract that makes this work: if two objects are equal, their hash
codes must be equal. (The reverse need not hold; collisions are fine.)
public sealed class Isbn : IEquatable<Isbn>
{
private readonly string _digits;
public Isbn(string text) => _digits = text.Replace("-", "");
public bool Equals(Isbn? other) => other is not null && _digits == other._digits;
public override bool Equals(object? obj) => Equals(obj as Isbn);
public override int GetHashCode() => _digits.GetHashCode();
}
Hash the same normalised data you compare. Implementing
IEquatable<T> gives collections a typed Equals to call, which
avoids a cast (and, for structs, boxing).
When you cannot change the type, or need a different notion of
equality for one collection, pass a comparer instead:
new HashSet<string>(StringComparer.OrdinalIgnoreCase) treats "Ab"
and "aB" as one element.
Common mistakes
public class HalfDone{ public string Code; public HalfDone(string code) => Code = code; public override bool Equals(object? obj) => obj is HalfDone o && o.Code == Code; // GetHashCode deliberately NOT overridden} public class Done{ public string Code; public Done(string code) => Code = code; public override bool Equals(object? obj) => obj is Done o && o.Code == Code; public override int GetHashCode() => Code.GetHashCode();} public static class Solution{ public static string Describe() { var half = new HashSet<HalfDone> { new HalfDone("A1"), new HalfDone("A1") }; var done = new HashSet<Done> { new Done("A1"), new Done("A1") }; var key = new Done("B2"); var set = new HashSet<Done> { key }; key.Code = "B3"; // mutate a key that is already inside the set return $"without GetHashCode: {half.Count} | with: {done.Count} | " + $"after mutating key: Contains(same object)={set.Contains(key)}"; }}- Overriding
Equalsbut notGetHashCode.HalfDonesays two"A1"objects are equal, but each inherits a per-object hash code, so the set files them in different buckets and keeps both. The compiler warns (CS0659); treat that warning as an error. - Mutating a key after inserting it. The set stored
keyin the bucket for"B2". AfterCodechanges, a lookup hashes"B3", searches the wrong bucket, andContainsreturnsFalsefor the very object that is inside. Make key fields read-only. - Using
==on values typed asobject. That compares references, so two boxed42s are not==. UseEquals, or pattern-match back to the real type first.
What interviewers ask
- "What is the difference between
==andEquals?"==is an operator resolved at compile time from the static types; is virtual and resolved from the runtime type. For classes both default to reference equality unless overridden.
Exercise · Pro
Count stock scans written in different ways
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade