Sunday, November 17, 2013

Тонкости при использовании событий и делегатов

В этой статье я расскажу о некоторых нюансах при использовании событий (events) в языке C#. Подразумеваю, что читатель довольно неплохо разбирается с базовыми понятиями событий и делегатов. Поэтому начну из одной особенности, о которой многие программисты не знают. Приведем небольшой пример кода:
class Program
{
    public delegate void LogDelegate(string message);
    public static event LogDelegate LogEvent;
    public static int counter = 0;
    static void Main(string[] args)
    {
        var logToConsoleDelegate1 = new LogDelegate(LogToConsole);
        var logToConsoleDelegate2 = new LogDelegate(LogToConsole);
        var logToConsoleDelegate3 = new LogDelegate(LogToConsole);
        var logToConsoleDelegate4 = new LogDelegate(LogToConsole);
        try
        {
            LogEvent += logToConsoleDelegate1;
            LogEvent += logToConsoleDelegate2;
            LogEvent += logToConsoleDelegate3;
            LogEvent += logToConsoleDelegate4;

            LogEvent.Invoke("Test");
        }
        catch (Exception ex)
        {
            Console.WriteLine("Catch exception {0}", ex);
        }
        finally
        {
            LogEvent -= logToConsoleDelegate1;
            LogEvent -= logToConsoleDelegate2;
            LogEvent -= logToConsoleDelegate3;
            LogEvent -= logToConsoleDelegate4;
        }
        Console.ReadLine();
    }

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

        if (counter == 1)
            throw new ArgumentNullException("Test");
        counter++;
    }
}
Описан пример кода, в котором в одном из цепочки событий генерируется exception. Вопрос, будет ли продолжено выполнение после того, как мы перехватим событие? Если Вы предполагали, что будет вызвано три события и одно – которое вызвало исключение и перехвачено, то у меня для вас плохая новость. Если вызванный метод выбрасывает исключение, выполнение этого метода прекращается, исключение передается обратно коду, вызвавшему делегат  и оставшиеся в списке вызовов методы не вызываются. Перехват исключения в вызывающем коде не меняет этого поведения. Многие могут сказать, что пример надуманный и кому придет в голову делать столько привязок на один метод. А что, если сделать разные методы и так же перехватывать их? Перепишем пример с разными методами:
class Program
{
    public delegate void LogDelegate(string message);
    public static event LogDelegate LogEvent;
    static void Main(string[] args)
    {
        var program = new Program();
        var logToConsoleDelegate = new LogDelegate(LogToConsole);
        var logToMessageBoxDelegate = new LogDelegate(program.LogToMessageBox);
        var logToFileDelegate = new LogDelegate(program.LogToFile);
        var logToEventLogDelegate = new LogDelegate(program.LogToOutput);

        try
        {
            LogEvent += logToConsoleDelegate;
            LogEvent += logToMessageBoxDelegate;
            LogEvent += logToFileDelegate;
            LogEvent += logToEventLogDelegate;

            LogEvent.Invoke("Test");
        }
        catch (Exception ex)
        {
            Console.WriteLine("Catch exception {0}", ex);
        }
        finally
        {
            LogEvent -= logToConsoleDelegate;
            LogEvent -= logToMessageBoxDelegate;
            LogEvent -= logToFileDelegate;
            LogEvent -= logToEventLogDelegate;
        }
        Console.ReadLine();
    }

    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 void LogToMessageBox(string message)
    {
        Console.WriteLine("Call LoginToMessageBox {0}", message);
        MessageBox.Show(message);
        throw new ArgumentNullException("Test");
    }

    public void LogToOutput(string message)
    {
        Console.WriteLine("Call LogToOutput {0}", message);
    }
}
К сожалению, приведенный выше код тоже не работает из-за описанной выше проблемы. Вы можете переписать код таким образом:
static void Main(string[] args)
{
    var program = new Program();
    var logDelegate = new LogDelegate(LogToConsole);
    logDelegate += program.LogToMessageBox;
    logDelegate += program.LogToFile;
    logDelegate += program.LogToOutput;

    try
    {
        LogEvent += logDelegate;

        LogEvent.Invoke("Test");
    }
    catch (Exception ex)
    {
        Console.WriteLine("Catch exception {0}", ex);
    }
    finally
    {
        LogEvent -= logDelegate;
    }
    Console.ReadLine();
}
И это ничего не изменит. Так устроена работа делегатов. Единственное решение, которое я знаю на данный момент, – это перехватывать внутри функции код через try->catch и гасить ошибку. Пример:
public void LogToMessageBox(string message)
{
    try
    {
        Console.WriteLine("Call LoginToMessageBox {0}", message);
        MessageBox.Show(message);
        throw new ArgumentNullException("Test");
    }
    catch (Exception exception)
    {
        Console.WriteLine(exception.ToString());
    }
           
}
И другой способ для решения такой проблемы – это использовать известные паттерны, которые, по сути, помогают заменить использование событий. Это классический паттерн Observer (наблюдатель) и паттерн  Publisher->Subscriber, на основании которого работают Event Aggregator с Prism. Есть и другие варианты решения этой проблемы, но, к сожалению, я с ними не знаком. 

Злоупотребление лямбда-выражениями
Вторая проблема с использованием событий в C# для новичков выражается в использовании лямбда-выражений. Вот как выглядит пример с использованием лямбда-выражений:
class Program
{
    public static event EventHandler LogEvent;
    static void Main(string[] args)
    {
        LogEvent += (sender, arg) => { };
        Console.ReadLine();
    }

}
Очень часто новички начинают использовать такой удобный способ привязки повсюду. Вот какой способ использования я встретил однажды в реальном коде:
class Program
{
    public static event EventHandler LogEvent;
    static void Main(string[] args)
    {
        LogEvent += (sender, arg) => { };
        LogEvent -= (sender, arg) => { };
        Console.ReadLine();
    }
}
Разработчик даже не понимал, что он делает подписку на одно событие, а отписывается уже от другого. Он, наверное, предполагал, что JIT компилятор настолько умный и сам догадается о том, что  это один и тот же метод.
Но компилятор интерпретировал этот код так, как написал его разработчик. За это огромное спасибо разработчикам JIT компилятора. Вот как выглядит IL код приведенного выше метода:
.method private hidebysig static
    void Main (
        string[] args
    ) cil managed
{
    // Method begins at RVA 0x20cc
    // Code size 82 (0x52)
    .maxstack 2
    .entrypoint

    IL_0000: nop
    IL_0001: ldsfld class [mscorlib]System.EventHandler ConsoleApplicationTestEvent.Program::'CS$<>9__CachedAnonymousMethodDelegate2'
    IL_0006: brtrue.s IL_001b

    IL_0008: ldnull
    IL_0009: ldftn void ConsoleApplicationTestEvent.Program::'<Main>b__0'(objectclass [mscorlib]System.EventArgs)
    IL_000f: newobj instance void [mscorlib]System.EventHandler::.ctor(objectnative int)
    IL_0014: stsfld class [mscorlib]System.EventHandler ConsoleApplicationTestEvent.Program::'CS$<>9__CachedAnonymousMethodDelegate2'
    IL_0019: br.s IL_001b

    IL_001b: ldsfld class [mscorlib]System.EventHandler ConsoleApplicationTestEvent.Program::'CS$<>9__CachedAnonymousMethodDelegate2'
    IL_0020: call void ConsoleApplicationTestEvent.Program::add_LogEvent(class [mscorlib]System.EventHandler)
    IL_0025: nop
    IL_0026: ldsfld class [mscorlib]System.EventHandler ConsoleApplicationTestEvent.Program::'CS$<>9__CachedAnonymousMethodDelegate3'
    IL_002b: brtrue.s IL_0040

    IL_002d: ldnull
    IL_002e: ldftn void ConsoleApplicationTestEvent.Program::'<Main>b__1'(objectclass [mscorlib]System.EventArgs)
    IL_0034: newobj instance void [mscorlib]System.EventHandler::.ctor(objectnative int)
    IL_0039: stsfld class [mscorlib]System.EventHandler ConsoleApplicationTestEvent.Program::'CS$<>9__CachedAnonymousMethodDelegate3'
    IL_003e: br.s IL_0040

    IL_0040: ldsfld class [mscorlib]System.EventHandler ConsoleApplicationTestEvent.Program::'CS$<>9__CachedAnonymousMethodDelegate3'
    IL_0045: call void ConsoleApplicationTestEvent.Program::remove_LogEvent(class [mscorlib]System.EventHandler)
    IL_004a: nop
    IL_004b: call string [mscorlib]System.Console::ReadLine()
    IL_0050: pop
    IL_0051: ret
// end of method Program::Main
У нас было создано два анонимных метода, вместо одного, как предполагал разработчик. Исправить это очень легко:
class Program
{
    public static event EventHandler LogEvent;
    static void Main(string[] args)
    {
        EventHandler handler = (sender, arg) => { };
        LogEvent += handler;
        LogEvent -= handler;
        Console.ReadLine();
    }
}
И компилятор нам выдаст то, что нам нужно.
.method private hidebysig static
    void Main (
        string[] args
    ) cil managed
{
    // Method begins at RVA 0x20cc
    // Code size 54 (0x36)
    .maxstack 2
    .entrypoint
    .locals init (
        [0] class [mscorlib]System.EventHandler 'handler'
    )

    IL_0000: nop
    IL_0001: ldsfld class [mscorlib]System.EventHandler ConsoleApplicationTestEvent.Program::'CS$<>9__CachedAnonymousMethodDelegate1'
    IL_0006: brtrue.s IL_001b

    IL_0008: ldnull
    IL_0009: ldftn void ConsoleApplicationTestEvent.Program::'<Main>b__0'(objectclass [mscorlib]System.EventArgs)
    IL_000f: newobj instance void [mscorlib]System.EventHandler::.ctor(objectnative int)
    IL_0014: stsfld class [mscorlib]System.EventHandler ConsoleApplicationTestEvent.Program::'CS$<>9__CachedAnonymousMethodDelegate1'
    IL_0019: br.s IL_001b

    IL_001b: ldsfld class [mscorlib]System.EventHandler ConsoleApplicationTestEvent.Program::'CS$<>9__CachedAnonymousMethodDelegate1'
    IL_0020: stloc.0
    IL_0021: ldloc.0
    IL_0022: call void ConsoleApplicationTestEvent.Program::add_LogEvent(class [mscorlib]System.EventHandler)
    IL_0027: nop
    IL_0028: ldloc.0
    IL_0029: call void ConsoleApplicationTestEvent.Program::remove_LogEvent(class [mscorlib]System.EventHandler)
    IL_002e: nop
    IL_002f: call string [mscorlib]System.Console::ReadLine()
    IL_0034: pop
    IL_0035: ret
// end of method Program::Main

Замыкание на переменных цикла
Замыкание переменной  это захват значения некоего объекта посредством лямбда-выражений без дополнительных усилий вроде «out» и «ref» модификаторов к параметрам. Пример замыкания:
var funcs = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
       funcs.Add(() => i);
}
foreach (var f in funcs)
       Console.WriteLine(f());
Более подробно об этом можно посмотреть в моем блоге в статье про делегаты, а также найти соответствующую тему в блоге Сергея Теплякова. Поэтому просто приведу способ решения этой проблемы:
var funcs = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
       var temp = i;
       funcs.Add(() => temp);
}
foreach (var f in funcs)
       Console.WriteLine(f());
После исправления на каждой итерации цикла будет осуществляться захват нового экземпляра переменной temp, в результате чего каждый делегат будет ссылаться на свою копию переменной temp. 

Отличие событий от делегатов
Не упомянуть об отличии между делегатами и событиями в моей статье было бы непростительно, тем более что Вам наверняка задавали такой вопрос на собеседовании.
class Program
{
    public static event EventHandler LogEvent;
    static void Main(string[] args)
    {
        Test();
        Console.ReadLine();
    }

    public static void Test()
    {
        if(LogEvent != null)
            LogEvent(null, new EventArgs());
    }
}
Что будет, если в приведенной выше программе удалить ключевое слово event? Признаюсь честно, меня это вопрос тоже вводил в ступор. Главное при ответе на этот вопрос – помнить главные отличия  между delegate и event.
  • Благодаря ключевому слову event делегат наделяется некоторыми свойствами, что делает его отличным от делегата.
  • Событие нельзя использовать как локальную переменную, в отличие  от делегата.
  • Событие нельзя запустить вне класса, в котором оно объявлено. Это важное отличие.
Интересный нюанс также связан с тем, что если Вы будете использовать события вместо делегатов для того, чтобы предоставлять доступ к Вашему компоненту или классу, то другие разработчики не смогут стереть цепочку подписавшихся обработчиков.
class Program
{
    static void Main(string[] args)
    {
        var a = new A();
        a.LogEvent = null; // ошибка компиляции
        a.LogDelegate = null;
        Console.ReadLine();
    }
}

public class A
{
    public event EventHandler LogEvent;
    public EventHandler LogDelegate;

    public void Test()
    {
        if (LogEvent != null)
            LogEvent(null, new EventArgs());
    }
}
Теперь другие разработчики просто не смогут затереть a.LogEvent, так как это вызовет ошибку компиляции. Вы себя обезопасили тем, что добавили ключевое слово event. И теперь потенциальные злоумышленники не смогут причинить вам вред. 

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

Источники:
Делегаты - замыкание на переменных цикла (Мой блог)

No comments:

Post a Comment