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

Советы по использованию LINQ Framework

Опубликовано: 23.04.2008

Cоздание приложений, взаимодействующих с источниками данных, XML-документов или таких Web-сервисов, как Flickr или Amazon, существенно упростилось с выходом .NET Framework 3.5 благодаря появлению набора возможностей, получившего общее название LINQ (Language-Integrated Query). Мы начнем с очень краткого обзора LINQ, за которым последуют советы по разработке API, связанных с LINQ.

1. Краткий обзор LINQ

Довольно часто в программировании требуется обработка наборов значений. Вот хорошо известные примеры: извлечение из БД списка последних добавленных продуктов, или поиск email-адреса в службе каталогов типа Active Directory, или преобразование частей XML-документа в HTML в целях публикации в Web, или поиск значения в хеш-таблице.

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

LINQ включает:

// использование методов расширения
IEnumerable<string> names = set.Where(x=>x.Age>20).Select(x=>x.Name);
 
// использование SQL-подобного синтаксиса
IEnumerable<string> names = from x in set where x.Age>20 
  select x.Name; 

Взаимодействие между этими составляющими выглядит так: к любому IEnumerable<> можно обратиться с запросом, используя LINQ-методы расширения, большинству из которых нужно одно или несколько лямбда-выражений в качестве параметров; это приводит к выполнению запросов в памяти. В случаях, когда набор данных находится не в памяти (например, в БД), и/или запросы могут быть оптимизированы, набор данных представляется как IQueryable<>. Если в качестве параметров используются лямбда-выражения, они трансформируются компилятором в объекты Expression<>. Реализация IQueryable<> отвечает за обработку таких выражений. Например, реализация IQueryable<>, представляющая таблицу БД, будет трансформировать объекты Expression<> в SQL-запросы.

Методы расширения, Func<>, Action<> и Expression<>

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

Эти методы должны принимать как минимум один параметр, представляющий экземпляр, которым оперирует метод. Например, в C#, это делается с помощью использования модификатора this для этого параметра при определении метода.

public static bool IsPalindrome(this string s)
{
  // реализация
}

Этот пример позволяет написать, например:

“some string”.IsPalindrome();

...что в данном случае приведет к выводу в консоль false.

Класс, определяющий такие методы расширения, в данной статье называется «классом-расширением», он должен быть объявлен как static. Для использования методов расширения нужно импортировать пространство имен, в котором объявлен класс-расширение.

Объекты Func<> предоставляют универсальный делегат. Например:

Func<int,int,double> divide = (x,y) => (double)x / (double)y;
Console.WriteLine(divide(2,3));

В этом примере divide – это функция, принимающая два целых и возвращающая double. Последний параметр в определении Func<> - всегда возвращаемый тип. В случае функций, возвращающих void, используйте Action<>. Например:

Action<double> write=(aDouble)=>Console.WriteLine(aDouble);
write(divide(2,3));

Объекты Expression<> представляют определения функций, которые могут быть скомпилированы и впоследствии вызваны во время исполнения. Продолжим наш приме:

Expression<Func<int,int,double>> divideBody=(x,y)=>(double)x/(double)y;
Func<int,int,double> divide2=divideBody.Compile();
write(divide2(2,3));

Обратите внимание на то, что синтаксис объекта Expression<> очень похож на синтаксис объекта Func<>; на самом деле единственное различие – статическое объявление типа переменной (Expression<> вместо Func<>).

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

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

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

Есть, конечно, сценарии, где можно использовать методы расширения. Они перечислены ниже.

( Рассматривайте использование методов расширения в следующих сценариях:

Рассмотрим, например, отрасль телекоммуникаций. Будем считать, что модель предметной области содержит сетевые узлы. Для этой предметной области создана библиотека nodes.dll. Библиотека содержит такие типы как Node. У Node могут быть такие свойства как “address” или “manufacturer”. Теперь рассмотрим проблему создания путей соединения в такой сети (то есть маршрутизацию). Для этого создается вторая библиотека, routing.dll, зависящая от nodes.dll. Правильный инженерный подход говорит, что nodes.dll не должна зависеть от routing.dll. Однако можно представить, что в аспекте маршрутизации модель узлов сети приобретет новые характеристики, а именно – свойство, говорящее, является ли она конечной точкой маршрута, и ассоциированное с узлом число маршрутов. Для поддержки чистых зависимостей можно использовать статические методы, определенные в routing.dll, то есть Paths.IsNodePathEndpoint(Node n) или Paths.GetListOfPathsCrossingNode(Node n). Более естественным может показаться определение методов наподобие IsEndpoint() в Node. Но как вариант стоит рассматривать и определение методов расширения для типа Node в библиотеке маршрутизации.

( Избегайте, если нет крайней необходимости, определять методы расширения на System.Object. При этом помните, что пользователи VB не смогут использовать методы расширения, определенные таким образом, а стало быть, не смогут воспользоваться преимуществами простоты использования и синтаксиса, предоставляемыми методами расширения.

Дело в том, что в VB объявление переменной как object приводит к позднему связыванию для всех вызовов методов – в то время как привязки к методам расширения определяются во время компиляции (раннее связывание). Например:

public static class SomeExtensions
{
  static void Foo(this object o){...}
}
...
Object o = ...
o.Foo();

В этом примере вызов Foo в VB не пройдет. Вместо этого в VB надо просто написать:

SomeExtensions.Foo(o)

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

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

( Исключите переопределение методов расширения типа T методами расширения этого же типа.

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

<file 1>
namespace A
{
  public static class ExtMethodsInA
  {
    public static void ExtMethod(this T obj){...}
  }
}
<file 2>
namespace B
{
  public static class ExtMethodsInB
  {
    static void ExtMethod(this T obj){...}
  }
}
<file 3>
using A;
using B;

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

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

В предыдущем примере:

<file 3>
using A;
…
T someObj=...
//это вызывает A.ExtMethodsInA.ExtMethod
someObj.ExtMethod(); 

// чтобы исключить ошибки при компиляции файла 3, 
// мы вызываем методы расширения, определенные в пространстве имен B, явно
B.ExtMethodsInB.ExtMethod(someObj); 

( Определяйте методы расширения в том же пространстве имен, что и расширяемый тип, если тип является интерфейсом, и если методы расширения предполагается использовать во многих или во всех случаях.

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

Например, определяйте методы расширения для Telecom.Node не в пространстве имен Telecom, а, допустим, в пространстве имен Routing.

( Не называйте обобщенно пространство имен, предназначенное для методов расширения, (например, “Extensions”) – вместо этого используйте содержательные названия, например, “Routing”.

( При определении новых функций используйте новые LINQ-типы “Func<>” и “Expression<>”, а не собственные делегаты и предикаты. Следующий пример иллюстрирует это:

// вместо:
delegate bool Tester(int i);

class AClass
{
  public Tester MyTester{get;set;}
}

// используйте:
class AClass
{
  public Func<int,bool> MyTester{get;set;}
}

Преимущества использования Func<> в данном случае включают уменьшение числа сущностей благодаря использованию существующей абстракции, а также устранение зависимости от определения Tester.

Расширение LINQ

Есть три способа создать тип, способный участвовать в LINQ-запросах: реализовать IEnumerable<> (или производные от него интерфейсы), реализовать IQueryable<> или определить для типа Query Pattern, безотносительно этих двух интерфейсов

Используйте следующий шаблон действий:

Методы запросов определены в классе-расширении System.Linq.Enumerable как методы расширения IEnumerable<>. Независимо от способа участия типа в LINQ-запросах, работают следующие рекомендации:

( Учитывайте паттерны сигнатур, показанные на рисунке 1, переопределяя методы LINQ.

Мы используем S с или без индекса для указания типа-коллекции (т.е. IEnumerable<>, ICollection<>), и Т с или без индекса для указания типа элементов этой коллекции. Кроме того, мы используем O<T> для представления упорядоченных подтипов S<T>. Например, S<T> – это нотация, которую можно заменить на IEnumerable<int>. Первый параметр всех методов – тип объекта, к которому применяется метод, и он помечается префиксом “this” – независимо от того, реализуются эти методы как методы расширения или как методы-члены. Кроме того, везде, где используется Func<>, его можно заменить на Expression<Func<>>.

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

Листинг 1. Сигнатуры методов для Query Pattern.

S<T> Where(this S<T>, Func<T,bool>)
S<T2> Select(this S<T1>,Func<T1,T2>)
 
O<T> OrderBy(this S<T>, Func<T,K>), where K is  IComparable
 
O<T> ThenBy(this O<T>,Func<T,K>), where K is  IComparable
 
S<T> Union(this S<T>, S<T>)
 
S<T> Skip(this S<T>,int)
S<T> Take(this S<T>,int)
S<T> SkipWhile(this S<T>, Func<T,bool>)
 
T ElementAt(this S<T>,int)
 
S<T3> Join(this S<T1>, S<T2>,
 Func<T1,K1>,Func<T2,K2>,
 Func<T1,T2,T3>)
 
S<T3> SelectMany(this   S<T1>,Func<T1,S<T2>>,Func<T1,T2,T3>)
 
S<T2> SelectMany(this S<T1>,Func<T1,S<T2>>)

Расширение IEnumerable<>

Например, простое определение:

public class RangeOfInts:IEnumerable<int>
{
  public IEnumerator<int> GetEnumerator() {...}
  public IEnumerator GetEnumerator() {...}
}

Позволяет написать:

p var a=new RangeOfInts();
var b=a.Where(x=>x>10);

хотя RangeOfInts и не реализует метода “Where”

( Учитывайте возможность переопределения LINQ-паттернов для новых типов, реализующих IEnumerable<T>, если желательно подменить реализацию по умолчанию (в целях оптимизации). Переопределять их лучше как члены типов, чем как методы расширения.

Например, вместо:

public class MyDataSet<T>:IEnumerable<T>{...}
...
public static class MyDataSetExtensions
{
  public static MyDataSet<T> Where(this MyDataSet<T> o,Func<T,bool> f){...}
};

пишите так:

public class MyDataSet<T>:IEnumerable<T>
{
  public MyDataSet<T> Where(Func<T,bool> f){…}
...
}

( Реализуйте ICollection<T> для повышения производительности операторов запросов.

Например, поведение метода Count<> по умолчанию (определенное в System.Linq) – простой обход IEnumerable. Реализацию этого метода для типов-коллекций можно оптимизировать, так как они обычно предлагают механизм сложности O(1) для определения размера коллекции.

Расширение IQueryable<>

( Учитывайте возможность реализации IQueryable<T> при необходимости доступа к выражениям запросов. Например, это касается типов, представляющих потенциально большие наборы данных, генерируемые удаленными процессами, или некоторых оптимизаций. Примерами такого набора данных могут быть БД, файловая система, или хранящийся в памяти словарь, который требуется оптимизировать поиск, например, использовать поиск по ключам вместо полного перебора.

Следующие советы более детальны:

( Не реализуйте IQueryable<T>, если не представляете затрат, вызываемых его использованием.

( Генерируйте исключение NotSupportedException из методов IQueryable<T>, которые не могут быть логически обработаны вашим источником данных.

Вообразите, например, представление поток медиа-данных (хотя бы Internet-радио) в виде IQueryable<>. Метод Count логически не поддерживается, так как поток можно рассматривать как бесконечный.

Реализация Query Pattern

Query Pattern – это определение методов с рисунка 1 без реализации интерфейсов IEnumerable<> или IQueryable<>.

( Реализуйте хотя бы паттерн enumerable (т.е. метод GetEnumerator) для типов, представляющих коллекции данных. Это нужно, чтобы результат методов запросов поддерживал foreach.

( Реализуйте Query Pattern как члены экземпляров нового типа, если члены имеют смысл для типа даже вне контекста LINQ. В противном случае используйте методы расширения.

Представляйте упорядоченные последовательности как отдельный тип. Определите для этого типа метод “ThenBy”.

Это следует паттерну текущей реализации LINQ to Objects, а также позволяет ранее (во время компиляции) определение таких ошибок, как применение “ThenBy” к неупорядоченной последовательности.

Например, Framework предоставляет тип IOrderedEnumerable<>, возвращаемый “OrderBy”. Метод расширения “ThenBy” определен для этого типа, но не для IEnumerable<>.

( Создавая LINQ-методы, определенные Query Pattern, делайте так, чтобы они возвращали перечислимые типы (реализующие IEnumerable<>). В сущности, из метода Select можно возвращать что угодно, однако ожидается, что тип результата запроса должен быть хотя бы перечислим в цикле foreach, см. пример ниже, а также следующий пункт.

var set1=...
var set2=set1.Select(x=>x.SomeIntProperty);
foreach(int i in set2){...}

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

Ожидаемое поведение большинства членов Query Pattern состоит в том, что они просто конструируют новый объект, который, при перечислении, производит элементы набора, соответствующие запросу. Время вычисления, таким образом, соответствует времени перечисления.

Следующие методы являются исключениями из этого правила: All, Any, Average, Contains, Count, ElementAt, Empty, First, FirstOrDefault, Last, LastOrDefault, Max, Min, Single, Sum.

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

( Если нежелательно возвращение к базовой реализации IEnumerable<T>, исключите частичную реализацию Query Pattern.

Рассмотрим, например, пользовательский тип Т, реализующий IEnumerable<>. У типа Т переопределен Count, но не Where. Посмотрите на следующий пример:

var query=someT.Where(f2).Count();

В этом примере любая возможность оптимизации теряется после вызова Where. Вместо этого в примере будет использована версия Count, определенная для IEnumerable<>.

( Размещайте методы расширения в подпространстве имен “Linq” основного пространства имен. Например, методы расширения для System.Data находятся в пространстве имен System.Data.Linq.

( Используйте как параметр Expression<Func<>> вместо Func<>, если нужно исследовать запрос.

Как уже говорилось, взаимодействие с SQL СУБД выполняется через IQueryable<T>, а не через IEnumerable<T>, поскольку это дает возможность транслировать лямбда-выражения в SQL-выражения.

Еще одна причина для реализации IQueryable<T> – выполнение оптимизаций. Например, упорядоченный список может реализовать lookup (выражения Where) с бинарным поиском, что может быть гораздо эффективнее стандартных реализаций IEnumerable<T> или IQueryable<T>.


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

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