Back to Blog
C#

C# IComparer: Custom Sorting with IComparer<T>

c# icomparer: Learn how to implement IComparer<T> in C# to control sorting order for arrays and lists, handle nulls, and avoid common performance pitfalls.

IComparerC# SortingComparison<T>Custom ComparerArray.Sort
Illustration of sorting elements using a custom IComparer in C#

When you call Array.Sort on a list of objects, the default ordering is based on the type's default comparer. For many types, that is not the order you need. The IComparer<T> interface lets you define exactly how two instances should be ordered, without changing the class itself. This is the core of c# icomparer usage: you provide a small class or struct that encapsulates the comparison logic, and the sorting algorithm uses it to decide the sequence.

The Role of IComparer<T> in Custom Sorting

The IComparer<T> interface requires a single method: Compare(T x, T y). This method returns an integer that indicates the relative order of the two objects. A negative value means x precedes y, zero means they are equivalent, and a positive value means x follows y. The sorting methods in the .NET base class library—such as Array.Sort, List<T>.Sort, and Enumerable.OrderBy—accept an IComparer<T> instance and use it to compare elements during the sort.

This approach is useful when you need multiple sorting strategies for the same type. For example, a Person class might be sorted by name in one view and by age in another. Rather than implementing IComparable<Person> on the class, which fixes a single default order, you can create separate comparer classes for each scenario.

Implementing IComparer<T> with a Simple Example

Consider a Product class with Name and Price properties. To sort products by price ascending, you can define a comparer like this:

public class Product { public string Name { get; set; } public decimal Price { get; set; } } public class PriceComparer : IComparer<Product> { public int Compare(Product x, Product y) { if (x is null && y is null) return 0; if (x is null) return -1; if (y is null) return 1; return x.Price.CompareTo(y.Price); } }

The null checks are important because the sorting algorithm may pass null elements, especially when the array contains null references. Without these checks, x.Price would throw a NullReferenceException. The comparer should define a consistent policy for nulls—typically nulls are considered smaller than any non-null value.

Using IComparer<T> with Array.Sort and List.Sort

Once the comparer is defined, you pass it to the sorting method. For an array:

Product[] products = GetProducts(); Array.Sort(products, new PriceComparer());

For a List<Product>:

List<Product> productList = GetProductList(); productList.Sort(new PriceComparer());

The same comparer works with LINQ's OrderBy when you use the overload that accepts an IComparer<T>:

var sorted = products.OrderBy(p => p, new PriceComparer());

Notice that OrderBy expects the comparer to compare the elements themselves, not a key extracted from them. If you need to compare by a specific property, you can either write a comparer that does that internally or use the OrderBy(p => p.Price) form, which uses the default comparer for decimal. The IComparer<T> approach is more flexible when the comparison logic is complex or reused across multiple call sites.

Handling Nulls and Edge Cases

Null handling is a common source of bugs in comparer implementations. The .NET sorting methods do not guarantee that nulls are handled for you; the comparer must define the behavior. A consistent policy is to treat null as less than any non-null value. This is what the PriceComparer above does. If your type is a reference type, you should always check for null in Compare. For value types, null is not an issue, but you may still need to handle default values if they are semantically meaningful.

Another edge case is when two objects compare as equal. The sort order for equal elements is not guaranteed to be stable unless you use a stable sort algorithm. Array.Sort uses an unstable sort for most inputs, so if you need to preserve the original order of equal elements, you should use OrderBy with a stable sort or add a secondary comparison key.

Performance Considerations: Allocation and Reuse

Creating a new comparer instance for every sort call adds a small allocation. For one-off sorts, this is negligible. But if you sort frequently in a hot path, you can reuse a static instance to avoid repeated allocations. For example:

public static readonly PriceComparer Instance = new PriceComparer();

Then call Array.Sort(products, PriceComparer.Instance);. This is particularly useful when the comparer is stateless, as it often is. If the comparer holds configuration state, you need to create a new instance per use.

Another performance consideration is the cost of the comparison itself. If the comparison involves expensive calculations, such as parsing strings or hitting a database, the sort will be slow regardless of the comparer. In such cases, consider precomputing sort keys and using a key-based sort instead.

Choosing Between IComparer<T> and Comparison<T>

The Comparison<T> delegate is a simpler alternative when you only need a one-off comparison. Instead of creating a class, you can pass a lambda:

productList.Sort((x, y) => x.Price.CompareTo(y.Price));

This is concise and avoids the overhead of a separate class. However, it is less reusable. If the same comparison logic is needed in multiple places, an IComparer<T> implementation is better because it can be referenced by name and tested independently. Also, some APIs only accept IComparer<T>, not Comparison<T>, so you may need the interface for compatibility.

A practical guideline: use Comparison<T> for a quick, local sort where the logic is unlikely to be reused. Use IComparer<T> when the comparison is complex, needs to be shared across methods, or must be passed to APIs that require the interface.

Maintaining Readability with Named Comparers

One of the main advantages of IComparer<T> is that it gives a name to the sorting logic. A class called PriceComparer is self-documenting. When you see Array.Sort(products, new PriceComparer()), you immediately know that products are being sorted by price. This improves maintainability compared to inline lambdas, especially when the comparison logic is more than a few lines.

However, do not overuse it. If the comparison is a simple property access, a lambda is often clearer. The decision depends on how often the comparison is used and how complex it is. As with any abstraction, weigh the cost of an extra class against the benefit of reuse and readability.

Common Pitfalls and How to Avoid Them

A frequent mistake is to forget the null checks, leading to runtime exceptions when the collection contains nulls. Another is to implement Compare inconsistently—for example, returning -1 for both (x, y) and (y, x) when the objects are equal. This violates the contract and can produce unpredictable sort orders or even infinite loops in some algorithms. Always ensure that Compare is transitive and consistent: if Compare(a, b) is negative and Compare(b, c) is negative, then Compare(a, c) must be negative. Also, if Compare(a, b) returns zero, then Compare(b, a) must also return zero.

Another pitfall is using a comparer that depends on mutable state. If the comparer's behavior changes during the sort, the results are undefined. Keep comparers stateless or ensure that any configuration is fixed before the sort begins.

Finally, be aware of the sorting algorithm's stability. If you need a stable sort, use OrderBy with a comparer, because OrderBy is stable. Array.Sort is not stable for large arrays. This matters when you sort by one key and want to preserve the order of a previous sort.

By understanding these details, you can use IComparer<T> effectively to bring deterministic, maintainable ordering to your C# collections.

c# icomparer: Practical Usage and Code Examples | RYUSLOG DEV