среда, 17 февраля 2010 г.

Технология LINQ

Данная статья описывает технологию LINQ и ее использование в .NET. Исходный код к этой статье и ее текст в формате Microsoft Word можно найти здесь.

Оглавление

Технология LINQ.. 1

Оглавление. 1

Задачи технологии LINQ.. 3

Первая встреча. 4

Методы расширения. 5

Отложенное исполнение. 6

LINQ to SQL. 7

Запросы к базе данных. 11

Класс DataLoadOptions. 13

Интерфейсы IEnumerable и IQueryable. 15

Операции для работы с коллекциями. 17

Синтаксис операторов LINQ.. 17

Операторы проецирования. 18

Оператор Select. 18

Ключевое слово into. 19

Ключевое слово let. 19

Анонимные типы.. 20

Оператор SelectMany. 21

Объединение с помощью SelectMany. 22

Операторы фильтрации. 22

Операторы объединения. 23

Оператор Join. 23

Оператор GroupJoin. 24

Сложные ключи. 25

Операторы упорядочивания. 25

Оператор группирования. 26

Операции над множествами. 27

Методы преобразования. 27

Методы приведения типа коллекции. 27

Немедленное выполнение. 28

Операторы AsEnumerable и AsQueryable. 28

Поэлементные операции. 29

Методы агрегирования. 30

Квантификаторы.. 30

Методы генерирования коллекций. 31

LINQ to XML. 31

Конструирование XML. 31

Навигация по XML-документу. 32

Навигация по узлам–потомкам.. 33

Навигация по родительским элементам.. 33

Навигация по элементам одного уровня. 33

Навигация по атрибутам.. 33

Заключение. 34

Библиография. 34


Задачи технологии LINQ

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

И, несомненно, программам необходимо определенным образом обрабатывать эти наборы. Типичными операциями являются:

· Выбор из набора объектов только тех, которые удовлетворяют определенным условиям (фильтрация).

· Упорядочивание набора как минимум по одному, а иногда и по нескольким критериям (сортировка).

· Преобразование объектов набора в другие объекты, например, с целью отображения информации на экране (проецирование).

· Вычисление некоторых функций на наборе объектов (агрегирование).

Это далеко не полный список операций, выполняемых над наборами данных.

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

Несомненно, было бы значительно более удобно, если бы программист мог просто указать, какой именно результат ему нужен, а все действия, необходимые для получения этого результата, система выполнила бы сама. Более того, уже давно существует такой язык программирования, который осуществляет эту идею. Я говорю об SQL.

Наконец-то Microsoft предоставила нам возможность использовать подобный подход и в .NET. Встречайте технологию LINQ. Вот ее основные возможности:

· Существует удобный SQL-подобный синтаксис, позволяющий записать то, что вы хотите получить от коллекции. Все детали реализации скрыты от вас.

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

· LINQ практически одинаковым образом обрабатывает как коллекции объектов в памяти, так и записи базы данных и элементы XML.

Фактически вы можете применять синтаксис LINQ ко всем объектам, реализующим интерфейс IEnumerable.

Давате же начнем более подробное рассмотрение этой технологии.

Первая встреча

Пусть у нас есть простой массив, в котором мы храним имена людей (проект Beginning):

string[] names = new string[] { "John", "Tom", "Mary", "Ann", "Peter", "Sara", "Bill" };

Вот, наверное, наиболее простой LINQ-запрос, который может быть выполнен к этому массиву (класс Exercise1):

IEnumerable<string> result = from n in names select n;

Как видите, он очень похож на SQL-запрос, который в случае, если бы имена лежали в таблице Names, мог бы выглядеть так:

SELECT * FROM Names

Наверное наиболее существенным отличием этих запросов является то, что выражение fromin было вынесено в LINQ в начало запроса, тогда как в SQL оно находится уже после SELECT. Для этого существует определенная причина. Выражение fromin вводит коллекцию, с которой запрос будет работать. Поскольку оно стоит в самом начале, то IntelliSense с самого начала знает, с объектами какого типа предстоит работать, и обеспечивает строгую типизацию во время разработки.

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

IEnumerable<string> result = from n in names where n.Length > 3 select n;

А вот так мы можем добавить сортировку (класс Exercise3):

IEnumerable<string> result = from n in names where n.Length > 3 orderby n select n;

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

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

Методы расширения

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

class LINQSupportTest

{ }

Код типа:

LINQSupportTest test = new LINQSupportTest();

LINQSupportTest result = from t in test select t;

немедленно выдает ошибку компиляции:

Could not find an implementation of the query pattern for source type 'Beginning.Exercise4.LINQSupportTest'. 'Select' not found.

Как видно, не найден некий ‘Select’. На самом деле, это просто метод, у которого должна быть определенная сигнатура. Давайте добавим его:

class LINQSupportTest

{

public TResult Select(Func<LINQSupportTest, TResult> f)

{

return f(this);

}

}

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

Итак, LINQ работает на основе некоторых методов, которые должны быть у класса, к экземпляру которого делается LINQ-запрос. Но в этом случае возникает другой вопрос. Как уже было сказано, LINQ может работать с совершенно любой коллекцией, точнее с любым классом реализующим интерфейс IEnumerable. Это означает, что все эти классы должны иметь методы, необходимые для работы LINQ. Однако обращение к справке MSDN говорит нам, что в интерфейс IEnumerable не добавлено никаких методов. Если вам требуется реализовать его, то необходимо по-прежнему создать лишь метод GetEnumerator. Откуда же берутся остальные методы, необходимые для работы LINQ?

Эти методы на самом деле исполнены в виде методов расширения. Что это такое? Предположим, что у вас есть некоторый класс, который вы не можете изменить. Наиболее типичным примером являются классы, входящие в состав .NET Framework. Пусть, например, вы хотите, чтобы класс string имел метод, сообщающий вам, является ли текст в данной строке представлением целого числа. .NET Framework 3 дает вам такую возможность с помощью методов расширения (класс Exercise5).

Метод расширения определяется в ином классе, а не в том, у которого вы бы хотели, чтобы он был. Создадим класс StringExtender с методом IsInteger:

static class StringExtender

{

public static bool IsInteger(this string str)

{

int res;

return int.TryParse(str, out res);

}

}

Учтите, что этот класс должен быть статическим (static). Все его методы, поэтому, так же статические. Мы хотим, чтобы у класса string появился метод IsInteger. Для этого самый первый параметр этого метода должен иметь тип string и быть снабженным модификатором this. Вот и все, мы создали метод расширения. Чтобы использовать его как обычный метод класса string мы просто должны иметь ссылку на пространство имен, в котором определен класс StringExtender. После этого все становится просто:

Console.WriteLine("34 is integer. {0}", "34".IsInteger());

Console.WriteLine("AAA is integer. {0}", "AAA".IsInteger());

Итак, метод расширения – метод, определенный в одном классе, но использующийся экземплярами другого класса. На самом деле, компилятор просто преобразует вызов

"34".IsInteger()

в

StringExtender.IsInteger("34")

Вот так это работает.

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

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

Отложенное исполнение

Давайте рассмотрим следующий код (класс Exercise6):

List<string> names = new List<string> { "John", "Tom", "Mary", "Ann", "Peter", "Sara", "Bill" };

IEnumerable<string> result = from n in names orderby n select n;

names.Clear();

foreach (string name in result)

{

Console.WriteLine(name);

}

Как вы думаете, каков будет его результат? Вы правы, если решили, что программа совсем ничего не выведет на экран. Но почему? Ведь список имен был очищен после того, как мы выполнили наш LINQ-запрос. Ответом является отложенное исполнение. Дело в том, что выражение

from n in names orderby n select n

на самом деле возвращает не коллекцию, являющуюся результатом обработки списка names, а некоторый класс, который способен выполнить эту обработку. Но сама обработка будет произведена только в момент вызова foreach. Об этом необходимо постоянно помнить, чтобы избежать «странных» ошибок в вашем коде. Если же вам необходимо, чтобы запрос был выполнен немедленно, воспользуйтесь функциями ToArray или ToList. Более подробно речь о них пойдет позже.

LINQ to SQL

Мы посмотрели, как работает LINQ с различными коллекциями объектов в памяти компьютера. Давайте теперь посмотрим, как LINQ работает с базами данных (проект LINQtoSQL). В качестве базы данных мы будем использовать свободно распространяемую тестовую базу Northwind, установленную на Microsoft SQL Server. Следует сказать, что технология LINQ to SQL работает только с этой СУБД. Вам не удастся заставить ее работать с Oracle или с какой-либо иной системой баз данных, но в Интернет вы можете найти множество свободно распространяемых LINQ-провайдеров для любых типов баз данных.

Создадим новый проект и добавим в него файл типа LINQ to SQL Classes:

Откроется пустой дизайнер для создания классов для работы с базой данных. Теперь мы должны указать, с какой базой данных мы будем работать. Для этого откройте Server Explorer (ViewServer Explorer), щелкните правой кнопкой мыши на строке Data Connections и из появившегося контекстного меню выберите Add Connection… В появившемся окне Choose Data Source выберите Microsoft SQL Server:

Нажмите Continue. В появившемся окне Add Connection установите нужные параметры соединения:

Нажмите кнопку Ok. В вашем окне Server Explorer должно появиться новое соединение с указанной базой данных. Разверните его, щелкнув на крестике слева от названия соединения, затем так же разверните узел Tables. Выберите необходимые вам для работы таблицы и перетащите их область дизайнера:

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

Давайте посмотрим, что же для нас сделал дизайнер. Для этого откроем класс NWClasses.designer.cs, в котором находится сгенерированный для нас код. Во-первых, в нем содержится по одному классу на каждую использованную нами таблицу базы данных:

[Table(Name="dbo.Categories")]

public partial class Category : INotifyPropertyChanging, INotifyPropertyChanged

{

private static PropertyChangingEventArgs emptyChangingEventArgs = new PropertyChangingEventArgs(String.Empty);

private int _CategoryID;

...

private EntitySet<Product> _Products;

public Category()

{

this._Products = new EntitySet<Product>(new Action<Product>(this.attach_Products), new Action<Product>(this.detach_Products));

OnCreated();

}

[Column(Storage="_CategoryID", AutoSync=AutoSync.OnInsert, DbType="Int NOT NULL IDENTITY", IsPrimaryKey=true, IsDbGenerated=true)]

public int CategoryID

{

get

{

return this._CategoryID;

}

set

{

if ((this._CategoryID != value))

{

this.OnCategoryIDChanging(value);

this.SendPropertyChanging();

this._CategoryID = value;

this.SendPropertyChanged("CategoryID");

this.OnCategoryIDChanged();

}

}

}

[Association(Name = "Category_Product", Storage = "_Products", ThisKey = "CategoryID", OtherKey = "CategoryID")]

public EntitySet<Product> Products

{

get

{

return this._Products;

}

set

{

this._Products.Assign(value);

}

}

Каждый из классов обрамлен атрибутом Table, указывающим, к какой таблице этот класс относится. Кроме того, для каждого поля таблицы создается отдельное свойство, обрамленное атрибутом Column, в котором указывается название поля, его тип и некоторая иная служебная информация. Если ваша таблица связана с помощью вторичного ключа (foreign key) с другой таблицей, то для этой связи так же создается свойство, позволяющее получить записи из связанной таблицы для данной записи основной таблицы. Так в нашем примере одной записи таблицы Categories может соответствовать несколько записей таблицы Products. Этой связи соответствует свойство Products, обрамленное атрибутом Association, который содержит всю необходимую информацию о вторичном ключе.

Обратите внимание на тип свойства Products: EntitySet<Product>. Этот тип обеспечивает отложенную загрузку данных (lazy loading). Это означает, что когда создается экземпляр класса Category, информация из таблицы Products не извлекается. Она будет получена только после первого обращения к свойству Products. Это, несомненно, очень полезный механизм, позволяющий избежать загрузки из базы данных ненужной информации и снизить нагрузку на СУБД. При необходимости этот механизм можно отключить, о чем будет сказано позднее.

Кроме того, был так же создан класс-наследник DataContext: NWClassesDataContext. Это центральный класс технологии LINQ to SQL, обеспечивающий связь с базой данных. Он используется практически во всех запросах LINQ к базе. Этот класс имеет по одному свойству на каждую таблицу, помещенную нами в дизайнер. Через эти свойства вы можете обращаться к таблицам для получения записей.

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

xml version="1.0" encoding="utf-8" ?>

<configuration>

<configSections>

configSections>

<connectionStrings>

<add name="LINQtoSQL.Properties.Settings.NorthwindConnectionString"

connectionString="Data Source=.;Initial Catalog=Northwind;Integrated Security=True"

providerName="System.Data.SqlClient" />

connectionStrings>

configuration>

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

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

Запросы к базе данных

Давайте же напишем какой-нибудь запрос к базе данных. Пусть нам нужно вывести список наименований всех товаров. Следующий код выполняет эту работу (класс Exercise1):

using (NWClassesDataContext context = new NWClassesDataContext())

{

IEnumerable<string> productNames = from p in context.Products select p.ProductName;

foreach (string name in productNames)

{

Console.WriteLine(name);

}

}

Первоначально мы должны создать объект NWClassesDataContext. Как я уже говорил, он отвечает за все взаимодействие с базой данных. Содержимое таблицы товаров Products доступно нам через его свойство Products. Теперь имена всех товаров мы можем получить через простой LINQ-запрос:

from p in context.Products select p.ProductName

Обратите внимание, что он практически ничем не отличается от запроса к коллекции объектов в памяти компьютера. Такая унифицированность – несомненное достоинство технологии LINQ. Аналогичным образом мы можем выполнять фильтрацию и сортировку (класс Exercise2):

from p in context.Products where p.ProductName.StartsWith("P") orderby p.ProductName select p.ProductName

Как видите, операция выборки из базы данных осуществляется очень просто. Рассмотрим теперь, как сделать CRUD-операции: создание, удаление и обновление данных. Давайте добавим в базу информацию о новом поставщике (shipper) (класс Exercise3). Код, выполняющий это, выглядит следующим образом:

Shipper newShipper = new Shipper() { CompanyName = "Edlin Shipping", Phone = "555-55-55" };

context.Shippers.InsertOnSubmit(newShipper);

context.SubmitChanges();

Вы просто создаете новый объект Shipper, передаете его в метод InsertOnSubmit соответствующей таблицы и вызываете метод SubmitChanges контекста, чтобы сохранить изменения. Более того, если в базе данных для нашей таблицы прописаны автоматически генерирующиеся поля (обычное дело для первичных ключей), то после выполнения SubmitChanges наш объект автоматически получит корректное значение этого поля.

Удалить запись из базы данных так же очень просто (класс Exercise4). Для этого нужно передать объект, соответствующий удаляемой записи, методу DeleteOnSubmit соответствующей таблицы и вызываете метод SubmitChanges контекста, чтобы сохранить изменения:

context.Shippers.DeleteOnSubmit(shipper);

context.SubmitChanges();

Изменить поле записи еще проще. Вы изменяете соответствующее свойство объекта и вызываете метод SubmitChanges контекста (класс Exercise5):

shipper.CompanyName = "Confirmit Shipping";

context.SubmitChanges();

У вдумчивого читателя возникнет вопрос, как же это происходит. Откуда DataContext узнает, что объект изменился? Совершенно верно, DataContext хранит ссылки на все объекты, которые он когда-либо вернул. В момент вызова SubmitChanges он проверяет, нет ли в них изменений, и записывает найденные изменения в базу данных. Более того, DataContext всегда возвращает один и тот же объект для одной записи таблицы. Как он узнает, какой из объектов соответствует данной записи? По первичному ключу. Посмотрите, как определено свойство CategoryID нашего объекта Category:

[Column(Storage="_CategoryID", AutoSync=AutoSync.OnInsert, DbType="Int NOT NULL IDENTITY", IsPrimaryKey=true, IsDbGenerated=true)]

public int CategoryID

В атрибуте Column указано, что данное свойство является первичным ключом. При загрузке записей из базы данных DataContext проверяет, есть ли в его кэше объект Category с таким же первичным ключом. Если он есть, то будет возвращен уже существующий объект. Если его нет, то будет создан новый объект, так же сохраняемый в кэше. Следующий простой пример иллюстрирует это (класс Exercise6). Давайте получим одну запись разными способами, а потом попробуем изменить свойство одного из полученных объектов и посмотрим, изменилось ли соответствующее свойство другого.

Shipper newShipper = new Shipper() { CompanyName = "Edlin Shipping", Phone = "555-55-55" };

context.Shippers.InsertOnSubmit(newShipper);

context.SubmitChanges();

Shipper s1 = (from s in context.Shippers where s.CompanyName == "Edlin Shipping" select s).First();

Shipper s2 = (from s in context.Shippers where s.Phone == "555-55-55" select s).First();

s1.CompanyName = "Confirmit Shipping";

Console.WriteLine(s2.CompanyName);

Как видите, свойство CompanyName второго объекта так же изменилось.

С кэшированием возвращаемых классом DataContext объектов связан еще один тонкий момент. Пусть мы хотим для каждого существующего поставщика создать еще одну запись с несколько измененным именем. Код, который делает это, довольно прост на первый взгляд (класс Exercise7):

IEnumerable<Shipper> newShippers = from s in context.Shippers select new Shipper() { CompanyName = "New " + s.CompanyName, Phone = s.Phone };

context.Shippers.InsertAllOnSubmit(newShippers);

context.SubmitChanges();

В первой строке мы получаем все объекты Shipper из таблицы и для каждого из них создаем новый объект Shipper. Затем мы вставляем новые объекты в соответствующую таблицу и сохраняем изменения. Этот код прекрасно компилируется, но при выполнении генерирует исключение:

Explicit construction of entity type 'LINQtoSQL.Shipper' in query is not allowed.

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

Класс DataLoadOptions

Класс DataContext имеет свойство LoadOptions типа DataLoadOptions. Это свойство управляет загрузкой объектов из базы данных. Давайте рассмотрим, какие же возможности оно нам предоставляет.

Вы помните, что связи между таблицами, представленные в базе данных внешними ключами (foreign key), моделируются в классах LINQ to SQL свойствами с отложенной загрузкой. Рассмотрим следующий код (класс Exercise8):

IEnumerable<Category> categories = from c in context.Categories select c;

foreach (Category c in categories)

{

Console.WriteLine(c.CategoryName);

foreach (Product p in c.Products)

{

Console.WriteLine(" {0}", p.ProductName);

}

}

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

Сделать это можно с помощью объекта DataLoadOptions. Для этого перед выполнением LINQ-запроса необходимо создать объект этого типа, вызвать его метод LoadWith и присвоить объект свойству LoadOptions объекта DataContext. После того, как это присвоение произошло, модифицировать объект DataLoadOptions уже нельзя:

DataLoadOptions options = new DataLoadOptions();

options.LoadWith<Category>(c => c.Products);

context.LoadOptions = options;

Какие же параметры принимает метод LoadWith? Это generic–метод. Передаваемый ему класс – тот класс, свойство которого вы хотите заполнить из базы одновременно с ним. В нашем случае мы хотим, чтобы загружалось свойство Products класса Category, поэтому именно Category мы указываем в качестве класса–параметра метода LoadWith. Аргумент этого метода указывает, какое именно свойство должно загружаться совместно с указанным классом. Тип этого аргумента: Expression<Func<T, object>>, но вместо него всегда можно передать делегат Func<T, object>, т.к. он неявно приводим к нужному типу. Что такое делегат, вам, конечно же, известно, но что такое Expression? Этот класс представляет собой объектную модель кода.

Вдумайтесь в это хорошенько. Класс Expression содержит в себе всю информацию о коде: переменные и вызываемые методы, создание классов и конструкции if и for, все, все, все! Поскольку любой метод вы можете обернуть в делегат, то для него вы можете создать и объект Expression, тем самым получив абсолютно полную информацию о коде данного метода. И наоборот, вы можете программно создавать собственные методы, поскольку можно вручную создать объект Expression, а затем преобразовать его в делегат, что практически одно и то же, что и метод. Это мощнейший механизм, которым вы можете воспользоваться. Конечно, использование этой технологии – не простая задача, и ее описание выходит за пределы этой статьи, но, при необходимости, вы можете изучить ее и пользоваться.

Итак метод LoadWith принимает в качестве аргумента объект Expression. С его помощью он анализирует код переданного делегата и извлекает информацию о том, какое свойство должно быть загружено совместно с указанным классом.

Обратите внимание, как мы передали нужный делегат этому методу:

c => c.Products

Это новая форма записи делегатов – лямбда-выражение. Слева от знака => описываются параметры делегата. Если их несколько, они заключаются в скобки (например, (object sender, EventArgs e)), если параметров нет, используйте пустые скобки (). Справа от знака => идет тело делегата. Если оно состоит из одного оператора return, то вы можете даже не писать ключевое слово return. В ином случае используйте фигурные скобки {} и пишите тело как для обычного анонимного делегата. Вот примеры ряда лямбда-выражений:

c => c.Products

(object sender, EventArgs e) => Console.WriteLine(“Event obtained”)

() => { return “Hello, world!” }

О том, зачем потребовалась новая форма записи делегатов, мы поговорим позже. Теперь же рассмотрим еще одну возможность класса DataLoadOptions – автоматическую (или неявную) фильтрацию.

Предположим, что вас интересуют только товары с ценой, большей опеределенной суммы. Конечно же, вы можете вставлять оператор where в каждый ваш запрос, но есть способ лучше. Он заключается в использовании метода AssociateWith класса DataLoadOptions (класс Exercise9):

DataLoadOptions options = new DataLoadOptions();

options.AssociateWith<Category>(c => from p in c.Products where p.UnitPrice.HasValue && p.UnitPrice.Value > 20 select p);

context.LoadOptions = options;

Метод AssociateWith в данном случае говорит, что при загрузке значения свойства Products любых объектов класса Category должны браться только продукты, чья цена превышает 20 единиц.

Этот фильтр теперь неявно будет применяться для всех свойств Products класса Category, как бы вы их не загружали. Теперь простой перебор:

foreach (Product p in category.Products)

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

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

Интерфейсы IEnumerable и IQueryable

Предположим, что вам необходимо извлечь из базы данных информацию о тех продуктах, названия которых удовлетворяют определенному регулярному выражению. Если вы ничего не знаете о регулярных выражениях, не страшно. Пока вы можете считать, что они предоставляют способ проверить, удовлетворяет ли некоторая строка определенному шаблону или нет. Например, регулярное выражение ^[SP] проверяет, начинается ли строка с заглавных букв S или P. Конечно, эту проверку можно выполнить и без использования регулярных выражений, но для простоты примера этого будет достаточно.

Вот как выглядит соответствующий запрос к базе данных (класс Exercise10):

IEnumerable<Product> products = from p in context.Products where Regex.IsMatch(p.ProductName, "^[SP]") select p;

Класс Regex как раз и отвечает за проверку соответствия. Этот код прекрасно компилируется. Однако во время выполнения возникает исключение:

Method 'Boolean IsMatch(System.String, System.String)' has no supported translation to SQL.

Но если выполнить тот же запрос к массиву строк, а не к базе данных, то все пройдет замечательно (класс Exercise11). В чем же различие?

Различие в том, что коллекции объектов в памяти реализуют интерфейс IEnumerable, для которого реализация поддержки LINQ находится в классе Enumerable. Объекты же, которые используются для обращения к базе данных, реализуют интерфейс IQueryable (расширяющий IEnumerable), для которого реализация поддержки LINQ находится в классе Queryable. Содержа одни и те же методы, эти классы предоставляют различную их реализацию. Давайте разберемся, в чем их отличия.

Начнем с интерфейса IEnumerable. Вот как просто можно создать метод Where, реализующий фильтрацию в LINQ (класс Exercise12):

public static IEnumerable Where(this IEnumerable list, Funcbool> f)

{

foreach (T item in list)

{

if (f(item))

yield return item;

}

}

В этом примере использован необычный синтаксис, позволяющий реализовывать методы, возвращающие объект типа IEnumerable. Вместо того, чтобы создавать и возвращать объект данного типа, вы внутри метода пишете цикл, который возвращает каждую нужную вам очередную итерацию с помощью конструкции yield return. Когда вы попытаетесь перебрать объект IEnumerable с помощью foreach, вам будут переданы все объекты, которые вы возвращали с помощью yield return. На самом деле при компиляции данного кода будет неявно создан отдельный класс, реализующий интерфейс IEnumerable, который будет осуществлять ту же логику, что и ваш цикл. Вот, например, во что компилятор переделал наш метод:

public static IEnumerable<T> Where<T>(this IEnumerable<T> list, Func<T, bool> f)
{
    d__0 d__ = new d__0(-2);
    d__.<>3__list = list;
    d__.<>3__f = f;
    return d__;
}
 

Как видите, возвращается экземпляр некоторого класса <Where>d__0, который и будет осуществлять перебор.

Для нас же сейчас важен следующий момент. Предположим, что вы передали результат, возвращенный методом Where, в цикл foreach. Когда этот цикл запрашивает у объекта IEnumerable очередной элемент, вызывается очередная итерация цикла, записанного вами в реализации метода Where. Происходит очередное обращение к внутренней коллекции list, из нее выбирается очередной элемент и проверяется предикатом f. Если проверка прошла успешно, элемент возвращается, если нет, происходит новое обращение к внутренней коллекции list. Т.е. на каждой итерации цикла происходит хотя бы одно обращение к внутренней коллекции.

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

Повторю еще раз. Для интерфейса IEnumerable на каждой итерации цикла происходит хотя бы одно обращение к внутренней коллекции.

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

Объекты, предназначенные для работы с базой данных, реализуют интерфейс IQueryable, который хотя и является расширением IEnumerable, за счет чего может использоваться в циклах foreach, но имеет совсем другую реализацию поддержки LINQ. Помните, мы говорили о классе Expression, позволяющем некоторым образом узнать исходный код делегатов? Так вот, реализации интерфейса IQueryable в LINQ to SQL получают все делегаты всех методов Where, OrderBy, Select и ряда других, вызванных у этого интерфейса. Т.е. делегаты всех-всех этих методов, сколько бы их не было вызвано, собираются в одном месте. Затем с помощью функциональности класса Expression анализируется их исходный код и создается единственный SQL-запрос, который делает то же самое, что и все эти методы. Таким образом к базе данных выполняется единственный запрос. А потом в цикле перебираются уже возвращенные им результаты.

Теперь мы можем понять, почему не работает наш код обращения к базе данных с регулярными выражениями. Дело в том, что в SQL нет им соответствия, и IQueryable не может преобразовать ваш код в SQL-запрос. В этом состоит еще одно важное отличие IEnumerable от IQueryable. Первый поддерживает любые операции, второй – нет.

Операции для работы с коллекциями

Настало время посмотреть, какие же операции мы можем выполнять над коллекциями (проект Methods). Но прежде чем перейти непосредственно к этим операциям, давайте рассмотрим, какие различные синтаксисы вы можете применять здесь.

Синтаксис операторов LINQ

Как уже говорилось ранее, для записи запросов LINQ существует синтаксис, облегчающий восприятие (класс Exercise1):

from n in names where n.Length > 3 orderby n select n

Он удобен для чтения и понимания, что упрощает создание, сопровождение и развитие программы. Тем не менее, есть и еще способы создать тот же запрос (класс Exercise2). Вы помните, что механизмы LINQ основаны на методах расширения. Вы можете напрямую использовать их для построения LINQ-запросов:

IEnumerable<string> result = names.Where(n => n.Length > 3).OrderBy(n => n);

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

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

names.Where((n, i) => i % 2 == 0);

Поэтому квалифицированному программисту необходимо знать и уметь пользоваться обоими синтаксисами.

Операторы проецирования

Проецированием называется преобразование элементов входной коллекции в некоторые другие элементы (изменение типа). Проецирование никогда не изменяет длину входной коллекции, оно изменяет ее элементы. К операторам проецирования в LINQ относятся Select и SelectMany.

Оператор Select

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

names.Select(n => n.Length);

или

from n in names select n.Length;

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

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

Ключевое слово into

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

names.Select(n => Regex.Replace(n, "[aeiuo]", "", RegexOptions.IgnoreCase)).Where(n => n.Length >= 3);

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

Конечно, можно выполнить эту задачу «в две строки»:

IEnumerable<string> noVowels = from n in names select Regex.Replace(n, "[aeiuo]", "", RegexOptions.IgnoreCase);

IEnumerable<string> result = from n in noVowels where n.Length >= 3 select n;

Но возможна и более короткая запись с использованием ключевого слова into:

IEnumerable<string> result = from n in names select Regex.Replace(n, "[aeiuo]", "", RegexOptions.IgnoreCase) into nv where nv.Length >= 3 select nv;

Ключевое слово into, употребленное сразу после select, как бы снова открывает запрос, определяя новую переменную для элементов той коллекции, которую вернул этот select. У ключевого слова into есть еще одно применение, связанное с групповым объединением. Его мы рассмотрим позднее.

Ключевое слово let

Как уже было сказано, ключевое слово into может использоваться только непосредственно после select. Но существует значительно более мощный механизм, расширяющий возможности разработчика по написанию LINQ-запросов. Я говорю о ключевом слове let. Оно применяется для введения в запрос новой вспомогательной переменной (класс Exercise6). Вот как можно решить ту же задачу с его помощью:

IEnumerable<string> result = from n in names let nv = Regex.Replace(n, "[aeiuo]", "", RegexOptions.IgnoreCase) where nv.Length >= 3 select nv;

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

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

Анонимные типы

Давайте несколько усложним задачу. Пусть наш запрос должен вернуть не только строки с удаленными гласными, но и исходные строки, соответствующие им. Т.е. на выходе запроса мы хотим получить коллекцию экземпляров некоторого класса, одно из свойств которого содержит строку с удаленными гласными, длина которой не менее 3, а другое свойство – исходную строку, из которой она была получена. Очевидным подходом является создание отдельноего класса, который вы будете заполнять в запросе (класс Exercise7).

class Pair

{

public string Source { get; set; }

public string NoVowels { get; set; }

}

IEnumerable<Pair> result = from n in names let nv = Regex.Replace(n, "[aeiuo]", "", RegexOptions.IgnoreCase) where nv.Length >= 3 select new Pair() { Source = n, NoVowels = nv };

Что ж, этот подход имеет право на существование. Более того, если вы собираетесь возвращать результат вашего запроса из функции или передавать его в качестве параметра в другую функцию, этот подход является наилучшим. Но если вы хотите только обработать результаты запроса внутри того же метода, то есть способ лучше. Я говорю об анонимных типах. Анонимный тип не объявляется заранее, а строится непосредственно на месте. Он имеет только публичные свойства и методы, унаследованные от object. Вот как будет выглядеть наш запрос с использованием анонимного типа (класс Exercise8):

from n in names let nv = Regex.Replace(n, "[aeiuo]", "", RegexOptions.IgnoreCase) where nv.Length >= 3 select new { Source = n, NoVowels = nv };

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

var result = from n in names let nv = Regex.Replace(n, "[aeiuo]", "", RegexOptions.IgnoreCase) where nv.Length >= 3 select new { Source = n, NoVowels = nv };

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

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

Оператор SelectMany

Предположим, что вас интересует список файлов, содержащихся в каталогах диска C. Имеются ввиду только каталоги первого уровня. Можно попробовать решить эту задачу так (класс Exercise9):

var result = from d in Directory.GetDirectories(@"c:\") where !d.Contains(@"System") select Directory.GetFiles(d);

foreach (var files in result)

{

foreach (string file in files)

{

Console.WriteLine(file);

}

}

Фильтрация !d.Contains(@"System") находится здесь потому, что имеется системная папка System Volume Information, доступ к которой закрыт.

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

Но что, если мы хотим сразу получить плоский список файлов – коллекцию, содержащую сразу все файлы из всех каталогов? На помощь нам приходит метод SelectMany. В отличие от метода Select он преобразует элемент входной коллекции не в один элемент выходной коллекции, а в целую коллекцию. Но все полученные таким образом коллекции «сливаются» потом в одну. Таким образом, оператор SelectMany нарушает утверждение о том, что операторы проецирования не изменяют длину входной коллекции.

Вот как можно применить этот оператор в нашем случае (класс Exercise10):

var result = Directory.GetDirectories(@"c:\").Where(d => !d.Contains(@"System")).SelectMany(d => Directory.GetFiles(d));

Синтаксис, облегчающий восприятие, поддерживает оператор SelectMany с помощью введения второго from, который должен быть введен в любом месте до select:

var result = from d in Directory.GetDirectories(@"c:\") where !d.Contains(@"System") from f in Directory.GetFiles(d) select f;

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

Объединение с помощью SelectMany

Оператор SelectMany так же может использоваться для выполнения различных объединений. Рассмотрим как вы можете это сделать. В предыдущем примере вы получили список всех файлов всех каталогов корневого каталога диска C. Мы так же имеем список всех каталогов диска C. Что если нам нужно выполнить прямое (декартово) произведение этих двух списков, т.е. создать всевозможные пары «каталог–файл»? Вот как это можно сделать с помощью SelectMany (класс Exercise11):

var result = from d in directories from f in files select new { Directory = d, File = f };

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

var result = from d in directories from f in files where Path.GetDirectoryName(f) == d select new { Directory = d, File = f };

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

Операторы фильтрации

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

К операторам фильтрации в LINQ относятся Where, Take, Skip, TakeWhile, SkipWhile и Distinct. С оператором Where вы уже многократно сталкивались. Он принимает в качестве аргумента предикат (делегат, возвращающий bool), с помощью которого и осуществляется фильтрация. В качестве параметра этому делегату передается элемент коллекции. Так же у Where есть перегруженная версия, в которое предикату передается еще и номер элемента в коллекции. Но эта версия не поддерживается синтаксисом, облегчающим восприятие. Такие перегруженные версии, работающие с номером элемента в коллекции, есть у большинства операторов LINQ. Все они не поддерживаются синтаксисом, облегчающим восприятие, так что вам придется использовать функциональный синтаксис, чтобы использовать их. Более в данной статье я не буду упоминать об этих перегрузках. Просто помните, что они есть.

Остальные операторы фильтрации LINQ вообще не поддерживают синтаксис, облегчающий восприятие. Давайте рассмотрим их более подробно.

Метод Take извлекает из коллекции указанное число элементов с начала коллекции, а остальные отбрасывает, метод же Skip наоборот отбрасывает указанное число элементов, возвращая остаток коллекции. Оба эти метода полностью поддерживаются LINQ to SQL, значительно упрощая такую распространенную задачу, как разбиение результатов запроса на страницы для вывода их пользователю в табличном представлении (класс Exercise13):

(from n in names select n).Skip(1).Take(3);

Методы TakeWhile и SkipWhile очень похожи на них, только они возвращают/отбрасывают элементы до тех пор, пока верно указанное в переданных им предикатах условие. Однако LINQ to SQL не поддерживает эти методы.

Метод Distinct, так же как и его собрат в SQL, возвращает только различающиеся результаты (класс Exercise14):

int[] numbers = new int[] { 1, 2, 3, 1, 4, 2, 5, 6, 7, 7 };

IEnumerable<int> result = numbers.Distinct();

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

Операторы объединения

LINQ имеет два оператора, выполняющие объединение последовательностей: Join и GroupJoin. Оба они поддерживают синтаксис, облегчающий восприятие.

Оператор Join

С помощью данного оператора вы можете выполнять объединение, эквивалентное INNER JOIN. Этот оператор способен выполнять только объединение по равенству. В качестве примера будем объединять все те же файлы и каталоги (класс Exercise15). Вот как выглядит функциональный синтаксис этого объединения:

var result = directories.Join(files, d => d, f => Path.GetDirectoryName(f), (d, f) => new { Directory = d, File = f });

Огромное число параметров на первый взгляд пугает. Но на самом деле ничего страшного нет. Давайте разберемся.

Итак мы собираемся объединять каталоги, лежащие в коллекции directories, с файлами, лежащими в коллекции files. Та коллекция, у которой вызывается метод Join (в нашем случае это directories), называется внешней (outer).

Первый параметр метода Join – вторая коллекция, участвующая в объединении (files). Она называется внутренней (inner).

Далее вспомним, что оператор объединения проводит объединение только по равенству. Это значит, что он образует только такие пары элементов внешней и внутренней коллекции, у которых оказываются равными определенные характеристики. Т.е. в паре определенная характеристика элемента внешней коллекции равна определенной характеристике (не обязательно той же) элемента внутренней коллекции. Эти характеристики принято называть ключами (key). И, естественно, мы должны указать эти ключи. В нашем случае имя каталога должно совпадать с именем каталога файла. Поэтому для коллекции каталогов ключом является само имя (d => d), а для коллекции файлов – имя каталога файла (f => Path.GetDirectoryName(f)).

И, наконец, мы должны указать, что делать с полученной парой. Для этого служит последний параметр метода Join, называемый селектором (selector). В нашем случае мы сохраняем пару в анонимном классе: (d, f) => new { Directory = d, File = f }.

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

var result = from d in directories join f in files on d equals Path.GetDirectoryName(f) select new { Directory = d, File = f };

Как видите, две коллекции разделяются ключевым словом join, а ключи указываются в конструкции on–equals.

Может возникнуть вопрос, зачем нам применять оператор Join, если SelectMany способен сделать то же и значительно больше. Если оставить в стороне то, что синтаксис оператора Join более ясен для восприятия, нужно сказать, что Join выполняется быстрее при работе с коллекциями объектов в памяти. Это обусловлено тем, что он загружает внутреннюю коллекцию в специальную структуру, облегчающую поиск в ней (Lookup). Это избавляет от необходимости просматривать внутреннюю коллекцию для каждого элемента внешней коллекции. При работе с базами данных оба подхода генерируют одинаковый SQL-код, так что никакого выигрыша нет.

Оператор GroupJoin

Вторым оператором объединения является GroupJoin. Если оператор Join создавал пары «элемент внешней коллекции – элемент внутренней коллекции», то GroupJoin создает пары «элемент внешней коллекции – все элементы внутренней коллекции с соответствующим ключом». Т.е. каждый элемент внешней коллекции встречается в результате только один раз, и ему соответствует набор элементов внутренней коллекции.

Кроме того, если Join осуществляет внутреннее объединение, то GroupJoin – левое внешнее (LEFT JOIN). Это означает, что даже если во внутренней коллекции нет ни одного элемента, соответствующего данному элементу внешней коллекции, то пара все равно будет образована. Давайте обратимся к примеру (класс Exercise16):

var result = directories.GroupJoin(files, d => d, f => Path.GetDirectoryName(f), (d, fs) => new { Directory = d, Files = fs });

Результат запроса будет содержать все каталоги диска C, даже те, в которых не было файлов.

Единственное отличие синтаксиса GroupJoin от Join в том, что селектору результата (последний параметр) передается не один элемент внутренней коллекции для образования пары, а целый набор их. Нужно только помнить, что этот набор может быть и пустым.

Синтаксис, облегчающий восприятие, для оператора GroupJoin отличается от аналогичного синтаксиса для оператора Join только одной деталью. Помните, я говорил, что ключевое слово into имеет еще одно применение в запросах LINQ. Вот и пришло его время:

var result = from d in directories join f in files on d equals Path.GetDirectoryName(f) into fs select new { Directory = d, Files = fs };

Если оно стоит сразу же после конструкции equals, то оно вводит обозначение для элементов из внутренней коллекции, образующих пару (в нашем случае fs).

Сложные ключи

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

var result = from f1 in files join f2 in files on new { K1 = Path.GetDirectoryName(f1), K2 = Path.GetFileName(f1)[0] } equals new { K1 = Path.GetDirectoryName(f2), K2 = Path.GetFileName(f2)[0] } select new { File1 = f1, File2 = f2 };

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

Операторы упорядочивания

Операторы упорядочивания коллекций представлены в LINQ следующими методами: OrderBy, OrderByDescending, ThenBy, ThenByDescending. Те из них, что имеют в своем составе слово Descending, отличаются только тем, что проводят упорядочивание по убыванию, а не по возрастанию.

Метод OrderBy сортирует коллекцию по переданному ему ключу (класс Exercise18). Вот этот код сортирует коллекцию имен по их длине.

IEnumerable<string> result = names.OrderBy(n => n.Length);

Обратите внимание, что этот оператор возвращает объект типа IOrderedEnumerable<T>. Этот интерфейс является наследником IEnumerable. Зачем потребовался еще один интерфейс? Для правильного функционирования метода ThenBy. Предположим, что вы хотите упорядочить слова сначала по их длине, а затем по алфавиту (класс Exercise19):

IEnumerable<string> result = names.OrderBy(n => n.Length).ThenBy(n => n);

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

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

IEnumerable<string> result = from n in names orderby n.Length, n descending select n;

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

Оператор группирования

В LINQ имеется только один оператор группирования: GroupBy. Группирование – выделение из всей коллекции групп (подколлекций), в которых элементы имеют что-то общее. Точнее, у них одинаковый ключ. Вот как можно сгруппировать файлы по длине их имени (класс Exercise20):

var result = files.GroupBy(f => Path.GetFileNameWithoutExtension(f).Length);

Методу GroupBy передается делегат, вычисляющий для каждого элемента коллекции значение ключа, по которому будет происходить группировка. Что же возвращает GroupBy? Он возвращает коллекцию объектов, каждый из которых реализует интерфейс IGrouping. Этот интерфейс является наследником IEnumerable, поэтому его можно использовать в цикле foreach для перебора всех элементов в группе. Но кроме того, он имеет свойство Key, в котором содержится значение ключа для данной группы:

foreach (var item in result)

{

Console.WriteLine("{0}", item.Key);

foreach (var file in item)

{

Console.WriteLine(" {0}", file);

}

}

Синтаксис, облегчающий восприятие, для оператора GroupBy не вызывает затруднений:

var result = from f in files group f by Path.GetFileNameWithoutExtension(f).Length;

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

Операции над множествами

Любая коллекция в определенном смысле представляет собой множество элементов. Поэтому для нее допустимы операции над множествами. В LINQ их четыре: Concat, Union, Intersect и Except (класс Exercise21).

Метод Concat возвращает конкатенацию (склейку) двух последовательностей.

Метод Union так же возвращает конкатенацию двух последовательностей, но без повторений.

Метод Intersect возвращает элементы, присутствующие в обеих коллекциях.

Метод Except возвращает элементы, присутствующие в первой, но отсутствующие во второй коллекции.

Методы преобразования

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

Методы приведения типа коллекции

В .NET часто встречается ситуация, когда коллекция не реализует интерфейс IEnumerable<T>. Вместо этого она реализует обычный нетипизированный интерфейс IEnumerable. Так часто бывает, например, в классах пользовательского интерфейса. Кроме того, существуют и нетипизированные коллекции типа ArrayList, способные хранить элементы различных типов. Все методы LINQ требуют строгой типизации элементов, поэтому они не подходят для работы с такими коллекциями. Что же делать? Как преобразовать IEnumerable в IEnumerable<T>? Для этого существуют два метода: OfType и Cast (класс Exercise22). Метод OfType выделяет из коллекции элементы определенного типа:

var ofTypeResult = array.OfType<int>();

Все остальные элементы, тип которых не совпадает с указанным, будут просто отброшены. Это, наверное, наиболее полезный метод, позволяющий преобразовать IEnumerable в IEnumerable<T>.

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

Немедленное выполнение

Однако бывает так, что отложенное выполнение – не то, что нам нужно. Требуется немедленно выполнить запрос, а не ждать цикла foreach. Для подобных случаев в LINQ предусмотрен ряд методов: ToArray, ToList, ToDictionary и ToLookup (класс Exercise23).

Методы ToArray и ToList немедленно выполняют запрос и возвращают результат в виде массива или List соответственно:

string[] filesArray = files.ToArray();

List<string> filesList = files.ToList();

Методы ToDictionary и ToLookup действуют несколько сложнее. Они так же немедленно исполняют запрос и на основе результата заполняют Dictionary или Lookup. Dictionary представляет собой словарь пар «ключ–значение». Lookup отличается только тем, что одному ключу может соответствовать коллекция значений (именно этот Lookup используется для ускорения работы методов объединения, о чем говорилось ранее). Поэтому для методов ToDictionary и ToLookup мы должны как минимум указать, как вычисляется ключ для элементов входной коллекции:

Dictionary<string, string> filesDictionary = files.ToDictionary(f => f);

ILookup<int, string> filesLookup = files.ToLookup(f => Path.GetFileNameWithoutExtension(f).Length);

При этом в случае метода ToDictionary мы должны убедиться, что генерируемый ключ будет уникальным для каждого элемента коллекции. Иначе будет сгенерировано исключение.

Операторы AsEnumerable и AsQueryable

Помните, как мы пытались выполнить запрос с регулярными выражениями к базе данных? LINQ to SQL не позволяет преобразовать его в SQL, поэтому мы получали исключение. Но что, если нам действительно требуется выполнить такой запрос? Существует обходной путь. Он заключается в том, чтобы заставить фильтрацию записей выполняться не на IQueryable, а на IEnumerable. В этом случае не будет попытки преобразовать код в SQL, и все будет работать. Выполнить это можно с помощью операции AsEnumerable (проект LINQtoSQL, класс Exercise13):

from p in context.Products.AsEnumerable() where Regex.IsMatch(p.ProductName, "^[SP]") select p;

Она возвращает объект IEnumerable, не реализующий интерфейс IQueryable. Поэтому фильтрация объектов происходит уже в памяти компьютера, без преобразования запроса в SQL. Помните однако, что при этом из базы получается все таблица Products, что не всегда разумно.

Метод AsQueryable выполняет, как вы понимаете, обратную операцию. Останавливаться на нем подробно мы не будем.

Поэлементные операции

В LINQ имеются методы, позволяющие работать и с отдельными элементами коллекций. Например, метод ElementAt, как следует из его названия, возвращает элемент коллекции по указанному номеру. Метод First возвращает первый элемент коллекции. Он часто используется, когда вам нужен хотя бы один элемент, удовлетворяющий вашему запросу. Однако в случае, если запрос вернул пустую коллекцию, то этот метод генерирует исключение. Если это не то поведение, которое вам нужно, то вы можете воспользоваться методом FirstOrDefault. В случае, если коллекция пуста, он вернет «элемент по умолчанию», соответствующий типу коллекции. Для всех классов это null. Для структур разнообразие несколько большее. Вы можете узнать, какой объект является «объектом по умолчанию» для типа, вызвав следующий код:

int defaultInt = default(int);

Оператор default как раз и возвращает этот объект для указанного типа. Метод ElementAt так же имеет двойника ElementAtOrDefault для случая, когда в коллекции нет элемента с указанным номером.

Иногда вам, кроме того, чтобы получить первый элемент, удовлетворяющий запросу, требуется так же проверить, что это – единственный элемент, удовлетворяющий запросу. Например, вы получаете из базы данных объект по уникальному ключу. Если ваш запрос вернет несколько объектов, то это ошибка, и вы хотите об этом знать. Для таких случаев идеально подходит метод Single. Он работает так же, как и First, но в случае, если в коллекции, к которой он применен, более одного элемента, он генерирует исключение. Для пустых коллекций так же есть метод SingleOrDefault.

Последний элемент коллекции вам помогут получить методы Last и LastOrDefault.

На операторе DefaultIfEmpty давайте остановимся подробнее (класс Exercise24). Вернемся к нашему примеру с файлами и каталогами. Данный запрос вернет все пары «каталог – содержащийся в нем файл»:

var files = from d in Directory.GetDirectories(@"c:\") where !d.Contains(@"System") from f in Directory.GetFiles(d) select new { Directory = d, File = f };

Несложно заметить, что наш результат не содержит каталогов, в которых нет ни одного файла. Но что, если это не то, что нам нужно? Что, если мы хотим для каждого каталога без файлов так же организовать пару «каталог – null»? Сделать это можно очень просто с помощью оператора DefaultIfEmpty. Этот оператор, в случае если входная коллекция пуста, возвращает коллекцию, содержащую единственный «элемент по умолчанию» для данного типа. Таким образом искомый запрос изменится не сильно:

var files = from d in Directory.GetDirectories(@"c:\") where !d.Contains(@"System") from f in Directory.GetFiles(d).DefaultIfEmpty() select new { Directory = d, File = f };

Методы агрегирования

LINQ предоставляет вам ряд методов, которые способны вычислять некоторую функцию на всех элементах коллекции. К ним относятся:

· Min и Max, позволяющие получить минимальный и максимальный элементы коллекции.

· Count и LongCount, возвращающие размер (количество элементов) коллекции.

· Sum, возвращающий сумму элементов коллекции.

· Average, возвращающий среднее значение элементов коллекции.

· Aggregate, позволяющий вычислить пользовательскую функцию на коллекции.

Методы Min и Max работают с любыми типами, реализующими интерфейс IComparable. Но Sum и Average жестко закодированы только для работы с числовыми типами.

Давайте посмотрим, как использовать Aggregate (класс Exercise25). Пусть нам нужно узнать количество четных чисел в массиве. Вот как это можно сделать:

int[] arr = new int[] { 1, 2, 3, 4, 5, 5, 6, 7, 8, 8, 10 };

int even = arr.Aggregate(0, delegate(int seed, int item)

{

if (item % 2 == 0)

return seed + 1;

else

return seed;

});

Какие же параметры передаются функции Aggregate? Первый параметр – значение искомой величины на пустой коллекции. В нашем случае пустая коллекция содержит 0 четных чисел, поэтому мы передаем 0. Второй параметр – делегат, вычисляющий искомую величину. У него два аргумента – значение искомой величины в уже обработанной части коллекции (seed) и следующий элемент коллекции (item). Данный делегат на основе этих двух аргументов вычисляет новое значение функции и возвращает его. Это значение будет передано в аргумент seed на следующей итерации просмотра коллекции вместе со следующим элементом item. Именно таким образом происходит вычисление агрегатной функции.

Квантификаторы

Существует ряд методов, которые, будучи примененными к коллекции, возвращают булевское значение (класс Exercise26).

Так метод Contains проверяет, содержит ли коллекция переданый ему объект.

Метод Any проверяет, удовлетворяет ли хотя бы один элемент коллекции делегату, переданному в качестве аргумента. Т.е. возвратит ли этот делегат true, если ему передать один из элементов коллекции. Очень полезным является перегрузка метода Any без всяких аргументов. Она возвращает true, если в коллекции есть хотя бы один элемент. Всегда используйте arr.Any() вместо arr.Count() > 0, поскольку функция Count переберет все элементы коллекции, тогда как вам нужно только знать, есть ли первый элемент.

Функция All аналогична Any, но вернет true только если все элементы коллекции удовлетворяют делегату.

Так же существует метод SequenceEqual, который поможет вам сравнить две коллекции на поэлементное равенство.

Методы генерирования коллекций

В вашем распоряжении есть так же несколько методов, позволяющих вам сгенерировать коллекцию (класс Exercise27).

Метод Empty возвращает пустую коллекцию определенного типа.

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

IEnumerable<int> c1 = Enumerable.Range(1, 10);

Этот метод, так же как и Repeat, работает только с целыми числами. Repeat принимает элемент, который нужно повторять, и число, указывающее, сколько раз нужно повторять этот элемент:

IEnumerable<int> c2 = Enumerable.Repeat(5, 10);

LINQ to XML

Поддержка XML в .NET Framework существует с самой первой версии. Однако теперь разработчики Microsoft добавили ряд классов для работы с XML, которые весьма облегчают использование технологии LINQ с ними. Эти классы расположены в пространстве имен System.Xml.Linq. Кроме поддержки LINQ, эти классы предоставляют еще ряд интересных возможностей. Однако в этой статье мы рассмотрим только то, что касается их взаимодействия с LINQ (проект LINQtoXML). С другими их возможностями вы можете ознакомиться самостоятельно.

Конструирование XML

Документ XML в пространстве имен System.Xml.Linq представлен классом XDocument. Этот класс имеет функциональность для загрузки и сохранения документов XML из различных источников (класс Exercise1). Вот как можно создать документ XML:

XDocument document = new XDocument(

new XComment("List of persons"),

new XElement("Persons",

new XElement("Person", new XAttribute("Name", "Ivan")),

new XElement("Person", new XAttribute("Name", "Petr")),

new XElement("Person", new XAttribute("Name", "Sergey"))));

Как видите, конструктору класса XDocument передаются наполняющие его объекты. Синтаксис конструктора имеет вид: public XDocument(params object[] content). Ему можно передать любое количество произвольных объектов. В данном случае мы передаем один комментарий и один элемент, который будет являться корневым (root). Конструктор элементов XML, представленных классом XElement, так же, кроме имени элемента, принимает коллекцию произвольных объектов. В нашем случае мы передали ему ряд вложенных элементов, по одному на описание каждого человека. Но так же ему могут передаваться комментарии (XComment), атрибуты (XAttribute), и другие объекты. Если переданный вами объект не распознается системой как часть XML, у него будет вызван метод ToString, и результат будет записан в XML в виде простой строки.

Как же LINQ может помочь нам в создании XML-документов? Очень просто. Поскольку конструкторы XDocument и XElement принимают в качестве параметра коллекции объектов, то эти коллекции мы можем создавать с помощью LINQ (класс Exercise2):

string[] names = new string[] { "John", "Tom", "Mary", "Ann", "Peter", "Sara", "Bill" };

XDocument document = new XDocument(

new XComment("List of persons"),

new XElement("Persons", from n in names select new XElement("Person", new XAttribute("Name", n))));

Таким образом вы можете формировать XML-документы из любых коллекций объектов, которые у вас есть.

Навигация по XML-документу

Кроме создания XML очень частой задачей является его разбор, т.е. извлечение из XML определенных данных. Для решения этой задачи в классы XML добавлен ряд полезных методов. Часть из них возвращает коллекции, что делает возможным их использование в запросах LINQ. Давайте по-ближе познакомимся с этими методами.

Но перед тем, как перейти к их рассмотрению, я хочу заострить ваше внимание на разнице между понятиями node (узел) и element (элемент). Любой элемент является узлом, но не любой узел – элемент. К узлам так же относятся комментарии, инструкции процессора и т.д. Документ XML (XDocument), равно как и элемент (XElement) может содержать внутри себя как узлы, так и элементы.

Навигация по узлам–потомкам

Документ или элемент (а, вообще говоря, любой наследник XContainer) может содержать внутри себя вложенные узлы. Есть ряд методов, позволяющих получить доступ к ним. Так FirstNode и LastNode возвращают первый и последний вложенные узлы. Метод Nodes возвращает коллекцию всех вложенных узлов, а Elements – только вложенных элементов. Последний имеет перегрузку, принимающую в качестве параметра имя. В этом случае он вернет только те элементы, имя которых совпадает с данным (класс Exercise3). Кроме того, есть метод Element (без s), который возвращает первый вложенный элемент с указанным именем.

Весьма часто при поиске элементов нас интересуют не только те, что вложены непосредственно в данный элемент, но и те, что существуют на более глубоком уровне вложения. Такие узлы и элементы могут быть возвращены с помощью методов DescendantNodes и Descendants (класс Exercise4). А если в поиск должен быть включен и сам элемент, используйте DescendantNodesAndSelf и DescendantsAndSelf.

Навигация по родительским элементам

Классы XML позволяют искать не только «внутрь», но и «наружу». Т.е. можно искать и среди предков данного узла. Свойство Parent возвращает тот элемент, в который вложен данный узел. Обратите внимание, что у корневого элемента это свойство равно null. Методы Ancestors и AncestorsAndSelf возвращают коллекцию элементов-предков данного узла (класс Exercise5). При этом первый элемент данной коллекции – непосредственный предок данного узла (ancestors[0] == node.Parent), а каждый следующий элемент – предок предыдущего.

Навигация по элементам одного уровня

Существуют так же методы, позволяющие работать с узлами, расположенными на одном уровне с данным, т.е. с вложенными в один и тот же элемент с ним. Методы PreviousNode и NextNode дают доступ с предыдущему и следующему за эти узлам. Методы IsBefore и IsAfter позволяют узнать, стоит ли данный узел перед/после другого узла. Методы NodesBeforeSelf, NodesAfterSelf, ElementsBeforeSelf и ElementsAfterSelf возвращают коллекции узлов или элементов, стоящих до/после данного.

Навигация по атрибутам

Чтобы получить доступ к атрибутам элемента, вы можете воспользоваться методами FisrtAttribute, LastAttribute и Attributes. Первые два возвращают первый/последний атрибут элемента, последний – всю коллекцию атрибутов. Вы так же можете использовать метод Attribute (без s) для получения атрибута по имени.

С помощью описанных здесь и других методов вы можете свободно извлечь любую информацию из XML-документа.

Заключение

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

Библиография

1. LINQ. Карманный справочник. Пер. с англ. / Дж. Албахари, Б. Албахари. – СПб.: БХВ-Петербург, 2009.