Posts Tagged LINQ

ParallelFX, или как я испытывал параллельности

О существовании параллельных миров или пространств мы ничего наверняка сказать не можем, однако, если разговор заходит о компьютерах, то тут всё становится уже более определённо. Современные операционные системы поддерживают многозадачность (пусть иногда даже и псевдопараллельную). А в последнее время всё больше и больше ядер появляется в процессорах наших компьютеров.

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

Не так давно был анонсирован CTP библиотеки, находящийся на верхушке .NET Framework – Parallel FX Library. Наверняка, с её помощью можно будет быстрее писать многопоточные программы, которые к тому же будут менее подвержены ошибкам. Суть в том, что наш код автоматически распараллеливается на множестве имеющихся процессоров. Это чем-то похоже на распараллеливание запросов, которое делает СУБД, но здесь мы имеем это в нашем коде и с нашими объектами.

ParallelFX состоит из двух частей: PLINQ и TPL. PLINQ – это движок параллельного выполнения запросов для LINQ (Более подробно про LINQ в вы можете почитать, например, в моей статье), благодаря которому мы можем с лёгкостью распараллелить LINQ-запросы. TPL же вводит такие конструкции, как параллельные циклы; задачи (Task, маленькие части кода, которые могут быть выполнены независимо, они чем-то похожи на Thread, но легче синхронизируемые), и «будущие времена» (Future, специальная задача, которая возвращает результат). Стоит отметить, что информации по библиотеке совсем мало, а та, что есть, уже немного устарела, поскольку всё это ещё находится в разработке и меняется…

Что к чему

Для того, чтобы испытать PFX в действии вам потребуется .NET Framework 3.5, а так же PFX December 2007 CTP, который вы можете закачать отсюда. Если вы не поклонник сугубо текстовых редакторов и сборки из мейкфайлов, вам так же потребуется Visual Studio 2008. Так же было бы неплохо иметь доступ к многопроцессорному компьютеру, чтобы проверить всё собственноручно.

Добавьте ссылку на сборку System.Threading.dll, так, все необходимые нам классы находятся в пространстве имён System.Threading.

PLINQ

Допустим, у нас имеются какие-то сложные и большие LINQ-запросы. А машина, на которой исполняется наша программа – многоядерная. Так, мы хотим использовать каждое ядро по максимуму с минимумом затрат времени и сил. PLINQ – то, что нам поможет. Мы просто вызываем метод расширения AsParallel() для наших данных, и они «обвёртываются» чем-то, что знает, как всё распараллелить.

IEnumerable<T> data = ...;
var q = from a in data.AsParallel() where w(a) orderby o(a) select f(a);

Так же нам доступен «параллельный» аналог класса Enumerable – ParallelEnumerable с аналогичными статическими методами.

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

TPL и Параллельные циклы

Взгляните на последовательный простой цикл:

for (int i = 0; i < N; i++) {   a[i] = Math.Sqrt(a[i]);
}

Мы можем попросить нашу библиотеку распараллелить его. Для этого мы пользуемся параллельной версией цикла for – статической функцией из класса Parallel:

Parallel.For(0, N, i => {   a[i] = Math.Sqrt(a[i]);
});

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

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

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

Считаем Pi

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

Pi/4 = 1/1 - 1/3 + 1/5 - 1/7 + 1/9 - ...

Последовательное вычисление можно организовать так:

public double CalculatePi()
{   double sum = 0;   int sign = 1;   for (int i = 1; i < ITER_COUNT; i += 2, sign = -sign;)   sum += (double)sign / i;   return 4 * sum ;
}

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

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

const int ITER_COUNT = 100000000;
const int INNER_ITER_COUNT = 1000;     

public double CalculatePi_ParallelFor()
{   double sum = 0;   Parallel.For<double>(1, ITER_COUNT, INNER_ITER_COUNT, () => 0,   (i, state) =>   {   int sign = (i / 2) % 2 == 0 ? 1 : -1;   for (int j = i; j < i + INNER_ITER_COUNT; j += 2, sign=-sign)   state.ThreadLocalState += (double)sign / j;   }, partialSum => { lock (this) {sum += partialSum;} }   );   return 4 * sum;
}

Здесь в каждую параллельную итерацию внешнего цикла, помимо счётчика передаётся переменная состояния state, в её свойстве ThreadLocalState мы сохраняем «частичную сумму». Впоследствии все частичные суммы добавляются к общей сумме sum, формируя окончательный результат. Я запустил оба варианта на компьютере с четырёхядерным процессором, и вот что из этого вышло:

D:\>ParallelPi.exe
Calculating Pi in total 1000000000 iterations
And 1000 inner iterations in each process
OS: Microsoft Windows NT 5.2.3790 Service Pack 2
Processors count: 4     

Working Non-Parallel For...
> 3,14159265
00:00:07.3516085     

Working Parallel For...
> 3,14159265
00:00:03.9321267     

Time rating:
1. 00:00:03.9321267     Parallel For
2. 00:00:07.3516085     Non-Parallel For

Параллельная версия почти в двое быстрее последовательной:

image

Обратите внимания на график загрузки ядер: при параллельной обработке все четыре ядра используются на 100%, в то время как при последовательном исполнении процессор загружен на ~25%.

image

Запустив этот же расчёт на своей однопроцессорной машине, я получил следующий результат:

Calculating Pi in total 1000000000 iterations
And 1000 inner iterations in each process
OS: Microsoft Windows NT 5.1.2600 Service Pack 2
Processors count: 1    

Working Non-Parallel For...
> 3,14159265
00:00:04.4050481    

Working Parallel For...
> 3,14159265
00:00:09.2341356    

Time rating:
1. 00:00:04.4050481     Non-Parallel For
2. 00:00:09.2341356     Parallel For

Последовательная версия оказалась в два раза быстрее «параллельной». Наверняка так получилось из-за того, что затраты на организацию параллелизма (а как его получить на однопроцессорной машине?) не окупились из-за отсутствия реального распараллеливания.

image

Заключение

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

Мне было бы очень интересно услышать о ваших экспериментах с PFX, если вы решите испытать её :).

.NET, CSharp, framework, LINQ

Языки, бутылки или галопом по Европам

Как правило, языки программирования сами по себе не рождаются. Их создают для того, чтобы было легче писать программы. Или, чтобы было легче писать компиляторы других языков, на которых будет ещё легче (быстрее, надёжнее, интереснее) писать программы (или другие компиляторы). На сегодняшний день этих самых языков программирования существует уже сотни. Чтоб представить себе их разнообразие можно хотя бы бегло взглянуть на замечательный сайт «99 бутылок пива». И хотя все они (языки, хотя, и бутылки тоже) по-разному выглядят и воспринимаются, зачастую у них есть кое-что, что определяет их общность. Это – парадигма (или несколько парадигм), которой они отвечают.

Парадигма определяет то, какой способ написания программ предоставляет (к которому располагает) данный язык программирования. Парадигм существует тоже немало, и все они перекликаются друг с другом. Охватить все не ставилось задачей данной статьи, подробнее с данной темой можно познакомиться, например, на Википедии: http://ru.wikipedia.org/wiki/Парадигма_программирования. Далее мы рассмотрим несколько самых известных парадигм, и то, как они противопоставляются друг другу, и уживаются вместе в рамках отдельного языка.

Хочется отметить, что, по поводу того, какой язык или парадигма «лучше» ведутся вечные «священные войны». Я же не собираюсь вступать в них, и полагаю, что для каждой задачи существует свой более подходящий язык или модель программирования, с помощью которых она решается легче. Поэтому незачем ограничивать себя чем-то только одним, если можно использовать то, что лучше всего подходит в конкретном случае. А ещё лучше – комбинировать несколько подходов, чтобы получить плюсы (ну, или минусы, кому как) каждого.

Итак…

Всё по порядку…

Всем известно императивное программирование, в котором мы пишем программу в виде последовательности приказов, наподобие таких: «запиши значение 1643 в ячейку по адресу 66346» или «если a>10, то выпрыгни из окна». Это то, что мы делали на уроках Паскаля в школе, или при изучении Си ночью под подушкой с фонариком. Мы пошагово описываем компьютеру, что тот должен сделать, чтобы удовлетворить наши потребности. Мы изменяем переменные, используем ветвления с циклами, может быть, вызываем какие-нибудь функции. Эта парадигма настолько широко распространена, что, кажется, является общепринятой. Она лежит ближе всего к «железу», к тому, как работает компьютер. При первом знакомстве с программированием это впечатляет: я пишу магические команды, а эта железяка их исполняет, и ровно так, как я хочу, и никак иначе ;). По сути, перед нами красуется послушная машина Тьюринга.

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

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

int myglobalvar = 100;
...
...
int MyFunc(int n) {      return myglobalvar += n;
}

При увеличении количества и сложности кода уследить за нашими командами, структурами данных, да и за потоком исполнения программы становиться очень сложно, так что многие предпочитают уже упомянутое объектно-ориентированное программирование. Оно позволяет обернуть императивные приказы в методы объектов, а дальше уже работать непосредственно с ними, «посылая» им «сообщения»: «Раз ты фигура, нарисуй себя!» или «Вася, я не знаю, кто ты, человек или холодильник, но вижу, что ты реализуешь интерфейс ISleepable, так что бегом спать!». В данном случае мы выходим на новый уровень абстракций, пользуясь знаменитыми «китами»: инкапсуляцией, наследованием и полиморфизмом. Объектно-ориентированная парадигма породила множество идей и замечательных языков, которые её воплотили. Мы заботимся о повторном использовании кода, и ООП позволяет отлично с этим справляться. На данный момент ООП считается наиболее важной моделью программирования. Все знают или слышали о C++, Java, C#, и так далее (хоть и не все из перечисленных языков «чистые» объектно-ориентированные). В конечном итоге, мы опять же пишем императивный код, хоть и спрятанный за абстракциями в методах. Но мы уходим от ужасов глобальных переменных и всем доступных данных, и работаем с отдельными сущностями, каждая из которых занимается только своей отдельный задачей.

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

class ToggleSwitch
{     public void Toggle()      {         _isOn = !_isOn;     }         public bool IsOn()     {        return _isOn;     }        public override String ToString()     {         return String.Format("Toggled {0}", _isOn ? "On" : "Off");     }    private bool _isOn;
}

То, что методы имеют «побочные эффекты» нисколько не означает, что они «плохие». Здесь эти «побочные эффекты» как раз и являются сутью, поскольку с их помощью мы меняем состояние объекта.

Просто такие методы (как функции) нельзя назвать правильными функциями в строго математическом смысле. В этом есть и свои плюсы, и свои минусы. А если бы они были таковыми? Как и всё математическое, мы получили бы нечто элегантное, но в чистом виде весьма далёкое от практики. Мы можем писать «чистые» функции на любых языках программирования, но за этим нам придется следить самим, а ограничиваясь только такими функциями, мы потеряли бы многие преимущества императивного и объектно-ориентированного программирования. В ООП мы меньше задумываемся о функциях, а больше о классах, объектах, иерархиях наследования, инкапсуляции и отношениях объектов.

Пойди туда, не знаю куда…

Другой парадигмой, кардинально отличающейся от императивной, является декларативная парадигма программирования. В декларативных языках мы уже не программируем в терминах команд. Мы описываем то, что хотим получить, а как мы это получим – уже задача транслятора. Главное предоставить исчерпывающую информацию. Например, с помощью языка SQL мы декларативно описываем то, что хотим от нашей базы данных, а то, что будет происходить далее нас мало волнует: «Дайте мне имена всех студентов, у которых средний был меньше 3, которые умеют стоять на голове, и упорядочьте их по убыванию лексикографически». Мы оперируем «высокоуровневыми» проекциями, пересечениями, объединениями и так далее.

Декларативное программирование в свою очередь можно разделить на логическое и функциональное (весьма условно: как можно заметить, в технологии LINQ декларативные запросы на самом деле являются цепочками вызовов лямбда-функций). Логическое, с языками типа Prolog позволяет описать нашу программу в виде фактов и логических правил, из которых можно вывести другие факты. Это раздолье для систем искусственного интеллекта и других сложных проблем, которые в обыденной жизни встречаются не так уж и часто.

Функциональное программирование (ФП) позволяет представить программу в виде функций, в математическом их смысле. Здесь функция – это правило, которое некоторому элементу из области определения ставит в соответствие некоторый элемент из области значений. Таким образом, функция для данного аргумента всегда даст один и тот же результат. А ещё, поскольку функция – это правило, она не может менять «глобальных» или каких-то других переменных. Здесь вообще нет «переменных», как мы их понимаем в императивных языках. В функциональном программировании, переменная один раз получившая значение больше не может его изменить. Это кажется ужасным, как программировать в мире, где объекты не могут изменяться? Как оказывается, программировать можно, и даже очень эффективно.

ФП строится на формальной теории лямбда-исчисления. Вы можете изучить его математические основы, ознакомившись с работами его создателя, Алонзо Чёрча. Но нам, как программистам, более важно то, что мы можем получить в своём коде.

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

Хватит говорить! Как найти факториал с помощью твоего языка? Возьмём, например, Haskell:

fac :: Integer -> Integer
fac 0 = 1
fac n | n > 0 = n * fac (n - 1)

Первая строчка, в общем, не обязательна, поскольку компилятор сам способен вывести типы. Вообще, вывод типов в функциональных языках достаточно мощный и обычно основывается на модели типизации Хиндли – Милнера. Данная тема широко освящена в соответствующей литературе.

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

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

# List.map (fun x -> x*x) [1;5;10;20];;
- : int list = [1; 25; 100; 400]

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

Просто, для сравнения, если бы мы тоже хотели сделать на Java, нам бы, по крайней мере, пришлось создавать объект нового класса, пусть даже и анонимного. Вообразим, что у нас есть функция map, и интерфейс IMappable:

interface IMappable<T> {     T Proceed(T a);
}

Тогда мы можем сделать так (не забывая сделать import java.util.*):

Iterable<Integer> res = map(     new IMappable<Integer>() {         public Integer Proceed(Integer a) {             return a*a;         }     }, new Arrays.asList( new Integer[] {1,5,10,20} ));

Даже с использованием анонимного класса вся эта штука выглядит довольно неуклюже. Здесь метод map мог быть реализован так:

public static <T> Iterable<T> map(IMappable<T> m, Iterable<T> A) {     ArrayList<T> newA = new ArrayList<T>();     for (T pe : A )         newA.add( m.Proceed(pe) );     return newA;
}

Помимо всего здесь идёт работа с обобщениями, что ещё больше усложняет дело. А вот как map может быть определена на функциональном OCaml:

# let rec map f a =     match a with       | [] -> []       | h::t -> (f h) :: (map f t);;
val map : (’a -> ‘b) -> ‘a list -> ‘b list = <fun>

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

Заметьте, в OCaml-версии у нас на выходе может получиться список элементов другого типа. В Java-версии, тип выходного Iterable должен быть тем же. Это ограничение можно исправить, добавив ещё один параметр обобщения в метод map. Вы можете самостоятельно над этим поэкспериментировать.

Функциональные языки имеют много интересных особенностей. Например, сопоставление с образцом, стражи, карринг, поддержка ленивых вычислений и бесконечных списков, и так далее. Здесь всё вроде бы гладко. Однако особые проблемы начинаются, как было сказано, когда мы начинаем говорить, например, о вводе-выводе. Каждый язык находит свою точку соприкосновения с императивным миром. В «чистых» языках, например, Haskell, это монады. В «не чистых», например, OCaml, всё же существует поддержка императивного программирования. Так что мы можем написать так (OCaml):

#  print_string "Hello World!\n"
Hello World!
- : unit = ()

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

Но самое интересное начинается, когда мы вдруг осознаём, что было бы неплохо объединить эти два мира…

Смешаем всё вместе

В последнее всё больше людей начинает интересоваться функциональными «штуками». И развитие объектно-ориентированных языков идёт на пути интеграции с функциональными возможностями. Так, такие языки как Ruby и Python поддерживают лямбда-функции и замыкания. Не так давно появился язык F#, очень похожий на OCaml, но с полной поддержкой платформы .NET. Всё популярнее становятся мультипарадигменные языки, вроде Nemerle, Scala. А новая версия C# 3.0 с расширениями LINQ вводит язык большое количество функциональных возможностей. Есть как сторонники, так и противники такой тенденции. Такими темпами языки, которые изначально задумывались как простые, например, Java, обрастут огромным количеством языковых расширений, которые кто-то может назвать просто «синтаксическим сахаром». Нужно ли смешение парадигм в рамках одного языка, или лучше иметь несколько простых языков, поддерживающих по одной парадигме?

Если вы знакомы с языком C#, взгляните на следующий код, вычисляющий число Пи по формуле Лейбница за N/2 итераций при помощи LINQ:

var pi = 4 * ((from i in Enumerable.Range(1, ITER_COUNT)
                where i % 2 != 0
                let sign = (i / 2) % 2 == 0 ? 1 : -1
                select  (double)sign / i).Sum()
               );

Как вам такой код? :) Довольно забавно, особенно в контексте обычного синтаксиса C#. Однако, с другой стороны, это может показаться ужасным.

Посмотрим, как императивность и функциональность можно смешать в C# 3.0 на примере с массивом. Вначале мы определяем класс расширения с функцией Map:

static class Util
{     public static IEnumerable<T> Map<T>(this IEnumerable<T> a,                                         Func<T, T> f)     {         foreach (T e in a)             yield return f.Invoke(e);     }
}

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

var res = new int[] { 2, 5, 10, 12 }.Map(x => x * x);

Ну и распечатаем его:

foreach (var e in res)     Console.Write("{0} ", e);

Func<T, T> представляет собой делегат, который принимает один аргумент типа T, и возвращает значение типа T. В С# 3.0 лямбда-функции – это, по сути, делегаты, но для них предусмотрен специальный упрощённый синтаксис.

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

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

.NET, CSharp, Functional Programming, Haskell

C# 3.0 и LINQ: для тех, кто ещё не в курсе

Что было бы, если проснувшись утром, вы обнаружили в языке, на котором говорите, возможность декларативно описывать те вещи, которые вы хотите получить? На самом деле, в русском языке существует такая возможность, и мы пользуемся ей ежедневно. Но на практике она не всегда работает: мы приходим в магазин и говорим: «Дайте мне книги Дональда Кнута, вышедшие позже 1970 года, в заголовке которых встречается слово программирование». Наверняка, продавец косо на нас посмотрит (хотя, всё зависит от магазина), подведёт к книжной полке и предложит самим найти то, что нам требуется :).

Хотя не исключено и то, что он запишет наши требования и удалится куда-то к белому ящику с монитором, и, вернувшись, вручит стопку нужных нам книг. Этот случай будет означать то, что продавец понял тот запрос, что мы сформулировали ему на родном (native) языке, затем он транслировал его в какой-то свой ментальный формат и воспользовался каким-либо средством для поиска и выборки наших данных. Нам не пришлось самим искать эти книги на полке, а так же не пришлось брать листик бумаги и расписывать на нём SQL-запрос, или же разучивать специальные термины, используемые в культуре поиска книг. Мы воспользовались расширением своего родного языка, чтобы получить необходимую выборку.

Тут может возникнуть проблема «чистоты»: нужно ли загромождать чистый и простой язык новыми сомнительными конструкциями, вместо того, чтобы использовать для этих целей другой, специально для этого предназначенный? Разработчики .NET всё-таки решили добавить в C# и другие языки платформы .NET проблемно-ориентированное расширение LINQ, интегрированный язык запросов.

И теперь мы можем писать так:

var books = from b in all_books
            where b.Author == "Donald Knuth"
&& b.Title.ToLower().Contains("programming")
&& b.DatePublished.Year > 1970
            select new { b.Title, b.ISDN };

Причём такие запросы можно выполнять к любым объектам, реализующим IEnumerable. Для незнакомого с LINQ человека это может выглядеть непонятно, но я думаю, к концу статьи всё станет на свои места. Конечно, данная конструкция кажется неуместной в контексте обычных конструкций языка C#. Однако, использовать такой синтаксис или нет – дело конкретного случая. Аналогичные действия можно выполнить и с помощью «родного» синтаксиса С#, но они не будут выглядеть так впечатляюще :). На самом деле всё это транслируется в стандартные конструкции перед выполнением, так что кто-то может заметить, что перед нами синтаксический сахар в чистом виде.

Немного про LINQ

Идеи, появившиеся в новой версии C#, позаимствованы из экспериментального языка , который специально был создан для улучшения обработки XML и реляционных данных в языке C#. Так же некоторое сходство можно проследить с функциональным языком F# (потомок OCaml).

Расширения LINQ представлены в С# 3.0. Всё необходимое для работы с основными возможностями LINQ находится в пространстве имён System.Linq. Стоит отметить, что Microsoft представила целый набор новых технологий на базе LINQ для использования с базами данных, XML, объектами, реализующими IEnumerable. Технологии LINQ to Databases, LINQ to XML и LINQ to Entities выходят за рамки данной статьи.

Нам понадобится Visual Studio 2008, либо Visual Studio 2005 с установленным .NET Framework 3.5 и расширениями LINQ. Либо же, вы можете совершенно бесплатно загрузить Visual C# 2008 Express Edition отсюда.

Итак, начнём.

Что за декларативность и функциональность?

Работая с .NET, мы работаем с объектно-ориентированной средой, и ООП-языками: C#, VB.NET, и другими. В них мы оперируем классами, объектами, пространствами имён и так далее. ООП отлично справляется с отдельными объектами, однако, когда дело доходит, например, до коллекций объектов, всё усложняется. Коллекции гораздно тяжелее обрабатывать. Для этого было написано много вспомогательных классов. Но когда нам требуется сотворить нетривиальную операцию над коллекциями, мы заходим в лабиринт загадочных нагромождений излишнего кода.

LINQ пытается решить проблему сложных манипуляций, привлекая сильные стороны функционального программирования. Мы говорим, что функция – это объект, позволяем передавать её как параметр в другие функции, возвращать в качестве значения, хранить массивы из функций. Такие функции назвали λ-функциями. Для них предусмотрен свой синтаксис, однако, на самом деле они являются чем-то похожим на анонимные делегаты. Более того, мы можем передавать λ-функциям в качестве параметра другие функции, таким образом организовывая функции высших порядков. Берём нашу коллекцию (или несколько коллекций), которая реализует IEnumerable<T>, пропускаем через цепочку λ-функций, и на выходе получаем то, что нам необходимо.

LINQ пытается решить проблемы манипуляций с данными вообще. Посмотрите, бизнес-объекты в ООП, и данные в реляционной БД (или XML) имеют разную природу, но им нужно как-то согласовываться. Этим «мостиком» и может служить LINQ. Мы строим декларативные запросы в своём языке, которые хранятся в виде «деревьев выражений», и исполняем их, когда нам это потребуется.

Для того, чтобы получить всё это в нефункциональном языке, требуется добавить в него множество расширений. А так же некоторые синтаксические «штуки», которые облегчат кодирование. Всё это будет рассмотрено далее. Это именно то, что появилось в C# 3.0.

Вывод типа

Вывод типа (type-inference) – возможность, широко используемая в динамически-типизируемых языках. Идея состоит в том, что мы можем не указывать тип переменной, ссылающейся на объект, если этот тип может быть однозначно определён из значения (или выражения). Мы можем писать так:

var s = "here i am!";
var i = 123;
var varlist = new List<double>();

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

MySuperLongClass<MyLongClass<int, string>> obj =     new MySuperLongClass<MyLongClass<int, string>>()

Писать

var obj = MySuperLongClass<MyLongClass<int, string>>()

И тип переменной будет автоматически выведен из выражения. Однако при всём этом, С# остаётся строгим статически-типизируемым языком, и мы не можем позже присвоить нашей переменной объект другого типа.

Проверить, какой тип нам вывел компилятор, мы можем так:

Console.WriteLine(s.GetType());
Console.WriteLine(i.GetType());
Console.WriteLine(varlist.GetType());

Как видно, мы получили именно то, чего и ожидали:

System.String
System.Int32
System.Collections.Generic.List`1[System.Double]

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

var obj = null

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

Анонимные типы позволяют создавать экземпляры классов, которые мы не определяли ранее. Если нам «здесь и сейчас» нужен экземпляр какого-то класса, имя которого нам не важно, а важен только сам объект (и его свойства), мы воспользуемся следующей конструкцией:

var anonim = new { Name = "Inkognito", Age = 150 };

Вот что мы получим в итоге:

<>f__AnonymousType0`2[System.String,System.Int32]

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

Console.WriteLine("My name is {0}, and i am {1} years old!",
                  anonim.Name, anonim.Age);

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

Идея возможности расширять классы новыми методами без использования наследования не нова. Например, такую же возможность можно встретить в Ruby в виде примесей (mixins). Смысл в том, что мы пишем метод вне зоны определения класса, который мы собираемся расширять, а потом можем использовать этот метод как «родной» для этого класса. При этом мы можем расширять классы, для которых у нас нет исходного текста, нам он не требуется, поскольку методы расширения помещаются в так называемых статических классах, которые предназначены специально для хранения таких методов.

Допустим, нам очень не хватает в стандартном классе String метода, который бы преобразовывал нашу строку в строку, написанную ЗаБоРоМ. Мы определяем статический класс, в котором будет содержаться необходимый нам метод:

public static class Util
{
    public static string Zaborom(this string s)
    {
          StringBuilder builder = new StringBuilder();
          bool upper = true;
          foreach (char c in s)
          {
              if (upper)
                 builder.Append(c.ToString().ToUpper());
             else
                 builder.Append(c.ToString().ToLower());
             upper = !upper;
          }
          return builder.ToString();
    }
}

Обратите внимание на одну особенность – аргумент метода: this string s. Ключевое слово this в этом контексте как раз и указывает на то, что данный метод является методом расширения для класса string. Мы можем вызывать его следующими способами.

// Обычным, как будто мы вызываем статический метод:
s = "he-he-he! hello world!";
Util.Zaborom(s)                 

// Либо как метод-расширение:
s = "he-he-he! hello world!";
s.Zaborom()                 

// Конечно, так тоже можно :)
"he-he-he! hello world!".Zaborom();

Результат во всех случаях будет аналогичный:

He-hE-He! HeLlO WoRlD!

Хотя методы расширения используются по полной в LINQ, они и сами по себе не менее полезны.

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

Методы расширений позволяют выполнять довольно интересные трюки, например такие:

static class MyUtils
{
    public static int Kilobytes(this int bytes)
    {
        return bytes * 1024;
    }
}                 

// ...                 

5.Kilobytes(); // -> 5120
2.Kilobytes() + 10; // -> 2058

Применительно же к контейнерам, мы можем написать единый вариант функции Count(), подсчитывающий количество элементов в контейнере, следующим образом:

public static class EnumerableUtil
{
       public static int MyCount(this IEnumerable enumerable)
       {
           int count = 0;
           foreach (var e in enumerable)
               count++;
           return count;
       }
}

Автоматические свойства

Часто бывает так, что свойства некоторого объекта не делают ничего, кроме установки и чтения значений private-переменных. В таком случае писать тела таких свойств становится довольно утомительно, особенно если их много. От этой траты временны нас защитить призваны автоматические свойства: их синтаксис похож на описания свойств в интерфейсах, однако смысл этому придаётся другой.

class Person
{
    public Person(string name, string lastname)
    {
        Name = name;
        Lastname = lastname;
    }                 

    public string Name { get; private set; }
    public string Lastname { get; private set; }
    public int Age { get; set; }
    public bool IsProgrammer { get; set;}
}

Get-теры и Set-теры свойств могут объявляться с модификатором private, что означает запрет на использование из вне класса. Теперь можно использовать их, как обычные свойства:

Person me = new Person("Vasya", "Pupkin");
me.Age = 200; // good
me.Name = "Inkognito"; // error! private setter

Инициализация свойств

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

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

var persons = new Person[]
{
    new Person(”Harry”, “Hacker”) { Age = 30, IsProgrammer = true },
    new Person(”Vinnie”, “Pooch”) { IsProgrammer = false, Age = 45},
    new Person(”Kolo”, “Bok”) { IsProgrammer = true}
};

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

λ-выражения

λ-выражения (лямбда-выражения) – интересный механизм, который уже долгое время используется во многих языках программирования. Говоря неформально, λ-выражение – это функция, которую можно выполнять, передавать в качестве параметра, возвращать из метода и так далее. На самом деле λ-выражения в некотором виде уже присутствуют в C# 2.0 в виде замыканий анонимных делегатов:

int[] array = new[] { 1, 321, 234, 54, 34 };
var a = array.Where(
      delegate(int elem) { return elem % 2 == 0; }
);                 

foreach (var elem in a)
    Console.Write(elem + ” “);

Получим:

234 54 34

В этом примере используется стандартный метод расширения Where, который выбирает из IEnumerable элементы, соответствующие некоторому условию. Здесь мы использовали анонимный делегат для представления предиката. Однако такая запись достаточно громоздка, и в LINQ был введён дополнительный синтаксис, так что аналогичное можно записать так:

var a = array.Where(elem => elem % 2 == 0);

На самом деле эта конструкция есть экземпляр Func<int,bool>. Func инкапсулирует метод, его аргументы и возвращаемое значение. Так, например, метод расширения Where мог быть реализован так:

public static IEnumerable Where<T>(
            this IEnumerable<T> enumerable,
            Func<T, bool> condition )
{
    foreach (T e in enumerable)
        if (condition(e))
            yield return e;
}

Func же может представляет из себя следующее:

delegate R MyFunc<T, R>(T arg);

С помощью λ-выражений в купе с методами расширения тоже можно развлечься следующим образом:

static class MyUtils
{
        public delegate void TimesDelegate(int i);
        public static void Times (this int n, TimesDelegate func)
        {
            for (int i = 0; i < n; i++)
                func(i);
        }
}                 

// ...                 

5.Times(
    i => Console.WriteLine(i)
);

Ленивые вычисления

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

Не стоит думать, что раз вычисления ленивые, то их нужно уговаривать выполнить их работу. Согласно идее ленивых вычислений, вычисления следует откладывать до того момента, пока их результат действительно не понадобится. В императивных языках часто бывает так, что мы вычисляем некоторое значение, а потом оно никогда не используется – время тратится зря. На самом деле какая-то часть «ленивости» присутствует в некоторых языках, например, C++, при проверке условий вроде

If (a == true && b == 123)

Если a принимает значение false, то условия для b не проверяется, поскольку не влияет на результат.

Вернёмся к C#. На самом деле мы уже использовали ленивость в предыдущих разделах, когда рассматривали λ-выражения и методы расширения. Вспомним пример с нахождением чётных чисел:

var a = array.Where(elem => elem % 2 == 0);

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

foreach (var elem in a)
       Console.Write("{0}, ", elem);

Увидеть это можно, внеся изменения в Where (Не забудьте поместить его в какой-нибудь статический класс):

public static IEnumerable MyWhere<T>(
            this IEnumerable<T> enumerable,
            Func<T, bool> condition )
{
    foreach (T e in enumerable)
        if (condition(e))
        {
            Console.Write("(Мы в фильтре)");
            yield return e;
        }
}

Теперь следующий код

int[] array = new[] { 1, 321, 234, 54, 34 };           

Console.WriteLine(”Фильтруем”);
var lazy = array.MyWhere(elem => elem % 2 == 0);            

Console.WriteLine(”Распечатываем”);
foreach (var elem in lazy)
       Console.Write(”{0}, “, elem);

Даст такой вывод:

Фильтруем
Распечатываем
(Мы в фильтре)234, (Мы в фильтре)54, (Мы в фильтре)34,

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

Запросы

Вооружившись знанием из предыдущих разделов, напишем LINQ-запрос. Возьмём знакомый нам массив людей:

var persons = new Person[]
{
    new Person(”Harry”, “Hacker”) { Age = 30, IsProgrammer = true },
    new Person(”Vinnie”, “Pooch”) { IsProgrammer = false, Age = 45},
    new Person(”Kolo”, “Bok”) { IsProgrammer = true}
};

И найдём полные имена и возраст тех, кто является программистом, отсортируем по возрасту (не стоит забывать, что аналогичный запрос работал бы и с любой коллекцией из Person, которая реализует IEnumerable<Person>):

var programmers = persons.Where(p => p.IsProgrammer)
        .Select(p => new { Fullname = p.Name + " " + p.Lastname, p.Age })
        .OrderByDescending(p=>p.Age);

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

Console.WriteLine("Programmers: ");
foreach (var p in programmers)
    Console.WriteLine("{0}, {1} years old", p.Fullname, p.Age);

Примечательно, мы тут мы можем использовать специальный SQL-подобный синтаксис, так, предыдущий запрос перепишем так:

var programmers = from p in persons
                  where p.IsProgrammer
                  orderby p.Age descending
                  select new { Fullname = p.Name + " " + p.Lastname,
                               p.Age };

Чего мы и добивались. Однако это не всё, нам доступен весь спектр нужных вещей, например, таких как объединения (join). Допустим, у нас есть следующие классы:

public class Customer
{
      public int Key;
      public string Name;
}                 

public class Order
{
      public int CustomerKey;
      public string What;
}

И массивы (Обратите внимание на запятую после последнего элемента массива, синтаксической ошибки не возникает. Это сделано для того, чтобы было легче добавлять новые элементы в конец):

var customers = new Customer[]
{
    new Customer { Key=1, Name=”Vasya”},
    new Customer { Key=2, Name=”Petya”},
    new Customer { Key=12, Name=”Vova”},
};                 

var orders = new Order[]
{
    new Order { CustomerKey=1, What=”Book” },
    new Order { CustomerKey=1, What=”Clock” },
    new Order { CustomerKey=2, What=”Pen”},
    new Order { CustomerKey=12, What=”Phone”},
};

Вот простой Join-запрос:

var q = from c in customers
        join o in orders on c.Key equals o.CustomerKey
        select new { c.Name, o.What };

Даст нам следующее:

{ Name = Vasya, What = Book }
{ Name = Vasya, What = Clock }
{ Name = Petya, What = Pen }
{ Name = Vova,  What = Phone }

Мы так же можем использовать группировку (group by), аггрегирование (например, Sum(), Count()), и другие функции, полный список которых вы можете найти в документации по LINQ.

Деревья выражений

Как было сказано, в LINQ λ-функции представляються в виде деревьев выражений. Мы можем создавать λ-выражения динамически. Класс Expression<T> (который находится в System.Linq.Expressions) представляет собой такое дерево выражений. Взгляните на следующий пример:

Expression<Func<int, int>> square = p => p * p;

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

var square = square_expr.Compile();
square(5); // -> 25

А вот как мы можем построить дерево выражений вручную, используя статические методы из Expression:

ParameterExpression pe = Expression.Parameter(typeof(int), "num");
Expression<Func<int, int>> mysquare =
        Expression.Lambda<Func<int, int>>(
            Expression.Multiply(pe, pe),
            new ParameterExpression[]{ pe}
        );
mysquare.Compile()(5); // -> 25

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

Заключение

С каждой версией C# впитывает всё больше новых расширений. В данной статье было рассмотрено относительно проблемно-ориентированное расширение LINQ, которое помогает легче взаимодействовать с миром данных. Да и сами по себе новые возможности C# 3.0 достаточно полезны. Чтобы идти в ногу со временем, языки эволюционируют, впитывая в себя современные идеи. Наверное, не стоит думать, что то, что принесёт нам LINQ – это решение всех проблем, связанных с манипуляциями данными. Однако данная технология представляется достаточно элегантным «связующим звеном». Интересно будет понаблюдать за тем, как она приживётся, вытерпит ли проверку временем, и во что выльется в дальнейшем.

.NET, CSharp, framework, Functional Programming