LINQ

Если вы потратили много времени на C # и .NET, вероятно, вы столкнулись с LINQ (Language-Integrated Query), который позволяет вам использовать ряд мощных возможностей запросов непосредственно на языке C #.

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

Используя LINQ, мы можем:

  • Отфильтруйте по возрасту с помощью Where
  • Сопоставьте объект Person со строкой имени с помощью Select
  • Преобразовать запрос в список с помощью ToList
public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}

var people = new List<Person>
{
    new Person { Name = "Sam", Age = 27 },
    new Person { Name = "Suzie", Age = 17 },
    new Person { Name = "Harry", Age = 23 },
};

var adultNames = people
    .Where(person => 
    {
        Console.WriteLine("Filtering by age...");
        return person.Age >= 18;
    })
    .Select(person => person.Name)
    .ToList();

foreach(var name in adultNames)
    Console.Writeline(name);

foreach(var name in adultNames)
    Console.Writeline(name);

/* output
Filtering by age
Filtering by age
Filtering by age
Sam
Harry
Sam
Harry
*/

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

Итак, что произойдет, если мы оставим ToList?

var adultNames = people
    .Where(person => 
    {
        Console.WriteLine("Filtering by age...");
        return person.Age >= 18;
    })
    .Select(person => person.Name);

foreach(var name in adultNames)
    Console.Writeline(name);

foreach(var name in adultNames)
    Console.Writeline(name);

/* output
Filtering by age
Sam
Filtering by age
Filtering by age
Harry
Filtering by age
Sam
Filtering by age
Filtering by age
Harry
*/

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

Преимущества отложенного исполнения

Похоже, что отложенное выполнение является поведением LINQ по умолчанию, если только вы явно не укажете ему немедленно выполнить оценку (используя ToList, ToDictionary и т. д.). Так что в этом должна быть какая-то польза, верно?

1. Лучшая производительность

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

2. Построение запроса

Поскольку нет необходимости выполнять запрос немедленно, вы можете создать его в несколько этапов, возможно, пропустив дополнительную условную логику. Это дает вам дополнительные возможности для создания более сложных запросов.

public IEnumerable<Person> GetNames(IEnumerable<Person> people, bool onlyAdults)
{
    var query = people.AsEnumerable();

    if (onlyAdults)
    {
        // only add this filter when onlyAdults is true
        query = query.Where(person => person.Age >= 18);
    }

    query = query.Select(person => person.Name);

    return query.ToList();
}

3. Всегда переоценен

Поскольку запрос всегда переоценивается при каждом перечислении, вы можете добавлять / удалять / изменять элементы своей коллекции после того, как запрос был построен, и запрос будет знать об этих изменениях. Таким образом, вы знаете, что всегда просматриваете самые свежие данные.

var people = new List<Person>
{
    new Person { Name = "Sam", Age = 27 },
    new Person { Name = "Suzie", Age = 17 },
    new Person { Name = "Harry", Age = 23 },
};

var adultNames = people
    .Where(person => person.Age >= 18)
    .Select(person => person.Name);

foreach(var name in adultNames)
    Console.WriteLine(name);

people.Add(new Person { Name = "Sally", Age = 26 });

foreach(var name in adultNames)
    Console.WriteLine(name);

/* output
Sam
Harry
Sam
Harry
Sally
*/

Подводные камни отсрочки исполнения

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

Хотя в целом отложенное выполнение рассматривается как повышение производительности, могут быть случаи, когда оно действительно может значительно замедлить работу вашего приложения, если вы не будете осторожны. Каждый раз, когда вы знаете, что вам нужно будет повторить итерацию по одной и той же коллекции несколько раз (например, вложенный цикл for / foreach), убедитесь, что вы сначала получаете список. В противном случае вы будете оценивать всю коллекцию каждый раз, что резко снизит производительность. Это особенно верно, если исходная коллекция велика, поскольку, даже если ваш запрос выполняет большую фильтрацию, запрос будет применяться каждый раз ко всей исходной коллекции.

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

var listOfIds = new List<int> { 1, 5, 8, 15 };

var tasks = listOfIds.Select(id => _repository.GetAsync(id));
await Task.WhenAll(tasks);
var results = tasks.Select(task => task.Result).ToList();

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

var tasks = listOfIds.Select(id => _repository.GetAsync(id)).ToList();

Вывод

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