Сообщений 14    Оценка 65 [+1/-0]         Оценить  
Система Orphus

Лямбда-выражения

Глава из книги “C# 2008: ускоренный курс для профессионалов”

Автор: Трей Нэш
Источник: C# 2008: ускоренный курс для профессионалов
Материал предоставил: Издательство ''Вильямс''
Опубликовано: 09.02.2008
Исправлено: 15.04.2009
Версия текста: 1.0
Введение в лямбда-выражения
Лямбда-выражения
Лямбда-операторы
Деревья выражений
Операции над выражениями
Функции как данные
Полезные применения лямбда-выражений
Вернемся к итераторам и генераторам
Замыкания (захват переменной) и мемоизация
Приправа
Анонимная рекурсия
Резюме

Большинство новых средств C# 3.0 открывают программистам на C# мир выразительного функционального программирования. Функциональное программирование в его чистом виде – это методология программирования, построенная поверх неизменяемых переменных (иногда называемых символами), функций, которые могут производить другие функции, и рекурсии; и это лишь несколько его основ. К выдающимся языкам функционального программирования можно отнести Lisp, Haskell, F# и Scheme. Однако функциональное программирование не требует специального функционального языка, и вы можете с успехом реализовать его на традиционных императивных языках, таких как все C-подобные языки (включая C#). Новые средства C# 3.0 трансформируют язык в более выразительный гибридный язык, в котором приемы императивного и функционального программирования сосуществуют в гармонии. Лямбда-выражения – несомненно, самый большой кусок этого пирога функционального программирования.

ПРИМЕЧАНИЕ

F# – выдающийся новый язык функционального программирования для .NET Framework. Подробную информацию об этом языке читайте в книге Роберта Пикеринга (Robert Pickering) Foundations of F# (Berkeley, CA: Apress, 2007 г.).

ПРИМЕЧАНИЕ

Один из языков, которые я часто использую – C++. Те из вас, кто знаком с метапрограммированием на C++, определенно знакомы и с приемами функционального программирования. Если вы используете C++ и интересуетесь метапрограммированием, загляните в блестящую книгу Дэвида Абрахамса (David Abrahams) и Алексея Гуртового (Aleksey Gurtovoy) C++ Template Metaprogramming: Concepts, Tools, and Techniques from Boost and Beyond (Boston, MA: Addison-Wesley Professional, 2004 г.).

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

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

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

ПРИМЕЧАНИЕ

Некоторые примеры функционального программирования с анонимными методами приводятся в главе 14.

Лямбда-выражения принимают две формы. Форма, которая наиболее прямо заменяет анонимные методы, представляет собой блок кода, заключенный в фигурные скобки. Я предпочитаю называть это лямбда-операторами. Такие лямбда-операторы – прямая замена анонимных методов. Лямбда-выражения, с другой стороны, предоставляют еще более сокращенный способ объявлять анонимный метод и не требуют ни кода в фигурных скобках, ни оператора return. Оба типа лямбда-выражений могут быть преобразованы в делегаты. Однако лямбда-выражения без блоков операторов представляют собой нечто действительно впечатляющее. Вы можете преобразовать их в деревья выражений с помощью типов из пространства имен System.Linq.Expressions. Другими словами, функция, описанная в коде, превращается в данные. Тему создания деревьев выражений из лямбда-выражений я раскрою ниже, в разделе “Деревья выражений” настоящей главы.

Лямбда-выражения

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

x => x / 2

Это говорит следующее: “взять x в качестве параметра и вернуть результат следующей операции в x”. Обратите внимание, что лямбда-выражение лишено информации о типе. Это не значит, что выражение не имеет типа. Вместо этого компилятор выводит тип аргумента и тип результата из контекста его использования, и отсюда следует, что если вы присваиваете лямбда-выражение делегату, типы определения делегата используются для определения типов внутри лямбда-выражения. Следующий код показывает, что случается, когда лямбда-выражение присваивается типу делегата:

using System;
using System.Linq;

public class LambdaTest
{
    static void Main() {
        Func<int, double> expr = x => x / 2;

        int someNumber = 9;
        Console.WriteLine( "Результат: {0}", expr(someNumber) );
    }
}

Я выделил лямбда-выражение, чтобы подчеркнуть его. Тип Func<> – это новый вспомогательный тип, представленный в пространстве имен System, который вы можете использовать для объявления простых делегатов, принимающих до четырех аргументов и возвращающих результат. В данном случае я объявляю переменную expr, которая является делегатом, принимающим int и возвращающим double. Когда компилятор присваивает лямбда-выражение переменной expr, он использует информацию о типе делегата для определения того, что типом x должен быть int, а типом возврата – double.

Теперь, если вы выполните этот код, то заметите, что результат не совсем точен. То есть результат был округлен. Этого следовало ожидать, поскольку результат x/2 представлен как int, который затем приводится к double. Вы можете исправить это, специфицируя различные типы в объявлении делегата, как показано ниже:

using System;
using System.Linq;

public class LambdaTest
{
    static void Main() {
        Func<double, double> expr = (double x) => x / 2;

        int someNumber = 9;
        Console.WriteLine( "Result: {0}", expr(someNumber) );
    }
}

Лямбда-выражение имеет то, что называется явно типизированным списком параметров, и в этом случае x объявлена с типом double. Также обратите внимание, что тип expr теперь – Func<double, double> вместо Func<int, double>. Компилятор требует, чтобы всякий раз, когда вы используете типизированный список параметров в лямбда-выражении и присваиваете его делегату, то типы аргументов делегата должны совпадать в точности. Однако поскольку int явно преобразуем в double, вы можете передать someNumber в expr во время вызова, как было показано.

ПРИМЕЧАНИЕ

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

Когда лямбда-выражение присваивается делегату, тип возврата выражения обычно выводится из типов аргументов. Поэтому в следующем фрагменте кода тип возврата выражения – double, поскольку предполагаемый тип параметра x – double:

Func<double, int> expr = (x) => x / 2; // Ошибка компиляции!!!!

Однако, поскольку double не конвертируется неявно в int, компилятор выдает ошибку:

error CS1662: Cannot convert 'lambda expression' to
    delegate type 'System.Func<double,int>' because some of the return
    types in the block are not implicitly convertible to the delegate return
    type
ошибка CS1662: Не удается преобразовать 'лямбда-выражение' к типу
    делегата 'System.Func<double,int>', т.к. некоторые из типов возврата
    в блоке не являются неявно преобразуемыми к возвращаемому типу делегата

Исправить это можно, приведя результат тела лямбда-выражения к int:

Func<double, int> expr = (x) => (int) x / 2;
ПРИМЕЧАНИЕ

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

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

using System;
using System.Linq;

public class LambdaTest
{
    static void Main() {
        int counter = 0;
        WriteStream( () => counter++ );
        Console.WriteLine( "Финальное значение счетчика: {0}",
                           counter );
    }

    static void WriteStream( Func<int> counter ) {
        for( int i = 0; i < 10; ++i ) {
            Console.Write( "{0}, ", counter() );
        }
        Console.WriteLine();
    }
}

Обратите внимание, насколько просто с использованием лямбда-выражения передать функцию в качестве параметра в метод WriteStream. Более того, переданная функция захватывает окружение, внутри которого она выполняется, а именно – значение counter в Main. В старые добрые времена C# 1.0 это было болезненным процессом и приходилось делать нечто вроде следующего:

using System;

unsafe public class MyClosure
{
    public MyClosure( int* counter )
    {
        this.counter = counter;
    }

    public delegate int IncDelegate();
    public IncDelegate GetDelegate() {
        return new IncDelegate( IncrementFunction );
    }

    private int IncrementFunction() {
        return (*counter)++;
    }

    private int* counter;
}

public class LambdaTest
{
    unsafe static void Main() {
        int counter = 0;

        MyClosure closure = new MyClosure( &counter );

        WriteStream( closure.GetDelegate() );
        Console.WriteLine( "Финальное значение счетчика: {0}",
                           counter );
    }

    static void WriteStream( MyClosure.IncDelegate incrementor ) {
        for( int i = 0; i < 10; ++i ) {
            Console.Write( "{0}, ", incrementor() );
        }
        Console.WriteLine();
    }
}

Посмотрите, насколько много работы требовалось выполнить, чтобы обойтись без лямбда-выражений. Я выделил дополнительный код и прочие изменения. Первая задача – создать объект для представления делегата и его окружения. В данном случае среда – это указатель на переменную counter в методе Main. Я решил использовать класс для инкапсуляции функции и ее окружения. Обратите внимание на использование небезопасного кода в классе MyClass для достижения этого. Затем в методе Main я создал экземпляр MyClosure и передал делегат, созданный вызовом GetDelegate методу WriteStream.

Какой объем работы! К тому же и понять такой код совсем нелегко.

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

using System;

public class LambdaTest
{
    static void Main() {
        int counter = 0;
        WriteStream( delegate () {
                        return counter++;
                     } );

    Console.WriteLine( "Финальное значение счетчика: {0}",
                       counter );
    }

    static void WriteStream( Func<int> counter ) {
        for( int i = 0; i < 10; ++i ) {
            Console.Write( "{0}, ", counter() );
        }
        Console.WriteLine();
    }
}

Я выделил отличия между этим и исходным примером с лямбда-выражением. Определенно, он намного яснее, чем способ, которым приходилось пользоваться во времена C# 1.0. Однако он все еще не столь выразителен и краток, как версия с лямбда-выражением. И, наконец, в C# 3.0 мы получили элегантное средство определения потенциально очень сложных функций с применением лямбда-выражений, которые могут быть построены посредством сборки вместе других функций.

ПРИМЕЧАНИЕ

В предыдущем примере кода вы, вероятно, заметили последствия обращения к переменной counter внутри лямбда-выражения. В конце концов, counter – это локальная переменная внутри контекста Main, однако внутри контекста WriteStream на нее ссылаются при вызове делегата. В разделе “Остерегайтесь сюрпризов захваченных переменных” главы 10 я описал, как вы можете достичь того же результата с помощью анонимных методов. В терминологии функционального программирования это называется замыканием (closure). По сути, всякий раз, когда лямбда-выражение включает окружающую его среду, в результате получается такое замыкание. Как я покажу в следующем разделе, замыкания могут быть очень полезны. Однако, применяемые неправильно, замыкания чреваты неприятными сюрпризами.

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

using System;
using System.Linq;
using System.Collections.Generic;

public class LambdaTest
{
    static void Main() {
        var teamMembers = new List<string> {
            "Lou Loomis",
            "Smoke Porterhouse",
            "Danny Noonan",
            "Ty Webb"
        };

        FindByFirstName( teamMembers,
                         "Danny",
                         (x, y) => x.Contains(y) );
    }

    static void FindByFirstName(
                        List<string> members,
                        string firstName,
                        Func<string, string, bool> predicate ) {
        foreach( var member in members ) {
            if( predicate(member, firstName) ) {
                Console.WriteLine( member );
            }
        }
    }
}

В данном случае лямбда-выражение используется для создания делегата, принимающего два параметра типа string и возвращающего bool. Как вы можете видеть, лямбда-выражение представляет симпатичный и краткий способ создания предикатов. В разделе “Вернемся к итераторам и генераторам” далее в главе я представлю новую версию примера из главы 14, демонстрирующую использование лямбда-выражений в качестве предикатов для создания гибких итераторов.

Лямбда-операторы

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

(x, y) => x * y

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

(x, y) => { return x * y; }

В таком виде лямбда с блоками операторов почти идентичны анонимным методам. И есть одно главное отличие между лямбда с блоками операторов и простыми лямбда-выражениями. Первые могут быть преобразованы только в типы делегатов, в то время как вторые – и в делегаты, и в деревья выражений, посредством семейства типов, сосредоточенных вокруг System.Linq.Expressions.Expression<T>. О деревьях выражений мы поговорим в следующем разделе.

ПРИМЕЧАНИЕ

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

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

До сих пор я показывал вам лямбда-выражения, подменяющие функциональность делегатов. Но, остановившись на этом, я оказал бы вам плохую услугу. Дело в том, что компилятор С# 3.0 также обладает способностью преобразовывать лямбда-выражения в деревья выражений на основе типов из пространства имен System.Linq.Expressions. Позднее, в разделе “Функции как данные”, я объясню, чем они хороши. Например, вы уже видели, как можно конвертировать лямбда-выражение в делегат, как показано ниже:

Func<int, int> func1 = n => n+1;

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

Expression<Func<int, int>> expr = n => n+1;

Вот это действительно круто! Лямбда-выражение, вместо того, чтобы конвертироваться в вызываемый делегат, преобразуется в структуру данных, представляющую операцию. Типом переменной expr является Expression<T>, где T заменяется типом делегата, в который может быть преобразовано лямбда-выражение. Компилятор замечает, что вы пытаетесь конвертировать лямбда-выражение в экземпляр Expression<Func<int,int>>, и генерирует весь необходимый код, чтобы это произошло. В некоторый момент позднее вы можете скомпилировать выражение в полезный делегат, как показано в следующем примере:

using System;
using System.Linq;
using System.Linq.Expressions;

public class EntryPoint
{
    static void Main() {
        Expression<Func<int, int>> expr = n => n+1;
        Func<int, int> func = expr.Compile();

        for( int i = 0; i < 10; ++i ) {
            Console.WriteLine( func(i) );
        }
    }
}

Выделенная строка показывает шаг, на котором выражение компилируется в делегат. Если вы немножко задумаетесь, то сможете представить себе, как вы могли бы модифицировать это дерево выражений или даже комбинировать несколько деревьев выражений для создания более сложных деревьев выражений перед тем, как компилировать их. Можно даже определить новый язык выражений или реализовать анализатор для существующего языка выражений. Фактически, компилятор работает как анализатор выражений, когда вы присваиваете лямбда-выражение экземпляру типа Expression<T>. “За кулисами” он генерирует код для построения дерева выражений, и если вы используете ILDASM для просмотра сгенерированного кода, то увидите его в действии. Предыдущий пример может быть переписан без использования лямбда-выражений, как показано ниже:

using System;
using System.Linq;
using System.Linq.Expressions;

public class EntryPoint
{
    static void Main() {
        var n = Expression.Parameter( typeof(int), "n" );
        var expr = Expression<Func<int,int>>.Lambda<Func<int,int>>(
                       Expression.Add(n, Expression.Constant(1)),
                       n );
                   
        Func<int, int> func = expr.Compile();
        
        for( int i = 0; i < 10; ++i ) {
            Console.WriteLine( func(i) );
        }
    }
}

Выделенные строки заменяют единственную строку предшествующего примера, где переменной expr присваивается лямбда-выражение n => n+1. Думаю, вы согласитесь, что первый пример читать намного легче. Однако этот длинный пример помогает выразить действительную гибкость деревьев выражений. Давайте разобьем на шаги процесс построения выражения. Сначала вы должны представить параметры в списке параметров лямбда-выражения. В данном случае параметр всего один – переменная n. Поэтому мы начинаем со следующего:

var n = Expression.Parameter( typeof(int), "n" );
ПРИМЕЧАНИЕ

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

Эта строка кода говорит о том, что нам нужна переменная по имени n, относящаяся к типу int. Напомню, что в простом лямбда-выражении тип может быть определен на основе предоставленного типа делегата. Теперь нам нужно сконструировать экземпляр BinaryExpression, представляющий операцию сложения, как показано ниже:

Expression.Add(n, Expression.Constant(1))

Здесь я говорю, что выражение BinaryExpression должно состоять из прибавления константы – числа 1 – к параметру n. Наверное, вы уже ухватили суть. Каркас реализует форму шаблона проектирования Abstract Factory (Абстрактная фабрика) для создания экземпляров элементов выражения. То есть вы не можете создать новый экземпляр BinaryExpression или любого другого строительного блока деревьев выражений, и потому должны использовать статические методы класса Expression для создания этих экземпляров. Это дает нам, как потребителям, гибкость в выражении того, что мы хотим, и позволяет реализации Expression решать, какой тип нам действительно нужен.

Теперь, когда мы имеем BinaryExpression, мы должны использовать метод Expression.Lambda<> для привязки выражения (в данном случае n+1) с параметрами в списке параметров (в данном случае – n). Обратите внимание, что в примере я использую обобщенный метод Lambda<>, так что могу создать тип Expression<Func<int,int>>. Применение обобщенной формы предоставляет компилятору больше информации о типе, чтобы перехватывать любые ошибки, которые я мог бы внести, во время компиляции, не позволяя им нарушить работу приложения во время выполнения.

ПРИМЕЧАНИЕ

Если бы я использовал не обобщенную версию метода Expression.Lambda, то в результате получился бы экземпляр LambdaExpression. LambdaExpression также реализует метод Compile; однако вместо строго типизированного делегата он возвращает экземпляр типа Delegate. Прежде чем вы сможете вызвать экземпляр Delegate, вы должны привести его к определенному типу делегата, в данном случае – Func<int, int>, или к другому делегату с той же сигнатурой, либо же вы должны будете вызвать DynamicInvoke на делегате. Любой из этих способов может привести к генерации исключения во время выполнения, если обнаружится несоответствие между вашим выражением и типом делегата, который, как вы думаете, оно должно генерировать.

Операции над выражениями

Теперь я хотел бы продемонстрировать пример того, как можно взять дерево выражений, сгенерированное из лямбда-выражения, и модифицировать его для создания нового дерева выражений. В данном случае я возьму выражение (n+1) и превращу его в 2*(n+1):

using System;
using System.Linq;
using System.Linq.Expressions;

public class EntryPoint
{
    static void Main() {
        Expression<Func<int,int>> expr = n => n+1;
        // Теперь присвоим expr значение исходного
        // выражения, умноженное на 2.
        expr = Expression<Func<int,int>>.Lambda<Func<int,int>>(
                  Expression.Multiply( expr.Body,
                                       Expression.Constant(2) ),
                  expr.Parameters );
        Func<int, int> func = expr.Compile();
        for( int i = 0; i < 10; ++i ) {
            Console.WriteLine( func(i) );
        }
    }
}

Выделенные строки показывают стадию, на которой я умножаю исходное лямбда-выражение на 2. Очень важно отметить, что параметры, переданные методу Lambda<>, должны быть именно теми же экземплярами параметров, которые пришли из исходного выражения; т.е. expr.Parameters. Это обязательно. Вы не можете передать методу Lambda<> новый экземпляр ParameterExpression; в противном случае во время выполнения вы получите исключение, подобное описанному ниже, поскольку новый экземпляр ParameterExpression, даже имея то же имя, на самом деле является совершенно другим экземпляром параметра.

System.InvalidOperationException: Lambda Parameter not in scope
System.InvalidOperationException: Лямбда-параметр не находится в области определения

Существует много классов, унаследованных от класса Expression, и много статических методов создания его экземпляров и комбинации с другими выражениями. Я не стану здесь описывать их все. Поэтому я рекомендую обратиться к библиотеке документации MSDN, где содержатся все фантастические подробности о пространстве имен System.Linq.Expressions.

Функции как данные

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

Как вы, возможно, можете предположить, внутри контекста C# 3.0 деревья выражений чрезвычайно полезны в применении к LINQ. Полное представление о LINQ я приведу в главе 16, а пока самый важный факт: LINQ представляет естественный для языка, выразительный синтаксис описания операций над данными, которые невозможно естественным образом смоделировать объектно-ориентированным способом. Например, вы можете создать выражение LINQ для поиска в большом массиве, находящемся в памяти (или другом типе IEnumerable), элементов, соответствующих определенному шаблону. LINQ – расширяемый язык и может предоставлять средства оперирования с другими типами хранилищ, такими как XML и реляционные базы данных. Фактически C# 3.0 представляет реализацию LINQ для реляционных баз данных (включая LINQ to SQL, LINQ to Dataset, LINQ to Entities, LINQ to XML и LINQ to Objects), которые все вместе позволяют выполнять операции LINQ на любых типах, поддерживающих IEnumerable.

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

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

Деревья выражений обеспечивают вам эту важную возможность. Затем, когда вы завершаете операции с данными, вы можете транслировать дерево выражений в окончательную исполняемую операцию посредством механизма типа LambdaExpression.Compile и запустить ее. Если бы выражение изначально было доступно только в виде код IL, ваша гибкость была бы существенно ограничена. Я надеюсь, теперь вы можете оценить действительную мощь деревьев выражений в C# 3.0.

Полезные применения лямбда-выражений

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

Вернемся к итераторам и генераторам

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

ПРИМЕЧАНИЕ

В главе 9 итераторы представлены через оператор yield, а в разделе “Заимствование из функционального программирования” главы 14 рассматриваются пользовательские итераторы.

Те из вас, кто программировал на C++ и знаком с применением стандартной библиотеки шаблонов (STL), увидят в этой нотации нечто знакомое. Большинство алгоритмов, определенных в пространстве имен std в заголовочном файле <algorithm>, требуют для выполнения своей работы предоставления предикатов. Когда STL впервые появилась в начале 90-х годов, она захватила сообщество программистов C++ подобно свежему бризу функционального программирования.

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

using System;
using System.Linq;
using System.Collections.Generic;

public static class IteratorExtensions
{
    public static IEnumerable<TItem>
        MakeCustomIterator<TCollection, TCursor, TItem>(
                     this TCollection collection,
                     TCursor cursor,
                     Func<TCollection, TCursor, TItem> getCurrent,
                     Func<TCursor, bool> isFinished,
                     Func<TCursor, TCursor> advanceCursor) {
        while( !isFinished(cursor) ) {
            yield return getCurrent( collection, cursor );
            cursor = advanceCursor( cursor );
        }
    }
}

public class IteratorExample
{
    static void Main() {
        var matrix = new List<List<double>> {
            new List<double> { 1.0, 1.1, 1.2 },
            new List<double> { 2.0, 2.1, 2.2 },
            new List<double> { 3.0, 3.1, 3.2 }
        };

        var iter = matrix.MakeCustomIterator(
                     new int[] { 0, 0 },
                     (coll, cur) => coll[cur[0]][cur[1]],
                     (cur) => cur[0] > 2 || cur[1] > 2,
                     (cur) => new int[] { cur[0] + 1,
                                          cur[1] + 1 } );

        foreach( var item in iter ) {
            Console.WriteLine( item );
        }
    }
}

Смотрите, насколько многократно используемым является MakeCustomIterator<>! Общеизвестно, что для того, чтобы привыкнуть к применению лямбда-синтаксиса, нужно некоторое время, и те, кто привык к императивному стилю кодирования, могут столкнуться с определенными трудностями в его понимании. Обратите внимание, что он принимает три аргумента обобщенного типа. TCollection – это тип коллекции, который в данном примере специфицирован в точке использования как List<List<double>>. TCursor – тип курсора, который в данном случае является простым массивом целых чисел, который может рассматриваться как координаты переменной matrix. А TItem – тип, возвращаемый кодом через оператор yield. Остальные параметры MakeCustomIterator<> – типы делегатов, которые он использует для определения того, как выполнять итерацию по коллекции. Для начала ему нужен способ получения доступа к текущему элементу коллекции, который выражается следующим лямбда-выражением:

(coll, cur) => coll[cur[0]][cur[1]]

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

(cur) => cur[0] > 2 || cur[1] > 2

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

(cur) => new int[] { cur[0] + 1, cur[1] + 1 }

Другие реализации MakeCustomIterator<> могут принимать первый параметр типа IEnumerable<T>, который в данном примере должен быть IEnumerable<double>. Однако, когда вы накладываете это ограничение, то все, переданное MakeCustomIterator<>, должно реализовывать IEnumerable<>. Переменная matrix реализует IEnumerable<>, но не в той форме, которую легко использовать, поскольку это IEnumerable<List<double>>. Вдобавок вы можете предположить, что коллекция реализует индексатор, как описано в разделе “Индексаторы” главы 4, но тогда это наложит ограничение на повторную применяемость MakeCustomIterator<> и то, какие объекты вы можете использовать в нем. В примере, приведенном выше, индексатор в действительности используется для обращения к конкретному элементу, но его применение вынесено наружу и упаковано в лямбда-выражение, предназначенное для доступа к текущему элементу.

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

(coll, cur) => coll[cur[0]][cur[1]] * 2;

Можете вы представить, насколько мучительно было реализовать MakeCustomIterator<> с применением делегатов во времена C# 1.0? Именно это я имею в виду, говоря о том, что всего лишь добавление синтаксиса лямбда-выражений в C# 3.0 открывает глаза разработчику на невероятные возможности. Если вы запустите предыдущий пример, то увидите, что я прохожу матрицу по диагонали, что показывает следующий вывод:

1
2.1
3.2

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

using System;
using System.Linq;
using System.Collections.Generic;

public class IteratorExample
{
    static IEnumerable<T> MakeGenerator<T>( T initialValue,
                                            Func<T, T> advance ) {
        T currentValue = initialValue;
        while( true ) {
            yield return currentValue;
            currentValue = advance( currentValue );
        }
    }

    static void Main() {
        var iter = MakeGenerator<double>( 1,
                                          x => x * 1.2 );
        var enumerator = iter.GetEnumerator();
        for( int i = 0; i < 10; ++i ) {
            enumerator.MoveNext();
            Console.WriteLine( enumerator.Current );
        }
    }
}

После запуска этого кода вы увидите следующий результат:

1
1.2
1.44
1.728
2.0736
2.48832
2.985984
3.5831808
4.29981696
5.159780352

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

Замыкания (захват переменной) и мемоизация

В разделе “Остерегайтесь сюрпризов захваченных переменных” главы 10 я описал, как анонимные методы могут захватывать контекст своего лексического окружения. Многие называют этот феномен захватом переменной. На языке функционального программирования это также известно как замыкание (closure). Ниже показан простой пример замыкания в действии:

ПРИМЕЧАНИЕ

Более развернутую дискуссию о замыканиях вы найдете по адресу http://en.wikipedia.org/wiki/Closure_%28computer_science%29.

using System;
using System.Linq;

public class Closures
{
    static void Main() {
        int delta = 1;
        Func<int, int> func = (x) => x + delta;

        int currentVal = 0;
        for( int i = 0; i < 10; ++i ) {
            currentVal = func( currentVal );
            Console.WriteLine( currentVal );
        }
    }
}

Переменная delta и делегат func формируют замыкание. Тело выражения ссылается на delta и потому должно иметь доступ к ней при последующем выполнении. Чтобы обеспечить это, компилятор “захватывает” переменную для делегата. “За кулисами” же происходит вот что: тело делегата содержит ссылку на действительную переменную delta. Более того, поскольку захваченная переменная доступна как делегату, так и контексту, содержащему лямбда-выражение, это означает, что захваченная переменная может быть изменена вне контекста и вне связи с делегатом. Такое поведение можно использовать в ваших интересах, но если его не ожидать, оно может вызвать серьезную путаницу.

ПРИМЕЧАНИЕ

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

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

x => x * 3
x => x + 3.1415

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

using System;
using System.Linq;

public class Compound
{
    static Func<T, S> Chain<T, R, S>( Func<T, R> func1,
                                      Func<R, S> func2 ) {
        return x => func2( func1(x) );
    }

    static void Main() {
        Func<int, double> func = Chain( (int x) => x * 3,
                                        (int x) => x + 3.1415 );

        Console.WriteLine( func(2) );
    }
}

Метод Chain<> принимает два делегата и производит третий делегат, комбинируя первые два. В методе Main вы можете видеть, как я использую его для производства составного выражения. Делегат, который вы получаете после вызова Chain<>, эквивалентен делегату, который вы получаете при преобразовании следующего лямбда-выражения в делегат:

x => (x * 3) + 3.1415

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


Здесь Fk – число Фибоначчи.

ПРИМЕЧАНИЕ

Weisstein, Eric W. “Reciprocal Fibonacci Constant.” From MathWorld – A Wolfram Web Resource. http://mathworld.wolfram.com/ReciprocalFibonacciConstant.html.

Чтобы начать демонстрацию вычислений этой константы, сначала понадобится операция для вычисления n-го числа Фибоначчи:

using System;
using System.Linq;

public class Proof
{
    static void Main() {
        Func<int, int> fib = null;
        fib = (x) => x > 1 ? fib(x-1) + fib(x-2) : x;
    
        for( int i = 30; i < 40; ++i ) {
            Console.WriteLine( fib(i) );
        }
    }
}

Первое, что бросается в глаза при взгляде на этот код – формирование процедуры Фибоначчи, т.е. делегата fib. Он формирует замыкание на самом себе! Это определенно форма рекурсии, и она делает то, что надо. Однако если вы все-таки запустите этот пример, имея в своем распоряжении суперкомпьютер, то заметите, насколько медленно он работает, даже если все, что он делает – это вычисления с 30-го по 39-е число Фибоначчи! Если это так, нам даже не стоит надеяться продемонстрировать константу Фибоначчи. Такая медлительность обусловлена тем фактом, что каждое число Фибоначчи, которое мы вычисляем, требует немного больше работы, чем вычисление двух предыдущих чисел Фибоначчи, и в результате затрачиваемое время растет лавинообразно.

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

ПРИМЕЧАНИЕ

Подробнее о мемоизации вы можете прочесть по адресу http://en.wikipedia.org/wiki/Memoization. Кроме того, Вес Дайер (Wes Dyer) отлично описывает мемоизацию в своем блоге http://blogs.msdn.com/wesdyer/archive/2007/01/26/function-memoization.aspx.

using System;
using System.Linq;
using System.Collections.Generic;

public static class Memoizers
{
    public static Func<T,R> Memoize<T,R>( this Func<T,R> func ) {
        var cache = new Dictionary<T,R>();
        return (x) => {
            R result = default(R);
            if( cache.TryGetValue(x, out result) ) {
                return result;
            }
            result = func(x);
            cache[x] = result;
            return result;
        };
    }
}

public class Proof
{
    static void Main() {
        Func<int, int> fib = null;
        fib = (x) => x > 1 ? fib(x-1) + fib(x-2) : x;
        fib = fib.Memoize();

        for( int i = 30; i < 40; ++i ) {
            Console.WriteLine( fib(i) );
        }
    }
}

Прежде всего, обратите внимание, что в Main добавился только один дополнительный оператор, в котором я применяю метод Memoize<> к делегату, чтобы произвести новый делегат. Все прочее остается без изменений, так что прозрачная взаимозаменяемость обеспечена. Метод Memoize<> упаковывает оригинальный делегат, который передан через аргумент func с другим замыканием, включающим экземпляр Dictionary<> для хранения кэшированных значений данного делегата func. В процессе Memoize<> взятие одного делегата и возврат другого реализует кэш, который значительно повышает эффективность. Всякий раз, когда вызывается делегат, он сначала проверяет, нет ли в кэше ранее вычисленного значения.

ПРЕДУПРЕЖДЕНИЕ

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

Запустите предыдущий пример и увидите поразительную разницу. Теперь мы вполне можем приступить к вычислению обратной константы Фибоначчи, модифицировав метод Main следующим образом:

static void Main() {
    Func<ulong, ulong> fib = null;
    fib = (x) => x > 1 ? fib(x-1) + fib(x-2) : x;
    fib = fib.Memoize();

    Func<ulong, decimal> fibConstant = null;
    fibConstant = (x) => {
        if( x == 1 ) {
            return 1 / ((decimal)fib(x));
        } else {
            return 1 / ((decimal)fib(x)) + fibConstant(x-1);
        }
    };
    fibConstant = fibConstant.Memoize();

    Console.WriteLine( "\n{0}\t{1}\t{2}\t{3}\n",
                       "Номер",
                       "Фибоначчи".PadRight(24),
                       "1/Фибоначчи ".PadRight(24),
                       "Константа Фибоначчи".PadRight(24) );
    
    for( ulong i = 1; i <= 93; ++i ) {
        Console.WriteLine( "{0:D5}\t{1:D24}\t{2:F24}\t{3:F24}",
                           i,
                           fib(i),
                           (1/(decimal)fib(i)),
                           fibConstant(i) );
    }
}

Выделенный текст показывает делегат, который я создал для вычисления n-й обратной константы Фибоначчи. Вызывая этот делегат все с большим и большим значением x, вы должны заметить, что результат становится все ближе и ближе к обратной константе Фибоначчи. Обратите внимание, что я также осуществил мемоизацию делегата fibConstant. Если этого не делать, можно спровоцировать переполнение стека из-за рекурсии, по мере вызова fibConstant все с большими и большими значениями x. Так что вы можете убедиться, что мемоизация также экономит пространство стека за счет пространства кучи. В каждой строке вывода код показывает для информации промежуточное значение, но самое интересное значение – в крайней правой колонке. Обратите внимание, что я прекратил вычисление на итерации номер 93. Это потому, что ulong на 94-м числе Фибоначчи переполнится. Я мог бы решить эту проблему, используя BigInteger из пространства имен System.Numeric. Однако это не обязательно, поскольку 93-я итерация обратной константы Фибоначчи, показанная здесь, достаточно близка к цели этого примера.

3.359885666243177553039387

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

ПРИМЕЧАНИЕ

Вы можете увидеть более точное значение обратной константы Фибоначчи по адресу http://www.research.att.com/~njas/sequences/A079586.

Приправа

В предыдущем разделе, посвященном замыканиям, я продемонстрировал, как создать метод, который принимает функцию, переданную в виде делегата, и производит новую функцию. Это очень мощная концепция, и мемоизация, показанная в предыдущем разделе – пример отличного ее применения. В этом разделе я хотел бы продемонстрировать вам технику “приправы” (currying), которая по сути означает создание операции (обычно – метода), принимающей функцию с несколькими параметрами (обычно – делегат) и производящей только один параметр.

ПРИМЕЧАНИЕ

Подробнее об этом приеме читайте в http://en.wikipedia.org/wiki/Currying.

Если вы – программист C++, знакомый с STL, то, несомненно, использовали операцию “приправы”, если имели дело с любой из привязок параметров вроде Bind1st и Bind2nd.

Предположим, что имеется лямбда-выражение, которое выглядит так:

(x, y) => x + y

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

using System;
using System.Linq;
using System.Collections.Generic;

public static class CurryExtensions
{
    public static Func<TArg1, TResult>
        Bind2nd<TArg1, TArg2, TResult>(
            this Func<TArg1, TArg2, TResult> func,
            TArg2 constant ) {
        return (x) => func( x, constant );
    }
}

public class BinderExample
{
    static void Main() {
        var mylist = new List<double> { 1.0, 3.4, 5.4, 6.54 };
        var newlist = new List<double>();

        // Здесь - исходное выражение.
        Func<double, double, double> func = (x, y) => x + y;
        
        // Здесь - "приправленная" функция.
        var funcBound = func.Bind2nd( 3.2 );
        foreach( var item in mylist ) {
            Console.Write( "{0}, ", item );
            newlist.Add( funcBound(item) );
        }

        Console.WriteLine();
        foreach( var item in newlist ) {
            Console.Write( "{0}, ", item );
        }
    }
}

“Мясо” этого примера – в расширяющем методе Bind2nd<>, который я выделил. Вы можете видеть, что он создает замыкание и возвращает новый делегат, принимающий только один параметр. Затем, когда вызывается этот новый делегат, он получает только один параметр – первый параметр для исходного делегата, и передает ему константу в качестве второго параметра. Для примера я выполняю итерацию по списку myList, при этом строя новый список, содержащийся в переменной newList, используя “приправленную” версию оригинального метода, чтобы добавить 3.2 к каждому элементу.

Для сравнения я хочу показать и другой способ “приправы”, немного отличающийся от показанного в предыдущем примере:

using System;
using System.Linq;
using System.Collections.Generic;

public static class CurryExtensions
{
    public static Func<TArg2, Func<TArg1, TResult>>
        Bind2nd<TArg1, TArg2, TResult>(
            this Func<TArg1, TArg2, TResult> func ) {
        return (y) => (x) => func( x, y );
    }
}

public class BinderExample
{
    static void Main() {
        var mylist = new List<double> { 1.0, 3.4, 5.4, 6.54 };
        var newlist = new List<double>();
        
        // Здесь - исходное выражение.
        Func<double, double, double> func = (x, y) => x + y;
        
        // Здесь - "приправленная" функция.
        var funcBound = func.Bind2nd()(3.2);

        foreach( var item in mylist ) {
            Console.Write( "{0}, ", item );
            newlist.Add( funcBound(item) );
        }

        Console.WriteLine();
        foreach( var item in newlist ) {
            Console.Write( "{0}, ", item );
        }
    }
}

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

Анонимная рекурсия

В ранее приведенном разделе “Замыкание (захват переменной) и мемоизация” я показал форму рекурсии, использующую замыкания при вычислении чисел Фибоначчи. Для продолжения дискуссии давайте рассмотрим подобное замыкание, которое можно использовать для вычисления факториала числа:

Func<int, int> fact = null;
fact = (x) => x > 1 ? x * fact(x-1) : 1;

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

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

Func<int, int> fact = null;
fact = (x) => x > 1 ? x * fact(x-1) : 1;
Func<int, int> newRefToFact = fact;

Поскольку объекты в CLR относятся к ссылочным типам, newRefToFact и fact теперь ссылаются на один и тот же делегат. Теперь предположим, что вы сделали нечто вроде следующего:

Func<int, int> fact = null;
fact = (x) => x > 1 ? x * fact(x-1) : 1;
Func<int, int> newRefToFact = fact;
fact = (x) => x + 1;

Рекурсия разрушена! Заметили, почему? Причина в том, что мы модифицировали захваченную переменную fact. Мы присвоили ей ссылку на новый делегат, основанный на лямбда-выражении (x) => x+1. Но newRefToFact все еще ссылается на лямбда-выражение (x) => x > 1 ? x * fact(x-1) : 1. Однако, когда делегат, на который ссылается newRefToFact, вызывает fact, то он получает новое выражение (x) => x + 1, которое изменяет поведение имеющейся ранее рекурсии.

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

ПРИМЕЧАНИЕ

Теоретические сведения об анонимной рекурсии вы найдете в статье по адресу http://en.wikipedia.org/wiki/Anonymous_recursion.

delegate TResult AnonRec<TArg,TResult>( AnonRec<TArg,TResult> f, TArg arg );
AnonRec<int, int> fact = (f, x) => x > 1 ? x * f(f, x-1) : 1;

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

Чтобы подробнее ознакомиться с этой техникой, настоятельно рекомендую прочесть статью в блоге Веса Дайера (Wes Dyer) “Anonymous Recursion in C#” (“Анонимная рекурсия в C#”) по адресу http://blogs.msdn.com/wesdyer. Вес Дайер – один из членов команды C# и ревностный сторонник функционального программирования. В этой статье он демонстрирует, как реализовать Y-комбинатор с фиксированной точкой, обобщающий понятие анонимной рекурсии, показанное ранее.

ПРИМЕЧАНИЕ

Y-комбинаторы с фиксированной точкой описаны по адресу http://en.wikipedia.org/wiki/Fixed_point_combinator.

Резюме

В этой главе я представил синтаксис лямбда-выражений, которые большей частью являются заменой анонимных методов. Фактически, очень жаль, что лямбда-выражения не появились в C# 2.0, потому что тогда не было бы необходимости в анонимных методах. Я продемонстрировал, как вы можете преобразовывать лямбда-выражения без тел операторов в делегаты. Вдобавок вы увидели, как лямбда-выражения без тел операторов конвертируются в деревья выражений на основе типа Expression<T>, определенного в пространстве имен System.Linq.Expression. Вы можете применять трансформации к деревьям выражений перед их компиляцией в делегат и вызовом. Я завершил главу демонстрацией полезных применений лямбда-выражений. К ним относится создание обобщенных итераторов, мемоизация с использованием замыканий, привязка параметров делегатов с помощью “приправы”, а также представление концепции анонимной рекурсии. Почти все эти концепции лежат в основе функционального программирования. Несмотря на то что все эти приемы можно было реализовать в C# на основе анонимных методов, добавление в язык лямбда-синтаксиса сделало их применение более естественным и менее сложным.

Следующая глава посвящена языку LINQ – кульминации всех новых средств C# 3.0. Также я продолжу уделять внимание связанным с ним аспектам функционального программирования.


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
    Сообщений 14    Оценка 65 [+1/-0]         Оценить