воскресенье, 4 декабря 2011 г.

"Магия" Linq

imageЯ неоднократно наблюдал, что начинающие разработчики относятся к Linq (да и вообще к многим фреймворкам), как к некоторой магии. Они используют “заклинания”, получают требуемый результат – и все. При этом многие даже не пытаются понять, что же происходит за кулисами изящных синтаксических конструкций, считая это безумно сложным, низкоуровневым и недоступным простым смертным.
На самом же деле, большинство фреймворков в основе своей очень просты. Зная особенности и принципы, лежащие в основе реализации технологии можно предполагать её поведение определенных случаях, избегать скрытых “подводных камней” и потерь производительности.
Итак, из чего состоят магические заклинания Linq? В первую очередь это, конечно, синтаксические возможности C#: методы-расширения лямбда-выражения. Именно они делают конструкции Linq таким мощным инструментом. Конечно, C# имеет также встроенный язык запросов, но его возможности значительно ниже, чем полный набор предлагаемых методов. И, на мой взгляд, встроенный язык запросов менее выразительный, чем лямбды.
Ключевая мысль, которую я хочу донести: конструкции Linq – это не магические заклинания, а всего лишь синтаксически красиво оформленные вызовы совершенно конкретных методов. Эти методы написаны живыми людьми и не выполняют никаких потусторонних операций. Никто еще не придумал способа мгновенного поиска требуемого элемента в произвольном наборе данных, и выполнение операций Linq тоже требует какого-то времени. Часто трудно отследить сколько именно времени выполняется та или иная операция – это связано с тем, что без применения специальных усилий большинство Linq выражений вычисляются лениво (Lazy).

Немного конкретики

На одном из форумов был задан вопрос, как побыстрее и поэффективнее найти в списке элементов требуемый. Первый же ответ – “не мучайся, напиши Linq запрос, и все будет ОК”. На самом же деле, получение нужных элементов с помощью метода Where для LinqToObjects совсем неэффективно – это просто полный перебор. Да, это более изящно, чем писать {foreach(…) и if (…) }, но это ни капельки не быстрее. Но когда мы говорим о Linq2Sql или EF – картина совсем другая. В этом случает предикат внутри Where превращается в SQL запрос, который выполнятся средствами СУБД. Тут уже много зависит от структуры БД, наличия индексов и пр., но, как правило, такие запросы выполняются существенно быстрее чем простой перебор.
Другой пример. Linq предлагает замечательные метода: Count и ElementAt. И вот однажды я встречаю код, подобный следующему (названия методов и переменных случайны, любое совпадение с реальным кодом непреднамеренно):
var objects = sourceObject.Where(x => SomePredicate(x));
for (int i = 0; i < objects.Count(); i++)
{
    var obj = objects.ElementAt(i);
    ProcessIt(obj);

Что я хочу сказать. Linq выражения вычисляются лениво. Это замечательно в некоторых сценариях. Но не все, к сожалению понимают, что когда вызывается метод Count(), то набор вычисляется полностью, начиная с первого и до последнего элемента. Причем при каждом вызове Count() – заново. В результате, в этом примере, при обращении к Count() метод SomePredicate вызовется не N раз (N – количество элементов в sourceObjects), а N*M раз (M – количество элементов в objects).

А тут еще и ElementAt. Так же как и Count, этот метод всегда начинает перебор с начала набора. Т.е. если в исходном наборе элементов хотя бы несколько десятков, то производительности данного участка кода можно только посочувствовать. А ведь решение так близко:


var objects = sourceObject.Where(x => SomePredicate(x));
foreach(var obj in objects)
{
    ProcessIt(obj);
}

Такое решение использует преимущества ленивого вычисления последовательности и обращается к каждому элементу только один раз.Также есть возможность сформировать всю последовательность сразу, используя методы ToList() или ToArray(), тогда можно спокойно использовать и длину последовательности, и обращаться к каждому элементу по индексу. Но надо помнить, что длина последовательности заранее неизвестна и её размещение в памяти всей последовательности само по себе может оказаться проблемой. Вывод – универсального решения не бывает. Все зависит от того, что требуется получить в итоге.

К чему это я


Не сотворите себе кумира. Не относитесь к красотам синтаксических изяществ и супер возможностям фреймворков как магическим заклинаниям. Чудес не бывает. Разработчик должен понимать все что делает его программа, хотя бы в общих чертах. Любая волшебная функция написана простыми людьми, её код не содержит в себе ни капли волшебства и все её действия можно понять. Фреймворки необходимы, они позволяют создавать качественно новые продукты, за счет комбинирования возможностей, предоставляемых уже готовыми библиотеками, 10 лет назад о таком нельзя было и мечтать. Однако перед использованием библиотек не поленитесь потратить время на изучение документации и несколько экспериментов, что понять не только что может делать эта библиотека, но и как она это делает.

2 комментария:

  1. Прошу прощения, совсем не знаю Linq, но кажется, что в нём должны быть средства, чтобы то же самое записать как sourceObject.Where(SomePredicate).Map(ProcessIt).

    ОтветитьУдалить
  2. Да, это можно записать и почти так, но с небольшими замечаниями.
    В штатной поставке у класса списка есть метод ForEach, принимающий делегат, который вызывается для каждого элемента списка. Приходится писать так:
    sourceObject.Where(SomePredicate).ToList().ForEach(ProcessIt)

    Сделать вызов ForEach сразу после Where не получится. Я предполагаю, что это связано с общей политикой ленивости вычислений Linq, в то время как List - материализованная коллекция, являющаяся копией исходной.

    Кроме того, такая конструкция не всегда решает проблему. Иногда приходится сохранить перечисление, которое вернулось из Where, чтобы всегда получать новый актуальный результат (если исходная коллекция изменяется). Тогда записать конструкцию в одну цепочку не получится.

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

    ОтветитьУдалить