Привет, Хабр!
Когда вы первый раз наткнётесь на метод 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?
Всех желающих приглашаем 15 мая на открытый урок «Локализация текстов в Symfony», на котором познакомимся с компонентом symfony/translation и научимся извлекать данные из БД с помощью нестандартного маппинга. Записаться можно на странице курса "Symfony Framework".