C# Comparison Operator Overloading
c# comparison operator overloading: Learn how to overload comparison operators in C# with practical examples, including equality, ordering, and the relationship with G...
c# comparison operator overloading requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
When you define a custom type in C#, the default behavior of ==, !=, <, >, <=, and >= is often not what you need. For value types, the default equality uses reflection to compare fields, which is slow and can produce surprising results when fields are references. For reference types, these operators perform reference equality, not value equality. Overloading comparison operators lets you define meaningful comparisons that match the semantics of your type.
This article focuses on overloading comparison operators in C#, covering the syntax, the rules the compiler enforces, and the practical considerations that affect correctness and maintainability.
Operator Overloading Syntax in C#
Comparison operator overloading in C# uses the public static modifier, and each overload must accept two parameters of the enclosing type (or a related type, but typically the same). The return type is bool for all comparison operators.
public struct Temperature { public double Celsius { get; } public Temperature(double celsius) { Celsius = celsius; } public static bool operator ==(Temperature left, Temperature right) { return left.Celsius == right.Celsius; } public static bool operator !=(Temperature left, Temperature right) { return !(left == right); } public static bool operator <(Temperature left, Temperature right) { return left.Celsius < right.Celsius; } public static bool operator >(Temperature left, Temperature right) { return right < left; } public static bool operator <=(Temperature left, Temperature right) { return !(right < left); } public static bool operator >=(Temperature left, Temperature right) { return !(left < right); } }
The compiler requires that comparison operators be overloaded in pairs: if you define ==, you must also define !=; if you define <, you must also define >. You may define all six, but you cannot mix and match arbitrarily. For example, you cannot define only == and <; you must define the corresponding != and >.
Implementing Equality with GetHashCode
Overloading == and != is only part of the story. When you define value equality, you must also override Equals(object) and GetHashCode(). Failing to do so leads to subtle bugs when your type is used in dictionaries, hash sets, or LINQ operations that rely on hashing.
The rule is: if two objects are equal according to ==, they must produce the same hash code. The reverse is not required, but a good hash function minimizes collisions.
Here is an example for a Money type that compares an amount and a currency:
public readonly struct Money { public decimal Amount { get; } public string Currency { get; } public Money(decimal amount, string currency) { Amount = amount; Currency = currency ?? throw new ArgumentNullException(nameof(currency)); } public static bool operator ==(Money left, Money right) { return left.Amount == right.Amount && left.Currency == right.Currency; } public static bool operator !=(Money left, Money right) => !(left == right); public override bool Equals(object obj) { return obj is Money other && this == other; } public override int GetHashCode() { return HashCode.Combine(Amount, Currency); } }
Without the GetHashCode override, the base implementation returns a value that is not tied to the fields used in ==. Two Money instances with the same amount and currency would compare equal with == but would be placed in different buckets in a HashSet<Money>, causing duplicate entries and inconsistent behavior.
The HashCode.Combine method is available in .NET Core 2.1 and later, and in .NET Standard 2.1. It avoids the common mistake of XOR-ing hash codes, which tends to produce poor distribution when fields are related.
Ordering Operators and IComparable
Defining <, >, <=, and >= gives your type ordering semantics, but it does not integrate with sorting methods that expect IComparable<T>. Methods like List<T>.Sort and Array.Sort use Comparer<T>.Default, which looks for an IComparable<T> implementation. If your type only has overloaded operators, sorting will fail with an InvalidOperationException unless you pass a custom comparer.
The standard approach is to implement IComparable<T> and IComparable, and then use that comparison in the operator overloads. Here is an updated Temperature example:
public readonly struct Temperature : IComparable<Temperature> { public double Celsius { get; } public Temperature(double celsius) { Celsius = celsius; } public int CompareTo(Temperature other) { return Celsius.CompareTo(other.Celsius); } public static bool operator <(Temperature left, Temperature right) { return left.CompareTo(right) < 0; } public static bool operator >(Temperature left, Temperature right) { return left.CompareTo(right) > 0; } public static bool operator <=(Temperature left, Temperature right) { return left.CompareTo(right) <= 0; } public static bool operator >=(Temperature left, Temperature right) { return left.CompareTo(right) >= 0; } }
By implementing IComparable<T>, your type works directly with sorting and ordering operations. You avoid duplicating the comparison logic in every operator, and you have a single source of truth for ordering.
Implement IComparable (the non-generic interface) as well when your type may be used with older collections or in reflection-based scenarios. The method is CompareTo(object obj) and it should call the generic implementation after checking the type.
Consistency and Pitfalls
Comparison operators must be consistent with each other and with the type's other comparison methods. The most common inconsistency is between == and Equals. If you overload == but leave Equals(object) untouched, then objA.Equals(objB) may return a different result than objA == objB. Some code paths use one, and others use the other, leading to subtle bugs.
Another issue arises when you define ordering operators but use a different rule for equality. For example, if < returns false for two objects because they are equal, but == returns false because the objects have different references, then the ordering is not consistent. A SortedSet<T> expects that CompareTo returns 0 for objects that are considered equal. If not, the set may fail to maintain its invariants.
The safest pattern is to derive equality and ordering from a single comparison result. For equality, use CompareTo(other) == 0. For ordering, use the sign of CompareTo. This guarantees consistency.
Below is a corrected Temperature with equality:
public readonly struct Temperature : IEquatable<Temperature>, IComparable<Temperature> { public double Celsius { get; } public Temperature(double celsius) { Celsius = celsius; } public bool Equals(Temperature other) { return Celsius.Equals(other.Celsius); } public override bool Equals(object obj) { return obj is Temperature other && Equals(other); } public override int GetHashCode() { return Celsius.GetHashCode(); } public int CompareTo(Temperature other) { return Celsius.CompareTo(other.Celsius); } public static bool operator ==(Temperature left, Temperature right) { return left.Equals(right); } public static bool operator !=(Temperature left, Temperature right) { return !left.Equals(right); } public static bool operator <(Temperature left, Temperature right) { return left.CompareTo(right) < 0; } public static bool operator >(Temperature left, Temperature right) { return left.CompareTo(right) > 0; } public static bool operator <=(Temperature left, Temperature right) { return left.CompareTo(right) <= 0; } public static bool operator >=(Temperature left, Temperature right) { return left.CompareTo(right) >= 0; } }
For value types, consider implementing IEquatable<T> as well. It avoids boxing when the Equals(T) method is called directly, which is a common performance concern in generic collections and LINQ operations.
Null Handling and Reference Types
When overloading comparison operators for reference types, you must decide how to handle null. The C# compiler does not enforce null safety for operators, so you must check for null explicitly. A common pattern is to treat null as less than any non-null value, and two nulls as equal.
public sealed class Person : IComparable<Person> { public string Name { get; } public Person(string name) { Name = name; } public int CompareTo(Person other) { if (other is null) return 1; // this > null return string.Compare(Name, other.Name, StringComparison.Ordinal); } public static bool operator <(Person left, Person right) { if (left is null) return right is not null; // null is less than non-null if (right is null) return false; return left.CompareTo(right) < 0; } public static bool operator >(Person left, Person right) { if (left is null) return false; if (right is null) return true; return left.CompareTo(right) > 0; } public static bool operator <=(Person left, Person right) { return !(left > right); } public static bool operator >=(Person left, Person right) { return !(left < right); } }
Note that <= and >= are implemented using the other operators. This reduces duplication and ensures consistency, as long as the underlying comparisons are transitive.
For equality operators with reference types, follow the same principle: == should be equivalent to Equals. If you define == to compare field values, also override Equals and GetHashCode accordingly. Be aware that ReferenceEquals is the only way to check reference identity; after overloading ==, the built-in reference comparison is no longer available unless you call object.ReferenceEquals explicitly.
Performance Considerations
A well-written operator overload is typically as fast as a direct method call, since the JIT compiler can inline simple implementations. The main performance concern arises when the default implementation is used instead of an overload. For value types, the default Equals uses reflection and is orders of magnitude slower than a hand-written comparison. Overloading == for a struct that is frequently compared in hot loops can reduce CPU time significantly, but the exact gain depends on the type's size and the number of fields.
Another performance aspect is the interplay with GetHashCode. If GetHashCode uses fields that are not stored inline or that require allocation (such as a string), hashing becomes more expensive. In the Money example, Currency is a reference, so hashing it has a dereference cost. This is unavoidable if the field is part of the equality definition, but you should be aware that the hash function's quality directly affects dictionary performance. A poor hash function that returns the same value for many distinct objects can degrade Dictionary lookups to linear time.
When implementing comparison operators for a large struct, consider whether the comparison can short-circuit on the most discriminating field first. For example, comparing two long strings with == may compare the full string, but if you also have a length field, you could check the length first. However, you must ensure the field is truly quick to compare and that the short-circuit does not violate equality semantics.
Compatibility with C# Language Rules
The C# compiler imposes several constraints on operator overloading that affect how you design your type. First, at least one of the two parameters must be of the enclosing type. This prevents operators from being defined on unrelated types. In practice, you almost always use the enclosing type for both parameters, but the language allows an operator between the enclosing type and another type, provided one of them is the enclosing type.
Second, the operator keyword methods are always public static. You cannot make them virtual or abstract. This means operator behavior is resolved at compile time, not at runtime. If you derive a class from a base that overloads an operator, the base's operator will be used for base-type references, even if the runtime object is derived. This is a common source of confusion.
For value types, these rules are usually straightforward. For reference types, you may need to decide whether operator overloading is the right design at all. Some developers argue that operators on reference types are confusing because null handling is required and because the semantics may be unclear. An alternative is to use only Equals and IComparable<T> and avoid operator overloads unless the type has a natural, obvious comparison meaning, such as a DateTime or a Version object.
The choice depends on your domain. If your type represents a scalar value like money, temperature, or length, operators make the usage intuitive. If your type is an entity with an identity, such as a database record, operators are often misleading and should be avoided.
Where This Commonly Breaks in Production
One frequent production bug is comparing two objects of a type that has overloaded == but has not overridden GetHashCode. When this type is used as a key in a dictionary, lookups fail intermittently. The symptom is that a key that is equal to the lookup key cannot be found. The root cause is that the hash code is inconsistent with equality. Even though the first lookup may succeed by chance, subsequent operations fail with an ArgumentException in HashSet.Add when it encounters a duplicate.
Another issue is that the == operator is not automatically used by LINQ methods like Contains or Distinct. These methods use the default equality comparer, which calls Equals, not the overloaded operator. If you have overloaded == but not Equals, Contains will use reference equality for reference types and the default value equality for structs. This leads to inconsistent behavior between list.Contains(item) and item == another. Fixing this requires consistent implementation across Equals, GetHashCode, and the operators.
A less obvious problem occurs with nullable types. When you have a ? nullable of a struct with overloaded operators, C# lifts the operators automatically. For example, Temperature? a, b; can use a == b, and the lifted operator returns true if both have values and the underlying operator returns true, or if both are null. This works, but you must be careful with nullable comparisons that involve HasValue because the lifted == treats a null and a non-null as not equal, which is generally the correct behavior.
Finally, avoid overloading comparison operators for types that are not immutable. If a type's fields can change after the instance is placed in a collection, the collection's sorting and hashing behavior becomes unreliable. The standard guidance is to implement operator overloading only on immutable types. This is not enforced by the compiler, but it is necessary for correct behavior in practice.
Final Code Example: Composite Comparison
Here is a complete example of a Vector2 struct that overrides all comparison operators and implements IComparable<T> based on vector length, with a tie-breaker on the X component to ensure a strict ordering.
public readonly struct Vector2 : IEquatable<Vector2>, IComparable<Vector2> { public double X { get; } public double Y { get; } public Vector2(double x, double y) { X = x; Y = y; } public double Length => Math.Sqrt(X * X + Y * Y); public bool Equals(Vector2 other) { return X == other.X && Y == other.Y; } public override bool Equals(object obj) => obj is Vector2 other && Equals(other); public override int GetHashCode() => HashCode.Combine(X, Y); public int CompareTo(Vector2 other) { int lengthComparison = Length.CompareTo(other.Length); if (lengthComparison != 0) return lengthComparison; return X.CompareTo(other.X); } public static bool operator ==(Vector2 left, Vector2 right) => left.Equals(right); public static bool operator !=(Vector2 left, Vector2 right) => !left.Equals(right); public static bool operator <(Vector2 left, Vector2 right) => left.CompareTo(right) < 0; public static bool operator >(Vector2 left, Vector2 right) => left.CompareTo(right) > 0; public static bool operator <=(Vector2 left, Vector2 right) => left.CompareTo(right) <= 0; public static bool operator >=(Vector2 left, Vector2 right) => left.CompareTo(right) >= 0; }
This struct can be used in List<Vector2> and immediately sorted with list.Sort(), because Comparer<Vector2>.Default recognizes IComparable<Vector2>. The equality operators are aligned with Equals and GetHashCode, so dictionary and set usage is safe. The ordering is strict, meaning CompareTo returns 0 only when X and Y are both equal, which matches the equality definition.
When you design your own type, follow the same pattern: choose a consistent comparison basis, delegate all operators to Equals or CompareTo, and override GetHashCode to match equality. This avoids the majority of production issues that arise from operator overloading.