Как работает SelectMany в LINQ

от автора

Привет, Хабр!

Когда вы первый раз наткнётесь на метод SelectMany() в LINQ, может показаться, что это тот же Select(), только с вишенкой в виде какой-то автоматической распаковки коллекций. На деле же у этого маленького утилитарного метода гораздо более хитрая внутренняя механика, и понимание того, как он плющит коллекции, существенно расширит ваш инструментарий при работе с данными.

Зачем вообще нужен SelectMany()

LINQ в C# — это DSL для работы с коллекциями, одна из супер фич которого декларативность.

Select() — проекция каждого элемента исходной последовательности в новый вид. SelectMany() — проекция, дающая много элементов на каждый входной, и автоматический флаттенинг результата в одну плоскую последовательность.

Проще говоря, когда вы делаете Select(x => somethingThatYieldsIEnumerable(x)), то без SelectMany() вы получите коллекцию коллекций. С SelectMany() все вложенные коллекции выплюнутся в один поток элементов.

Поведение SelectMany() на массивах и списках

Допустим, есть есть массив предложений, и нужно собрать все слова в одну коллекцию:

string[] sentences = {     "Привет Хабр",     "LINQ это мощно",     "SelectMany flatten" };  // Используем SelectMany: IEnumerable<string> words = sentences     .SelectMany(s => s.Split(' '));  foreach (var word in words)     Console.WriteLine(word);

Для каждого элемента s из массива sentences вызывается проекция s.Split(' '), возвращающая string[]. Вместо того, чтобы возвращать последовательность массивов (IEnumerable<string[]>), SelectMany последовательно переливает все строки из каждого массива в результирующую плоскую последовательность IEnumerable<string>.

Аналогично на List<List<int>>:

var listOfLists = new List<List<int>> {     new List<int> {1, 2},     new List<int> {3, 4, 5},     new List<int> {6} };  var allNumbers = listOfLists.SelectMany(inner => inner);  Console.WriteLine(string.Join(", ", allNumbers));  // Выведет: 1, 2, 3, 4, 5, 6

inner — это каждая вложенная List<int>, а .SelectMany(inner => inner) возвращает все её элементы в единый поток.

Как SelectMany() плющит коллекции

Внутренне SelectMany делает примерно следующее (упрощённо):

public static IEnumerable<TResult> SelectMany<TSource, TResult>(     this IEnumerable<TSource> source,     Func<TSource, IEnumerable<TResult>> selector) {     foreach (var item in source)     {         var subCollection = selector(item);         foreach (var subItem in subCollection)             yield return subItem;     } }

Вы получаете плоский поток элементов, несмотря на рваную структуру входных данных.

Отличие от двух вложенных Select

Часто можно встретить код вида:

var nested = sentences     .Select(s => s.Split(' '))     .Select(arr => arr); // просто демонстрация вложенного Select

Это даст IEnumerable<string[]>, т.е коллекцию массивов строк. Если вы затем захотите расплющить вручную, придётся писать:

var flattened = nested.SelectMany(arr => arr);

Или:

var flattened = nested     .Select(arr => arr)     .SelectMany(arr => arr);

Но обратите внимание, что две последовательные операции Select().Select() не эквивалентны одной SelectMany(), поскольку за одну и ту же работу они берутся по-разному.

При использовании Select().Select() первый Select берёт исходный элемент и проецирует его в коллекцию, а второй Select просто применяет проекцию к каждому элементу уже полученной коллекции массивов, не распаковывая вложенные элементы. В результате вы получаете вложенную структуру, а не ровный поток значений.

Метод SelectMany() всё делает за один проход: он идёт по исходной последовательности, для каждого элемента получает коллекцию и сразу выдаёт её вложенные элементы в общий поток. В случае же двух операций Select вы сначала проходите исходный источник и собираете коллекцию массивов, а затем ещё раз итерируетесь по этой коллекции при втором Select.

Если ваша цель — сплющить вложенные коллекции в одну плоскую последовательность, SelectMany() решит задачу и лаконичнее, и эффективнее.

Проекции, флаттенинг и композиции

Проекция с превращением

Иногда хочется не просто расплющить, но и трансформировать каждый элемент вложенных коллекций:

var customers = GetCustomers(); // IEnumerable<Customer> var allOrders = customers     .SelectMany(c => c.Orders,                  (customer, order) => new {                     CustomerName = customer.Name,                     OrderId = order.Id,                     Total = order.Items.Sum(i => i.Price)                 });  foreach (var record in allOrders)     Console.WriteLine($"{record.CustomerName} -> {record.OrderId}, sum: {record.Total}");

Здесь в перегруженном варианте SelectMany мы передаём два параметра: функцию выбора вложенной коллекции (c => c.Orders) и функцию результатирования, объединяющую контекст внешнего и внутреннего элементов.

Флаттенинг с условием

Можно применять предикаты перед распаковкой, чтобы фильтровать ненужное:

var recentErrors = logFiles     .SelectMany(         file => File.ReadLines(file)                     .Where(line => line.Contains("ERROR") && DateTime.Parse(line[..10]) > DateTime.Now.AddDays(-1))     );  foreach (var errorLine in recentErrors)     Console.WriteLine(errorLine);

Применительно, если нужно забрать из множества файлов только свежие ошибки и сразу их обработать.

Цепочка вызовов

SelectMany отлично сочетается с другими LINQ-операторами:

var activeProductNames = warehouses     .SelectMany(w => w.Products)     .Where(p => p.Stock > 0)     .OrderBy(p => p.Name)     .Select(p => p.Name)     .Distinct()     .ToList();

Здесь:

  1. Расфасовываем продукты из всех складов.

  2. Фильтруем только доступные.

  3. Сортируем.

  4. Берём только имена.

  5. Убираем дубликаты.

Пример: Flatten JSON-полей

Допустим, есть задача: из списка JSON-объектов вытащить все вложенные записи, лежащие в поле "дети". Каждый родительский объект содержит массив этих детей, и нужно получить единый список всех детей из всех родителей — без вложенных циклов и ручной распаковки.

using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json.Linq;  class Program {     static void Main()     {         // Список родительских JSON-объектов, каждый из которых содержит массив "дети"         var jsonList = new List<JObject>         {             JObject.Parse(@"{                 'id': 1,                 'дети': [                     { 'имя': 'Аня',   'возраст': 7 },                     { 'имя': 'Борис', 'возраст': 10 }                 ]             }"),             JObject.Parse(@"{                 'id': 2,                 'дети': [                     { 'имя': 'Вера',   'возраст': 5 },                     { 'имя': 'Гена',   'возраст': 9 }                 ]             }")         };          // SelectMany распаковывает всех детей из всех объектов в один список         var всеДети = jsonList             .SelectMany(родитель => родитель["дети"]                 .Children<JObject>()) // достаём каждого ребёнка как JObject             .ToList();          // Работаем с детьми как с единым потоком объектов         foreach (var ребёнок in всеДети)         {             Console.WriteLine($"Имя: {ребёнок["имя"]}, Возраст: {ребёнок["возраст"]}");         }     } }

Вместо вложенных foreach вы пишете один SelectMany и сразу получаете нужные данные на выходе.

Выводы

SelectMany — лучший способ за один проход сплющить вложенные коллекции в единую последовательность, избегая двойного Select и сохраняя память благодаря ленивому выполнению. А в каких сценариях вы чаще всего используете SelectMany?


Всех желающих приглашаем 15 мая на открытый урок «Локализация текстов в Symfony», на котором познакомимся с компонентом symfony/translation и научимся извлекать данные из БД с помощью нестандартного маппинга. Записаться можно на странице курса «Symfony Framework».


ссылка на оригинал статьи https://habr.com/ru/articles/905870/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *