Introduction
This is the last part of the series about equality and comparison in C# see part1 here and part2 here if you want to know about equality checks.
In this post let’s see the different ways we have in C# to do comparisons. There are two standard ways for determining order between objects in C#, the <
and >
operators and the IComparable
and IComparable<T>
interfaces. We also have some plug-in protocols, that we can use for ordering in collections.
The < and > operators
The <
and >
operators make more sense for numerical types. You can overload those when it makes sense to do so. As with the ==
operator they are statically resolved at compile time and that makes them extremely fast.
In contrast with the ==
operator, they are not considered the primary way for ordering. The IComparable
interface, is used more frequently and for this reason when a type implements them, it usually has also an implementation of the IComparable
interfaces but the reverse is not always the case.
Because the <
and >
operators remind the mathematical operations of greater and less than something, they are usually implemented when there is a meaning for our type to have a concept of greater and less.
Let’s see an example:
public struct Area
{
public float SquareMeters { get; init; }
public static bool operator >(Area area1, Area area2)
{
return area1.SquareMeters > area2.SquareMeters;
}
public static bool operator <(Area area1, Area area2)
{
return area1.SquareMeters < area2.SquareMeters;
}
}
Area myHouse = new Area() { SquareMeters = 100f };
Area myNeighborsHouse = new Area() { SquareMeters = 110f };
Console.WriteLine(myHouse > myNeighborsHouse); // false
If someone defines the <
operator, he is required to also define the >
.
The same holds true for the <=
and >=
operators.
In contrast with equality, records don’t automatically overload comparison operators, so there is no “structural comparison” by default for records, as this would not make sense for every type, like equality does.
The IComparable and IComparable<T> interfaces
The two IComparable
interfaces, have one method, called CompareTo
. The non generic version has an object
type argument, which will cause boxing in value types and that makes it slower for those types. It is considered a good practice for a type to implement both of those interfaces.
The way they work in a statement myObject2.CompareTo(myObject1)
is like this:
- if
myObject2
is greater thanmyObject1
, or there is a context where it comes aftermyObject1
the statement returns a positive number. - if they are equal or considered the same the statement returns 0.
- if
myObject2
is less thanmyObject1
, or there is a context where it comes beforemyObject1
the statement returns a negative number.
From the above, we can see that there is a difference between equality and comparison, that is also a rule that we have to follow, or else the sorting algorithms won’t work: As long as two objects are considered equal by using the Equals
method, then the CompareTo
method must return 0. But if the CompareTo
method returns 0, that doesn’t mean that that the Equals
method also returns true.
If the above seems confusing, you can try remembering it like this: If we have a class that represents people and has three fields: The year, month and day of their birth, then the Equals
method will return true when all those fields are equal. In contrast the CompareTo
method, can return 0 when only the year fields are equal. Some people may be considered that they have the same age when they compare their age by year of birth, but actually someone can be older even by a few days. (or hours, minutes etc if you also have those fields).
The IComparer and IComparer<T> interfaces
Both of those interfaces have one method named Compare
that takes two parameters. As before the non generic version has arguments of type object and will be less performant for value types because of boxing.
Both are used as plug-in compare methods to ordered collections. They are only useful for those collections that are ordered (ex. sorted dictionary).
As with the equality comparers, there is also an abstract class that derives from both of those interfaces called Comparer<T>
that we can derive from and requires that we only override the generic version of the Compare
method, as the non generic is already implemented for us.
As before the Equals
rules apply, so it is a good idea for a guard clause at the beginning of our implementation, that returns 0, if the Equals
method is true.
The StringComparer for string comparison
The StringComparer
is a predefined plug-in comparer class for strings. We saw a usage in the previous part, as it also implements IEqualityComparer
besides the IComparer
interfaces. For that reason is not only useful for equality checks in collections, but also for comparison checks in ordered collections.
The IStructuralComparable interface
The IStructuralComparable
interface is the plug-in option for structural comparison, in the same way the IStructuralEquatable
interface is, for structural equality, that we saw in part 2.
Bonus: GameObject equality and checking equality with null in Unity
Finally there is equality between gameobjects in the Unity game engine.
Unity is a C++ engine, that uses C# as its scripting language. Every time we create a game object in Unity by inheriting from the MonoBehaviour class, what is actually happening, is that our C# object points to an area in memory, where a C++ object is created.
Any access to our object has to go read from the C++ part in memory. When we try to check for equality between our MonoBehaviour objects, it is pretty expensive, because of the memory context change, so it should be avoided in performance critical parts of our code, like the Update event method.
There is another problem with equality checking in Unity. Checking equality with null.
In a normal C# scenario, checking equality with null, is really fast, as null means that our reference doesn’t point anywhere in memory. In Unity though, that creates a unique situation.
If a game object is destroyed, the C++ representation of the object is destroyed immediately as C++ is not managed. The C# representation exists for sometime, until it is marked by the garbage collector. When this happens, depends on our build. In release builds, an object is eligible for garbage collection when there are no references to it anymore (+ some other conditions that are not related to the subject right now). In Debug builds, an object is eligible for garbage collection when there are no references to it AND is out of scope. In any case, there is a possibility that the C++ object is destroyed, but our C# object is not.
That creates a problem that an equality check with null may be false, but because the C# object points to an area in memory where the C++ object is destroyed, our program will throw.
Unity avoids that by overloading the equality checks, so that when we check for null, in reality we check against a boolean variable IsDestroyed that returns true if the C++ object is destroyed. This creates two big differences compared to normal null equality checks:
- The null check for game objects is very expensive compared to normal null checks, as we access the C# object.
- The syntactic sugar that C# offers for equality with null, won’t actually work. In fact it will work as C# intends, but because the equality check for null is overloaded for the previous reason described, but the syntactic sugar cannot be changed, we are always in danger of our program to crash.
From the above, as a general rule, avoid equality comparisons between game objects in performance critical parts of your code and never use null equality checks by using the C# syntactic sugar if there is a chance you object to be destroyed.
The End
And with that, we end the different ways to compare objects in C#. With this 3 part series I wanted to have an overview of all the possible ways someone can do equality and comparison checks in C#.
Thank you for reading, if you think I forgot something or if you have any questions or comments, you can use the comments section or contact me directly via the contact form or by email. Also if you don’t want to miss any of the new blog posts, you can always subscribe to my newsletter or the RSS feed.