Виртуальные события в C#: что-то пошло не так

в 13:10, , рубрики: .net, C#, chatbots, microsoft, open source, pvs-studio, static code analysis, Visual Studio, Блог компании PVS-Studio, Программирование

Виртуальные события в C#: что-то пошло не так - 1 Недавно я работал над новой C#-диагностикой V3119 для статического анализатора PVS-Studio. Назначение диагностики — выявление потенциально небезопасных конструкций в исходном коде C#, связанных с использованием виртуальных и переопределенных событий. Давайте попробуем разобраться: что же не так с виртуальными событиями в C#, как именно работает диагностика и почему Microsoft не рекомендует использовать виртуальные и переопределенные события?

Введение

Думаю, читатель хорошо знаком с механизмами виртуальности в C#. Наиболее простым для понимания является пример с работой виртуальных методов. В этом случае виртуальность позволяет выполнить одно из переопределений виртуального метода в соответствии с типом времени выполнения объекта. Проиллюстрирую данный механизм на простом примере:

class A
{
  public virtual void F() { Console.WriteLine("A.F"); }
  public void G() { Console.WriteLine("A.G"); }
}
class B : A
{
  public override void F() { Console.WriteLine("B.F"); }
  public new void G() { Console.WriteLine("B.G"); }
}
static void Main(....)
{
  B b = new B();
  A a = b;
  
  a.F();
  b.F();

  a.G();
  b.G();
}

В результате выполнения, на консоль будет выведено:

B.F
B.F
A.G
B.G

Все правильно. Так как оба объекта a и b имеют тип времени выполнения B, то и вызов виртуального метода F() для обоих этих объектов приведет к вызову переопределенного метода F() класса В. С другой стороны, по типу времени компиляции объекты a и b различаются, соответственно, имея типы A и B. Поэтому вызов метода G() для каждого из этих объектов приводит к вызову соответствующего метода для класса A или B. Более подробно про использование ключевых слов virtual и override можно узнать, например, здесь.

Аналогично методам, свойствам и индексаторам, виртуальными могут быть объявлены и события:

public virtual event ....

Сделать это можно как для «простых», так и для явно реализующих аксессоры add и remove событий. При этом, работая с виртуальными и переопределенными в производных классах событиями, логично было бы ожидать от них поведения, схожего, например, с виртуальными методами. Однако — это не так. Более того, MSDN прямым текстом не рекомендует использовать виртуальные и переопределенные события: «Do not declare virtual events in a base class and override them in a derived class. The C# compiler does not handle these correctly and it is unpredictable whether a subscriber to the derived event will actually be subscribing to the base class event».

Но не будем сразу сдаваться и попробуем все же реализовать "… declare virtual events in a base class and override them in a derived class".

Эксперименты

В качестве первого эксперимента создадим консольное приложение, содержащее объявление и использование в базовом классе двух виртуальных событий (с неявной и явной реализацией аксессоров add и remove), а также содержащее производный класс, переопределяющий эти события:

class Base
{
  public virtual event Action MyEvent;
  public virtual event Action MyCustomEvent
  {
    add { _myCustomEvent += value; }
    remove { _myCustomEvent -= value; }
  }
  protected Action _myCustomEvent { get; set; }
  public void FooBase()
  {
    MyEvent?.Invoke(); 
    _myCustomEvent?.Invoke();
  }
}
class Child : Base
{
  public override event Action MyEvent;
  public override event Action MyCustomEvent
  {
    add { _myCustomEvent += value; }
    remove { _myCustomEvent -= value; }
  }
  protected new Action _myCustomEvent { get; set; }
  public void FooChild()
  {
    MyEvent?.Invoke(); 
    _myCustomEvent?.Invoke();
  }
}
static void Main(...)
{
  Child child = new Child();
  child.MyEvent += () =>
    Console.WriteLine("child.MyEvent handler");
  child.MyCustomEvent += () =>
    Console.WriteLine("child.MyCustomEvent handler");
  child.FooChild();
  child.FooBase();
}

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

child.MyEvent handler
child.MyCustomEvent handler

Используя отладчик или тестовый вывод, легко убедиться в том, что в момент вызова child.FooBase() значение обеих переменных MyEvent и _myCustomEvent равно null, и программа не «падает» лишь благодаря использованию оператора условного доступа при попытке инициировать события MyEvent?.Invoke() и _myCustomEvent?.Invoke().

Итак, предупреждение MSDN не было напрасным. Это действительно не работает! Подписка на виртуальные события объекта, имеющего тип времени выполнения производного класса Child, не приводит к одновременной подписке на события базового класса Base.

В случае с неявной реализацией события компилятор автоматически создает для него методы-аксессоры add и remove, а также поле-делегат, которое используется для подписки или отписки. Проблема, по всей видимости, заключается в том, что в случае использования виртуального события, базовый и дочерний классы будут иметь индивидуальные (не виртуальные) поля-делегаты, связанные с данным событием.

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

Попробуем все же добиться ожидаемого поведения виртуального события при помощи второго эксперимента. Для этого используем виртуальное и переопределенное событие с явной реализацией аксессоров add и remove, а также связанное с ними виртуальное свойство-делегат. Немного изменим текст программы из первого эксперимента:

class Base
{
  public virtual event Action MyEvent;
  public virtual event Action MyCustomEvent
  {
    add { _myCustomEvent += value; }
    remove { _myCustomEvent -= value; }
  }
  public virtual Action _myCustomEvent { get; set; }  //<= virtual
  public void FooBase()
  {
    MyEvent?.Invoke(); 
    _myCustomEvent?.Invoke();
  }
}
class Child : Base
{
  public override event Action MyEvent;
  public override event Action MyCustomEvent
  {
    add { _myCustomEvent += value; }
    remove { _myCustomEvent -= value; }
  }
  public override Action _myCustomEvent { get; set; }  //<= override
  public void FooChild()
  {
    MyEvent?.Invoke(); 
    _myCustomEvent?.Invoke();
  }
}
static void Main(...)
{
  Child child = new Child();
  child.MyEvent += () =>
    Console.WriteLine("child.MyEvent handler");
  child.MyCustomEvent += () =>
    Console.WriteLine("child.MyCustomEvent handler");
  child.FooChild();
  child.FooBase();
}

Результат выполнения программы:

child.MyEvent handler
child.MyCustomEvent handler
child.MyCustomEvent handler

Обратите внимание на тот факт, что произошло два срабатывания обработчика для события child.MyCustomEvent. В режиме отладки несложно определить, что теперь при вызове _myCustomEvent?.Invoke() в методе FooBase() значение делегата _myCustomEvent не равно null. Таким образом, ожидаемого поведения для виртуальных событий нам удалось добиться только путем использования событий с явно реализованными аксессорами add и remove.

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

  • Вы можете оказаться в ситуации, когда будете вынуждены использовать виртуальные события. Например, наследуя от абстрактного класса, в котором объявлено абстрактное событие с неявной реализацией. В результате вы получите в своем классе переопределенное событие, которое, возможно, будете использовать. В этом нет ничего опасного до момента, пока вы не решите наследовать уже от своего класса и вновь переопределить это событие.
  • Подобные конструкции редки, но все же встречаются в реальных проектах. В этом я убедился после того, как реализовал C#-диагностику V3119 для статического анализатора PVS-Studio. Диагностическое правило ищет объявления виртуальных или переопределенных событий с неявной реализацией, которые используются в текущем классе. Небезопасной считается ситуация, когда такие конструкции найдены и класс может иметь наследников, а событие может быть переопределено (не sealed). То есть когда гипотетически возможна ситуация с переопределением виртуального или уже переопределенного события в производном классе. Найденные таким образом предупреждения приведены в следующем разделе.

Примеры из реальных проектов

Для тестирования качества работы анализатора PVS-Studio мы используем пул тестовых проектов. После добавления в анализатор нового правила V3119, посвященного виртуальным и переопределенным событиям, была выполнена проверка всего пула проектов. Остановимся на анализе полученных предупреждений.

Roslyn

Проверке данного проекта при помощи PVS-Studio ранее была посвящена статья. Теперь же я просто приведу список предупреждений анализатора, связанных с виртуальными и переопределенными событиями.

Предупреждение анализатора PVS-Studio: V3119 Calling overridden event 'Started' may lead to unpredictable behavior. Consider implementing event accessors explicitly or use 'sealed' keyword. GlobalOperationNotificationServiceFactory.cs 33

Предупреждение анализатора PVS-Studio: V3119 Calling overridden event 'Stopped' may lead to unpredictable behavior. Consider implementing event accessors explicitly or use 'sealed' keyword. GlobalOperationNotificationServiceFactory.cs 34

private class NoOpService :
  AbstractGlobalOperationNotificationService
{
  ....
  public override event EventHandler Started;
  public override event 
    EventHandler<GlobalOperationEventArgs> Stopped;
  ....
  public NoOpService()
  {
    ....
    var started = Started;  //<=
    var stopped = Stopped;  //<=
  }
  ....
}

В данном случае мы, скорее всего, имеем дело с ситуацией вынужденного переопределения виртуальных событий. Базовый класс AbstractGlobalOperationNotificationService абстрактный и содержит определение абстрактных событий Started и Stopped:

internal abstract class 
  AbstractGlobalOperationNotificationService :
  IGlobalOperationNotificationService
{
  public abstract event EventHandler Started;
  public abstract event 
    EventHandler<GlobalOperationEventArgs> Stopped;
  ....
}

Дальнейшее использование переопределенных событий Started и Stopped не совсем понятно, так как делегаты просто присваиваются локальным переменным started и stopped в методе NoOpService и никак не используются. Тем не менее, данная ситуация потенциально небезопасна, о чем и предупреждает анализатор.

SharpDevelop

Проверка этого проекта также ранее была описана в статье. Приведу список полученных предупреждений V3119 анализатора.

Предупреждение анализатора PVS-Studio: V3119 Calling overridden event 'ParseInformationUpdated' may lead to unpredictable behavior. Consider implementing event accessors explicitly or use 'sealed' keyword. CompilableProject.cs 397

....
public override event EventHandler<ParseInformationEventArgs> 
  ParseInformationUpdated = delegate {};
....
public override void OnParseInformationUpdated (....)
{
  ....
  SD.MainThread.InvokeAsyncAndForget
    (delegate { ParseInformationUpdated(null, args); });  //<=
}
....

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

Предупреждение анализатора PVS-Studio: V3119 Calling overridden event 'ShouldApplyExtensionsInvalidated' may lead to unpredictable behavior. Consider implementing event accessors explicitly or use 'sealed' keyword. DefaultExtension.cs 127

....
public override event 
  EventHandler<DesignItemCollectionEventArgs>
  ShouldApplyExtensionsInvalidated;
....
protected void ReapplyExtensions
  (ICollection<DesignItem> items)
{
  if (ShouldApplyExtensionsInvalidated != null) 
  {
    ShouldApplyExtensionsInvalidated(this,  //<=
      new DesignItemCollectionEventArgs(items));
  }
}
....

Снова обнаружено использование переопределенного виртуального события.

Space Engineers

И этот проект ранее проверялся при помощи PVS-Studio. Результат проверки приведен в статье. Новая диагностика V3119 выдала 2 предупреждения.

Предупреждение анализатора PVS-Studio: V3119 Calling virtual event 'OnAfterComponentAdd' may lead to unpredictable behavior. Consider implementing event accessors explicitly. MyInventoryAggregate.cs 209

Предупреждение анализатора PVS-Studio: V3119 Calling virtual event 'OnBeforeComponentRemove' may lead to unpredictable behavior. Consider implementing event accessors explicitly. MyInventoryAggregate.cs 218

....
public virtual event 
  Action<MyInventoryAggregate, MyInventoryBase>
  OnAfterComponentAdd;
public virtual event 
  Action<MyInventoryAggregate, MyInventoryBase>
  OnBeforeComponentRemove;
....
public void AfterComponentAdd(....)
{
  ....
  if (OnAfterComponentAdd != null)
  {
    OnAfterComponentAdd(....);  // <=
  }                
}
....
public void BeforeComponentRemove(....)
{
  ....
  if (OnBeforeComponentRemove != null)
  {
    OnBeforeComponentRemove(....);
  }
}
....

Здесь мы имеем дело с объявлением и использованием не переопределенных, а виртуальных событий. В целом, ситуация не отличается от ранее рассмотренных.

RavenDB

Проект RavenDB представляет собой так называемую «NoSQL» (или документно-ориентированную) базу данных. Его подробное описание доступно на официальном сайте. Проект разработан с использованием .NET и его исходные коды доступны на GitHub. Проверка RavenDB анализатором PVS-Studio выявила три предупреждения V3119.

Предупреждение анализатора PVS-Studio: V3119 Calling overridden event 'AfterDispose' may lead to unpredictable behavior. Consider implementing event accessors explicitly or use 'sealed' keyword. DocumentStore.cs 273

Предупреждение анализатора PVS-Studio: V3119 Calling overridden event 'AfterDispose' may lead to unpredictable behavior. Consider implementing event accessors explicitly or use 'sealed' keyword. ShardedDocumentStore.cs 104

Оба эти предупреждения выданы для схожих фрагментов кода. Рассмотрим один из таких фрагментов:

public class DocumentStore : DocumentStoreBase
{
  ....
  public override event EventHandler AfterDispose;
  ....
  public override void Dispose()
  {
    ....
    var afterDispose = AfterDispose;  //<=
    if (afterDispose != null)
      afterDispose(this, EventArgs.Empty);
  }
  ....
}

Переопределяемое в классе DocumentStore событие AfterDispose объявлено как абстрактное в базовом абстрактном классе DocumentStoreBase:

public abstract class DocumentStoreBase : IDocumentStore
{
  ....
  public abstract event EventHandler AfterDispose;
  ....
}

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

Предупреждение анализатора PVS-Studio: V3119 Calling virtual event 'Error' may lead to unpredictable behavior. Consider implementing event accessors explicitly. JsonSerializer.cs 1007

....
public virtual event EventHandler<ErrorEventArgs> Error;
....
internal void OnError(....)
{
  EventHandler<ErrorEventArgs> error = Error; //<=
  if (error != null)
    error(....);
}
....

Здесь происходит объявление и использование виртуального события. И вновь существует риск неопределенного поведения.

Заключение

Думаю, на этом можно закончить наше исследование и сделать вывод о том, что действительно не стоит использовать неявно реализованные виртуальные события. Из-за особенностей их реализации в C#, использование таких событий может приводить к неопределенному поведению. В случае, если вы все же вынуждены использовать переопределенные виртуальные события (например, при наследовании от абстрактного класса), это следует делать с осторожностью, используя явно заданные аксессоры add и remove. Вы также можете использовать ключевое слово sealed при объявлении класса или события. И, конечно, следует использовать инструменты статического анализа кода, например, PVS-Studio.

Автор: PVS-Studio

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js