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 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);
}
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
}
5. User-defined class implementing IEquatable<T>
If one wants to implement value equality in user-defined reference types (classes), consider using
Records
available fromC# 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 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)