C# Comparison Operators: Syntax and Behavior
c# comparison operators: Understand C# comparison operators, including relational, equality, and overloaded operators, with practical examples and guidance for correct...
C# comparison operators allow you to compare values and determine relationships between them. These operators — <, >, <=, >=, ==, and != — are fundamental to control flow, sorting, and validating logic. Their behavior differs depending on whether the operands are primitive types, floating-point numbers, strings, or custom objects. Understanding these differences is essential to writing correct and maintainable C# code.
The Basic Relational Operators
The relational operators (<, >, <=, >=) work as you'd expect for numeric types. They compare the relative order of two values and return a boolean result. For integers, the comparison is straightforward and deterministic:
int a = 10; int b = 20; bool less = a < b; // true bool greater = a > b; // false bool lessOrEqual = a <= b; // true bool greaterOrEqual = a >= b; // false
The same operators apply to floating-point types (float, double, decimal). However, floating-point numbers introduce special values like NaN (Not a Number). In IEEE 754, any relational comparison with NaN evaluates to false, even when comparing NaN to itself. This can cause unexpected behavior if you're not careful:
double x = double.NaN; bool result = x > 0; // false bool result2 = x <= 0; // false bool result3 = x == double.NaN; // false
To check for NaN, use double.IsNaN(x) rather than relying on comparison operators.
Equality Operators and Reference Types
For reference types (classes, arrays, delegates), the == operator performs reference equality by default unless the type overloads it. Reference equality means two variables are equal only if they point to the same object in memory. Value equality — where two distinct objects are considered equal based on their contents — requires custom implementation. Consider this example:
public class Point { public int X { get; set; } public int Y { get; set; } } var p1 = new Point { X = 3, Y = 4 }; var p2 = new Point { X = 3, Y = 4 }; bool sameReference = p1 == p2; // false, different objects
Even though p1 and p2 have identical field values, the default reference comparison returns false. To compare values, you must override Equals and GetHashCode, and also overload the == and != operators.
For strings, however, the == operator performs case-sensitive ordinal comparison of the contents, not reference equality. String literals in C# are interned, but that's an implementation detail; the operator always compares the actual characters.
Operator Overloading for Custom Types
You can define your own comparison behavior for custom types by overloading the relational and equality operators. The C# language requires that overloaded operators be declared as public static methods inside the type. For instance, a Temperature class might compare by Celsius value:
public readonly struct Temperature { public double Celsius { get; } public Temperature(double celsius) => Celsius = celsius; public static bool operator <(Temperature left, Temperature right) => left.Celsius < right.Celsius; public static bool operator >(Temperature left, Temperature right) => left.Celsius > right.Celsius; public static bool operator <=(Temperature left, Temperature right) => left.Celsius <= right.Celsius; public static bool operator >=(Temperature left, Temperature right) => left.Celsius >= right.Celsius; public static bool operator ==(Temperature left, Temperature right) => left.Celsius == right.Celsius; public static bool operator !=(Temperature left, Temperature right) => left.Celsius != right.Celsius; public override bool Equals(object? obj) => obj is Temperature other && this == other; public override int GetHashCode() => Celsius.GetHashCode(); }
When overloading, consistency matters. If you define <, you should also define >, <=, >=, because callers will reasonably expect the full set. Additionally, overriding == without also overriding Equals and GetHashCode leads to inconsistent behavior when the type is used in collections like HashSet<T> or Dictionary<TKey, TValue>. The rule is: Equals and GetHashCode must be consistent with the == operator.
Using IComparable and IComparer for Ordering
In practice, instead of manually overloading every operator, types often implement IComparable<T> or IComparable. These interfaces provide a single CompareTo method that returns a negative value, zero, or a positive value to indicate ordering. The comparison operators can then be built on top of CompareTo:
public class Product : IComparable<Product> { public string Name { get; set; } public decimal Price { get; set; } public int CompareTo(Product? other) { if (other is null) return 1; return Price.CompareTo(other.Price); } public static bool operator <(Product left, Product right) => left.CompareTo(right) < 0; public static bool operator >(Product left, Product right) => left.CompareTo(right) > 0; }
Using IComparable<T> keeps the logic centralized and makes the type compatible with sorting methods like List<T>.Sort() and LINQ's OrderBy. However, remember that for a total ordering that is transitive and consistent, the CompareTo method must follow the same rules as the operators. If you find yourself writing complex comparison logic that depends on multiple fields, implement a Comparer<T> class instead.
Deconstructing the Nullable and Floating-Point Edge Cases
Nullable value types (T?) add another layer of complexity. When comparing two nullable values, the C# compiler automatically lifts the operators. The result is false when either operand is null, except for == which returns true if both are null, and false if only one is null. For relational operators (<, >, <=, >=), if either operand is null, the result is false because the underlying value is considered not to exist.
For floating-point comparisons, even when NaN is not involved, the concept of epsilon arises. Due to representation errors, two numbers that should be equal might differ slightly. Using == on floats can lead to false negatives. In such cases, check whether the absolute difference is smaller than a tolerance:
double a = 0.1 + 0.2; double b = 0.3; bool equal = Math.Abs(a - b) < 1e-9; // often true, but use tolerance appropriately
Be cautious: decimal avoids the binary representation issue but is slower than double. Choose the type based on the precision your domain requires, not on operator convenience.
Performance and Maintainability Considerations
Writing custom comparison operators can impact performance, though in most applications the effect is negligible. The JIT compiler may inline simple operators, but calls to CompareTo or Equals that allocate or involve virtual calls add overhead. If you are comparing millions of objects in a hot loop, consider using a struct that implements IEquatable<T> and avoid boxing. For structs, the default Equals implementation uses reflection and is slow; overriding Equals and GetHashCode also improves performance in collections.
Maintainability also plays a role. Overloading operators for a simple data type that is only used in one comparer may be unnecessary. You can instead implement a custom IComparer<T> and pass it to sorting methods, keeping operator semantics untouched. This separation avoids potential inconsistencies and makes the comparison rule explicit.
Common Pitfalls and How to Avoid Them
One common mistake is mixing reference equality with value equality when using == on an interface. If your interface does not override ==, the default reference behavior applies, even if the concrete type has overloaded operators. Example:
interface IShape { double Area { get; } } class Circle : IShape { public double Area { get; set; } public static bool operator ==(Circle a, Circle b) => a.Area == b.Area; // ... other members } IShape s1 = new Circle { Area = 10 }; IShape s2 = new Circle { Area = 10 }; bool same = s1 == s2; // false, reference comparison
The compiler chooses the == operator based on the static type of the operands (IShape), which does not have an overload. To get value equality through an interface, cast to the concrete type or use Equals. This subtlety often leads to bugs in generic algorithms that operate on interfaces.
Another mistake is implementing CompareTo without handling null references consistently. The convention is to treat null as less than any non-null reference, so CompareTo(null) should return a positive value. Following this ensures that sorting lists containing nulls works as expected. Tools like code analyzers can flag these inconsistencies, but reinforcing the rule manually is equally important.
When to Choose Other Comparison Mechanisms
C# comparison operators are not the only way to compare values. For scenarios where you need multiple sorting orders, use IComparer<T> classes. For checking membership, HashSet<T> uses GetHashCode and Equals, not operators. In LINQ, OrderBy and ThenBy rely on IComparer<T> or a default comparer, which for value types uses their IComparable implementation. In many cases, the built-in comparison for primitive types is sufficient, and you should avoid adding overloads that duplicate existing behavior.
For performance-critical code, consider using Span<T> and slicing to compare raw memory with methods like MemoryExtensions.SequenceEqual. That approach bypasses operator overloads entirely and can be significantly faster for large buffers. Yet, for everyday domain types, the overhead of operators is negligible, and readability should lead your decision.
Finally, remember that C# 9 introduced the record types, which provide built-in value equality for properties. With record, the compiler generates an equality implementation that compares property values using EqualityComparer<T>.Default. This is often the most maintainable approach for data-carrying types because you avoid writing boilerplate operator code.
public record Person(string FirstName, string LastName, int Age); var p1 = new Person("Alice", "Smith", 30); var p2 = new Person("Alice", "Smith", 30); bool equal = p1 == p2; // true, value-based
The consistency of operators with Equals and GetHashCode in records is automatic, which removes a whole class of subtle bugs. Therefore, when designing types that primarily hold data, prefer record over a hand-written class with operator overloads unless you need custom comparison logic that differs from property-wise equality.