Avatar ·

Как использовать Distinct() с лямбда-выражением для фильтрации дубликатов по свойству объекта?

Всем привет! Я новичок в C# и столкнулся с проблемой при работе с коллекциями. У меня есть список объектов класса Person:

public class Person
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Age { get; set; }
}

Я заполняю список данными, и в нём оказываются дубликаты по полю Name. Мне нужно получить только уникальные имена, но при этом сохранить объекты целиком (или хотя бы выбрать один из дубликатов).

Я знаю, что есть метод Distinct(), но он работает только для примитивных типов или если переопределить Equals и GetHashCode в классе. А я не хочу менять класс Person — он используется в других местах, и сравнение по всем полям мне не подходит.

Я видел, что в LINQ есть перегрузка Distinct(IEqualityComparer), но писать отдельный класс компаратора для каждого случая кажется слишком громоздким. Можно ли как-то передать лямбда-выражение прямо в Distinct(), чтобы указать, по какому свойству сравнивать? Например, что-то вроде list.Distinct(p => p.Name)?

Подскажите, как элегантно решить эту задачу без лишнего кода. Заранее спасибо!

<<>>

Решение: создаём универсальный компаратор с лямбдой

К сожалению, встроенный метод Distinct() не принимает лямбда-выражение напрямую. Но мы можем написать небольшой вспомогательный класс LambdaEqualityComparer, который принимает функцию-селектор ключа. Это делается один раз, а затем используется в любом месте проекта.

Вот полный пример:

public class LambdaEqualityComparer<T, TKey> : IEqualityComparer<T>
{
    private readonly Func<T, TKey> _keySelector;

    public LambdaEqualityComparer(Func<T, TKey> keySelector)
    {
        _keySelector = keySelector;
    }

    public bool Equals(T x, T y)
    {
        if (x == null && y == null) return true;
        if (x == null || y == null) return false;
        return EqualityComparer<TKey>.Default.Equals(_keySelector(x), _keySelector(y));
    }

    public int GetHashCode(T obj)
    {
        if (obj == null) return 0;
        return EqualityComparer<TKey>.Default.GetHashCode(_keySelector(obj));
    }
}

Теперь вы можете использовать его так:

var people = new List<Person>
{
    new Person { Id = 1, Name = "Alice", Age = 30 },
    new Person { Id = 2, Name = "Bob", Age = 25 },
    new Person { Id = 3, Name = "Alice", Age = 35 } // дубликат по Name
};

// Получаем уникальных людей по имени (останется первый встреченный)
var uniqueByName = people
    .Distinct(new LambdaEqualityComparer<Person, string>(p => p.Name))
    .ToList();

foreach (var person in uniqueByName)
{
    Console.WriteLine($"{person.Id}: {person.Name}, {person.Age}");
}
// Вывод: 1: Alice, 30
//        2: Bob, 25

Ещё более элегантный вариант — extension-метод

Чтобы синтаксис был совсем лаконичным, можно добавить метод расширения:

public static class EnumerableExtensions
{
    public static IEnumerable<T> DistinctBy<T, TKey>(
        this IEnumerable<T> source, 
        Func<T, TKey> keySelector)
    {
        return source.Distinct(new LambdaEqualityComparer<T, TKey>(keySelector));
    }
}

// Использование:
var uniqueByName = people.DistinctBy(p => p.Name).ToList();

Важные замечания

  • Порядок элементов: Distinct() сохраняет порядок исходной последовательности, возвращая первый встреченный элемент из каждой группы дубликатов.
  • Null-значения: компаратор корректно обрабатывает null как для самих объектов, так и для ключей.
  • Производительность: метод использует хэш-таблицу внутри, поэтому работает за O(n) в среднем.

Если вы используете .NET 6 или новее, то можете воспользоваться встроенным методом DistinctBy() из LINQ — он делает то же самое!

// .NET 6+
var uniqueByName = people.DistinctBy(p => p.Name).ToList();

Таким образом, у вас есть несколько вариантов: написать свой компаратор один раз, использовать extension-метод или перейти на современную версию .NET, где это уже встроено.

<<>>

Альтернативный подход: группировка с последующим выбором первого элемента

Если вы не хотите писать вспомогательные классы, можно обойтись стандартными методами LINQ — GroupBy() и Select(). Это менее производительно (O(n log n) из-за группировки), но работает без дополнительного кода:

var uniqueByName = people
    .GroupBy(p => p.Name)
    .Select(g => g.First())
    .ToList();

Этот подход делает то же самое: группирует людей по имени, а затем из каждой группы берёт первого встреченного. Результат будет идентичен DistinctBy().

Плюсы:

  • Не требует написания дополнительных классов или методов.
  • Интуитивно понятно даже новичкам.

Минусы:

  • Медленнее на больших коллекциях (группировка требует сортировки или хэширования с дополнительными накладными расходами).
  • Код чуть длиннее, чем DistinctBy().

Этот вариант хорошо подходит для небольших списков (до нескольких тысяч элементов) или когда вы не хотите создавать вспомогательные утилиты. Для серьёзных проектов рекомендую всё-таки использовать DistinctBy — либо встроенный, либо написанный вручную, как в первом ответе.