воскресенье, 19 февраля 2012 г.

[Перевод] Джон Скит. Реализация LINQ to Objects: Часть 2 – Where

Это перевод второй части серии статей Джона Скита "Реализация LINQ to Objects". С оригинальной версией поста можно ознакомиться здесь.


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

Мы собираемся реализовать выражение/метод/оператор "Where". Его достаточно просто понять в общем, но отложенное выполнение и потоковый вывод могут вызвать проблемы. Он обобщенный, но использует только один параметр типа (что, на мой взгляд, важно: чем больше параметров типа имеет метод, тем сложнее его понять в общем). О, и это стартовая точка для выражений запросов, что является бонусом.

Что это такое?

У «Where» есть 2 перегрузки:

public static IEnumerable<TSource> Where(
    this IEnumerable<TSource> source,
    Func<TSource, bool> predicate)

public static IEnumerable<TSource> Where(
    this IEnumerable<TSource> source,
    Func<TSource, int, bool> predicate)

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

  • Это – методы расширения. Они объявляются в невложенном статическом классе высшего уровня, и первый параметр содержит модификатор «this». Они могут быть вызваны, как будто являются методами объекта типа первого параметра.
  • Это – обобщенные методы. В данном случае присутствует всего лишь один параметр типа (TSource), который указывает тип последовательности, с которой мы работаем. Поэтому для, скажем, списка строк, TSource будет типа string.
  • Они принимают обобщенные делегаты семейства Func<...>. Они часто определяются при помощи лямбда выражений, но также можно предоставить и делегат.
  • Они работают с последовательностями, которые представлены интерфейсом IEnumerable<T>, с итератором, который представляется интерфейсом IEnumerable<T>./li>

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

Теперь, несколько важных деталей о поведении:

  • Исходная последовательность никоим образом не модифицируется: это, к примеру, не похоже на List<T>.RemoveAll.
  • Метод использует отложенное выполнение – до того момента, пока вы не попытаетесь достать элементы из конечной последовательности, элементы не начнут извлекаться из исходной последовательности.
  • Несмотря на отложенное выполнение, он сразу проверит, не являются ли параметры null.
  • Он возвращает результаты в потоке – ему всегда нужен только один результат в один момент времени, и он будет возвращать его, не храня на него ссылку. Это значит, что вы можете применять этот метод к последовательностям бесконечной длины (например, к последовательности случайных чисел.)
  • Каждый раз, когда вы будете обходить конечную последовательность, он будет проходить по исходной последовательности точно один раз.
  • Освобождение итератора конечной последовательности освободит соответствующий итератор исходной последовательности. (В случае если вы не знали, оператор foreach в C# использует блок try/finally, чтобы удостовериться, что итератор всегда освобождается, как бы ни закончился цикл).

Многие из этих деталей также будут справедливы и для большинства остальных операторов.

Перегрузка, которая принимает Func<TSource, int, bool> позволяет предикату использовать индекс внутри последовательности так же, как и значение. Индекс всегда начинается с 0, и увеличивается на 1 каждый раз вне зависимости от предыдущих результатов предиката.

Что мы собираемся тестировать?

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

Давайте взглянем на некоторые тесты. Во-первых, вот первый «позитивный» тест – мы начинаем с массивом целых чисел, и используем лямбда-выражения, чтобы включить только элементы меньше 4. (Слово «фильтр» является вездесущим, но не уместным. Гораздо проще говорит о «фильтрации» элементов, чем об их «включении», но предикат представлен в положительном смысле.)

[Test]
public void SimpleFiltering()
{
    int[] source = { 1, 3, 4, 2, 8, 1 };
    var result = source.Where(x => x < 4);
    result.AssertSequenceEqual(1, 3, 2, 1);
}

Я оставил TestExtensions из MoreLINQ, несмотря на то, что NUnit поставляется с CollectionAssert. Я считаю, что с методами расширения легче работать по трём причинам:

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

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

Пока всё идет хорошо. Теперь давайте проверим валидацию аргументов:

[Test]
public void NullPredicateThrowsNullArgumentException()
{
    int[] source = { 1, 3, 7, 9, 10 };
    Func<int, bool> predicate = null;
    Assert.Throws<ArgumentNullException>(() => source.Where(predicate));
}

[Test]
public void WithIndexNullSourceThrowsNullArgumentException()
{
    IEnumerable<int> source = null;
    Assert.Throws<ArgumentNullException>(() => source.Where((x, index) => x > 5));
}

Меня не беспокоит проверка имени внутри ArgumentNullException, я тестирую то, что аргументы проверяются мгновенно. Я не перечисляю конечную последовательность – поэтому, если валидация является отложенной, тест провалится.

Последний интересный тест на данный момент также проверяет отложенное выполнение. Он использует вспомогательный класс ThrowingEnumerable. Это последовательность, которая бросает исключение InvalidOperationException, если вы когда-либо попытаетесь пройти по ней. По существу, мы хотим проверить две вещи:

  • Вызов только Where не начинает процесс обхода исходной последовательности.
  • Когда мы вызываем GetEnumerator() чтобы получить итератор, а потом MoveNext() на этом итераторе, мы должны начать обход не бросая исключения

Мы должны будем сделать что-то подобное для других операторов, поэтому я написал небольшой вспомогательный метод в ThrowingEnumerable:

internal static void AssertDeferred<T>(
Func<IEnumerable<int>, IEnumerable<T>> deferredFunction)
{
    ThrowingEnumerable source = new ThrowingEnumerable();
    var result = deferredFunction(source);
    using (var iterator = result.GetEnumerator())
    {
        Assert.Throws<InvalidOperationException>(() => iterator.MoveNext());
    }
}

Теперь мы можем проверить, действительно ли Where откладывает выполнение:

[Test]
public void ExecutionIsDeferred()
{
    ThrowingEnumerable.AssertDeferred(src => src.Where(x => x > 0));
}

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

Давайте имплементируем это!

Имея тесты, которые проходят с настоящим LINQ to Objects, пришло время имплементировать его в нашем производственном коде. Мы собираемся использовать блоки итераторов, которые были представлены в C# 2, чтобы облегчить имплементацию IEnumerable. У меня есть несколько статей, которые вы можете прочесть, если вы хотите получить более глубокие знания… или прочесть шестую главу C# in Depth (любое из изданий). Они дают нам возможность отложенного выполнения бесплатно… но это может быть как хорошо, так и плохо, в чем мы скоро убедимся.

В действительности имплементация будет выглядеть следующим образом:

// Наивная имплементация
public static IEnumerable<TSource> Where<TSource>(
    this IEnumerable<TSource> source,
    Func<TSource, bool> predicate)
{
    foreach (TSource item in source)
    {
        if (predicate(item))
        {
            yield return item;
        }
    }
} 

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

Подумать только, некоторые тесты уже проходят. Теперь нам осталось добавить только валидацию аргументов. Это легко, правда? Давайте так и сделаем:

public static IEnumerable<TSource> Where<TSource>(
            this IEnumerable<TSource> source,
            Func<TSource, bool> predicate)
{
    if (source == null)
    {
        throw new ArgumentNullException("source");
    }
    if (predicate == null)
    {
        throw new ArgumentNullException("predicate");
    }

    foreach (TSource item in source)
    {
        if (predicate(item))
        {
            yield return item;
        }
    }
}

Хмм. Наши тесты валидации до сих пор не проходят, и установка точки прерывания на операторе «throw» не помогает… на ней не происходит остановки. Что же не так?

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

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

public static IEnumerable<TSource> Where<TSource>(
    this IEnumerable<TSource> source,
    Func<TSource, bool> predicate)
{
    if (source == null)
    {
        throw new ArgumentNullException("source");
    }
    if (predicate == null)
    {
        throw new ArgumentNullException("predicate");
    }
    return WhereImpl(source, predicate);
}

private static IEnumerable<TSource> WhereImpl<TSource>(
    this IEnumerable<TSource> source,
    Func<TSource, bool> predicate)
{
    foreach (TSource item in source)
    {
        if (predicate(item))
        {
            yield return item;
        }
    }
}

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

public static IEnumerable<TSource> Where<TSource>(
            this IEnumerable<TSource> source,
            Func<TSource, int, bool> predicate)
{
    if (source == null)
    {
        throw new ArgumentNullException("source");
    }
    if (predicate == null)
    {
        throw new ArgumentNullException("predicate");
    }
    return WhereImpl(source, predicate);
}

private static IEnumerable<TSource> WhereImpl<TSource>(
    this IEnumerable<TSource> source,
    Func<TSource, int, bool> predicate)
{
    int index = 0;
    foreach (TSource item in source)
    {
        if (predicate(item, index))
        {
            yield return item;
        }
        index++;
    }
}

Теперь тесты зеленые, и мы закончили. Хотя подождите… мы еще не использовали имплементацию всеми возможными способами.

Выражения запросов

Ранее мы вызывали метод напрямую (хоть он и метод расширения) – но LINQ также обеспечивает нас выражениями запросов. Вот наш тест «SimpleFiltering», переписанный с их использованием:

[Test]
public void SimpleFilteringWithQueryExpression()
{
    int[] source = { 1, 3, 4, 2, 8, 1 };
    var result = from x in source
                 where x < 4
                 select x;
    result.AssertSequenceEqual(1, 3, 2, 1);
}

Это сгенерирует точно такой же код, что и наш предыдущий тест. Компилятор, в сущности, транслирует эту форму в предыдущую, оставляя условие (x < 4) в виде лямбда-выражения, позже переводя его соответствующим образом (в данном случае, в делегат). Вы можете быть удивлены, что это работает, ведь у нас пока нет метода Select… но в этом случае мы имеем «холостую» Select-проекцию; на самом деле мы не проводим никакой трансформации. Пока в запросе присутствует что-либо еще (в нашем случае - это условие «where») - компилятор эффективно опускает оператор «select», поэтому не имеет значения, имплементирован он или нет. Если бы мы изменили «select x» на «select x * 2», компиляция нашей только-Where имплементации LINQ не прошла бы.

Факт того, что выражения запросов базируются только на шаблонах типа этого, является очень мощной возможностью для расширения – так, к примеру, LINQ to Rx имплементирует только те операторы, которые актуальны в данной среде. Таким же образом, когда дело касается выражений запросов, компилятор C# ничего не «знает» о IEnumerable - поэтому полностью обособленные интерфейсы, такие, как IObservable, работают как положено.

Чему мы научились?

Многое было затронуто здесь, в обоих смыслах имплементации и основных принципов LINQ:

  • LINQ to Objects основан на методах расширения, делегатах и IEnumerable<T>
  • Операторы используют отложенное выполнение, где это свойственно и выдают свои данных в виде потока, где это возможно.
  • Операторы не меняют источник, вместо этого они возвращают новую последовательность, которая будет содержать нужные данные.
  • Выражения запросов основываются на трансляции паттернов компилятором; вы не должны имплементировать больше паттерном, чем требует соответствующее выражение запроса.
  • Блоки итераторов великолепно подходят для имплементации отложенного выполнения...
  • …но затрудняют валидацию аргументов
Загрузка кода

Linq-To-Objects-2.zip

Многие спрашивали меня о репозитории для кода проекта, и это имеет смысл. Я как раз сейчас создаю репозиторий; скорее всего это будет сделано до того, как я опубликую следующий пост.

воскресенье, 12 февраля 2012 г.

[Перевод] Джон Скит. Реализация LINQ to Objects: Часть 1 – Вступление

Этим постом я начинаю перевод замечательной серии статей Джона Скита под названием «Edulinq: Reimplementing LINQ to Objects». С оригинальными записями можно ознакомиться в виде постов, либо в формате электронной книги. Просьба указывать на неточности перевода в комментариях.


Примерно полтора года назад я выступил на дне DDD в Рединге, стараясь реализовать столько LINQ to Objects, сколько это было возможно за час. Основываясь на поступивших отзывах, я быстро продвинулся вперед… и по-прежнему был далёк до финиша. Тем не менее, я до сих пор считаю, что это интересное упражнение, поэтому я решил, что повторю его в свободное время, попутно ведя блог. Всё будет помечено тэгом "Edulinq", поскольку это самый простой способ получить все части в правильном порядке без других моих постов.

Общий подход

План – имплементировать весь LINQ to Objects, объясняя каждый метод (или группу методов) в посте блога. Я собираюсь делать код рабочего качества, но я не буду включать никакой XML документации: я и так пишу, как это работает, и не собираюсь делать это дважды. Я буду добавлять оптимизацию там, где это необходимо, надеюсь, делая лучше, чем собственно сам LINQ to Objects.

Подход будет очень прост: для каждого метода LINQ я буду писать модульные тесты (большинство из которых я не буду показывать в постах) и убеждаться, что они работают относительно обычной .NET имплементации. Далее я закомментирую директиву using для System.Linq, и вместо неё добавлю такую же для JonSkeet.Linq. Тесты провалятся, я имплементирую методы, и постепенно они опять заработают. Это не совсем классический подход TDD, но он достаточно хорошо работает.

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

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

Цель – не закончить с LINQBridge: я буду использовать .NET 3.5 (в основном для того, чтобы использовать методы расширения без создания своих собственных атрибутов) и я точно не собираюсь начинать беспокоиться об установщиках и т.п. Цель всего этого исключительно образовательная: если вы пройдете через все эти записи, вы получите углублённое понимание работы LINQ в общем и LINQ to Objects в частности. Например, такая тема, как отложенное выполнение часто оказывается понята неправильно: взгляд на реализацию может хорошо всё прояснить.

Тестирование

Модульные тесты будут написаны при помощи NUnit (только ради моего с ним ознакомления). Довольно очевидно, что одна из вещей, которую нам придется часто тестировать – это проверка, являются ли две последовательности равными. Мы будем делать это при помощи класса TestExtensions из MoreLINQ (который я только что добавил в проект). На нетбуке, который я, возможно, буду использовать для написания большей части кода, установлен только C# Express 2010, поэтому я собираюсь использовать внешний NUnit GUI. Я установил его стартовым приложением в файле проекта… чего вы не сможете сделать из C# Express напрямую, но редактировать файл проекта просто, следует лишь добавить следующее:


<startaction>Program</startaction>      
<startprogram>C:\Program Files\NUnit-2.5.7.10213\bin\net-2.0\nunit-x86.exe</startprogram>

Это – довольно безобразный хак, но он работает. «Дополнительным параметром командной строки» будет JonSkeet.Linq.Tests.dll: текущим каталогом является каталог bin/debug, поэтому всё хорошо. Очевидно, что если вы хотите запускать тесты сами, и у вас установлен ReSharper или что-то подобное, вы сможете видеть результаты, интегрированные в Visual Studio.

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

Структура кода

Так же, как и реальный LINQ to Objects, я буду создавать огромный статический класс Enumerable, но я буду делать это, используя разделяемые классы с одним название метода (но с несколькими перегрузками) на файл. Поэтому, к примеру, Where будет имплементирован в Where.cs и тестирован в WhereTest.cs.

Первая порция кода

Доступен первый zip файл: Linq-To-Objects-1.zip. Он пока не содержит рабочего кода – только 4 теста для Where, чтобы я мог проверить, что NUnit работает правильно. Следующая остановка… имплементация Where.

четверг, 9 февраля 2012 г.

Добро пожаловать!

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

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