Делегаты и Лямбда выражения в C# .Net — Шпаргалка или коротко о главном

Привет, Дорогой читатель!


Почти все кто мало-мальски работал в .Net знает что такое Делегаты (Delegates). А те кто не знает о них, почти наверняка хотя бы в курсе о Лямбда-выражениях (Lambda expressions). Но лично я постоянно то забываю о синтаксисе их объявления, то возвращаюсь к многостраничным объяснениям умных людей о том, как компилятор реагирует на подобные конструкции. Если у Вас случается такая проблема, то милости прошу!

Делегаты


Делегат это особый тип. И объявляется он по особому:

delegate int MyDelegate (string x);

Тут все просто, есть ключевое слово delegate, а дальше сам делегат с именем MyDelegate, возвращаемым типом int и одним аргументом типа string.

По факту же при компиляции кода в CIL — компилятор превращает каждый такой тип-делегат в одноименный тип-класс и все экземпляры данного типа-делегата по факту являются экземплярами соответствующих типов-классов. Каждый такой класс наследует тип MulticastDelegate от которого ему достаются методы Combine и Remove, содержит конструктор с двумя аргументами target (Object) и methodPtr (IntPtr), поле invocationList (Object), и три собственных метода Invoke, BeginInvoke, EndEnvoke.

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

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

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

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

void MyFunc(myDelegate deleg, int arg){deleg.Invoke(arg);}

Создавая в коде экземпляр делегата его конструктору передается метод (подойдет и экземплярный и статический, главное чтобы сигнатура метода совпадала с сигнатурой делегата). Если метод экземплярный то в поле target записывается ссылка на экземпляр-владелец метода (он нужен нам, ведь если метод экземплярный то это как минимум подразумевает работу с полями этого объекта target), а в methodPtr ссылка на метод. Если метод статический то записываются в поля target и methodPtr будут записаны null и ссылка на метод соответственно.

Инициализировать переменную делегата можно через создание экземпляра делегата:

MyDeleg x = new MyDeleg(MyFunc);

Или упрощенный синтаксис без вызова конструктора:

MyDeleg x = MyFunc;

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

Методы делегатов:

Invoke — синхронное выполнение метода который храниться в делегате.
BeginInvoke, EndEnvoke — аналогично но асинхронное.

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

delegInst.Invoke(argument);

это аналогично записи:

delegInst(argument);

А зачем делегату поле invocationList?


Поле invocationList имеет значение null для экземпляра делегата пока делегат хранит ссылку на один метод. Этот метод можно всегда перезаписать на другой приравняв через "=" переменной новый экземпляр делегата (или сразу нужного нам метода через упрощенный синтаксис). Но так же можно создать цепочку вызовов, когда делегат хранит ссылки на более чем один метод.
Для этого нужно вызвать метод Combine:

MyDeleg first = MyFunc1;
MyDeleg second = MyFunc2;
first = (MyDeleg) Delegate.Combine(first, second);

Метод Combine возвращает ссылку на новый делегат в котором поля target и methodPtr пусты, но invocationList, который содержит две ссылки на делегаты: тот что был раньше в переменной first и тот что еще хранится в second. Надо понимать что добавив третий делегат через метод Combine и записав его результат в first, то метод вернет ссылку на новый делегат с полем invocationList в котором будет коллекция из трех ссылок, а делегат с двумя ссылками будет удален сборщиком мусора при следующем цикле очистки.

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

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

Deleg first = first.Remove(MyFunc2);

Переопределенные для делегатов операторы += и -= являются аналогами методов Combine и Remove:

first = (Deleg) Delegate.Combine(first, second);

аналогично следующей записи:

first += MyFunc2;

И соответственно:

first = first.Remove(MyFunc2);

аналогично следующей записи:

first -= MyFunc;

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

Также стоит упомянуть что библиотека FCL уже содержит наиболее популярные типы делегатов (обобщенные и нет). Например делегат Action<T> представляет собой метод без возвращаемого значения но с аргументом, а Fucn<T, TResult> и с возвращаемым значением и аргументом.

Лямбда-операторы и лямбда-выражения


Так же экземпляр делегата можно инициализировать лямбда-оператором (lambda-operator) или лямбда-выражением (lambda-expression). Так как в целом это одно и то же, то далее по тексту я буду их просто называть «лямбды» в местах, где не нужно подчеркивать их различия.
Стоит упомянуть, что они были введены в C# 3.0, а до них существовали анонимные-функции появившиеся в C# 2.0.

Отличительной чертой лямбд является оператор =>, который делит выражение на левую часть с параметрами и правую с телом метода.

Допустим у нас есть делегат:

delegate string MyDeleg (string verb);

Тогда общий синтаксис лямбда-оператора будет следующим:

MyDeleg myDeleg = (string x) => { return x; };

Это именно Лямбда-оператор так как мы обрамляем его тело в фигурные скобки, что позволяет нам поместить в него более одного оператора:

MyDeleg myDeleg = (string x) => { var z = x + x; return z; };

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

MyDeleg myDeleg = (x) => { return x; };

В случае если имеется лишь один аргумент то можно опустить обрамляющие его скобки:

MyDeleg myDeleg = x => { return x; };

Если в сигнатуре делегата аргументов нет то необходимо указать пустые скобки:

AnotherDeleg myDeleg = () => { return x; };


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

— можно опустить фигурные скобки, обрамляющие тело лямбды;

— без вышеупомянутых фигурных скобок нам не нужно использовать ключевое слово return перед оператором и точку запятой после оператора в теле лямбды:

В итоге код определения лямбды может стать крошечным:
MyDeleg myDeleg = x => x+x;


А что о лямбдах думает компилятор?


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

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

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

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

В итоге


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

Отдельное спасибо великому Джеффри Рихтеру, который конечно статью не прочтет, но написал просто замечательную книгу «CLR via C#», которую я перечитываю снова и снова, и информацию из которой я использовал при написании данной шпаргалки.

Всем большое спасибо!

Комментарии 15

    +4
    Если метод экземплярный то в поле target записывается ссылка на экземпляр-владелец метода (он нужен нам, ведь если метод экземплярный то это как минимум подразумевает работу с полями этого объекта target), а в methodPtr ссылка на метод. Если метод статический то записываются в поля target и methodPtr будут записаны null и ссылка на метод соответственно.

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


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




    Также не могу не напомнить, что лямбда-выражение не обязано быть делегатом, оно может быть и типа Expression<>

      +1

      В обратную сторону тоже работает и экземплярные методы можно использовать как статические (без сохранения target). А при вызове передавать объект первым параметром.

        +1

        А можно поподробнее? А То я в свое время велосипедил трамплин, что не очень удобно:


        public static MethodInfo CompileToInstanceMethod(this LambdaExpression expression, TypeBuilder tb, string methodName)
        {
            var paramTypes = expression.Parameters.Select(x => x.Type).ToArray();
            var proxyParamTypes = new Type[paramTypes.Length - 1];
            Array.Copy(paramTypes, 1, proxyParamTypes, 0, proxyParamTypes.Length);
            var proxy = tb.DefineMethod(methodName, MethodAttributes.Public | MethodAttributes.Virtual, expression.ReturnType, proxyParamTypes);
            var method = tb.DefineMethod($"<{proxy.Name}>__StaticProxy", MethodAttributes.Private | MethodAttributes.Static, proxy.ReturnType, paramTypes);
            expression.CompileToMethod(method);
        
            proxy.GetILGenerator().EmitCallWithParams(method, paramTypes.Length);
            return proxy;
        }
          +1

          Странное у вас какое-то решение. У меня не получалось заставить LambdaExpression иметь TypeBuilder первым аргументом...


          А с делегатом — все просто. Если вам нужна не реализация какого-то интерфейса или переопределение виртуального метода, а просто метод чтобы вызвать его через делегат — то этот метод объявляется в статическом классе, а контекст передается ему первым параметром.

            0

            Не знаю, что странного, вроде со своей задачей справлялся. Правда, потом переносил на Core, а там выпилен CompileToMethod, к сожалению. Обсуждается его возврат, но покааа там разберутся…


            В такой формулировке знал, но изначально подумал, что чего-то я пропустил в этой жизни и можно сильно упростить себе жизнь. Но — нет :)

          0
          Еще есть типы Func<> и Action<>, наследники делегата, упрощающие жизнь отсутсвием необходимости явно объявлять тип делегата и совместимые с Expression<>, делегатами и анонимными методами. Не использовал ключевое слово delegate с их появления.
            0

            Func<> и Action<> — это такие же делегаты, вот их определение из referencesource:


            public delegate void Action();
            public delegate void Action<in T>(T obj); 
            public delegate void Action<in T1,in T2>(T1 arg1, T2 arg2);
            public delegate void Action<in T1,in T2,in T3>(T1 arg1, T2 arg2, T3 arg3);
            public delegate void Action<in T1,in T2,in T3,in T4>(T1 arg1, T2 arg2, T3 arg3, T4 arg4);

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


            А еще Func<> и Action<> не могут иметь ref или out-параметров, а также для их параметров нельзя указать пользовательские атрибуты.

              0
              Абсолютно согласен, хотя на самом деле в статье есть абзац про их Generic версии, которые по моему более универсальны. Подсветил их жирным шрифтом после прочтения вашего комментария)
            +1
            Перечитайте свой текст заново. Много ошибок, большинство по невнимательности.

            А за статью спасибо, содержательная статья.
              0
              Большое спасибо, поправил что нашел)
              0
              Получается довольно бессмысленно, но работает.
              void MyFunc(myDelegate deleg, int arg){deleg.Invoke(arg);}

              Очень даже не бессмысленно.


              Пусть нам нужно выполнить тяжёлую обработку большого набора данных. Такой метод может распараллеливать вычисления автоматически между потоками (PLINQ, Parallel.ForEach) или компьютерами (Ignite):
              IEnumerable<TRes> Apply<TArg, TRes>(Func<TArg, TRes> func, IEnumerable<TArg> args)


              P.S. .Net -> .NET

                +2
                Ну блин, так хорошо начали…
                MyDeleg myDeleg = (string x) => { x + "world!"};
                
                CS1643 Not all code paths return a value in lambda expression

                Правило простое, если лямбду поместить в { } (блок кода?), например, то она автоматически становится анонимной функцией и снова требует ключевое слово return:
                MyDeleg @delegate = x => { return x + "world!"; };


                  0
                  Верно, даже не буду оправдываться, а просто поправлю текст статьи. Спасибо)
                  0

                  Простенько, но со вкусом.

                    0

                    Я то думал тут будет действительно шпаргалка, а тут целая глава Рихтера:). Спасибо за статью, повторение мать учения!

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

                    Самое читаемое