Wednesday, March 12, 2014

Введение в делегаты

Здравствуйте, уважаемые читатели моего блога. Сегодня поделюсь своим опытом и знаниями о работе делегатов. Я уже писал несколько статей по делегатам и событиям, но они подразумевали, что у читателя статьи уже будет хоть малейшая база для понимания принципов работы делегатов. Но как показывает практика, тема делегатов является одной из самых сложных, с которыми сталкиваются разработчики при изучении языка C#. Припоминаю сам то время, когда начал изучать язык C# по книге Эндрю Троелсена: после первого прочтения темы о делегатах принципы их работы остались для меня непонятными; после второго прочтения тема немного прояснилась. Затем я прочитал книгу "Джеффри Рихтер, CLR via C#" и понял, что делегаты непростая вещь, потому что не понял с первого раза, что пытался донести читателям Рихтер. Первопричина этого была в недостаточном опыте и в отсутствии целостной картины принципов работы и использования делегатов. Разработчики, которые перешли на язык C# с языка С++, знакомы с понятием "указатель на функцию". Делегаты – это нечто подобное указателям на функцию с языка С++, но с тем отличием, что они безопасны, и в отличии от того же С++, с делегатами нельзя повредить память, обратиться не к тому адресу и т.д. Если не вдаваться в дебри подробностей, то для новичков легче воспринимать делегаты как обертку над функциями. Давайте смоделируем ситуацию, где нам нужно в приложении залогировать ошибку.
class Program
{
       static void Main(string[] args)
       {
             Calculate();
             Console.ReadLine();
       }

       public static void Calculate()
       {
             try
             {
                    var a = 2;
                    var b = 0;
                    var result = a / b;       
             }
             catch (DivideByZeroException ex)
             {
                    //Log exception
             }
       }

       public static void LogToConsole(string message)
       {
             Console.WriteLine("Call LoginToConsole {0}", message);
             Console.WriteLine(message);
       }

       public void LogToFile(string message)
       {
             Console.WriteLine("Call LoginToFile {0}", message);
             File.WriteAllText(Path.Combine(Directory.GetCurrentDirectory(), "test.txt"), message);
       }
}
У нас есть две функции – LogToConsole и LogToFile, которые позволяют залогировать ошибку, которая возникает у функции Calculate(). Мы можем использовать эти функции следующим образом:
public static void Calculate()
{
       try
       {
             var a = 2;
             var b = 0;
             var result = a / b;
       }
       catch (DivideByZeroException ex)
       {
             //Log exception
             string message = ex.ToString();
             //Способ 1
             Program p = new Program();
             p.LogToFile(message);
             //Способ 2
             LogToConsole(message);
       }
}
В данном примере мы можем использовать первый вариант вызова функции, в котором создадим экземпляр класса Program, а после этого вызвать функцию класса ProgramLogToFile(), для того чтобы залогировать нужную информацию в файл. Либо второй вариант через вызов статической функции LogToConsole. Вроде ничего сложного в этом нет. Но возникает вопрос при чем тут делегаты к этому коду. Теперь давайте немного усложним код и представим что у нас есть третий класс который должен в случае ошибки вывести на экран соответствующее сообщение. Давайте назовем это класс ErrorHelper и посмотрим, как он реализован
public class ErrorHelper
{
       public void ShowError(string message)
       {
             MessageBox.Show(message);
       }
}
Чтобы использовать этот класс, нам необходимо добавить для нашего консольного приложения ссылку на System.Windows.Forms.
И в проекте добавить использование этого пространства имен.
using System.Windows.Forms;
Теперь наш код, который вычисляет некие действия в функции Calculate(), усложнится еще больше, так как ему необходимо знать о классе ErrorHelper, который может быть в другой сборке.
public static void Calculate()
{
       try
       {
             var a = 2;
             var b = 0;
             var result = a / b;
       }
       catch (DivideByZeroException ex)
       {
             //Log exception
             string message = ex.ToString();
             //Способ 1
             Program p = new Program();
             p.LogToFile(message);
             //Способ 2
             LogToConsole(message);
             //Способ 3
             ErrorHelper error = new ErrorHelper();
             error.ShowError(message);
       }
}
Как видим, код становится все более сложным в понимании и сопровождении. Когда мы решим избавиться от класса ErrorHelper, нам нужно будет зайти в метод Calculate() и удалить оттуда его использование. Давайте попробуем посмотреть на эту ситуацию через призму делегатов. Как они могут помочь нам упростить этот код, и избавиться от такой логики. Все делегаты построены в языке C# вокруг класса Delegate. Класс Delegate является базовым для типов делегатов. Однако только система и компиляторы могут явно наследовать классы Delegate и MulticastDelegate. Мы также не можем создать свой тип, наследуемый от классов Delegate и MulticastDelegate. В C# для того, чтобы создать делегат, нужно использовать ключевое слово delegate, благодаря ему компилятор языка может создавать классы, производные от MulticastDelegate. Поэтому разработчикам везде нужно использовать это ключевое слово. Среда выполнения предоставляет такие методы для выполнения делегата, как Invoke для синхронного выполнения запроса, а также методы BeginInvoke и EndInvoke для возможности асинхронного вызова делегата. Вызывать метод Invoke не обязательно, так как исполнительная среда вызывает этот метод автоматически. Этот метод используется для того, чтобы с помощью рефлексии получить сигнатуру типа делегата. Хотя вы можете вызывать этот метод явно. Ничего плохого после этого не произойдет. Делегат  это экземпляр типа делегата, содержащий указатели на:

  • метод экземпляра типа и целевой объект, который можно присваивать переменной этого типа;
  • метод экземпляра типа со скрытым параметром this, доступным в списке формальных параметров. Такой делегат называется открытым делегатом экземпляра;
  • статический метод;
  • статический метод и целевой объект, который можно присвоить первому параметру этого метода. Такой делегат называется закрытым в отношении своего первого аргумента. (Источник)
Примечание: на платформе .NET Framework версий 1.0 и 1.1 делегат может представлять метод только в том случае, если сигнатура этого метода точно соответствует сигнатуре, определенной типом делегата. Поэтому поддерживаются только первый и третий элементы приведенного выше списка, а первый элемент требует точного соответствия типов. Если вы прочитали текст выше и знаете, как это работает, то пояснения ниже могут быть вам неинтересны. Но если у вас осталось недопонимание надобности делегатов, прошу посмотреть на пример, приведенный ниже.


Если внимательно посмотреть на функцию, которая приведена в примере, и те функции, которые используем мы, то можем заметить, что они имеют одинаковую структуру. Все эти функции принимают параметр string и возвращают void. Поскольку я упоминал выше, что делегат, по сути, является указателем на функцию, давайте создадим делегат, который будет иметь такую же структуру.
public delegate void ErrorLoggerDelegate(string message);
Наш делегат имеет такую же структуру, как и наши функции, это позволит подставлять функцию, которая имеет такую же сигнатуру и параметры, как созданный делегат. Давайте посмотрим, как изменится наша реализация после добавления делегата ErrorLoggerDelegate.
class Program
{
       public delegate void ErrorLoggerDelegate(string message);
       static void Main(string[] args)
       {
             Calculate(null);
             Console.ReadLine();
       }

       public static void Calculate(ErrorLoggerDelegate action)
       {
             try
             {
                    var a = 2;
                    var b = 0;
                    var result = a / b;
             }
             catch (DivideByZeroException ex)
             {
                    if (action != null)
                           action(ex.ToString());
             }
       }

       public static void LogToConsole(string message)
       {
             Console.WriteLine("Call LoginToConsole {0}", message);
             Console.WriteLine(message);
       }

       public void LogToFile(string message)
       {
             Console.WriteLine("Call LoginToFile {0}", message);
             File.WriteAllText(Path.Combine(Directory.GetCurrentDirectory(), "test.txt"), message);
       }
}

public class ErrorHelper
{
       public void ShowError(string message)
       {
             MessageBox.Show(message);
       }
}
Теперь наша функция Calculate принимает один параметр созданного нами делегата ErrorLoggerDelegate. Поскольку мы передали для примера null, мы не использовали ни одного метода, реализованного для примера. Но давайте попробуем передать в метод Calculate функцию LogToConsole для примера, чтобы вывести ошибку на экран. С помощью делегата это можно сделать несколькими способами. Ниже приведены способы вызова метода Calculate.
static void Main(string[] args)
{
       //Вариант 1
       Calculate(LogToConsole);

       //Вариант 2
       ErrorLoggerDelegate errorLoggerDelegate = new ErrorLoggerDelegate(LogToConsole);
       Calculate(errorLoggerDelegate);

       //Вариант 3
       ErrorLoggerDelegate errorLoggerDelegate1 = LogToConsole;
       Calculate(errorLoggerDelegate1);

       //Вариант 4
       ErrorLoggerDelegate errorLoggerDelegate2 = message => Console.WriteLine(message);
       Calculate(errorLoggerDelegate2);

       //Вариант 5
       ErrorLoggerDelegate errorLoggerDelegate3 = Console.WriteLine;
       Calculate(errorLoggerDelegate3);

       //Вариант 6
       ErrorLoggerDelegate errorLoggerDelegate4 = delegate(string message) { Console.WriteLine(message); };
       Calculate(errorLoggerDelegate4);

       Console.ReadLine();
}
Сразу попрошу не пугаться с приведенным выше кодом. Он не так страшен, как может показаться с первого взгляда. Давайте теперь пройдемся по каждой строчке и рассмотрим, что она делает.
//Вариант 1
Calculate(LogToConsole);
В данной строке мы передаем в метод Calculate функцию LogToConsole. Это возможно, потому что наш делегат имеет такую же сигнатуру метода, как и наш метод. При передачи LogToConsole происходит преобразование кода в такой вид:
Program.Calculate(new Program.ErrorLoggerDelegate(Program.LogToConsole));
Как преобразовывается наш код, мы можем посмотреть с помощью утилиты ILSpy или Reflector. ILSpy – бесплатная утилита, которая позволяет просматривать исходный код ваших приложений, написанных на языке C# с исполняемых файлов (.exe) или динамических библиотек, написанных на языке C# или VB.NET (.dll).  Просто все наши функции, которые мы используем, будут использовать обертку в виде класса, наследуемого от MulticastDelegate. Если мы откроем ту же утилиту ILSpy, то сможем убедиться в этом.
В приведенном примере приложение ConsoleApplicationTestAccess приложение, на котором демонстрируются примеры с использованием делегатов. Сразу прошу прощение за неудачное название тестируемого проекта. Все дело в лени создавать новый проект; примеры набрасывал в другом проекте, который использовался для других целей. Язык C# не позволяет передавать функции как параметры в другие функции, но позволяет передавать классы как параметр функции. Поэтому разработчики C# придумали такой механизм, который позволяет использовать для таких целей класс MulticastDelegate, в котором и хранятся указатели на наши функции. Для того чтобы код, который написан для примера, было легче читать и понимать, я постарался не использовать ключевого слова var, чтобы вы видели, какой тип мы используем.
ErrorLoggerDelegate errorLoggerDelegate = new ErrorLoggerDelegate(LogToConsole);
Calculate(errorLoggerDelegate);
Вариант номер два который приведен выше интерпретируется в следующий код
Program.ErrorLoggerDelegate errorLoggerDelegate = new Program.ErrorLoggerDelegate(Program.LogToConsole);
            Program.Calculate(errorLoggerDelegate);
Отличие от варианта 1 состоит только в том, что мы сохраняем результат во временную переменную.
//Вариант 3
ErrorLoggerDelegate errorLoggerDelegate1 = LogToConsole;
Calculate(errorLoggerDelegate1);
Вариант 3 идентичный варианту 2 за тем исключением, что у нас используется неявное приведение. На уровне IL кода у нас не будет отличия. И если вы добавите данный код в Visual Studio, то она вам предложит заменить вариант номер 2 на вариант номер 3. Поэтому используйте тот вариант, который вам больше нравится.
Вариант 4 и вариант 6, приведенные ниже, основываются на анонимных методах.
//Вариант 4
ErrorLoggerDelegate errorLoggerDelegate2 = message => Console.WriteLine(message);
Calculate(errorLoggerDelegate2);
//Вариант 6
ErrorLoggerDelegate errorLoggerDelegate4 = delegate(string message) { Console.WriteLine(message); };
Calculate(errorLoggerDelegate4);
В этих двух примерах отличие в том, что четвертый вариант использует синтаксис, основанный на лямбда-выражениях, а вариант 6 использует вариант, основанный на делегатах и который будет работать для .NET Framework 2.0. Использование анонимных методов позволяет сократить издержки на кодирование при создании делегатов, поскольку не требуется создавать отдельный метод. Например, указание блока кода вместо делегата может быть целесообразно в ситуации, когда создание метода может показаться ненужным действием. Хорошим примером является запуск нового потока. Этот класс создает поток и содержит код, выполняемый потоком без создания дополнительного метода для его делегата. Благодаря анонимным методам мы можем создавать нужную реализацию на лету.
Остался вариант 5, который для внимательных читателей не принесет ничего нового, так как он аналогичен варианту 3.
//Вариант 5
ErrorLoggerDelegate errorLoggerDelegate3 = Console.WriteLine;
Calculate(errorLoggerDelegate3);
В чем аналогичность вариантов 5 и 3, думаю, вы уже знаете. Поскольку мы рассмотрели вариант с использованием делегата, пора перейти к позитивным новостям. В .NET Framework 4.0 появились делегаты, с помощью которых можно реализовывать базовую логику. Например, делегат Action<T> инкапсулирует метод, который принимает один параметр и не возвращает значений. Делегат Action инкапсулирует метод, который не принимает ни одного параметра и не возвращает значений. И делегат Func<T, TResult> инкапсулирует метод с одним параметром и возвращает значение типа, указанного в параметре TResult. Делегат Action<T>  как Func<T, TResult> принимает до 16 параметров. Отличие только в том, что Action<T> не возвращает значение (возвращает void). Давайте перепишем наш пример с использованием делегата Action<T>, вместо ErrorLoggerDelegate.
static void Main(string[] args)
{
       Action<string> action = LogToConsole;
       Calculate(action);

       Console.ReadLine();
}

public static void Calculate(Action<string> action)
{
       try
       {
             var a = 2;
             var b = 0;
             var result = a / b;
       }
       catch (DivideByZeroException ex)
       {
             if (action != null)
                    action(ex.ToString());
       }
}
Удобно, не правда ли? Теперь мы вплотную приблизились к вопросу, что делать, если нужно передать несколько разных функций в метод. Первый вариант, который вам, наверное, пришел в мысль, если вы не использовали делегаты ранее, – это, наверное что-то вроде такого варианта:
class Program
{
       static void Main(string[] args)
       {
             Action<string> action1 = LogToConsole;
             Program p = new Program();
             Action<string> action2 = p.LogToFile;
             ErrorHelper errorHelper = new ErrorHelper();
             Action<string> action3 = errorHelper.ShowError;

             List<Action<string>> actions = new List<Action<string>>(new[]
                    {
                           action1,
                           action2,
                           action3
                    });
             Calculate(actions);

             Console.ReadLine();
       }

       public static void Calculate(List<Action<string>> actions)
       {
             try
             {
                    var a = 2;
                    var b = 0;
                    var result = a / b;
             }
             catch (DivideByZeroException ex)
             {
                    foreach (var action in actions)
                    {
                           action(ex.ToString());
                    }     
             }
       }

       public static void LogToConsole(string message)
       {
             Console.WriteLine("Call LoginToConsole {0}", message);
             Console.WriteLine(message);
       }

       public void LogToFile(string message)
       {
              Console.WriteLine("Call LoginToFile {0}", message);
             File.WriteAllText(Path.Combine(Directory.GetCurrentDirectory(), "test.txt"), message);
       }
}

public class ErrorHelper
{
       public void ShowError(string message)
       {
             MessageBox.Show(message);
       }
}
В примере мы создали массив делегатов и переписали функцию Calculate так, чтобы она принимала список делегатов. Этот пример будет работать, но, как мы помним, делегаты наследуются от класса MulticastDelegate, а этот класс нам дает некоторые преимущества. Одним из таких преимуществ есть то, что MulticastDelegate – это, по сути, обертка над коллекцией делегатов. Список этих делегатов мы можем получить, используя метод GetInvocationList. Возникает вопрос: как же добавить в этот список нужные функции? Для этого у класса Delegate, от которого, в свою очередь, наследуется класс MulticastDelegate, есть методы Combine для добавления и метод Remove для его удаления. Но эти методы немного неудобны, так как они возвращают класс Delegate, который нужно приводить к нужному типу.
Program p = new Program();
Action<string> action2 = p.LogToFile;
Action<string> action1 = LogToConsole;
Action<string> action3 = (Action<string>)Delegate.Combine(action1, action2);
Лучше использовать перегруженные методы += и -=, которые будут вам сразу приводиться к нужному типу. Давайте перепишем наш пример с использованием перегруженных методов += и -= .
class Program
{
       static void Main(string[] args)
       {
                   
             Program p = new Program();
             ErrorHelper errorHelper = new ErrorHelper();
             Action<string> action = LogToConsole;
             action += p.LogToFile;
             action += errorHelper.ShowError;
             Calculate(action);

             Console.ReadLine();
       }

       public static void Calculate(Action<string> action)
       {
             try
             {
                    var a = 2;
                    var b = 0;
                    var result = a / b;
             }
             catch (DivideByZeroException ex)
             {
                    if (action != null)
                           action(ex.ToString());
             }
       }

       public static void LogToConsole(string message)
       {
             Console.WriteLine("Call LoginToConsole {0}", message);
             Console.WriteLine(message);
       }

       public void LogToFile(string message)
       {
             Console.WriteLine("Call LoginToFile {0}", message);
             File.WriteAllText(Path.Combine(Directory.GetCurrentDirectory(), "test.txt"), message);
       }
}

public class ErrorHelper
{
       public void ShowError(string message)
       {
             MessageBox.Show(message);
       }
}
Теперь метод Calculate принимает не список делегатов (List<>), а принимает делегат Action<string>, в котором мы передали все необходимые нам делегаты. Получить список делегатов, которые переданы нам как параметр, можно вызвав описанный выше метод GetInvocationList. Выглядеть это может так:
var actions = action.GetInvocationList();

Если вы поняли, как работают делегаты в языке C# и у вас слово делегат не вызывает недопонимание, вы можете посмотреть статью "Использование делегатов в C#" и статью "Тонкости при использовании событий и делегатов", которые содержат пояснения некоторых нюансов использования делегатов, которые могут вам встретиться при разработке приложений; часто вопросы из этих статей задают на собеседованиях. Статья получилась немаленькой, но надеюсь, что она позволила вам понять основные моменты использования делегатов.    

No comments:

Post a Comment