Привет, Хабр!
Когда вы первый раз наткнётесь на метод 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();
Здесь:
Расфасовываем продукты из всех складов.
Фильтруем только доступные.
Сортируем.
Берём только имена.
Убираем дубликаты.
Пример: 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?
Если ваша цель — вывести .NET-программирование на новый уровень, не упустите шанс освоить асинхронные потоки, Channels, Pipelines и работу с памятью на открытом уроке 19 мая. Мы разберем, как строить высокопроизводительные системы, оптимизировать обработку данных и работать с памятью на уровне Senior. Присоединяйтесь, чтобы узнать, как эффективно использовать .NET для создания масштабируемых приложений.
