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

C#
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;
}
Output
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:

C#
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);
Output
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:

  1. Implement IEquatable<T> with a typed Equals(T? other).
  2. Override Equals(object? obj) to call it.
  3. Override GetHashCode() so equal objects have equal hash codes.
  4. Overload == and != to match.
C#
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;
}
Output
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:

C#
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);
}
Output
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:

C#
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})";
}
Output
(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):

C#
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",
    };
}
Output
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:

C#
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);
Output
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.

Try it yourself · Program.cs
Edit the code, copy it into Program.cs and run it with dotnet run Program.cs (.NET 10 or later). How to set up .NET
Output
5/6
1/6
True