Віртуальні події в C#: щось пішло не так

Нещодавно я працював над новою 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"); }
new public 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 class derived. The C# компілятор does not handle these correctly and it is unpredictable whether a subscribe 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 class derived".

Експерименти
В якості першого експерименту створимо консольний додаток, що містить оголошення та використання в базовому класі двох віртуальних подій (з неявною і явною реалізацією аксессоров 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.


Якщо хочете поділитися цією статтею з англомовної аудиторією, то прошу використовувати посилання на переклад: Sergey Khrenov. Virtual events in C#: something went wrong.

Прочитали статтю і є питання?Часто до наших статей задають одні і ті ж питання. Відповіді на них ми зібрали тут: Відповіді на питання читачів статей про PVS-Studio, версія 2015. Будь ласка, ознайомтеся зі списком.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.