Equality and Operator Overloading
What does it mean for two objects to be "equal"? For classes, C# says: only if they're the same object. Often that's not what you want — two Money values of 5 euros should be equal. This lesson shows how to define equality properly with Equals, GetHashCode and IEquatable<T>, and how to give your types operators like ==, + and <.
Reference equality vs value equality
var a = new Point(1, 2); var b = new Point(1, 2); var c = a; Console.WriteLine(a == b); // different objects Console.WriteLine(a == c); // same object Console.WriteLine(a.Equals(b)); string s1 = "hello"; string s2 = string.Concat("hel", "lo"); Console.WriteLine(s1 == s2); // string overrides equality Console.WriteLine(ReferenceEquals(s1, s2)); class Point(int x, int y) { public int X { get; } = x; public int Y { get; } = y; }
False True False True False
By default, == and Equals on a class compare references. string is a class too, but it overrides equality to compare characters — and so can your types.
The easy way: records
If a type is mainly data, make it a record (lesson 40). It gets value equality, ==, != and a matching GetHashCode automatically:
var a = new Point(1, 2); var b = new Point(1, 2); Console.WriteLine(a == b); Console.WriteLine(a.Equals(b)); Console.WriteLine(a.GetHashCode() == b.GetHashCode()); record Point(int X, int Y);
True True True
When a class needs custom equality — because it's not a record, or because only some properties should count — you implement it by hand, as below.
Implementing equality by hand
To give a class value equality correctly you need four things:
- Implement
IEquatable<T>with a typedEquals(T? other). - Override
Equals(object? obj)to call it. - Override
GetHashCode()so equal objects have equal hash codes. - Overload
==and!=to match.
var a = new Isbn("978-0-13-468599-1"); var b = new Isbn("9780134685991"); Console.WriteLine(a.Equals(b)); Console.WriteLine(a == b); Console.WriteLine(a != b); var shelf = new HashSet<Isbn> { a, b }; Console.WriteLine(shelf.Count); class Isbn : IEquatable<Isbn> { public string Digits { get; } 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(); public static bool operator ==(Isbn? left, Isbn? right) => left is null ? right is null : left.Equals(right); public static bool operator !=(Isbn? left, Isbn? right) => !(left == right); public override string ToString() => Digits; }
True True False 1
Here two ISBNs are equal if their digits match, regardless of hyphens. is null and is not null always check for a null reference without calling your overloaded ==, which avoids infinite recursion.
Why GetHashCode matters
Dictionary and HashSet first use the hash code to find a bucket, then call Equals. If equal objects have different hash codes, lookups silently fail. The rule: if two objects are equal, their hash codes must be equal. HashCode.Combine builds a good hash from several values:
var cache = new Dictionary<GridCell, string> { [new GridCell(2, 3)] = "tree", }; Console.WriteLine(cache.ContainsKey(new GridCell(2, 3))); Console.WriteLine(cache[new GridCell(2, 3)]); class GridCell(int row, int col) : IEquatable<GridCell> { public int Row { get; } = row; public int Col { get; } = col; public bool Equals(GridCell? other) => other is not null && Row == other.Row && Col == other.Col; public override bool Equals(object? obj) => Equals(obj as GridCell); public override int GetHashCode() => HashCode.Combine(Row, Col); }
True tree
Keep the properties that take part in equality immutable. If an object's hash code changes while it's inside a dictionary, the dictionary can no longer find it.
Arithmetic operators
Overloading operators lets your types read like built-in ones. An operator is a public static method with the keyword operator:
var v1 = new Vector2(3, 4); var v2 = new Vector2(1, -2); Console.WriteLine(v1 + v2); Console.WriteLine(v1 - v2); Console.WriteLine(v1 * 2); Console.WriteLine(-v1); Console.WriteLine(v1.Length); var position = new Vector2(0, 0); position += new Vector2(1, 1); // += uses + automatically position += new Vector2(1, 1); Console.WriteLine(position); readonly record struct Vector2(double X, double Y) { public double Length => Math.Sqrt(X * X + Y * Y); public static Vector2 operator +(Vector2 a, Vector2 b) => new(a.X + b.X, a.Y + b.Y); public static Vector2 operator -(Vector2 a, Vector2 b) => new(a.X - b.X, a.Y - b.Y); public static Vector2 operator *(Vector2 v, double k) => new(v.X * k, v.Y * k); public static Vector2 operator -(Vector2 v) => new(-v.X, -v.Y); public override string ToString() => $"({X}, {Y})"; }
(4, 2) (2, 6) (6, 8) (-3, -4) 5 (2, 2)
Only overload operators where the meaning is obvious — vectors, money, matrices, dates. order + customer would just confuse readers.
Comparison operators
For types with a natural order, implement IComparable<T> and overload <, >, <= and >= (they must be declared in pairs):
var small = new FileSize(512); var big = FileSize.FromMegabytes(3); Console.WriteLine(small < big); Console.WriteLine(big >= small); Console.WriteLine(big); List<FileSize> sizes = [big, small, new FileSize(2048)]; sizes.Sort(); Console.WriteLine(string.Join(", ", sizes)); readonly record struct FileSize(long Bytes) : IComparable<FileSize> { public static FileSize FromMegabytes(double mb) => new((long)(mb * 1024 * 1024)); public int CompareTo(FileSize other) => Bytes.CompareTo(other.Bytes); public static bool operator <(FileSize a, FileSize b) => a.CompareTo(b) < 0; public static bool operator >(FileSize a, FileSize b) => a.CompareTo(b) > 0; public static bool operator <=(FileSize a, FileSize b) => a.CompareTo(b) <= 0; public static bool operator >=(FileSize a, FileSize b) => a.CompareTo(b) >= 0; public override string ToString() => Bytes switch { >= 1024 * 1024 => $"{Bytes / 1024.0 / 1024:F1} MB", >= 1024 => $"{Bytes / 1024.0:F1} KB", _ => $"{Bytes} B", }; }
True True 3.0 MB 512 B, 2.0 KB, 3.0 MB
Conversion operators
You can also define how your type converts to and from others. implicit conversions happen automatically and must never lose information; explicit ones need a cast:
Celsius c = new(100); Fahrenheit f = (Fahrenheit)c; // explicit double raw = c; // implicit Console.WriteLine($"{c.Degrees} °C = {f.Degrees} °F, raw {raw}"); readonly record struct Celsius(double Degrees) { public static implicit operator double(Celsius c) => c.Degrees; public static explicit operator Fahrenheit(Celsius c) => new(c.Degrees * 9 / 5 + 32); } readonly record struct Fahrenheit(double Degrees);
100 °C = 212 °F, raw 100
Try it yourself
Complete a Fraction type with + and *, keeping fractions in lowest terms so that equal values compare equal.
5/6 1/6 True