« Все записи

Маленькие чудеса C#/.NET: ToDictionary() и ToList()

Примечание переводчика: за основной трилогией "Маленькие чудеса C#" Джеймса Майкла Харе, которую я перевел здесь, здесь и здесь, и которую автор зарекся продолжать, все же последовали "Последующие Продолжения". Переведем и их, тем более, что там есть, что почитать.

Cерия "маленьких чудес" получила так много положительных откликов, что я решил сделать ее повторяющейся темой в моем блоге, по мере того как новые идеи будут возникать в моей голове.

Есть два простых, но мощных метода расширения LINQ, о которых вы можете знать, а можете и не знать, но они действительно могут упростить задачу преобразования запросов коллекций в коллекции: ToDictionary() и ToList().

Введение: LINQ и отложенное выполнение

В зависимости от ваших знаний о LINQ вы могли и не обращать внимание на то, что многие из его выражений запросов делают за кулисами. Предположим, что для нашего примера у нас есть несколько надуманный класс POCO (POCO расшифровывается как традиционный объект среды CLR, и обозначает класс, который обладает набором свойств с методами для их получения и установки и (обычно) малой функциональностью - эта концепция пришла из POJO в Java).

// простой POCO класс продукта  
public class Product
{
    public string Name { get; set; }
    public int Id { get; set; }
    public string Category { get; set; }
}

Очень простой класс, не так ли? Я не говорю, что приложение должно быть таким простым, просто для того, чтобы сосредоточиться на самом LINQ, не так важно, что мы пытаемся запрашивать. В нашей программе мы можем создать простую коллекцию этих объектов для целей нашего примера, как показано ниже:

var products = new List<Product>
{
    new Product { Name = "CD Player", Id = 1, Category = "Electronics" },
    new Product { Name = "DVD Player", Id = 2, Category = "Electronics" },
    new Product { Name = "Blu-Ray Player", Id = 3, Category = "Electronics" },
    new Product { Name = "LCD TV", Id = 4, Category = "Electronics" },
    new Product { Name = "Wiper Fluid", Id = 5, Category = "Automotive" },
    new Product { Name = "LED TV", Id = 6, Category = "Electronics" },
    new Product { Name = "VHS Player", Id = 7, Category = "Electronics" },
    new Product { Name = "Mud Flaps", Id = 8, Category = "Automotive" },
    new Product { Name = "Plasma TV", Id = 9, Category = "Electronics" },
    new Product { Name = "Washer", Id = 10, Category = "Appliances" },
    new Product { Name = "Stove", Id = 11, Category = "Electronics" },
    new Product { Name = "Dryer", Id = 12, Category = "Electronics" },
    new Product { Name = "Cup Holder", Id = 13, Category = "Automotive" },
};

Теперь у нас есть коллекция объектов Product, и нам нужно сделать запрос к ней. Например, мы можем получить перечисление всех экземпляров Product, для которых значение поля Category равно "Electronics" (электроника):

Результатом многих методов расширения, таких как Where(), является создание итератора, который выполняет запрос при перемещении по списку. Таким образом, на данный момент electronicProducts это не List<Product>, а просто IEnumerable<Product>, которые будут вычисляться "на лету" по мере перемещения по списку. Это называется отложенным выполнением, которое является одной из важных вещей в LINQ, потому что выражение не вычисляется, пока не понадобится результат. Таким образом, на данный момент electronicProducts ожидает, что мы что-то сделаем с ним для того, чтобы получить результат в виде списка!

Позвольте мне проиллюстрировать, что я имею ввиду:

// выбираем всю электронику, это 7 продуктов из списка
IEnumerable<Product> electronicProducts = products.Where(p => p.Category == "Electronics");
 
// теперь очищаем исходный список, который мы запросили
products.Clear();
 
// теперь мы перебираем список электроники, которую мы выбрали вначале
Console.WriteLine(electronicProducts.Count());

Как вы думаете, мы получим 7 или 0? Ответ - ноль, поскольку, хотя мы создали запрос в строке 2, затем мы очистили список в 5 строке. Таким образом, когда мы реально выполняем запрос в строке 8 (когда мы выполняем Count()), список пуст и не будет найдено никаких результатов.

Если вас что-то смущает, подумайте об этом так: создание запроса с помощью методов расширения LINQ (а также синтаксиса выражений LINQ) во многом похоже на определение хранимой процедуры, которая не работает, пока вы реально не вызываете ее. Я знаю, что это не на 100% точная аналогия, но она показывает, что выражение LINQ, которое мы построили вначале не выполняется до тех пор, пока мы не обрабатываем IEnumerable.

Метод расширения ToList()

Именно поэтому, если вы хотите сразу же получить (и сохранить) результат вашего выражения LINQ, вы должны поместить его в другую коллекции до того, как оригинальная коллекция может быть изменена. Можно, конечно, построить список вручную и заполнить его в один из многих способов:

IEnumerable<Product> electronicProducts = products.Where(p => p.Category == "Electronics");
 
// Вы можете создать список, а затем "вручную" осуществить перебор - ГРОМОЗДКО!
var results = new List<Product>();
 
foreach (var product in electronicProducts)
{
    results.Add(product);
}
 
// ИЛИ, вы можете воспользоваться AddRange() - ХОРОШО!
var results2 = new List<Product>();
results2.AddRange(electronicProducts);
 
// ИЛИ, вы можете воспользоваться конструктором List, который принимает IEnumerable<T> - ЛУЧШЕ!
var results3 = new List<Product>(electronicProducts);

Вы можете "вручную" осуществить перебор, что очень громоздко, а можете воспользоваться AddRange() или конструктором List<T>, который принимает IEnumerable<T>, и они сделают это за вас.

Но есть еще кое-что, что вы можете использовать. LINQ содержит метод расширения ToList(), который принимает IEnumerable<T> и использует его для заполнения List<T>. Это очень удобно, если вы хотите всего за один шаг выполнить запрос и использовать его для заполнения списка:

var electronicProducts = products.Where(p => p.Category == "Electronics").ToList();

Теперь коллекция electronicProducts вместо того, чтобы быть IEnumerable<T>, которая выполняется динамически на исходной коллекции, будет отдельной коллекцией, и изменения в исходной коллекции не повлияют на нее.

Это имеет плюсы и минусы, конечно. Как правило, если вам нужны только перебор результатов и их обработка, вам не нужно сохранять их в отдельном списке, поскольку это только ненужное расходование памяти, которую затем необходимо будет очищать. Однако, если вы хотите сохранить подмножество и назначить его другому классу, ToList() оказывается очень удобен, поскольку вам не нужно беспокоиться по поводу изменений в оригинальной коллекции.

Метод расширения ToDictionary()

Если ToList() принимает IEnumerable<T> и преобразует его в List<T>, то, если вы попытаетесь догадаться, что делает ToDictionary(), вы, вероятно, окажетесь правы. В основном ToDictionary() является очень удобным методом для приема результатов запроса (или любого IEnumerable<T>) и организации их в словарь <TKey, TValue>. Фокус в том, что нужно определить, как T превращается в TKey и TValue соответственно.

Скажем, у нас есть супер-мега-большой список продуктов, и мы хотим преобразовать его в словарь <int, Product>, чтобы получить возможность максимально быстрого поиска по ID. Мы могли бы сделать что-то вроде этого:

var results = new Dictionary<int, Product>();
foreach (var product in products)
{
    results.Add(product.Id, product);
}

Это в целом выглядит как довольно добротный фрагмент кода, но теперь с LINQ больше нет необходимости вручную описывать такую логику. Мы можем также легко сказать:

var results = products.ToDictionary(product => product.Id);

Здесь создается словарь Dictionary<int, Product>, в котором ключом является свойство Id продукта, а значением - сам продукт. Это простейшая форма ToDictionary(), где вы просто указываете ключ. Что делать, если вы хотите иметь что-то другое в качестве значения?  Например, что если вас не интересует продукт целиком, а вы просто хотите быть в состоянии преобразовать ID в наименование (Name)? Мы можем сделать так:

var results = products.ToDictionary(product => product.Id, product => product.Name);

Здесь создается словарь Dictionary<int, string>, в котором ключом является свойство Id продукта, а значением - свойство Name каждого продукта. Как видите, этот метод весьма мощный для преобразования IEnumerable<T> из любой коллекции или результата запроса в словарь.

Или же давайте немного побезумствуем! Что делать, если мне нужнен словарь (и я делал это много раз в различных ситуациях в своей работе), который содержит списки логических групп?

Примечание: Существует класс Lookup<TKey, TValue> и метод расширения ToLookup(), который может делать подобные вещи. Это не совсем идентичные решения (Dictionary и Lookup имеют различия в интерфейсах, и поведение их индексаторов в случаях, когда ключ не найден, отличается).

Итак, в нашем примере с продуками мы хотим создать Dictionary<string, List<Product>>, в котором ключом является категория, а значением - список всех продуктов в этой категории. В былые времена вы, возможно, создали бы цикл вроде этого:

// создаем свой словарь в котором будут храниться результаты
var results = new Dictionary<string, List<Product>>();
 
// проходим по продуктам
foreach (var product in products)
{
    List<Product> subList;
 
    // если категории еще нет в словаре, создаем новый список и добавляем его в словарь
    if (!results.TryGetValue(product.Category, out subList))
    {
        subList = new List<Product>();
        results.Add(product.Category, subList);
    }
 
    // добавляем продукт в новый (или существующий) дочерний список
    subList.Add(product);
}

Но это очень много кода для того чтобы сделать то, что должно быть так просто! Его сложно поддерживать, и любому, кто впервые видит этот код, вероятно, придется проанализировать ее для полного понимания.

К счастью для нас, мы можем воспользоваться ToDictionary() и ToList(), чтобы решить эту задачу с помощью нашего друга - метода расширения LINQ GroupBy():

// Одна строка кода!
var results = products.GroupBy(product => product.Category)
    .ToDictionary(group => group.Key, group => group.ToList());

GroupBy() - это выражение запроса LINQ, которое создает IGrouping с ключевым полем Key, а также IEnumerable с элементами в группе. Поэтому, как только мы сделали GroupBy(), все что мы должны дальше сделать, это конвертировать эти группы в словарь: наш селектор ключа (group => group.Key) возвращает поле группировки (Category) и делает его ключем в словаре, а селектор значений (group => group.ToList()) возвращает элементы группы и преобразует его в List<Product> как значение нашего словаря!

Так гораздо легче читать и писать, и так намного меньше кода для модульного тестирования! Многие будут утверждать, что лямбда-выражения более трудны для чтения, но так как они теперь  являются неотъемлемой частью  языка C#, понимание их должно стать обязательным для серьезного разработчика. Я думаю, что по мере того, как вы будете использовать их все больше и больше, вы найдете, что их краткость действительно приводит к лучшему пониманию кода и читабельности.   

Резюме

Надеюсь, вам понравились два моих последних маленьких чуда. Возможно, они не такие большие, но эти два методы расширения LINQ вместе взятые могут добавить много приемов в код обработки коллекций с весьма небольшими техническими расходами!

Progg it

comments powered by Disqus