In C#, when handling custom classes (reference types) in collections like List or HashSet, you may find that they are not determined to be “equal” as intended. This is because the default comparison behavior of classes is based on “reference (memory address) matching” rather than “value (property) matching.”
To eliminate duplicates with HashSet<T> or search correctly with List<T>.Contains, you must implement the IEquatable<T> interface and define appropriate equality logic.
This article explains how to implement a class that determines equality based on values, using “Inventory Items” in a warehouse management system as an example.
Why Implement IEquatable<T>?
Normally, objects defined as a class allocate different memory areas each time they are created with new. Therefore, even if all property values are the same, they are treated as “different objects.”
By implementing IEquatable<T> and overriding the Equals and GetHashCode methods, developers can treat objects as identical based on defined rules (e.g., equal if Product Code and Batch Number match). This implementation is mandatory, especially when using objects as keys in HashSet<T> or Dictionary<TKey, TValue>.
Practical Code Example: Inventory Item Deduplication
The following code defines an InventoryItem class with a product code and batch number, defining its equality. You can see how the HashSet correctly ignores duplicates even when adding a separate instance with the same data.
using System;
using System.Collections.Generic;
namespace WarehouseManagement
{
class Program
{
static void Main()
{
// Create a collection that automatically eliminates duplicate items using HashSet
var uniqueInventory = new HashSet<InventoryItem>();
// 1. Add Product A (Batch 101)
uniqueInventory.Add(new InventoryItem("PROD-A", 101));
// 2. Add Product A (Batch 101) again
// References differ, but values are equal, so HashSet ignores the addition.
bool isAdded = uniqueInventory.Add(new InventoryItem("PROD-A", 101));
Console.WriteLine($"Result of adding duplicate data: {isAdded}"); // False
// 3. Add Product A (Batch 102)
// Batch number differs, so it is added as a distinct item.
uniqueInventory.Add(new InventoryItem("PROD-A", 102));
// Output results
Console.WriteLine("\n--- Inventory List ---");
foreach (var item in uniqueInventory)
{
Console.WriteLine($"Code: {item.ProductCode}, Batch: {item.BatchNumber}");
}
}
}
/// <summary>
/// Class representing an inventory item.
/// Implements IEquatable<T> to provide value-based equality.
/// </summary>
public class InventoryItem : IEquatable<InventoryItem>
{
public string ProductCode { get; }
public int BatchNumber { get; }
public InventoryItem(string productCode, int batchNumber)
{
ProductCode = productCode;
BatchNumber = batchNumber;
}
// --- Implementation of IEquatable<T> ---
/// <summary>
/// Determines whether this instance is equal to another InventoryItem object.
/// </summary>
public bool Equals(InventoryItem other)
{
// 1. If other is null, they are not equal
if (other is null) return false;
// 2. If references are the same (same object), they are equal
if (ReferenceEquals(this, other)) return true;
// 3. Check if all important property values match
// Using StringComparison.Ordinal is safe for string comparison
return ProductCode.Equals(other.ProductCode, StringComparison.Ordinal)
&& BatchNumber == other.BatchNumber;
}
// --- Override object methods ---
/// <summary>
/// Equality check as an object (Non-generic version)
/// </summary>
public override bool Equals(object obj)
{
// Type check: if it is InventoryItem, call the generic Equals
return Equals(obj as InventoryItem);
}
/// <summary>
/// Generate Hash Code
/// This is a critical method used by HashSet and Dictionary.
/// </summary>
public override int GetHashCode()
{
// In modern C#, use HashCode.Combine to safely generate hashes.
// This reduces the risk of hash collisions.
return HashCode.Combine(ProductCode, BatchNumber);
}
// Although not mandatory, it is common to overload == and != operators.
public static bool operator ==(InventoryItem left, InventoryItem right)
{
if (left is null) return right is null;
return left.Equals(right);
}
public static bool operator !=(InventoryItem left, InventoryItem right)
{
return !(left == right);
}
}
}
Execution Result
Result of adding duplicate data: False
--- Inventory List ---
Code: PROD-A, Batch: 101
Code: PROD-A, Batch: 102
Technical Points and Precautions
1. The “Three-Set” Implementation
When customizing equality logic, you should principally implement the following three items as a set:
IEquatable<T>.Equals(T other)method- Override
object.Equals(object obj)method - Override
object.GetHashCode()method
Especially if you forget to implement GetHashCode, HashSet and Dictionary will not work correctly (because if hash codes differ, the comparison by Equals is never performed).
2. Using HashCode.Combine
Older code examples often use the XOR operator (^) to create hash codes, but this is not recommended as it is prone to hash collisions (different values resulting in the same hash). In .NET Core 2.1+ (and .NET Standard 2.1+), using HashCode.Combine allows for easy generation of fast and well-distributed hash codes.
3. record Types (C# 9.0+)
If the class is intended solely for “holding data” and immutability is acceptable, consider using the record type instead of class. The record type automatically generates the equality logic described above (implementation of IEquatable, Equals, GetHashCode, == operator) by the compiler, significantly reducing the amount of code.
Summary
By properly implementing IEquatable<T>, you can treat custom classes as “values” just like numbers or strings. This is an essential technique when performing searches or deduplication within collections, so please understand the relationship between Equals and GetHashCode to implement them correctly.
