Wrap a Delegate in IEqualityComparer: Does C# Have an Out-of-the-Box Solution for LINQ and Dictionary Set Operations?

When working with C# collections, LINQ queries, or dictionaries, equality comparisons are foundational. Whether you’re using Distinct() to filter unique elements, Union() to combine sets, or Dictionary<TKey, TValue> to map keys to values, the .NET runtime relies on equality logic to determine if two objects are "the same."

By default, equality is determined by the object.Equals() method (or IEquatable<T>.Equals() if implemented) and the GetHashCode() method. However, there are countless scenarios where you need custom equality rules—for example, comparing Person objects by their Email property instead of reference, or ignoring case when comparing strings.

A common desire in such cases is to use delegates (e.g., lambdas) to define equality logic inline, rather than creating a separate class that implements IEqualityComparer<T>. This raises a critical question: Does C# provide a built-in way to wrap a delegate into an IEqualityComparer<T> for use with LINQ and dictionaries?

In this blog, we’ll explore this question in depth, covering:

  • The role of IEqualityComparer<T> in .NET.
  • Why delegates are useful for custom equality.
  • Whether C# has an out-of-the-box solution for wrapping delegates into IEqualityComparer<T>.
  • How to build a custom delegate-based IEqualityComparer<T>.
  • Practical examples with LINQ and dictionaries.

Table of Contents#

  1. Understanding IEqualityComparer<T>
  2. Why Delegate-Based Equality Logic?
  3. Out-of-the-Box Solution: Does C# Provide One?
  4. Building a Custom Delegate-Based IEqualityComparer<T>
  5. Practical Examples
  6. Potential Pitfalls and Best Practices
  7. Conclusion
  8. References

1. Understanding IEqualityComparer<T>#

Before diving into delegates, let’s recap the role of IEqualityComparer<T>. This interface defines a contract for comparing two objects of type T for equality. It has two methods:

public interface IEqualityComparer<in T>
{
    bool Equals(T? x, T? y);
    int GetHashCode(T obj);
}
  • Equals(T x, T y): Returns true if x and y are considered equal.
  • GetHashCode(T obj): Returns a hash code for obj, which must be consistent with Equals (i.e., if Equals(x, y) is true, GetHashCode(x) must equal GetHashCode(y)).

IEqualityComparer<T> is used extensively in .NET:

  • LINQ methods like Distinct(), Union(), Intersect(), and Except() accept an IEqualityComparer<T> to customize equality.
  • Dictionary<TKey, TValue>, HashSet<T>, and SortedSet<T> use it to compare keys/elements.

2. Why Delegate-Based Equality Logic?#

Creating a custom IEqualityComparer<T> class for every unique equality rule works, but it’s often verbose. For example, to compare Person objects by their Name property, you’d need:

public class PersonNameComparer : IEqualityComparer<Person>
{
    public bool Equals(Person? x, Person? y) => 
        string.Equals(x?.Name, y?.Name, StringComparison.Ordinal);
 
    public int GetHashCode(Person obj) => 
        obj?.Name?.GetHashCode(StringComparison.Ordinal) ?? 0;
}

This works, but if you need a new rule (e.g., compare by Email), you must create a new class.

Delegates (e.g., Func<T, T, bool> for equality and Func<T, int> for hash codes) offer a more concise alternative. With delegates, you could define equality logic inline using a lambda:

// Compare Person objects by Name (inline lambda)
var nameComparer = CreateComparer<Person>(
    equals: (p1, p2) => string.Equals(p1?.Name, p2?.Name),
    getHashCode: p => p?.Name?.GetHashCode() ?? 0
);

This is far more flexible: no need for separate classes, and logic can be defined on the fly. The problem? .NET requires an IEqualityComparer<T> instance, not raw delegates. Thus, we need a way to wrap these delegates into an IEqualityComparer<T>.

3. Out-of-the-Box Solution: Does C# Provide One?#

The million-dollar question: Does C# (or .NET) include a built-in type to wrap delegates into IEqualityComparer<T>?

Short answer: No.

After checking .NET Framework, .NET Core, and .NET 5+ (including the latest .NET 8), there is no official DelegateEqualityComparer<T> or similar type in the base class libraries (BCL). The closest alternatives are:

  • StringComparer: A specialized comparer for strings (e.g., StringComparer.OrdinalIgnoreCase), but it’s hardcoded for strings, not general-purpose.
  • EqualityComparer<T>.Default: Uses T’s built-in equality (via IEquatable<T> or object.Equals), but doesn’t support delegates.

Thus, developers must implement their own delegate-based IEqualityComparer<T> if they want to use lambdas for custom equality.

4. Building a Custom Delegate-Based IEqualityComparer<T>#

Creating a reusable DelegateEqualityComparer<T> is straightforward. The class will:

  • Accept two delegates: one for Equals and one for GetHashCode.
  • Implement IEqualityComparer<T> by invoking these delegates.

Here’s a robust implementation:

using System;
 
public class DelegateEqualityComparer<T> : IEqualityComparer<T>
{
    private readonly Func<T?, T?, bool> _equals;
    private readonly Func<T, int> _getHashCode;
 
    // Constructor: Enforce non-null delegates
    public DelegateEqualityComparer(
        Func<T?, T?, bool> equals, 
        Func<T, int> getHashCode)
    {
        _equals = equals ?? throw new ArgumentNullException(nameof(equals));
        _getHashCode = getHashCode ?? throw new ArgumentNullException(nameof(getHashCode));
    }
 
    // Implement IEqualityComparer<T>
    public bool Equals(T? x, T? y) => _equals(x, y);
    public int GetHashCode(T obj) => _getHashCode(obj);
 
    // Optional: Static factory method for cleaner syntax
    public static IEqualityComparer<T> Create(
        Func<T?, T?, bool> equals, 
        Func<T, int> getHashCode) => 
        new DelegateEqualityComparer<T>(equals, getHashCode);
}

Key Features:#

  • Null Safety: The constructor throws ArgumentNullException if either delegate is null, preventing runtime errors.
  • Flexibility: Works with any T and any custom equality logic defined via lambdas.
  • Reusability: Instantiate once and reuse across LINQ, dictionaries, etc.

5. Practical Examples#

Let’s put DelegateEqualityComparer<T> to work with real-world scenarios.

Example 1: LINQ Set Operations (Distinct, Union)#

Suppose you have a list of Product objects and want to find distinct products by their Sku (stock-keeping unit), ignoring case:

public class Product
{
    public string Name { get; set; } = string.Empty;
    public string Sku { get; set; } = string.Empty; // e.g., "abc123", "ABC123"
}
 
// Sample data with duplicate SKUs (case variations)
var products = new List<Product>
{
    new Product { Name = "Laptop", Sku = "abc123" },
    new Product { Name = "Laptop", Sku = "ABC123" }, // Duplicate (case-insensitive)
    new Product { Name = "Phone", Sku = "def456" }
};
 
// Define equality: Compare SKUs case-insensitively
var skuComparer = DelegateEqualityComparer<Product>.Create(
    equals: (p1, p2) => string.Equals(p1?.Sku, p2?.Sku, StringComparison.OrdinalIgnoreCase),
    getHashCode: p => StringComparer.OrdinalIgnoreCase.GetHashCode(p.Sku)
);
 
// Use with LINQ's Distinct()
var distinctProducts = products.Distinct(skuComparer).ToList();
 
// Result: 2 items ("abc123" and "def456")

Here, Distinct() uses our delegate-based comparer to treat "abc123" and "ABC123" as equal.

Example 2: Dictionary with Delegate-Defined Key Equality#

Dictionaries rely on IEqualityComparer<TKey> to resolve key collisions. Suppose you want a dictionary where keys are strings compared case-insensitively (e.g., "Apple" and "apple" map to the same value):

// Define key equality: Case-insensitive strings
var caseInsensitiveComparer = DelegateEqualityComparer<string>.Create(
    equals: (s1, s2) => string.Equals(s1, s2, StringComparison.OrdinalIgnoreCase),
    getHashCode: s => StringComparer.OrdinalIgnoreCase.GetHashCode(s)
);
 
// Create dictionary with custom key comparer
var fruitCounts = new Dictionary<string, int>(caseInsensitiveComparer)
{
    { "Apple", 5 },
    { "Banana", 3 }
};
 
// Add "apple" (case variation) – should update the existing "Apple" key
fruitCounts["apple"] = 10; 
 
// Result: fruitCounts["Apple"] is now 10 (no duplicate key error)

Without the custom comparer, fruitCounts["apple"] would throw a ArgumentException (duplicate key), but here it correctly updates the existing entry.

6. Potential Pitfalls and Best Practices#

While delegate-based comparers are powerful, they come with caveats:

1. Consistency Between Equals and GetHashCode#

The most critical rule: If Equals(x, y) returns true, GetHashCode(x) must equal GetHashCode(y). Violating this breaks dictionaries and hash sets, as they use hash codes to group objects.

Bad Example:

// Bug: GetHashCode uses Name, but Equals uses Email!
var badComparer = DelegateEqualityComparer<Person>.Create(
    equals: (p1, p2) => string.Equals(p1?.Email, p2?.Email),
    getHashCode: p => p?.Name?.GetHashCode() ?? 0 // Inconsistent!
);

Fix: Align GetHashCode with Equals:

var goodComparer = DelegateEqualityComparer<Person>.Create(
    equals: (p1, p2) => string.Equals(p1?.Email, p2?.Email),
    getHashCode: p => p?.Email?.GetHashCode() ?? 0 // Consistent with Equals
);

2. Null Handling#

Lambdas must explicitly handle null inputs to avoid NullReferenceException. For example:

// Safe null handling: Check for nulls first
var safeComparer = DelegateEqualityComparer<string>.Create(
    equals: (s1, s2) => 
        s1 == null && s2 == null ? true : 
        s1 == null || s2 == null ? false : 
        string.Equals(s1, s2, StringComparison.OrdinalIgnoreCase),
    getHashCode: s => s == null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(s)
);

3. Performance#

Lambdas have a tiny performance overhead compared to compiled methods. For hot paths (e.g., high-throughput LINQ queries), consider a dedicated IEqualityComparer<T> class instead.

7. Conclusion#

C# does not provide a built-in delegate-based IEqualityComparer<T>, but implementing your own is simple and highly rewarding. The DelegateEqualityComparer<T> class we built lets you define custom equality logic with lambdas, making LINQ set operations and dictionary key comparisons more concise and flexible.

By following best practices—ensuring consistency between Equals and GetHashCode, handling nulls, and being mindful of performance—you can leverage this pattern to write cleaner, more maintainable code.

8. References#