понедельник, 21 февраля 2011 г.

Виртуальные события в языке C#

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

Итак, события в языке C# по сути являются реализацией известного паттерна publish/subscribe и содержат всего пару методов add и remove, для подписки и отписки от события, и закрытое поле с мультикаст делегатом, который, собственно, этих самых подписчиков и содержит. А раз событие – это по сути методы, а методы могут быть виртуальными, можно сделать вывод, что события тоже могут быть виртуальными (тем более что свойства ведь могут быть виртуальными и этот факт не вредит ничьему ментальному здоровью). Итак, теоретически – с виртуальными событиями все должно быть нормально, однако это как раз тот случай, когда теория с практикой несколько расходятся.

Но обо всем по порядку; давайте рассмотрим следующий пример. Предположим у вас есть некоторый базовый класс, скажем, класс Base, который содержит событие с именем SomeEvent, и есть производный класс Derived, который по какой-то причине хочет иметь возможность переопределить событие базового класса. Вполне возможно, что причина столь необычной идеи уходит корнями к безумной преданности вашего высокодушевного сознания к замечательной книге банды четырех, которую вы перечитываете длинными зимними вечерами. Так вот, в очередной раз перечитывая паттерны поведения вы осознали, что к событиям вполне можно применить паттерн «Метод шаблона» сделав непосредственный процесс подписки/отписки виртуальным, а процесс генерации события – невиртуальным, таким образом четко разделив ответственность между базовым классом и его наследником.

В результате, это идея вылилась в следующий код:

// Базовый класс с виртуальным событием. Реализация паттерна проектирования
// "Метод шаблона", когда само событие является виртуальным и переопределяется
// наследником, а процесс вызова события реализован с помощью открытого
// невиртуального метода
class Base
{
    // Само вирутуальное событие
    public virtual event EventHandler SomeEvent;

    // Фукнция генерации события
    // (сделана открытой только в демонстрационных целях)
    public void InvokeSomeEvent()
    {
        var handler = SomeEvent;
        if (handler != null)
            handler(this, EventArgs.Empty);
    }
}

// Класс-наследник, переопределяющий виртуальное событие
class Derived : Base
{
    // Переопределяем виртуальное событие
    public override event EventHandler SomeEvent;
}

class Program
{
    static void Main(string[] args)
    {
        Derived derived = new Derived();
        derived.SomeEvent += (s, e) => Console.WriteLine("Some event handler.");
        derived.InvokeSomeEvent();
        Console.ReadLine();
    }
}

Запускам этот код на выполнение и … не видим в консоли ничего, поскольку наш обработчик события не вызывается.

Все дело в том, что событие (field-like event) в базовом классе Base все также разворачивается в пару открытых методов add/remove и *закрытое* (private) поле делегата с типом EventHandler, который модифицируется в обозначенных выше методах. И когда компилятор встречает имя такого события в коде, то он трактует его по разному, в зависимости от того, где находится это обращение: «снаружи» этого класса или «внутри» него. Если обращение к событию происходит внутри класса, то ты вместо события как такового мы обращаемся к нижележащему делегату, а если же обращение происходит снаружи класса – то мы «видим» наше событие через «призму» двух методов подписки/отписки. Это сделано специально для разграничения ответственности: внешний код может лишь подписываться и отписываться на событие, а вот «зажигать» событие или его модифицировать напрямую (установив его в null, например) – не может. Но проблема заключается в том, что в нашем случае то же самое происходит и в классе Derived: для него также генерируется свое собственное закрытое поле  и его именно оно изменяется в классе Derived.

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

class Base
{
    // Реализуем событие "вручную"
    public virtual event EventHandler SomeEvent
    {
        // Обращаемся к своему собственному закрытому полю
        add { _baseClassBackingDelegate += value; }
        remove { _baseClassBackingDelegate -= value; }
    }

    // Фукнция генерации события
    private void InvokeSomeEvent()
    {
        // Обращаемся к полю делегата, а не к имени события,
        // поскольку вызов делегата осуществляется именно через поле делегата
        var handler = _baseClassBackingDelegate;
        if (handler != null)
            handler(this, EventArgs.Empty);
    }

    // Поле делегата является закрытым, а не защищенным,
    // что делает невозможным подписку/описку именно к этому полю
    // из класса наследника
    private EventHandler _baseClassBackingDelegate;
}

class Derived : Base
{
    // Переопределяем событие
    public override event EventHandler SomeEvent
    {
        // Обращаемся к собственному закрытому полю
        add { _derivedClassBackingDelegate += value; }
        remove { _derivedClassBackingDelegate -= value; }
    }

    // В классе наследнике объявляется еще одно поле делегата
    private EventHandler _derivedClassBackingDelegate;
}

Теперь должно быть понятно, почему наше событие не было вызвано: наш метод обратного вызова из-за переопределения события в классе Derived был добавлен в поле делегата производного класса, а метод InvokeSomeEvent вызывает все методы обратного вызова, сохраненные в классе Base.

Так уж выходит, что наша попытка сделать виртуальное field-like событие с невиртуальным методом генерации этого события приводит именно туда, куда ведут многие другие благие намерения, а именно к трудноуловимым ошибкам и головной боли наших коллег, которые будут поддерживать этот код в будущем.  Основная идея наследования вообще и применение паттерна проектирования “Метод шаблона” в частности, направлены на то, чтобы сделать отношения между базовым классом и наследником простыми и понятными, такими, чтобы базовый класс было легко использовать правильно и сложно – неправильно. В случае же с виртуальными событиями сложно вообще понять, какая именно роль и обязанности возлагаются на базовый класс, а какая – на производный. И хотя использование виртуальных событий все же возможно (*) (если делать это осторожно и со знанием дела), я бы предпочел более четкий контракт между базовым классом и его наследниками в виде более конкретных и понятных виртуальных функций.

P.S. Нужно отметить, что хотя теоретически компилятор мог бы определять подобные ситуации и генерировать защищенное поле в базовом классе, но делать этого никто не будет по одной простой причине: подобные изменения будут ломающими (так называемые breaking changes). Просто представьте себе, что у кого-то в продакшне находится подобный код с виртуальным событием, но с дополнительной функцией InvokeSomeEvent в производном классе. Тогда, после перехода на новую версию языка C#, начнет вызываться дополнительный код, который до этого не вызывался и не тестировался, что может привести к непредсказуемому поведению приложения и или непонятным ошибкам во время выполнения.

---------------------------------------

(*) В данном случае речь не идет о использовании событий в интерфейсах, поскольку во-первых, в этом случае мы получаем абстрактное, а не виртуальное событие, а во-вторых, реализация интерфейса – это отношение “может” (can-do), а не как ни отношение “является” (is-a), которое существует между базовым классом и его наследником. Как раз в случае с интерфейсом обязанности сторон четкие и понятные: интерфейс просто объявляет контракт, а конкретный класс его реализует.

13 комментариев:

  1. Так вроде выглядит неплохо:
      class Base
      {
        public virtual event EventHandler SomeEvent;

        public void InvokeSomeEvent()
        {
          var handler = SomeEvent;
          if (handler != null)
            handler(this, EventArgs.Empty);
        }
      }

      class Derived : Base
      {
        public override event EventHandler SomeEvent
        {
          add { base.SomeEvent += value; }
          remove { base.SomeEvent -= value; }
        }
      }

      class Program
      {
        static void Main(string[] args)
        {
          Derived derived = new Derived();
          derived.SomeEvent += (s, e) => Console.WriteLine("Some event handler.");
          derived.InvokeSomeEvent();
          Console.ReadLine();
        }
      }

    ОтветитьУдалить
  2. @Vasiliy: да, такой вариант, конечно, работает. Смысл в том, что нельзя, чтобы в обоих случаях (в базовом и производном классах) было именно field-like событие, что не совсем очевидно с первого взгляда.

    ОтветитьУдалить
  3. А хоть один юз-кейс этого безобразия придумать-то удалось? В какой задаче витртуальность события былда бы незаменимой или просто очень удобной фичей?

    ОтветитьУдалить
  4. @_FRED_: неа, не удалось. Кроме того, даже если и появится подобный юз-кейс (например, добавить процесс логирования в классы наследники о подписке на события в базовом классе), то всегда можно сделать это явно - добавить виртуальную функцию, с нормальным именем, которая бы точнее говорила о контракте между базовым классом и наследником.

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

    ОтветитьУдалить
  5. Вот в ObservableCollection<> чудаки такие события заиспользовали http://msdn.microsoft.com/en-us/library/ms668610.aspx Долго мидитировал, но не понял - зачем.

    По сути единственный бенефит - возможность переопределить в наследнике логику подписки\отписки. Но вот где и как это можно было бы использовать - ума не приложу. А вот разложенные таким образом грабли встречать приходилось.

    ОтветитьУдалить
  6. @_FRED_: ах ты ж елки палки!!! Там правда виртуальное событие!!!! Да, и оно перестает файрится при переопределении его как field-like событие (что неудивительно).

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

    ОтветитьУдалить
  7. А как на счет такого юз-кейса - поддержка потокобезопасности? Кажется, что вполне применимо.

    ОтветитьУдалить
  8. @ak: что имелось ввиду по поводу юз-кейса и потокобезопасности? Не понял, если честно.

    Потокобезопасность там есть, точнее, она была в field-like евентах до 4-го фреймворка в одном виде, а потом в другом. Т.е. подписка и отписка потокобезопасны...

    В общем, не понял я.

    ОтветитьУдалить
  9. Мне приходилось использовать виртуальные события в очень, скажем так, специфическом сценарии. Нужно было вызывать подписанные события параллельно (а не последовательно, как это делается в multicast-делегатах - к тому же, в случае экзепшена в одном из них, дальнейшая обработка событий идёт лесом ЕМНИП). В результате там было поле List> (соответственно, ) и в методе Invoke... стартовали потоки на каждое из событий листа (+ некий код, обеспечивающий потокобезопасность самой подписки и вызова событий с минимальным временем локов, но это неважно).

    В общем, хочу сказать, что само определение "события" в .NET многими программистами понимается несколько неверно. Событие - это такой же "чёрный ящик", как и property (и не нужно делать никаких предположений о том, как оно вызывается и как вообще там хранятся делегаты), и стандартное field like поведение - это примерно то же самое, что и auto property, т.е. базовая реализация для очевидных сценариев.

    А вообще, зло - это не виртуальные события как таковые. Зло - это виртуальные события + field like реализация в базовом классе. Т.е. если делаешь событие виртуальным, то обеспечь своё собственное хранилище для делегатов. Или делай событие абстрактным (и так же, абстрактный метод Invoke, что и было в моём случае с параллельным вызовом событий).

    Примерно так.

    ОтветитьУдалить
  10. Да, кстати, концептуальная ошибка в приведённом примере кроется не в том, что field like event нельзя использовать для виртуального события, а в том, что InvokeSomeEvent() "завязано" на конкретную реализацию в базовом классе - это примерно то же самое, как из метода использовать приватное поле, которое наружу торчит в виде виртуального проперти. И потом никто не мешает потомку переопределить проперти (без вызова базовых сеттеров и геттеров), в результате чудесным образом логика в методах предка будет сломана нахрен.

    ОтветитьУдалить
  11. Спасибо, отличные примеры!

    Да, конечно, проблемы с виртуальными событиями будут только с виртуальным field-like событием в базовом классе.

    Но даже, если бы мне понадобилось виртуальное не field-like событие, я бы предпочел добавить более информативные виртуальные методы, нежели методы add и remove.

    ОтветитьУдалить
  12. Я правильно понял насчёт примера со свойствами?

    class MyClass
    {
    private string _someName;

    public virtual string SomeName
    {
    get { return _someName; }
    set { _someName = value; }
    }

    public void SomeMethod()
    {
    Console.WriteLine($"Hello, {_someName}");
    }
    }

    class MyClassDerived : MyClass
    {
    public override string SomeName { get; set; }
    }

    static Main()
    {
    MyClass mc = new MyClass();
    mc.SomeName = "Petya";
    mc.SomeMethod();
    mc = new MyClassDerived();
    mc.SomeMethod(); // тут уже будет пусто...логика поломалась
    }

    ОтветитьУдалить