Equality and Operator Overloading
|
This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline. This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
"Are these two things the same?" has several answers in C#, and the type decides which one you get. This page covers what the defaults are, how to change them correctly, and how to give a type its own operators.
Reference Equality versus Value Equality
public class PlainClass
{
public int Value { get; init; }
}
public struct PlainStruct
{
public int Value { get; init; }
}
public record RecordType(int Value);
public static class DefaultEquality
{
public static void Run()
{
var c1 = new PlainClass { Value = 1 };
var c2 = new PlainClass { Value = 1 };
Console.WriteLine(c1 == c2); // False -- reference identity
Console.WriteLine(c1.Equals(c2)); // False
var s1 = new PlainStruct { Value = 1 };
var s2 = new PlainStruct { Value = 1 };
// Console.WriteLine(s1 == s2); // error CS0019: no == on a plain struct
Console.WriteLine(s1.Equals(s2)); // True -- ValueType.Equals compares fields
var r1 = new RecordType(1);
var r2 = new RecordType(1);
Console.WriteLine(r1 == r2); // True -- synthesised value equality
Console.WriteLine(r1.Equals(r2)); // True
Console.WriteLine(ReferenceEquals(r1, r2)); // False -- still distinct objects
Console.WriteLine(object.Equals(null, null)); // True -- null-safe static helper
}
}
| Type | Default Equals |
Default == |
|---|---|---|
|
Reference identity |
Reference identity |
|
Field-by-field (via reflection — slow) |
Not defined — a compile error |
|
Type + all fields |
Type + all fields |
|
All fields |
All fields |
|
Ordinal content |
Ordinal content (overloaded) |
Most BCL value types |
Content |
Content (overloaded) |
The reflection-based ValueType.Equals is the main reason to prefer a record struct — or to implement
IEquatable<T> by hand — over a plain struct you compare often.
Overriding Equals and GetHashCode
If you override one, you must override the other. The contract:
-
x.Equals(x)is true (reflexive). -
x.Equals(y)equalsy.Equals(x)(symmetric). -
If
x.Equals(y)andy.Equals(z)thenx.Equals(z)(transitive). -
It is consistent — repeated calls give the same answer while nothing changes.
-
x.Equals(null)is false. -
Equal objects must have equal hash codes. Unequal objects may share one.
-
Neither method throws.
public sealed class Money2 : IEquatable<Money2>
{
public Money2(decimal amount, string currency)
=> (Amount, Currency) = (amount, currency);
public decimal Amount { get; }
public string Currency { get; }
// The strongly-typed overload: no boxing, no cast.
public bool Equals(Money2? other)
=> other is not null
&& Amount == other.Amount
&& string.Equals(Currency, other.Currency, StringComparison.Ordinal);
public override bool Equals(object? obj) => Equals(obj as Money2);
// HashCode.Combine handles up to eight members and mixes them properly.
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
public static bool operator ==(Money2? left, Money2? right)
=> left is null ? right is null : left.Equals(right);
public static bool operator !=(Money2? left, Money2? right) => !(left == right);
}
|
Never derive a hash code from mutable state. Put an object in a |
For a hierarchy, compare the runtime type so a base instance never equals a derived one:
public class Point2D : IEquatable<Point2D>
{
public Point2D(int x, int y) => (X, Y) = (x, y);
public int X { get; }
public int Y { get; }
public virtual bool Equals(Point2D? other)
=> other is not null
&& GetType() == other.GetType() // not `other is Point2D` -- that breaks symmetry
&& X == other.X && Y == other.Y;
public override bool Equals(object? obj) => Equals(obj as Point2D);
public override int GetHashCode() => HashCode.Combine(GetType(), X, Y);
}
public sealed class Point3DDerived(int x, int y, int z) : Point2D(x, y), IEquatable<Point3DDerived>
{
public int Z { get; } = z;
public bool Equals(Point3DDerived? other) => base.Equals(other) && Z == other!.Z;
public override bool Equals(Point2D? other) => Equals(other as Point3DDerived);
public override bool Equals(object? obj) => Equals(obj as Point3DDerived);
public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), Z);
}
A record does all of this correctly via EqualityContract, which is the strongest reason to use one for data.
IEquatable<T>, IComparable<T> and Comparers
public readonly struct SemanticVersion
: IEquatable<SemanticVersion>, IComparable<SemanticVersion>
{
public SemanticVersion(int major, int minor, int patch)
=> (Major, Minor, Patch) = (major, minor, patch);
public int Major { get; }
public int Minor { get; }
public int Patch { get; }
public bool Equals(SemanticVersion other)
=> Major == other.Major && Minor == other.Minor && Patch == other.Patch;
public override bool Equals(object? obj) => obj is SemanticVersion other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Major, Minor, Patch);
public int CompareTo(SemanticVersion other)
{
int byMajor = Major.CompareTo(other.Major);
if (byMajor != 0)
{
return byMajor;
}
int byMinor = Minor.CompareTo(other.Minor);
return byMinor != 0 ? byMinor : Patch.CompareTo(other.Patch);
}
public static bool operator ==(SemanticVersion left, SemanticVersion right) => left.Equals(right);
public static bool operator !=(SemanticVersion left, SemanticVersion right) => !left.Equals(right);
public static bool operator <(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) < 0;
public static bool operator >(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) > 0;
public static bool operator <=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) <= 0;
public static bool operator >=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) >= 0;
public override string ToString() => $"{Major}.{Minor}.{Patch}";
}
Implementing IComparable<T> means declaring <, >, ⇐, >=, == and != too (analyzer CA1036), so the
operators agree with CompareTo.
When the order or equality is not intrinsic to the type — or you need several — supply a comparer instead:
public sealed class CaseInsensitiveNameComparer : IEqualityComparer<string>, IComparer<string>
{
public static readonly CaseInsensitiveNameComparer Instance = new();
public bool Equals(string? x, string? y)
=> string.Equals(x, y, StringComparison.OrdinalIgnoreCase);
public int GetHashCode(string obj)
=> obj.GetHashCode(StringComparison.OrdinalIgnoreCase);
public int Compare(string? x, string? y)
=> string.Compare(x, y, StringComparison.OrdinalIgnoreCase);
}
public static class ComparerDemo
{
public static void Run()
{
var set = new HashSet<string>(CaseInsensitiveNameComparer.Instance) { "Ada" };
Console.WriteLine(set.Contains("ADA")); // True
string[] names = ["banana", "Apple", "cherry"];
Array.Sort(names, CaseInsensitiveNameComparer.Instance);
Console.WriteLine(string.Join(",", names)); // Apple,banana,cherry
// EqualityComparer<T>.Default picks IEquatable<T> when available, and does not box.
Console.WriteLine(EqualityComparer<SemanticVersion>.Default
.Equals(new(1, 0, 0), new(1, 0, 0))); // True
// Comparer<T>.Create builds one from a lambda.
IComparer<string> byLength = Comparer<string>.Create((a, b) => a.Length.CompareTo(b.Length));
Console.WriteLine(byLength.Compare("ab", "abc") < 0);
}
}
EqualityComparer<T>.Default and Comparer<T>.Default are what every BCL collection uses when you do not
supply one, and they are the right way to compare T in generic code.
Overloading Operators
An overloaded operator is a public static method named operator X. Some come in mandatory pairs.
public readonly struct Vector3
{
public Vector3(double x, double y, double z) => (X, Y, Z) = (x, y, z);
public double X { get; }
public double Y { get; }
public double Z { get; }
// Binary arithmetic
public static Vector3 operator +(Vector3 a, Vector3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
public static Vector3 operator -(Vector3 a, Vector3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z);
public static Vector3 operator *(Vector3 v, double k) => new(v.X * k, v.Y * k, v.Z * k);
public static Vector3 operator *(double k, Vector3 v) => v * k; // both orders
public static Vector3 operator /(Vector3 v, double k) => new(v.X / k, v.Y / k, v.Z / k);
// Unary
public static Vector3 operator -(Vector3 v) => new(-v.X, -v.Y, -v.Z);
public static Vector3 operator +(Vector3 v) => v;
// Equality -- == and != must be declared together
public static bool operator ==(Vector3 a, Vector3 b)
=> a.X == b.X && a.Y == b.Y && a.Z == b.Z;
public static bool operator !=(Vector3 a, Vector3 b) => !(a == b);
public override bool Equals(object? obj) => obj is Vector3 v && this == v;
public override int GetHashCode() => HashCode.Combine(X, Y, Z);
public double Length => Math.Sqrt((X * X) + (Y * Y) + (Z * Z));
public override string ToString() => $"({X}, {Y}, {Z})";
}
| Operators | Rule |
|---|---|
|
Overloadable individually. |
|
Unary; |
|
Must be declared as a pair. Overriding |
|
Each pair must be declared together. |
|
Not directly overloadable — they follow from |
|
Not an operator — write an indexer. |
|
Not an operator — write a conversion. |
|
Not overloadable at all. |
checked Operators
C# 11 lets a type provide an overflow-checking variant, used inside a checked context:
public readonly struct SmallInt
{
private readonly int _value;
public SmallInt(int value) => _value = value;
public static SmallInt operator +(SmallInt a, SmallInt b)
=> new(unchecked(a._value + b._value));
public static SmallInt operator checked +(SmallInt a, SmallInt b)
=> new(checked(a._value + b._value)); // used in a `checked` context
public override string ToString() => _value.ToString(CultureInfo.InvariantCulture);
}
User-Defined Compound Assignment Operators (C# 14)
Before C# 14, x += y always compiled to x = x + y — a new instance per step. C# 14 lets a type define the
compound operator in place, which matters for mutable accumulators:
public sealed class Accumulator
{
private readonly List<int> _items = [];
public int Count => _items.Count;
// C# 14: an instance member, returning void, mutating in place.
public void operator +=(int value) => _items.Add(value);
public void operator -=(int value) => _items.Remove(value);
// C# 14 also allows user-defined ++ and -- in the same in-place form.
public void operator ++() => _items.Add(_items.Count);
// The classic binary operator still exists for the non-mutating case.
public static Accumulator operator +(Accumulator left, int value)
{
var result = new Accumulator();
result._items.AddRange(left._items);
result._items.Add(value);
return result;
}
}
public static class CompoundAssignmentDemo
{
public static void Run()
{
var accumulator = new Accumulator();
accumulator += 1; // calls the instance `operator +=` -- no new Accumulator
accumulator += 2;
accumulator++;
Console.WriteLine(accumulator.Count); // 3
}
}
The compiler prefers the user-defined compound operator when one exists and the target is a variable; otherwise
it falls back to x = x + y.
User-Defined Conversions
public readonly struct Celsius
{
public Celsius(double degrees) => Degrees = degrees;
public double Degrees { get; }
// implicit: always safe, never loses information, never throws.
public static implicit operator Celsius(double degrees) => new(degrees);
public static implicit operator double(Celsius temperature) => temperature.Degrees;
// explicit: may lose information or throw -- the caller must write the cast.
public static explicit operator Fahrenheit(Celsius temperature)
=> new((temperature.Degrees * 9 / 5) + 32);
public override string ToString() => $"{Degrees:F1} °C";
}
public readonly struct Fahrenheit(double degrees)
{
public double Degrees { get; } = degrees;
public override string ToString() => $"{Degrees:F1} °F";
}
public static class ConversionDemo
{
public static void Run()
{
Celsius body = 37.0; // implicit from double
double asNumber = body; // implicit to double
var inFahrenheit = (Fahrenheit)body; // explicit
Console.WriteLine($"{body} = {inFahrenheit} ({asNumber})");
}
}
The guidance from the framework design guidelines: make a conversion implicit only when it cannot fail and
cannot surprise; make it explicit otherwise; and prefer a named method (ToFahrenheit(), Parse) over a
conversion when the operation is expensive or the direction is not obvious. Conversions to and from object,
to or from a base or derived type, and between two types where neither is yours, are all forbidden.
Operators via Static Abstract Interface Members
Generic code gets operators through interfaces (C# 11). Implement the numeric interfaces and your type works with every generic-math algorithm:
public readonly struct Meters2 :
IAdditionOperators<Meters2, Meters2, Meters2>,
ISubtractionOperators<Meters2, Meters2, Meters2>,
IAdditiveIdentity<Meters2, Meters2>,
IComparisonOperators<Meters2, Meters2, bool>,
IEquatable<Meters2>
{
public Meters2(double value) => Value = value;
public double Value { get; }
public static Meters2 AdditiveIdentity => new(0);
public static Meters2 operator +(Meters2 a, Meters2 b) => new(a.Value + b.Value);
public static Meters2 operator -(Meters2 a, Meters2 b) => new(a.Value - b.Value);
public static bool operator ==(Meters2 a, Meters2 b) => a.Value == b.Value;
public static bool operator !=(Meters2 a, Meters2 b) => a.Value != b.Value;
public static bool operator <(Meters2 a, Meters2 b) => a.Value < b.Value;
public static bool operator >(Meters2 a, Meters2 b) => a.Value > b.Value;
public static bool operator <=(Meters2 a, Meters2 b) => a.Value <= b.Value;
public static bool operator >=(Meters2 a, Meters2 b) => a.Value >= b.Value;
public bool Equals(Meters2 other) => Value == other.Value;
public override bool Equals(object? obj) => obj is Meters2 m && Equals(m);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => $"{Value} m";
}
public static class GenericSum
{
public static T Total<T>(IEnumerable<T> values)
where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
T total = T.AdditiveIdentity;
foreach (T value in values)
{
total += value;
}
return total;
}
public static void Demo()
=> Console.WriteLine(Total<Meters2>([new(1.5), new(2.5)])); // 4 m
}
See Interfaces and Generics.
See Also
-
Records — value equality for free, done correctly.
-
Interfaces —
IEquatable<T>,IComparable<T>and generic math. -
Operators and Expressions — the built-in meanings you are overloading.
-
Collections and Iterators — where
GetHashCodecorrectness actually bites. -
Strings and Text —
StringComparisonand why string equality needs a choice.