DEV Community

Vedant Phougat
Vedant Phougat

Posted on

Implementing IEquatable<T> on User-Defined Types

Table of content


1. Introduction

In this blog post, we will dive into the equality comparison with a focus on implementing IEquatable<T> and I will try to answer the following on the way:

  • How equality comparison worked for user-defined types before the introduction of IEquatable<T>?
  • How to implement IEquatable<T> for both user-defined structs and classes?
  • How to test our implementation to ensure reliability and consistency?

If one were to accurately, reliably, and consistently implement IEquatable<T> then the developer must also override Equals(Object? obj) and GetHashCode() as well, here is why:


2. What and why of IEquatable<T>?

It is a generic interface, that allows an object of type A to compare itself to another object of the same type.

The IEquatable<T> was primarily introduced for structs to:

  • Provide a more efficient equality comparison than the default, reflection-based equality comparison defined in the overridden Equals method in the System.ValueType class.
  • Avoid the boxing/unboxing overhead of Equals(Object? obj) by enabling strongly-typed equality with the Equals(T other) method.

3. Equality comparison prior to IEquatable<T>

3.1. Value Type (struct)

  • Default implementation: If we don’t override the Equals(Object? obj) method, equality comparisons rely on the default behavior provided by Object.Equals(Object? obj) method, which is overridden in the Equals implementation of the System.ValueType class. This approach involves:
    • Boxing: The struct instance is boxed into an object by allocating memory on the heap and copying the struct's value to this memory.
    • Reflection: The System.ValueType.Equals implementation uses reflection to iterate over and compare each field of the struct for equality.
  • Overrding Equals(Object? obj): It improves performance by eliminating reflection compared to the default implementation. However, it still incurs the performance overhead associated with boxing and unboxing. To avoid this completely, implement IEquatable<T>.
public struct Count
{
    //value property
    //Count constructor

    public override Boolean Equals(Object? obj)
    {
        if (obj is Count count)    //UNBOXING
        {
            return Value == count.Value;    //direct field access
        }

        return false;
    }
}

public class Program
{
    public static void Main(String[] args)
    {
        var count = new Count(10);
        var isEqual = count.Equals(count);    //BOXING
    }
}
Enter fullscreen mode Exit fullscreen mode

3.2. Reference Type (class)

  • Default behaviour: The Equals(Object? obj) method in System.Object performs reference equality, meaning it checks if the references (memory addresses) of the two objects are same. If the references match, the objects are considered equal.
    • However, if our business requirement defines equality based on identical data, the default behaviour will not work. Two objects with identical data but different references will not be considered equal.
  • Overriding Equals(Object? obj): We can define equality for our user-defined reference types by overriding the Equals(Object? obj) method.
    • This allows explicit implementation of equality logic based on the properties or fields of our user-defined type.
    • However, this approach involves the overhead of type casting between Object and the specific type. While this overhead is negligible for a single comparison, it can become significant when performing equality checks on a collection of objects.
class Person
{
    public string Name { get; set; }

    public override bool Equals(object? obj)
    {
        if (obj is Person other)  //casting to Person type.
        {
            return Name == other.Name;
        }

        return false;
    }
}
Enter fullscreen mode Exit fullscreen mode

3.3. Recommendation

For best performance and clarity, it is highly recommended to implement the IEquatable<T> interface on user-defined types (both value and reference types). This provides a strongly-typed, efficient, and reusable equality comparison mechanism.


4. User-defined struct implementing IEquatable<T>

User-defined struct types don't support the == and != operators by default. Overloading them ensures that developers can use these operators naturally while maintaining consistency with Equals.

4.1. Implementation

In here we will implement IEquatable<T> on a struct, called Rectangle.

public struct Rectangle : IEquatable<Rectangle>
{
    public Int32 Height { get; }
    public Int32 Width { get; }

    public Rectangle(Int32 height, Int32 width)
    {
        Height = height;
        Width = width;
    }

    //contains the actual equal logic
    public Boolean Equals(Rectangle other)
    {
        return
            Height.Equals(other.Height) &&
            Width.Equals(other.Width);
    }

    public override Boolean Equals(Object? obj)
    {
        return
            //'is' operator will return false if obj is null
            obj is Rectangle other &&
            //upon successful casting, Equals is called
            Equals(other);
    }

    public override Int32 GetHashCode()
    {
        return HashCode.Combine(Height, Width);
    }

    public override String ToString()
    {
        return $"Rectangle [Width={width}, Height={height}]";
    }

    public static Boolean operator ==(Rectangle left, Rectangle right) => left.Equals(right);

    public static Boolean operator !=(Rectangle left, Rectangle right) => !left.Equals(right);
}
Enter fullscreen mode Exit fullscreen mode

4.2. Testing

In here, we will write unit test cases for Equals(T other) and Equals(Object? obj) methods. The code snippet uses the following libraries:

Testing these methods verifies that:

  • The equality logic correctly identifies when two instances of the struct are equal or not equal.
  • The .NET collections, such as HashSet<T>, Dictionary<T> etc., work as expected with our user-defined struct
  • The edge cases like null comparisons, comparisons with other types, and comparisons between identical or different instances are handled correctly without throwing unexpected errors
public class Equals
{
    #region IEquatable<Rectangle>.Equals tests
    [Fact]
    public void ReturnsFalse_WhenHeightDoesNotMatches()
    {
        //Arrange
        var current = new Rectangle(10, 20);
        var other = new Rectangle(20, 20);    //height mismatch

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenWidthDoesNotMatches()
    {
        //Arrange
        var current = new Rectangle(10, 20);
        var other = new Rectangle(10, 10);     //width mismatch

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsTrue_WhenBothHeightAndWidthMatches()
    {
        //Arrange
        var current = new Rectangle(10, 20);
        var other = new Rectangle(10, 20);

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeTrue();
    }
    #endregion IEquatable<Rectangle>.Equals tests

    #region Object.Equals tests
    [Fact]
    public void ReturnsFalse_WhenObjectIsNull()
    {
        //Arrange
        var current = new Rectangle(10, 20);

        //Assert
        var actual = current.Equals(null);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenObjectIsOfTypeRectangleAndNull()
    {
        //Arrange
        var current = new Rectangle(10, 20);
        var other = (Object?)((Rectangle) null);

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenObjectIsNotOfTypeRectangleAndNull()
    {
        //Arrange
        var current = new Rectangle(10, 20);
        var other = (Object?)((Point) null);

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeFalse();
    }

    [Theory]
    [InlineData(10, 20, 20, 20)]
    [InlineData(10, 20, 10, 10)]
    public void ReturnsFalse_WhenObjectIsOfTypeRectangleAndNotNullAndNotEqual(
    currentHeight, currentWidth, otherHeight, otherWidth)
    {
        //Arrange
        var current = new Rectangle(currentHeight, currentWidth);
        var other = new Rectangle(otherHeight, otherWidth);

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenObjectIsNotOfTypeRectangleAndNotNull()
    {
        //Arrange
        var current = new Rectangle(10, 20);
        var other = new Point(10, 20);

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsTrue_WhenObjectIsOfTypeRectangleAndIsEqual()
    {
        //Arrange
        var current = new Rectangle(10, 20);
        var other = new Rectangle(10, 20);

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeTrue();
    }
    #endregion Object.Equals tests
}
Enter fullscreen mode Exit fullscreen mode

5. User-defined class implementing IEquatable<T>

User-defined reference types support == and != operators by default, but these only check for reference equality. To ensure consistency with the logic in Equals, these operators must be explicitly overloaded.

5.1. Implementation

In here we will implement IEquatable<T> on a class, called Person.

public class Person : IEquatable<Person>
{
    public String FirstName { get; set; }
    public String LastName { get; set; }
    public DateTime Dob { get; set; }
    public String Email { get; set; }

    public Person(String firstName, String lastName, DateTime dob, String email)
    {
        FirstName = firstName;
        LastName = lastName;
        Dob = dob;
        Email = email;
    }

    public Boolean Equals(Person? other)
    {
        if (other is null)
        {
            return false;
        }

        return
            FirstName.Equals(other.FirstName) &&
            LastName.Equals(other.LastName) &&
            Dob.Equals(other.Dob) &&
            Email.Equals(other.Email);
    }

    public override Boolean Equals(Object? obj)
    {
        return
            obj is Person other &&
            Equals(other);
    }

    public override Int32 GetHashCode()
    {
        return HashCode.Combine(FirstName, LastName, Dob, Email);
    }

    public override String ToString()
    {
        return $"Person [Name={FirstName} {LastName}, Dob={Dob}, Email={Email}]";
    }

    public static Boolean operator ==(Person? left, Person? right)
    {
        //if references of the two objects is equal or both are null; returns true.
        if (ReferenceEquals(left, right))
        {
            return true;
        }

        if (left is null || right is null)
        {
            return false;
        }

        return left.Equals(right);    //calls the Equals(T other) method
    }

    public static Boolean operator !=(Person? left, Person? right)
    {
        return !(left == right);
    }
}
Enter fullscreen mode Exit fullscreen mode

5.2. Testing

In here, we will write unit test cases for Equals(T other) and Equals(Object? obj) methods.

public class Equals
{
    #region IEquatable<Person>.Equals tests
    [Fact]
    public void ReturnsFalse_WhenOtherObjectIsNull()
    {
        //Arrange
        var dob = new DateTime(20, 1, 1991);
        var current = new Person("first-name", "last-name", dob, "test@example.com");

        //Assert
        var actual = current.Equals(null);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenFirstNameDoesNotMatches()
    {
        //Arrange
        var dob = new DateTime(20, 1, 1991);
        var current = new Person("first-name", "last-name", dob, "test@example.com");
        var other = new Person(//everything matches except the first name);


        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenLastNameDoesNotMatches()
    {
        //Arrange
        var dob = new DateTime(20, 1, 1991);
        var current = new Person("first-name", "last-name", dob, "test@example.com");
        var other = new Person(//everything matches except the last name);

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenDobDoesNotMatches()
    {
        //Arrange
        var dob = new DateTime(20, 1, 1991);
        var current = new Person("first-name", "last-name", dob, "test@example.com");
        var other = new Person(//everything matches except the dob);

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenEmailDoesNotMatches()
    {
        //Arrange
        var dob = new DateTime(20, 1, 1991);
        var current = new Person("first-name", "last-name", dob, "test@example.com");
        var other = new Person(//everything matches except the email);

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsTrue_WhenFirstNameLastNameDobAndEmailMatches()
    {
        //Arrange
        var dob = new DateTime(20, 1, 1991);
        var current = new Person("first-name", "last-name", dob, "test@example.com");
        var other = new Person(//everything matches);

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeTrue();
    }
    #endregion IEquatable<Person>.Equals tests

    #region Object.Equals tests
    [Fact]
    public void ReturnsFalse_WhenObjectIsNull()
    {
        //Arrange
        var dob = new DateTime(20, 1, 1991);
        var current = new Person("first-name", "last-name", dob, "test@example.com");

        //Assert
        var actual = current.Equals(null);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenObjectIsOfTypePersonAndNull()
    {
        //Arrange
        //Arrange
        var dob = new DateTime(20, 1, 1991);
        var current = new Person("first-name", "last-name", dob, "test@example.com");
        var other = (Person?)null;

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenObjectIsNotOfTypePersonAndNull()
    {
        //Arrange
        var dob = new DateTime(20, 1, 1991);
        var current = new Person("first-name", "last-name", dob, "test@example.com");
        var other = (String?) null;

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenObjectIsOfTypePersonNotNullAndNotEqual()
    {
        //Arrange
        var dob = new DateTime(20, 1, 1991);
        var current = new Person("first-name", "last-name", dob, "test@example.com");
        var other = new Person(//with one or more properties that doesn't match);

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenObjectIsNotOfTypePersonAndNotNull()
    {
        //Arrange
        var dob = new DateTime(20, 1, 1991);
        var current = new Person("first-name", "last-name", dob, "test@example.com");
        var other = new Point(10, 20);

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsTrue_WhenObjectIsOfTypePersonAndIsEqual()
    {
        //Arrange
        var dob = new DateTime(20, 1, 1991);
        var current = new Person("first-name", "last-name", dob, "test@example.com");
        var other = new Person(//everything matches);

        //Assert
        var actual = current.Equals(other);

        //Act
        actual.Should().BeTrue();
    }
    #endregion Object.Equals tests
}
Enter fullscreen mode Exit fullscreen mode

5.3. When inheritance steps in

When a user-defined class inherits from another user-defined class, the child class should handle equality checks for its own fields, while relying on the parent class to handle equality for shared fields. If the parent class is at the top of the inheritance hierarchy and directly derives from System.Object, it does not need to call base.Equals(Object? obj) in its equality implementation.

For implementation details visit: How to implement value equality in a reference type (class)

While browsing the code snippet, keep in mind that there are 2 user-defined classes which provide implementation of Equals method:

  • TwoDPoint, that derives directly from the System.Object class.
  • ThreeDPoint, that derives from the TwoDPoint class.

6. Conclusion

Implementing IEquatable<T> in user-defined types ensures:

  • Improved performance and type-safety, by avoiding boxing for value types and providing type-safe equality checks.
  • Consistency across equality checks, by centralizing equality logic in the Equals(T other) method.
  • Compatibility of user-defined types with .NET collections.

7. See also

Top comments (0)