Avatar ·

Как в LINQ применить Distinct() только по одному свойству объекта?

Всем привет! Я начинающий C# разработчик и столкнулся с такой проблемой. У меня есть список объектов класса `Product`: ```csharp public class Product { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public string Category { get; set; } } ``` И есть список: ```csharp List products = new List { new Product { Id = 1, Name = "Ноутбук", Price = 50000, Category = "Электроника" }, new Product { Id = 2, Name = "Мышь", Price = 1500, Category = "Электроника" }, new Product { Id = 3, Name = "Ноутбук", Price = 55000, Category = "Электроника" }, new Product { Id = 4, Name = "Книга", Price = 500, Category = "Книги" }, new Product { Id = 5, Name = "Мышь", Price = 1200, Category = "Электроника" } }; ``` Мне нужно оставить только уникальные элементы, но сравнивать их не по всем полям, а только по свойству `Name`. То есть из списка выше я хочу получить, например, первый попавшийся объект с именем "Ноутбук", первый с именем "Мышь" и один с именем "Книга". Стандартный `Distinct()` сравнивает объекты по ссылке или по `Equals`, что не подходит. Как это сделать красиво и правильно? Я слышал про `GroupBy`, но может есть более прямое решение? Заранее спасибо за помощь! <<>>

Решение с помощью GroupBy и Select

Самый простой и интуитивно понятный способ — сгруппировать элементы по нужному свойству, а затем взять первый элемент из каждой группы:

var distinctByName = products
    .GroupBy(p => p.Name)
    .Select(g => g.First())
    .ToList();

Этот код:

  • Группирует все продукты по полю Name
  • Из каждой группы выбирает первый элемент (можно также Last() или кастомную логику)
  • Возвращает список уникальных по имени продуктов

Результат: объекты с Id = 1 (Ноутбук), 2 (Мышь), 4 (Книга).

Создание собственного EqualityComparer

Более продвинутый, но гибкий подход — реализовать IEqualityComparer<T> и передать его в Distinct():

public class ProductNameComparer : IEqualityComparer<Product>
{
    public bool Equals(Product x, Product y)
    {
        if (ReferenceEquals(x, y)) return true;
        if (x is null || y is null) return false;
        return x.Name == y.Name;
    }

    public int GetHashCode(Product obj)
    {
        return obj.Name?.GetHashCode() ?? 0;
    }
}

// Использование:
var distinctByName = products.Distinct(new ProductNameComparer()).ToList();

Преимущества: можно переиспользовать компаратор в разных местах, легко добавить дополнительные поля для сравнения. Недостаток: нужно писать отдельный класс.

Расширение MoreLINQ — DistinctBy

Если вы используете библиотеку MoreLINQ (NuGet: morelinq), то задача решается одной строкой:

var distinctByName = products.DistinctBy(p => p.Name).ToList();

Это самый лаконичный вариант. Метод DistinctBy принимает функцию селектора ключа и возвращает элементы с уникальными значениями этого ключа (сохраняя первый встреченный элемент).

Сравнение подходов

  • GroupBy + First — работает «из коробки», легко читается, но создаёт промежуточные группы. Для больших коллекций может быть чуть медленнее.
  • Свой компаратор — максимально гибко, но требует кода. Подходит для частого использования.
  • DistinctBy — идеально, если можно добавить зависимость. Чисто, быстро, эффективно.

Для большинства случаев я рекомендую GroupBy — он не требует внешних библиотек и интуитивно понятен даже новичкам.

Важное замечание

Все эти методы используют первый встреченный элемент из группы одинаковых по ключу объектов. Если вам нужен, например, самый дешёвый или самый новый — используйте GroupBy с кастомной агрегацией:

var cheapestByName = products
    .GroupBy(p => p.Name)
    .Select(g => g.OrderBy(p => p.Price).First())
    .ToList();
<<>>

Альтернативный подход: HashSet и ручная фильтрация

Если вам не нравится группировка, а компараторы кажутся избыточными, можно использовать HashSet для отслеживания уже встреченных ключей:

var seenNames = new HashSet<string>();
var distinctByName = new List<Product>();

foreach (var product in products)
{
    if (seenNames.Add(product.Name))
    {
        distinctByName.Add(product);
    }
}

Этот код:

  • Проходит по коллекции один раз (O(n))
  • Добавляет имя в HashSet — если имя уже есть, Add вернёт false
  • Если имя уникальное — добавляет объект в результат

Преимущества: максимальная производительность, нет накладных расходов на группировку или компараторы. Недостаток: это императивный код, а не декларативный LINQ.

Когда использовать

  • Когда важна скорость на больших коллекциях (миллионы записей)
  • Когда нужно выполнить дополнительную логику при первом появлении ключа
  • Когда вы пишете код для .NET Framework без доступа к MoreLINQ

Этот подход легко обернуть в extension-метод для повторного использования:

public static IEnumerable<T> DistinctBy<T, TKey>(
    this IEnumerable<T> source, 
    Func<T, TKey> keySelector)
{
    var seenKeys = new HashSet<TKey>();
    foreach (var element in source)
    {
        if (seenKeys.Add(keySelector(element)))
        {
            yield return element;
        }
    }
}

Так вы получите свой собственный DistinctBy без внешних зависимостей.