Продолжается подписка на наши издания! Вы не забыли подписаться?

C# 3.0 и LINQ

Автор: Иван Бодягин
RSDN Group
Опубликовано: 22.10.2008

Концепции C# 3.0, позволившие создать LINQ

Введение

Впервые, завеса тайны над C# 3.0 и проектом LINQ была приоткрыта примерно полтора года назад, когда создатели и идейные вдохновители этого проекта, Дон Бокс и Андрес Хэйлсберг, выступили на PDC 2005 и поведали миру о том, что же это такое. С тех пор этот проект неоднократно обсуждался как в программистских форумах и блогах, так и на больших конференциях, в том числе и в России – Platform, DevDays, WebDevCon, etc.… Однако время шло, релиза все не случалось, интерес угасал, и проект начал обрастать всяческими слухами, естественно, имеющими мало общего с действительностью, как всяким приличным слухам и положено. В последнее же время, в силу ряда объективных причин – осень, полнолуние, неотвратимая близость релиза C# 3.0, LINQ, следующей версии Фреймворка и студии, разговоры о LINQ и C# 3.0 вспыхнули с новой силой, и со всей очевидностью стало ясно, что ясности в этом вопросе в народе нет. Эта небольшая статья призвана устранить сумбур, восполнить пробел и всячески осветить данный вопрос.… Впрочем, это сверхзадача, первоочередная же задача менее амбициозна – не запутать еще больше…

Тот факт, что .Net Framework не монолитен, а состоит из нескольких компонентов, то есть Common Language Runtime (далее просто «рантайм»), набора библиотек FCL и набора компиляторов – ни для кого не секрет (но многие об этом забывают :) ).

Теперь же появляется еще одна сущность – LINQ (Language Integrated Query), и данная статья посвящена как раз описанию места, которое занимает LINQ во всей этой кухне, что во что integrated и как этим можно пользоваться...

Для начала попытаемся сформулировать задачу: для чего вообще вводится LINQ, и какие проблемы с его помощью собираются решать?

На данный момент наиболее распространен объектно-ориентированый подход к разработке. Это достаточно удобная абстракция для большинства практических случаев. Однако она не является панацеей, и далеко не для всех сценариев представляет собой оптимальный выбор. Это хорошо видно на примере баз данных – несмотря на повальное засилье ООП, для персистентных хранилищ данный подход так и не сумел завоевать хоть сколько-нибудь заметную популярность.

ПРИМЕЧАНИЕ

Важное замечание – здесь и далее, когда упоминается ООП, то имеется в виду не ООП вообще, как подход к проектированию, а то, как этот подход выражается в наиболее распространенных современных языках программирования (C++, Java, C#).

Если же попытаться выделить тот класс задач, где ООП перестает справляться "на отлично", то выяснится, что пока мы имеем дело с объектом, как таковым, то все работает замечательно, но как только целый и неделимый объект превращается в контейнер и становится коллекцией других объектов – ситуация заметно осложняется... Понятное дело, что выразительности ООП достаточно, чтобы не ощущать этот недостаток в полной мере, и, в принципе, с ним мириться…. Но некий дискомфорт все равно присутствует, и особенно остро он ощущается при работе с реляционными источниками данных (там есть и другие проблемы, но они лежат совсем уж за пределами данной темы, однако все вместе это дает эффект перманентного раздражения седалищного нерва). Microsoft предпринял свою попытку вскрыть этот нарыв, на первый взгляд довольно удачную.

Итак, задача формулируется следующим образом: надо добавить в ОО-язык (каковым и является C#) средства работы с коллекциями. Для достижения этой цели было решено перенять положительный опыт реляционных СУБД, которые, по сути, и являются набором коллекций. Одной из основных причин успеха современных РСУБД, использующих SQL, является декларативность языка запросов. Он не требует описывать, «как» достичь результата, а требует лишь указать, «что» надо получить, а «как» – это уже забота оптимизатора и движка самой СУБД. Это позволяет не заботиться о конкретном алгоритме и предоставляет оптимизатору максимум свободы при выборе эффективного решения.

Современные императивные (в том числе и объектно-ориентированные) языки программирования до сих пор не предоставляли декларативных средств обработки данных (подобных SQL). Однако достаточно давно существуют функциональные языки программирования, в которых обработка данных (и особенно списков) достигла высокого уровня и фактически выглядит декларативной. В Microsoft решили взять на вооружение достижения функциональных языков программирования в данной области.

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

Собственно, дальнейший рассказ о том, что это за новая функциональность, ради чего она сделана именно такой, что из этого получилось, и как это можно использовать.

Новые возможности C# 3.0

Начнем, пожалуй, с конца, уж простите за банальность, так будет проще рассказывать об остальном... :)

Вывод типа (Type Inference)

При объявлении переменных, и особенно при работе с коллекциями (более подробно к этим сценариям мы вернемся позже) , частенько получается громоздкий и избыточный код. Ведь в большинстве случаев переменная имеет такой же тип, что и инициализирующее ее выражение. Стало быть, указание типа переменной только засоряет код и отнимает время. Для облегчения жизни в подобных случаях в C# 3.0 введена такая функциональность, как «вывод типов» (type inference). Суть этого дела довольно проста – если компилятор может вычислить тип из правой части выражения, то декларировать его в левой части не обязательно. Дабы не углубляться в излишний формализм, прибегнем к примеру:

// Вместо того, чтобы писать 
MyLongFooClassWithTemplate<MyLongTypeParameter> local 
= new MyLongFooClassWithTemplate<MyLongTypeParameter>();

// Можно объявить переменную так:
var localVar = new MyLongFooClassWithTemplate<MyLongTypeParameter>();

// А можно даже и так:
var i = 1;
var s = "SomeString";
var z = i + i * i;
var c = 'c';

Console.WriteLine(i.GetType()); // System.Int32
Console.WriteLine(s.GetType()); // System.String
Console.WriteLine(c.GetType()); // System.Char
Console.WriteLine(z.GetType()); // System.Int32

Ключевое слово «var» вместо типа переменной говорит компилятору о том, что тип выражения надо вычислить из правой части. Обратите внимание – именно компилятору, то есть типа вычисляется не во время выполнения кода, а на этапе компиляции. Язык по прежнему статически типизирован, и все ошибки, связанные с неправильным использованием типов, будут отловлены на этапе компиляции.

ПРИМЕЧАНИЕ

Ключевое слово «var», по всей видимости, использовано потому, что переменная, объявленная с его помощью, может быть изменена в дальнейшем. К сожалению, ключевое слово «var» вызывает ассоциации с динамически типизированными языками. В некоторых функциональных языках используются ключевые слова «let» или «def», но они используются для объявления переменных, доступных только для чтения (характерных для функциональных языков). Ну да не важно, главное не путаться и помнить, что объявленная таки образом переменная получает тип во время компиляции. :)

На самом деле, можно считать, что выведение типов есть и в C# 2.0. Если помните, в случае обобщенного метода тип можно не указывать, он вычисляется из параметра, например так:

// Если у нас есть такой метод, занимающийся выводом типа на консоль
public static void Print<T>(T variable)
{
  Console.WriteLine(variable.GetType());
}

// то использовать его можно так:
// (обратите внимание, явно тип параметра нигде не указывается)
Print(i); // System.Int32
Print(s); // System.String

Но это уж очень урезанная версия данной конструкции.

Как можно заметить, в C# 3 эту функциональность несколько расширили, однако использовать ее можно только в локальных переменных. Скажем, с членами класса такой фокус уже не сработает.

Справедливости ради надо сказать, что в функциональных языках, из которых данный механизм был позаимствован, он используется давно, с удовольствием и гораздо шире, чем будет предложено в третьей версии С#. Строго говоря, даже в статически типизированных языках в большинстве случаев можно обойтись без объявления типа переменной – компилятору известно о типах гораздо больше, чем кажется на первый взгляд, и вывести тип выражения он может в самых причудливых случаях. Но обычно разработчика все же заставляют декларировать тип переменной, это нужно для контроля и облегчения дальнейшей поддержки, код с явно объявленным типом гораздо проще сопровождать.

C# – это первый широко используемый язык общего назначения, где подобная функциональность вводится в достаточно большом объеме. В данном случае разработчики языка пытаются соблюсти баланс между удобством при написании приложения и простотой поддержки кода.

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

Анонимные типы (Anonymous Type).

Пришло время обсудить одну из ситуаций, когда неудобно декларировать тип переменной при ее объявлении. При полноценной работе с коллекциями тип результата операции или набора операций над коллекциями может сильно отличаться от типов обрабатываемых коллекций. Данный факт порождает следующую проблему... В господствующих статически типизированных языках (таких, как C++, C#, Java) мы обязаны сначала описать тип данных, а потом уже использовать переменные этого типа. Таким образом, при классическом подходе у нас есть два пути.

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

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

Чтобы выйти из этой ситуации, ввели такое понятие, как «анонимные типы» (anonymous type). Анонимные типы – это возможность создать новый тип, декларируя его не заранее, а непосредственно при создании переменной, причем типы и имена полей выводятся компилятором автоматически из инициализации.

Но, опять-таки, ближе к делу, на примере оно всяко понятнее:

// Вот так выглядит объявление нового типа:
var anon = new { a = 3, b = 4.81, c = "string data" };

// дальше можно спокойно использовать
Console.WriteLine(“a = “ + anon.a + “\tb = “ + anon.b + “\tc = ” anon.c);

// а так можно посмотреть, что же создалось:
Console.WriteLine(anon.GetType());

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

Как легко можно заметить, здесь также не обошлось без функциональности, описанной в предыдущем разделе. Причем вывод типов помог здесь аж два раза. Во-первых, при объявлении переменной. Без этого нам пришлось бы описывать тип дважды, и все удовольствие от анонимности типа нивелировалось бы утомительностью его объявления. А во-вторых, при описании полей их тип нигде не указан и вычисляется из типа инициализирующего выражения.

К сожалению, у анонимного типа есть один досадный недостаток, серьезно ограничивающий возможности его использования. Его нельзя экспортировать за пределы метода, в котором его создали. Связано это с тем, что при выходе третьей версии C# рантайм не будет изменен – будет использован все тот же рантайм, что поставлялся с .Net Framework 2.0, а в нем не были предусмотрены анонимные типы. Эта непредусмотрительность выливается в то, что невозможно разрешить конфликт имен при экспорте анонимного типа с совпадающей сигнатурой. По этой причине анонимный тип нельзя сделать видимым за пределами сборки. Ограниченность же пределами метода объясняется тем, что если разрешить экспортировать анонимный тип за пределы метода, то протащить его через метод за пределы сборки труда не составит (в принципе, можно было бы запретить использовать эти типы в публичных методах, так как остальные не могут быть видны за пределами сборки – прим.ред.).

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

Таким образом, технология анонимных типов в принципе отработана, но есть существенное отличие от кортежей. В ФЯ поля в кортежах не именованы, то есть добраться до значения поля в кортеже можно только с помощью специальных синтаксических форм или по индексу (исключением в данном случае является SQL, результат SQL-запроса как раз представляет собой именованный кортеж). Это не является недостатком именно кортежей, так как в типичных сценариях их использования это проблем не вызывает. Но поскольку создатели C# держали в голове, что одним из основных сценариев использования анонимных типов будет работа с коллекциями, то они совершенно сознательно сделали поля анонимных типов именованными. К именованным полям можно обращаться напрямую (без дополнительной декомпозиции), что удобно в повседневной жизни. Учитывая, что имена полей обычно выводятся компилятором автоматически, код получается кратким и понятным.

Я уделил этому так много внимания потому, что если бы анонимные типы были не именованными, то уже сейчас, даже при текущей версии рантайма, их можно было бы использовать за пределами сборки (что с успехом и делают функциональные языки для .Net, например F#). Но с учетом ориентированности новой функциональности именно на работу с коллекциями, ввод вместо кортежей полноценного типа, пусть и с ограниченной областью видимости, кажется более правильным решением.

Расширяющие методы (Extension Methods)

Следующим этапом работы с коллекциями будет создание набора операторов, необходимых для типичных преобразований... Или не типичных. Собственно, задача сводится к тому, чтобы дать возможность произвести некую операцию над уже готовым объектом, при этом исходный код объекта, возможно, уже не доступен. В принципе, задача не байт весть какая сложная... В предыдущих версиях C#, как и в других ОО–языках, она решалась следующим образом – создавался утилитный класс, в котором определялся метод, выполняющий необходимую операцию, и этот метод в качестве параметра принимал на вход наш объект.

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

public static class Util
{
  public static int ElementCount(IEnumerable enumerable)
  {
    int i = 0;
    foreach (var e in enumerable)
      i++;
    return i;
  }
}

// И тогда, для любого массива
IEnumerable<int> array = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

// Можно было бы посчитать количество элементов
Console.WriteLine(Util.ElementCount(array)); // 10

Но тут есть пара мелких неудобств. Во-первых, чтобы использовать класс Util и его методы, надо знать, что он есть, по массиву-то никак не вычислишь, что существует Util с таким замечательным методом. А во-вторых, если нам понадобится передать в утилитный метод какие-нибудь параметры, да еще совершить несколько операций подряд, то синтаксис этого дела будет довольно страшненьким. Если нужно использовать несколько утилитных методов одновременно, становится сложно не заблудиться в лесу скобок. Необходимость предварять имена статических методов именами классов, в которых они объявлены, еще больше усугубляет ситуацию. Сравните две следующие строчки:

Util.f1(Util.f2(Util.f3(x, y))))
f1().f2().f3(x, y)

Выход придумали следующий (точнее, позаимствовали из следующего стандарта C++, который так, похоже, и не появится на свет до выхода C# 3.0 – прим.ред.) – достаточно изменить одну строчку кода в утилитном классе:

// добавить this перед первым параметром метода
public static int ElementCount(this IEnumerable enumerable)
...
// и вызывать этот утилитный метод можно так 
int count = array.ElementCount();

Метод, помеченный this, было решено назвать расширяющим методом (extension method).

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

Введением расширяющих методов удалось достичь следующего: во-первых, мы избавились от необходимости явно писать имя утилитного класса. Во-вторых, подобный синтаксис гораздо прозрачнее, если придется записывать несколько операций подряд. В-третьих, автодополнение в Visual Studio само подскажет о наличии подобных методов расширения. Ну и наконец, в четвертых, чтобы это заработало, необходимо, чтобы утилитный класс был объявлен как static, и его методы также были static (что, в общем, логично), таким образом, это провоцирует писать более чистый код – утилитный класс будет именно утилитным и ничем другим (впрочем, это спорное утверждение; многие не находят ничего зазорного в том, чтобы объявлять расширяющие методы в любых, а не только статических, классах – прим.ред.).

ПРИМЕЧАНИЕ

При этом добавление this не отменяет старого способа обращения к утилитному методу, его вполне можно вызвать как раньше: int count = Util.ElementCount(array);

Однако надо понимать, что эта функциональность – синтаксический сахар в чистом виде, просто удобная форма вызова утилитных методов и никаких чудес здесь не происходит.

Лямбда-выражения (Lambda Expression).

Итак, картина потихоньку начинает вырисовываться. Теперь у нас есть возможность строить примерно такие выражения:

// псевдокод
var result = CollectionA.Join(CollectionB, <условие объединения>).GroupBy(<условие группировки>).Where(<условие фильтрации>).Select(<описание нового типа>);

Но остается неясным вопрос с различными условиями и предикатами – как их передавать в выражение? Очевидно, должна существовать некая функция, которая выполняет проверку этих условий, и у нас должна быть возможность эту функцию в выражение подпихнуть.

Тут опять будет уместно сделать небольшое лирическое отступление в сторону функциональных языков. Для программирования в функциональном стиле необходимо, чтобы язык поддерживал работу с функциями как первоклассными значениями (first-class functions). На практике это означает соблюдение следующих условий:

В C# версии 1.0 не поддерживались подобного рода конструкции. Пытливому уму должно быть очевидно, что делегаты в принципе подходят на роль функций первого класса, но в первой версии C# их нельзя было объявить в произвольном месте. Скажем, внутри метода сделать этого не удалось бы.

Давайте в очередной раз прибегнем к практической иллюстрации. Допустим, нам захотелось отфильтровать коллекцию значений типа int таким образом, чтобы остались только четные числа.

// для начала опишем ExtensionMethod для фильтрации
public static IEnumerable Filter<T>(
  this IEnumerable<T> enumerable, 
  ConditionDelegate<T> condition)
{
  foreach (T e in enumerable)
    if (condition(e))
      yield return e;
} 

// Естественно, где-то должен быть описан и ConditionDelegate
public delegate bool ConditionDelegate<T>(T condition);

Если бы мы попытались реализовать механизм работы с коллекциями с помощью обычных методов, то пришлось бы писать примерно так:

// сначала где-то реализовать тело метода
public static bool IsEven(int n)
{
  return n % 2 == 0;
}

// Ну а потом произвести непосредственно фильтрацию, передав ссылку на метод,
// запакованный в делегат, в качестве параметра.
foreach (var e in array.Filter(new ConditionDelegate<int>(IsEven)))
  Console.Write(e + ", ");

В некоторых случаях это даже удобно (если нужно использовать одно и то же условие в нескольких местах), но на практике такие методы используются по большей части только один раз, и объявлять их намного удобнее «по месту». По этой причине уже во второй версии C# ввели такую конструкцию, как «анонимный метод». Анонимный метод уже полностью удовлетворяет определению первоклассной функции, так как может быть определен в любом месте, в том числе и в теле метода. Таким образом, вышеприведенный код фильтрации с использованием анонимных методов может трансформироваться в следующее:

foreach (var e in array.Filter(
  delegate(int n) { return n % 2 == 0; }))
Console.Write(e + ", ");

Очевидно, здесь уже не нужно объявлять заранее метод IsEven из предыдущего примера, так как его тело определено прямо в месте использования.

ПРИМЕЧАНИЕ

Обратите внимание: здесь мне не пришлось указывать тип параметра делегата при обращении к утилитному методу – вместо Filter<int>(…) используется Filter(…). Здесь нам опять приходит на помощь вывод типов.

Конечно, удобно описывать метод (анонимный) в месте использования, но кратким синтаксис анонимных методов не назовешь, и запрос, условия в котором задаются с их помощью, может получиться весьма длинным и плохо читаемым... Поэтому в C# 3.0 было решено свести синтаксис анонимных методов к минимуму. Лишними оказались ключевое слово «delegate», указание типа параметра (опять спасибо вычислению типов), фигурные скобки (если у анонимного метода имеется всего один параметр) и слово «return» (подразумевается, что если return нет, то функция состоит из одного выражения, результат которого и является возвращаемым результатом функции), а для отделения параметра от тела метода ввели оператор =>. Результат было решено назвать лямбда-выражением (Lambda Expression).

ПРИМЕЧАНИЕ

Слово «лямбда» позаимствовано из функциональных языков, берущих свое начало из лямбда-исчислений Черча (см. http://ru.wikipedia.org/wiki/Лямбда-исчисление). Еще самый первый функциональный язык, Lisp, основывался на лямбдах.

С применением вышеописанного синтаксиса вызов метода фильтрации будет выглядеть так:

foreach (var e in array.Filter(n => n % 2 == 0))
  Console.Write(e + ", ");

То есть объявление анонимного метода:

delegate(int n) { return n % 2 == 0; }

сократилось до:

n => n % 2 == 0

Естественно, применение лямбда-выражений не ограничивается передачей условий в функцию. Мы вполне можем использовать их следующим образом:

ConditionDelegate<int> isEven = n => n % 2 == ;
Console.WriteLine(isEven(2));

С помощью описанной выше функциональности уже можно реализовать довольно удобный механизм для работы с различного рода контейнерами и коллекциями. Вместе с C# 3.0 будет поставляться несколько библиотек с набором расширяющих методов и делегатов, где все это уже реализовано для любого типа, реализующего интерфейс IEnumerable. Таким образом, любой объект, реализующий IEnumerable, автоматически может участвовать в запросе.

Скажем, подсчитать количество четных элементов в вышеприведенном массиве можно будет примерно так:

var q = array.Where(n => n % 2 == 0).Count();

или так:

var q = array.Count(n => n % 2 == 0);

Но на самом деле это только полдела.

Вывод типов и лямбда-выражения

Здесь нужно сделать небольшое лирическое отступление с пояснением. Дело в том, что замечательная функциональность вывода типов работает со всеми типами переменных, кроме анонимных методов и лямбд. То есть нельзя написать вот так:

var func = n => n % 2 == 0;

Может показаться, что проблема состоит в том, что компилятор не может вывести тип функции. Но это не так. Используемые в теле функции константы однозначно говорят компилятору о типе параметра. Но, допустим, у нас определены следующие делегаты:

public delegate bool Predicate1(int n);
public delegate bool Predicate2(int n);
public delegate bool Predicate3<T>(T n);
public delegate R Predicate4<T, R>(T n);

Как компилятор догадается, какому из этих делегатов соответствует наша лямбда, сигнатуры ведь совпадают? Да никак. Собственно, в этом и проблема.

Все бы ничего – в принципе, объявить тип делегата слева – не проблема. Но ведь одна из основных причин введения в язык явного механизма вывода типов – удобство работы с анонимными типами, их-то нет ни какого смысла слева объявлять... Если наша гипотетическая лямбда должна вернуть не bool, а анонимный тип, как ее описать?

Допустим, есть такой делегат:

public delegate R Func<T, R>(T t); // а он на самом деле примерно такой
                                   // и есть в поставке библиотеки...:)

и нужно описать операцию проекции, делающую из объекта «пользователь» урезанную версию, которая содержит только имя, логин и e-mail. Иными словами, надо как-то объявить такую лямбду:

... = u => new { u.Name, u.Login, u.EMail };

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

Суть обходного маневра заключается в объявлении дополнительной функции, задача которой – рассказать компилятору, что за делегат мы хотим использовать. Итак, вводим метод:

public Func<T, R> MakeLambda<T, R>(Func<T, R> f, T t){ return f; }

Как видно, метод – совершенно абстрактный, и ничего конкретного не делает. Практически он просто возвращает переданную в него функцию (делегат). Но он возвращает совершенно конкретный делегат, и этого уже достаточно компилятору, чтобы вычислить тип лямбды. Значит можно сделать примерно такой вызов:

var userProjector = MakeLambda((User u) => new { u.Name, u.Login, u.EMail });
var limitedEdition = userProjector(u);

Что, собственно, и требовалось получить. :)

Дерево выражений (Expression Tree)

Описанный выше способ позволяет указать, «что» нам надо, однако должен существовать некий механизм, который возьмет в одну лапу «что», а в другую имеющийся набор коллекций, и поймет, «как» это делать. В случае коллекций в памяти эту роль брал на себя компилятор C#, а собственно алгоритм того, «как», описывался в соответствующих лямбда-выражениях и расширяющих методах. Но для других источников данных представление нашего «что» в виде байт-кода не очень удобно... Выход придумали следующий – ввели специальный тип Expression<T>, экземпляру которого можно присвоить лямбда-выражение. При компиляции тело лямбды не компилируется, а сохраняется в виде данных, представляющих собой Абстрактное Синтаксическое Дерево – AST (Abstract Syntactic Tree) – вышеупомянутой лямбды.

Выглядит это примерно так:

// Обычная лямбда
Predicate<int> isEven = n => n % 2 == 0;
Console.WriteLine(isEven); // System.Predicate`1[System.Int32]

// Expression 
Expression<Predicate<int>> exIsEven = n => n % 2 == 0;
Console.WriteLine(exIsEven); // n => ((n % 2) = 0)

Console.WriteLine(isEven(2));   // true
Console.WriteLine(exIsEven(2)); // Compilation error

Такой подход позволяет в ходе компиляции или выполнения разобрать любым другим интерпретатором полученное AST и реализовать запрос к определенным данным. Именно по такому принципу и работают различные реализации LINQ, например, LINQ 2 SQL и LINQ 2 XML.

Для удобства работы с AST Expression предоставляет довольно богатый API – набор методов, с помощью которых переданный участок кода можно как разобрать, так и «собрать», и даже скомпилировать в байт-код для последующего выполнения. Выглядит это примерно так:

// тело лямбды
//      
Console.WriteLine(exIsEven.Body); // ((n % 2)=0

// тип лямбды
Console.WriteLine(exIsEven.Type); // System.Predicate`1[System.Int32]

// имя параметра
Console.WriteLine(exIsEven.Parameters[0]); // n

Новое выражение можно построить и динамически:

// создать параметр лямбды
var parameterN = Expression.Parameter(typeof(int), "n");

//  собственно построение выражения
// 
var expression = Expression.Lambda<Predicate<int>>(
  Expression.Equal(        
    Expression.Modulo(
    parameterN,
    Expression.Constant(2)),
    Expression.Constant(0)
  ),
  parameterN);

// выводим, что получилось
Console.WriteLine(expression); // n => ((n % 2) =0)

// теперь это можно скомпилировать
Predicate<int> compiledExpression = expression.Compile();

// и использовать
Console.WriteLine();
Console.WriteLine(" 2 is Even ? " + compiledExpression(2)); // true
Console.WriteLine(" 3 is Even ? " + compiledExpression(3)); // false

Ленивые вычисления (Lazy Evaluation)

Суть явления под названием «ленивые вычисления» заключается в том, что вычисления откладываются до тех пор, пока не понадобится их результат. Такой подход позволяет гарантировать, что вычисляться будут только те данные, которые требуются для получения конечного результата, и тем самым позволяет описывать только зависимости функций друг от друга, и не следить за тем, чтобы не осуществлялось «лишних вычислений».

ПРИМЕЧАНИЕ

В исходном своем смысле «ленивые вычисления» подразумевали откладывание вычислений до того момента, пока их результат не понадобится программе. При этом, если программа однажды затребовала вычисляемое значение, то значение повторно не вычисляется. Итераторы C# же, используемые в LINQ, производят вычисления при каждом новом запросе данных. Поэтому называть их «ленивыми вычислениями» не вполне корректно, но, в общем, это несущественно. – прим.ред.

В случае C# это может быть проиллюстрировано следующим образом:

// используя несколько видоизмененный класс с расширяющим 
// методом из предыдущего примера
public static class Util
{

  public static IEnumerable<T> LazyFilter<T>(
    this IEnumerable<T> enumerable,
    Predicate<T> predicate)
  {
    foreach (var e in enumerable)
      if (predicate(e))
      {
        Console.Write("(true)");
        yield return e;
      }
  }
}

// следующий код 
IEnumerable<int> array = new [] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

Console.WriteLine("фильтр");
var q = array.LazyFilter(n => n % 2 == 0);
Console.WriteLine("итератор");
foreach (var v in q)
  Console.Write(v + ", ");

// выдаст такой результат:
фильтр
итератор
(true)2, (true)4, (true)6, (true)8, (true)10,

То есть, реальное обращение к элементам коллекции array произошло не в тот момент, когда была применена фильтрация и объявлена переменная «q», а когда нам понадобился результат этой фильтрации. Причина такого поведения в реализации утилитного метода LazyFilter<T>. Если бы он был описан не через yield return, а прямым перебором, то фильтрация произошла бы в момент использования.

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

Например, так:

var q = array.Where(...); // запрос не выполняется

// вычисления
// ...
q = q.Where(...); // и здесь не выполняется
q = q.Select(...).Where(...); // и даже здесь

// а вот здесь уже будет реальное выполнение запроса
foreach(var result in q)
...

Остались завершающие штрихи и мелкие приятности...

Auto Properties

Мы все прекрасно знаем правила гигиены при написании кошерного кода. Одно из них заключается в том, что в классе не должно быть публичных полей. Вместо них должно быть приватное поле, обернутое в свойство. Все бы ничего, но если ваш класс состоит практически из одних публичных свойств (а такое встречается довольно часто), то выглядит это довольно забавно, но ни разу не практично. Если написать подобный класс сильно помогают различного рода механизмы автодополнения, типа того же ReSharper-а, то прочитать это дело – задача нетривиальная...

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

Например, так:

public class Animal
{
  public int Id { get; set; }
  public string Name { get; set; }
  public bool CanFly { get; set; }
}

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

Если же мы хотим, чтобы свойство было доступно извне только для чтения, то синтаксис будет следующим:

  public int Id { get; private set; }

Совсем отказаться от объявления сеттера нельзя, так как свойство нельзя будет проинициализировать.

Частичные методы (Partial Methods)

В C# 2.0 появились частичные классы (partial class), которые сильно помогают при кодогенерации для отделения сгенерированного кода от рукописного (по сути, это реализация паттерна Generation Gap, встроенная в язык). Благодаря этому серьезно облегчилась работа с визуальными дизайнерами.

В LINQ2SQL или LNQ2Entity кодогенерация будет использоваться довольно активно. По этой причине решили не останавливаться на достигнутом, и в C# 3.0 введут частичные методы (partial methods).

По сути своей, частичные методы объявляются как обычные приватные методы с ключевым словом partial в начале и с пустым телом. Само тело метода может быть реализовано где угодно, но в том же классе. Если тело метода реализовано, то все вызовы компилятор переправит на него, если же реализации не будет, то компилятор просто выкинет вызов этого метода.

Пара важных нюансов:

ПРИМЕЧАНИЕ

В принципе, частичные методы можно воспринимать как облегченную версию событий (event). Если есть обработчик (реализация частичного метода), вызов события обрабатывается, если же реализации нет – при вызове ничего не происходит.

Для чего такая штука может пригодиться?

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

С помощью частичных методов довольно удобно обрабатывать различные ситуации с константами компиляции, например, реализовать тело логгера только в секции #if DEBUG … и спокойно использовать его, где надо. В Release версии все вызовы логгера будут автоматически удалены. Пустячок, а приятно. :)

Синтаксис

Приятным бонусом является новый синтаксис инициализации объектов и массивов. Если тип оборудован рядом публичных свойств, то теперь нет необходимости сначала создавать экземпляр объекта, а затем по отдельности инициализировать необходимые свойства или же описывать специальные конструкторы, в задачу которых входит исключительно инициализация свойств из значений параметров. Теперь все стало гораздо проще – начальные значения можно указать сразу при создании объекта, все остальное возьмет на себя компилятор. Например, создание экземпляра класса, описывающего какого-нибудь зверька, может выглядеть примерно так:

new Animal{ Id = 1, Name = "Eagle", CanFly = true }

а массива таких зверьков – так:

var animals = new Animal[]
{
  new Animal{ Id = 1, Name = "Eagle",    CanFly = true  },
  new Animal{ Id = 2, Name = "Cat",      CanFly = false },
  new Animal{ Id = 3, Name = "Hedgehog", CanFly = true  }
};

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

var q2 = from a in animals
  where a.CanFly
  orderby a.Id descending
  select new { a.Id, a.Name };

Понятно, что вся эта красивость превращается в уже пройденное нами:

var q = animals
  .Where(a => a.CanFly)
  .Select(a => new { a.Id, a.Name })
  .OrderByDescending(a => a.Id);

но первый вариант несколько приятнее... :)

Таким образом, собственно от LINQ-а в язык будет встроен лишь этот неприхотливый синтаксис, а вся остальная функциональность, на которой построен LINQ, ценна и сама по себе.


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

Copyright © 1994-2016 ООО "К-Пресс"