DEV Community

Vedant Phougat
Vedant Phougat

Posted on • Edited on

How to Implementing IEquatable<T>?

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?

To implement IEquatable<T> accurately, reliably, and consistently, 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 CommandKey.

public readonly struct CommandKey : IEquatable<CommandKey>
{
    public static readonly CommandKey None = "none";

    public String Key { get; }

    public CommandKey(String key)
    {
        ArgumentNullException.ThrowIfNull(key);

        Key = key;
    }

    public Boolean Equals(CommandKey other) => String.Equals(Key, other.Key);
    public override Boolean Equals(Object? obj) => obj is CommandKey other && Equals(other);
    public override Int32 GetHashCode() => Key.GetHashCode();
    //public override String ToString() => Key;
    //public static implicit operator CommandKey(String key) => new CommandKey(key);
    //public static implicit operator String(CommandKey commandKey) => commandKey.Key;
    public static Boolean operator ==(CommandKey left, CommandKey right) => left.Equals(right);
    public static Boolean operator !=(CommandKey left, CommandKey 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
/// <summary>
/// This class tests the equality logic for the <see cref="CommandKey"/> class.
/// It includes tests for the following methods and operators:
///     - <see cref="CommandKey.Equals(CommandKey)"/>
///     - <see cref="CommandKey.Equals(Object?)"/>
///     - == operator
///     - != operator
/// </summary>
public class Equals
{
    #region Equals(CommandKey)
    [Fact]
    public void ReturnsFalse_WhenCommandKeyIsDefault()
    {
        //Arrange
        CommandKey thisKey = "c";
        CommandKey otherKey = default;

        //Act
        var isEqual = thisKey.Equals(otherKey);

        //Assert
        isEqual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenCommandKeysAreDifferent()
    {
        //Arrange
        var thisKey = (CommandKey)"c";
        var otherKey = CommandKey.None;

        //Act
        var isEqual = thisKey.Equals(otherKey);

        //Assert
        isEqual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenCommandKeysDiffersInCasing()
    {
        //Arrange
        CommandKey thisKey = "c";
        CommandKey otherKey = "C";

        //Act
        var isEqual = thisKey.Equals(otherKey);

        //Assert
        isEqual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenCommandKeysDiffersInWhitespace()
    {
        //Arrange
        CommandKey thisKey = "c";
        CommandKey otherKey = " c ";

        //Act
        var isEqual = thisKey.Equals(otherKey);

        //Assert
        isEqual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsTrue_WhenBothCommandKeysAreDefault()
    {
        //Arrange
        CommandKey thisKey = default;
        CommandKey otherKey = default;

        //Act
        var isEqual = thisKey.Equals(otherKey);

        //Assert
        isEqual.Should().BeTrue();
    }

    [Fact]
    public void ReturnsTrue_WhenCommandKeysAreEqual()
    {
        //Arrange
        CommandKey thisKey = "c";
        CommandKey otherKey = "c";

        //Act
        var isEqual = thisKey.Equals(otherKey);

        //Assert
        isEqual.Should().BeTrue();
    }
    #endregion Equals(CommandKey)

    #region Equals(Object?)
    [Fact]
    public void ReturnsFalse_WhenKeyIsNull()
    {
        //Arrange
        CommandKey thisKey = "c";
        Object? otherKey = null;

        //Act
        var isEqual = thisKey.Equals(otherKey);

        //Assert
        isEqual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenKeyIsNotCommandKey()
    {
        //Arrange
        CommandKey thisKey = "c";
        var otherKey = 'c';

        //Act
        var isEqual = thisKey.Equals(otherKey);

        //Assert
        isEqual.Should().BeFalse();
    }

    [Fact]
    public void ReturnsFalse_WhenKeyIsDefaultCommandKey()
    {
        //Arrange
        CommandKey thisKey = "c";
        Object? otherKey = default(CommandKey);

        //Act
        var isEqual = thisKey.Equals(otherKey);

        //Assert
        isEqual.Should().BeFalse();
    }

    [Fact]  
    public void ReturnsTrue_WhenBothKeysAreDefaultCommandKey()
    {
        //Arrange
        CommandKey thisKey = default;
        Object? otherKey = default(CommandKey);

        //Act
        var isEqual = thisKey.Equals(otherKey);

        //Assert
        isEqual.Should().BeTrue();
    }

    [Fact]
    public void ReturnsTrue_WhenKeyIsCommandKeyAndEqual()
    {
        //Arrange
        CommandKey thisKey = "c";
        Object? otherKey = (CommandKey)"c";

        //Act
        var isEqual = thisKey.Equals(otherKey);

        //Assert
        isEqual.Should().BeTrue();
    }
    #endregion Equals(Object?)

    #region == operator
    [Fact]
    public void OperatorEquals_ReturnsFalse_WhenKeysAreNotEqual()
    {
        //Arrange
        CommandKey left = "c";
        CommandKey right = "d";

        //Act
        var isEqual = left == right;

        //Assert
        isEqual.Should().BeFalse();
    }

    [Fact]
    public void OperatorEquals_ReturnsFalse_WhenOnlyLeftKeyIsNull()
    {
        //Arrange
        CommandKey? left = null;
        CommandKey right = "c";

        //Act
        var isEqual = left == right;

        //Assert
        isEqual.Should().BeFalse();
    }

    [Fact]
    public void OperatorEquals_ReturnsTrue_WhenBothKeysAreNull()
    {
        //Arrange
        CommandKey? left = null;
        CommandKey? right = null;

        //Act
        var isEqual = left == right;

        //Assert
        isEqual.Should().BeTrue();
    }

    [Fact]
    public void OperatorEquals_ReturnsTrue_WhenKeysAreEqual()
    {
        //Arrange
        CommandKey left = "c";
        CommandKey right = "c";

        //Act
        var isEqual = left == right;

        //Assert
        isEqual.Should().BeTrue();
    }
    #endregion == operator

    #region != operator
    [Fact]
    public void OperatorNotEquals_ReturnsFalse_WhenKeysAreEqual()
    {
        // Arrange
        CommandKey left = "c";
        CommandKey right = "c";

        // Act
        var isEqual = left != right;

        // Assert
        isEqual.Should().BeFalse();
    }

    [Fact]
    public void OperatorNotEquals_ReturnsFalse_WhenBothKeysAreNull()
    {
        //Arrange
        CommandKey? left = null;
        CommandKey? right = null;

        //Act
        var isEqual = left != right;

        //Assert
        isEqual.Should().BeFalse();
    }

    [Fact]
    public void OperatorNotEquals_ReturnsTrue_WhenKeysAreNotEqual()
    {
        // Arrange
        CommandKey left = "c";
        CommandKey right = "d";

        // Act
        var isEqual = left != right;

        // Assert
        isEqual.Should().BeTrue();
    }

    [Fact]
    public void OperatorNotEquals_ReturnsTrue_WhenOnlyLeftKeyIsNull()
    {
        //Arrange
        CommandKey? left = null;
        CommandKey right = "c";

        //Act
        var isEqual = left != right;

        //Assert
        isEqual.Should().BeTrue();
    }
    #endregion != operator
}
Enter fullscreen mode Exit fullscreen mode

5. User-defined class implementing IEquatable<T>

If one wants to implement value equality in user-defined reference types (classes), consider using Records available from C# 9.0 onward.

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 shown in the above-mentioned link, 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)