1. Multi-level (Hierarchical) Sorting
Why isn't comparison always simple?
At C# interviews, people love to ask about sorting and comparing complex objects. And it's not just a trend—it's a reflection of real problems every dev faces. Searching records in a database, sorting tables and grids in the UI, uniqueness in collections—all of this is directly tied to correct object comparison.
Comparing numbers is easy. But if we want to sort users by last name, then by first name, and also keep in mind that some fields might be empty (null) or strings are in different languages... Now you need a systematic approach.
A common requirement: first sort by one property, then if equal—by another, and if still equal—by a third.
Example: User with First Name, Last Name, and Birth Date
public class User
{
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime BirthDate { get; set; }
// For beauty — print user info
public override string ToString()
=> $"{LastName} {FirstName} ({BirthDate:yyyy-MM-dd})";
}
Why do we need hierarchy?
Imagine we have a list of users and want to display them in alphabetical order: first by last name, then by first name. If both last and first names match—by birth date.
Comparison logic: "chain of responsibility"
It's a lot like how they rank participants in olympiads: first by score, if equal—by submission time, if still equal—alphabetically. In code, it's simple:
public class UserComparer : IComparer<User>
{
public int Compare(User x, User y)
{
// Compare last names
int result = string.Compare(x.LastName, y.LastName, StringComparison.OrdinalIgnoreCase);
if (result != 0) return result; // If different—done
// If last names are equal—compare first names
result = string.Compare(x.FirstName, y.FirstName, StringComparison.OrdinalIgnoreCase);
if (result != 0) return result;
// If last and first names are equal—compare birth dates
return x.BirthDate.CompareTo(y.BirthDate);
}
}
How to use:
var users = new List<User>
{
new User { FirstName = "Ivan", LastName = "Ivanov", BirthDate = new DateTime(1990, 1, 1) },
new User { FirstName = "Petr", LastName = "Ivanov", BirthDate = new DateTime(1992, 5, 1) },
new User { FirstName = "Anna", LastName = "Petrova", BirthDate = new DateTime(1985, 8, 30) }
};
users.Sort(new UserComparer());
users.ForEach(Console.WriteLine);
// Petrova Anna (1985-08-30)
// Ivanov Ivan (1990-01-01)
// Ivanov Petr (1992-05-01)
2. How to Compare Strings? Culture Matters
String Comparison: Ordinal, CurrentCulture, InvariantCulture
You'd think a string is a string, but it's not that simple! For example, Russian ё and е, German ss and ß, different cases...
In .NET, string comparison has special rules, set via StringComparison. This can affect both sorting and searching.
String comparison example:
// in German, ß is almost equivalent to ss
string a = "straße";
string b = "STRASSE";
bool eq1 = string.Equals(a, b, StringComparison.Ordinal); // false
bool eq2 = string.Equals(a, b, StringComparison.OrdinalIgnoreCase); // false
bool eq3 = string.Equals(a, b, StringComparison.CurrentCultureIgnoreCase); // true
In the first two cases, comparison is byte-by-byte—no culture or language features, so ß and SS are different. But in the third case, current culture is used (like German), and the string is seen as a native speaker would: ß is treated as ss, and case is ignored. So eq3 returns true.
How to pick the right comparison?
- Ordinal — fast, byte-based, good for technical stuff (like comparing IDs).
- CurrentCulture / InvariantCulture — for user-facing text, respects OS or set culture rules.
In Sort, Compare and other methods, always specify the comparison type:
string.Compare(x, y, StringComparison.CurrentCultureIgnoreCase)
Sorting quirks in different languages
Sorting in Russian—ё might come after е, or be treated as equal (depends on culture!). So if you're doing something serious (like a directory), check with your client or business analyst how to sort "special" letters.
3. Protection from null: Not All Objects Behave
What if a field is null?
In real apps, someone will forget to fill a field, and then when comparing—boom!—NullReferenceException. Our job is to be ready for that.
Example without null protection:
public int Compare(User x, User y)
{
return x.LastName.CompareTo(y.LastName); // if LastName == null, it's an error!
}
Example with protection (null is less than any non-null):
public int Compare(User x, User y)
{
// Use a special string comparer that handles nulls
int byLastName = Comparer<string>.Default.Compare(x.LastName, y.LastName);
if (byLastName != 0) return byLastName;
// and so on...
}
Even shorter:
public int Compare(User x, User y)
{
return string.Compare(x?.LastName, y?.LastName, StringComparison.OrdinalIgnoreCase);
}
"Nulls" at the start or end?
You can make it so all users with "empty" last names go to the start or end of the list—depends on the task.
public int Compare(User x, User y)
{
if (x.LastName == null && y.LastName == null) return 0;
if (x.LastName == null) return 1; // null — to the end
if (y.LastName == null) return -1; // null — to the end
return string.Compare(x.LastName, y.LastName, StringComparison.OrdinalIgnoreCase);
}
4. Comparing by Multiple Properties
A common newbie mistake is using arithmetic instead of the "chain of responsibility" for comparison, like:
// Don't do this!
public int Compare(User x, User y)
{
// Bad example
return (x.Age - y.Age) + string.Compare(x.FirstName, y.FirstName, StringComparison.Ordinal);
}
This way doesn't guarantee correct sorting: if the age difference is -100, and string comparison returns 1, the result is -99, which doesn't match the expected sort logic.
The right way is to use a clear sequence:
if there's already a difference—return it, otherwise check the next property.
5. Handy Nuances
What if objects are equal by main properties?
If objects end up "equal", it's important that the sort algorithm is stable: it doesn't change the order of elements that are equal according to comparison. Built-in List<T>.Sort() doesn't guarantee stability. If you need it (like when sorting tables with multiple user sort levels), use LINQ methods OrderBy/ThenBy—they are stable.
Comparison with optional/nullable fields
In .NET, you see models where a field is, for example, DateTime? (Nullable<DateTime>) or int?. The logic is simple: null is less than non-null, or vice versa—depends on the task. You can use helpers from the standard library:
int result = Nullable.Compare<DateTime>(u1.BirthDate, u2.BirthDate);
Comparison with extra rules
Sometimes you need comparison to consider the "weight" of a property, like VIP clients always go first. The solution—add a "VIP flag" at the start of sorting.
public int Compare(User x, User y)
{
// VIPs go first
int vipResult = y.IsVip.CompareTo(x.IsVip); // true = 1, false = 0; sort descending
if (vipResult != 0) return vipResult;
// The rest—usual way
int result = string.Compare(x.LastName, y.LastName, StringComparison.OrdinalIgnoreCase);
if (result != 0) return result;
return string.Compare(x.FirstName, y.FirstName, StringComparison.OrdinalIgnoreCase);
}
6. Tips and Best Practices
Always check for null
Modern C# is moving towards "null-safety", but old code isn't "null-safe". Always add protection or use the right methods.
Avoid "magic" numbers
Don't write return x.Field - y.Field; for fields where overflow is possible (overflow). If the fields are long—errors are very possible.
Use StringComparison
Don't rely on default string comparison behavior. Always pass StringComparison.OrdinalIgnoreCase or another one that fits your task.
Separate comparison and equality
Interfaces IComparable<T> and IEqualityComparer<T> solve different problems. Use comparers for sorting, equality for finding unique objects. Sometimes they can give different results!
Add tests for "edge" cases
Check that sorting works right if fields are equal, fields are empty, strings are in different cases or languages.
GO TO FULL VERSION