Equality

Published on Friday, September 1, 2017

Equality

Equality is such a simple concept at a high level, but don't fool yourself. The devil is in the details. Correctly implementing equality is actually much harder than you'd think, and most code I've seen has done it wrong. There are a lot of very subtle rules you should obey when implementing equality that are easy to forget or to implement incorrectly.

Think I'm exagerating, or just plain wrong? Well, consider the number of tests there are in the EquatableVerifier<T> in Testify.

EquatableVerifier<T> is a “contract verifier” provided in Testify, a unit testing helper framework I'm the author of. Contract verifiers provide a suite of tests for "contracts" that a type should obey. EquatableVerifier<T> ensures a type obeys the contract for IEquatable<T>.

Test Description
VerifyObjectEqualsIsOverridden Verifies that you've overridden the Object.Equals method.
VerifyObjectEqualsWithEqualItems Verifies that Object.Equals returns true when the objects are supposed to be equal.
VerifyObjectEqualsWithNull Verifies that Object.Equals returns false when the object passed to it is null.
VerifyGetHashCodeIsOverridden Verifies that you've overridden the Object.GetHashCode method.
VerifyGetHashCodeIsStable Verifies that Object.GetHashCode returns the same value when called more than once.
VerifyGetHashCodeWithEqualItems Verifies that Object.GetHashCode returns the same value when the objects are supposed to be equal.
VerifyObjectEqualsWithInequalItems Verifies that Object.Equals returns false when the objects are supposed to not be equal.
VerifyEqualsWithEqualItems Verifies that IEquatable<T>.Equals returns true when the objects are supposed to be equal.
VerifyEqualsWithInequalItems Verifies that IEquatable<T>.Equals returns false when the objects are supposed to not be equal.
VerifyEqualsWithNull Verifies that IEquatable<T>.Equals returns false when the object passed to it is null.
VerifyEqualityOperatorsDefined Verifies that the == and != operators are defined.
VerifyOpEqualityWithEqualItems Verifies that == returns true when the objects are supposed to be equal.
VerifyOpEqualityWithNotEqualItems Verifies that == returns false when the objects are supposed to not be equal.
VerifyOpInequalityWithEqualItems Verifies that != returns false when the objects are supposed to be equal.
VerifyOpInequalityWithNotEqualItems Verifies that != returns true when the objects are supposed to not be qual.
VerifyOpEqualityWithLeftNull Verifies that == returns false when the left hand side is null and the right hand side is not.
VerifyOpEqualityWithRightNull Verifies that == returns false when the left hand side is not null and the right hand side is.
VerifyOpEqualityWithRightAndLeftNull Verifies that == returns true when both the left hand side and the right hand side are null.
VerifyOpInequalityWithLeftNull Verifies that != returns true when the left hand side is null and the right hand side is not.
VerifyOpInequalityWithRightNull Verifies that != returns true when the left hand side is not null and the right hand side is.
VerifyOpInequalityWithRightAndLeftNull Verifies that != returns false when both the left hand side and the right hand side are null.
VerifyImmutability Verifies that changing any mutable properties does not change the equality behavior.

That's 20 tests performed to ensure equality is implemented correctly. There's ways to skip some of those tests (SkipImmutabilityTests, SkipOperatorTests), but you'd better have a darn good reason for skipping them. Some of those tests are more subtle then you'd think at first.

VerifyObjectEqualsIsOverridden and VerifyGetHashCodeIsOverridden are common mistakes. For that matter, overriding Object.Equals but not implementing IEquatable<T> is pretty common as well (there's no test for that, but the generic constraints on this verifier enforce it). Failing to override Object.GetHashCode will likely cause incorrect behavior if the type is used in a Dictionary, HashSet or in other cases where you might not expect, like with Enumerable.Distinct.

The tests that use null are all easily overlooked, and even easier to get wrong when you implement them.

Often an object with equality semantics should also have comparable semantics (i.e. the type should be collatable/sortable via the IComparable<T> interface). For that Testify has the ComparableVerifier<T>. This verifier includes all of the same tests as EquatableVerifier<T>and in addition it includes the following tests.

Test Description
VerifyCompareToObjWithNull Verifies that IComparable.CompareTo returns false when the object passed to is is null.
VerifyCompareToObjWithLesserItems Verifies that IComparable.CompareTo returns a value greater than zero when given an object that is supposed to be smaller.
VerifyCompareToObjWithEqualItems Verifies that IComparable.CompareTo returns zero when given an object that is supposed to be equal.
VerifyCompareToObjWithGreaterItems Verifies that IComparable.CompareTo returns a value less than zero when given an object that is supposed to be larger.
VerifyCompareToOtherWithLesserItems Verifies that IComparable<T>.CompareTo returns a value greater than zero when given an object that is supposed to be smaller.
VerifyCompareToOtherWithItems Verifies that IComparable<T>.CompareTo returns zero when given an object that is supposed to be equal.
VerifyCompareToOtherWithGreaterItems Verifies that IComparable<T>.CompareTo returns a value less than zero when given an object that is supposed to be larger.
VerifyCompareToOtherWithNull Verifies that IComparable<T>.CompareTo returns false when the object passed to is is null.
VerifyComparisonOperatorsDefined Verifies that <, <=, >, and >= operators are defined.
VerifyOpGreaterThanWithLesserItems Verifies that operator > returns false when the left hand side is less than the right hand side.
VerifyOpGreaterThanWithItems Verifies that operator > returns false when the left hand side is equal to the right hand side.
VerifyOpGreaterThanWithGreaterItems Verifies that operator > returns true when the left hand side is greater than the right hand side.
VerifyOpGreaterThanOrEqualWithLesserItems Verifies that operator >= returns false when the left hand side is less than the right hand side.
VerifyOpGreaterThanOrEqualWithItems Verifies that operator >= returns true when the left hand side is equal to the right hand side.
VerifyOpGreaterThanOrEqualWithGreaterItems Verifies that operator >= returns true when the left hand side is greater than the right hand side.
VerifyOpLessThanWithLesserItems Verifies that operator < returns true when the left hand side is less than the right hand side.
VerifyOpLessThanWithItems Verifies that operator < returns false when the left hand side is equal to the right hand side.
VerifyOpLessThanWithGreaterItems Verifies that operator < returns false when the left hand side is greater than the right hand side.
VerifyOpLessThanOrEqualWithLesserItems Verifies that operator <= returns true when the left hand side is less than the right hand side.
VerifyOpLessThanOrEqualWithItems Verifies that operator <= returns true when the left hand side is equal to the right hand side.
VerifyOpLessThanOrEqualWithGreaterItems Verifies that operator <= returns false when the left hand side is greater than the right hand side.
VerifyOpGreaterThanWithLeftNull Verifies that operator > returns false when the left hand side is null and the right hand side is not.
VerifyOpGreaterThanWithRightNull Verifies that operator > return true when the left hand side is not null and the right hand side is.
VerifyOpGreaterThanWithRightAndLeftNull Verifies that operator > returns false when both the left hand side and right hand side are null.
VerifyOpGreaterThanOrEqualWithLeftNull Verifies that operator >= returns false when the left hand side is null and the right hand side is not.
VerifyOpGreaterThanOrEqualWithRightNull Verifies that operator >= returns true when the left hand side is not null and the right hand side is.
VerifyOpGreaterThanOrEqualWithRightAndLeftNull Verifies that operator >= returns true when both the left hand side and right hand side are null.
VerifyOpLessThanWithLeftNull Verifies that operator < returns true when the left hand side is null and the right hand side is not.
VerifyOpLessThanWithRightNull Verifies that operator < returns false when the left hand side is null and the right hand side is not.
VerifyOpLessThanWithRightAndLeftNull Verifies that operator < returns false when both the left hand side and right hand side are null.
VerifyOpLessThanOrEqualWithLeftNull Verifies that operator <= returns true when the left hand side is null and the right hand side is not.
VerifyOpLessThanOrEqualWithRightNull Verifies that operator <= returns false when the left hand side is not null and the right hand side is.
VerifyOpLessThanOrEqualWithRightAndLeftNull Verifies that operator <= returns true when both the left hand side and right hand side are null.

So, 32 more tests. I hope you can see how easy it would be to mess this up now. Even more, I hope you see how much of a pain in the neck it would be to manually implement unit tests for every equatable/comparable type you create without something like the contract verifiers available in Testify.

If I've convinced you that implementing this stuff is hard, what are you to do about it? Well, with C# 7 there's a trick you can use and a template you can follow to always get this right. Here's an example using the class Point type.

public class Point : IEquatable<Point>, IComparable<Point>, IComparable
{
    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    public int X { get; }

    public int Y { get; }

    public int CompareTo(Point other) => other == null ? 1 : (X, Y).CompareTo((other.X, other.Y));

    int IComparable.CompareTo(object obj) => CompareTo(obj as Point);

    public bool Equals(Point other) => other == null ? false : (X, Y).Equals((other.X, other.Y));

    public override bool Equals(object obj) => Equals(obj as Point);

    public override int GetHashCode() => (X, Y).GetHashCode();

    public static bool operator ==(Point left, Point right) => EqualityComparer<Point>.Default.Equals(left, right);

    public static bool operator !=(Point left, Point right) => !EqualityComparer<Point>.Default.Equals(left, right);

    public static bool operator <(Point left, Point right) => Comparer<Point>.Default.Compare(left, right) < 0;

    public static bool operator <=(Point left, Point right) => Comparer<Point>.Default.Compare(left, right) <= 0;

    public static bool operator >(Point left, Point right) => Comparer<Point>.Default.Compare(left, right) > 0;

    public static bool operator >=(Point left, Point right) => Comparer<Point>.Default.Compare(left, right) >= 0;
}

The main operatons, IEquatable<T>.Equals and IComparable<T>.CompareTo are implemented using tuples, which implement those interfaces. You have to do a null check here, so there is a little bit of room for messing it up. The other methods just delegate to these. The operators are then implemented using EqualityComparer<T> and Comparer<T> which handle the null checks and then delegate to the IEquatable<T> and IComparable<T> implementations provided. Even GetHashCode gets to delegate to the implementation provided by tuples. Keep in mind this trick won't work (without some help) with types that aren't equatable/comparable, such as collections.

Here's the test for the above implementation.

public class PointTests
{
    [Fact]
    public void VerifyComparableContract()
    {
        var verifier = new ComparableVerifier<Point>
        {
            OrderedItemsFactory = () => new[]
            {
                new Point(0, 0),
                new Point(0, 1),
                new Point(1, 0),
                new Point(1, 1)
            }
        };
        verifier.Verify();
    }
}
comments powered by Disqus