Как стать автором
Обновить
549.88
OTUS
Цифровые навыки от ведущих экспертов

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

Уровень сложностиПростой
Время на прочтение5 мин
Количество просмотров742

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

Когда вы первый раз наткнётесь на метод 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".

Теги:
Хабы:
-3
Комментарии6

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS