Table of content
- 1. Introduction
- 2. What and why of
IEquatable<T>
? -
3. Equality comparison prior to
IEquatable<T>
-
4. User-defined struct implementing
IEquatable<T>
-
5. User-defined class implementing
IEquatable<T>
- 6. Conclusion
- 7. See also
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 overrideEquals(Object? obj)
andGetHashCode()
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 theSystem.ValueType
class. - Avoid the boxing/unboxing overhead of
Equals(Object? obj)
by enabling strongly-typed equality with theEquals(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 byObject.Equals(Object? obj)
method, which is overridden in theEquals
implementation of theSystem.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, implementIEquatable<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
}
}
3.2. Reference Type (class)
-
Default behaviour: The
Equals(Object? obj)
method inSystem.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 theEquals(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;
}
}
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 withEquals
.
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);
}
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
}
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 inEquals
, 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);
}
}
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
}
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 theSystem.Object
class. -
ThreeDPoint
, that derives from theTwoDPoint
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.
Top comments (0)