Вы когда-нибудь задавались вопросом о том, могут ли события быть виртуальными? Вполне возможно вас самих эта мысль никогда не посещала; возможно, вам в этом помог какой-нибудь дотошный парень на собеседование, в общем, не очень-то важно, думали вы об этом или нет, давайте подумаем над этим вопросом совместно прямо сейчас.
Итак, события в языке 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), которое существует между базовым классом и его наследником. Как раз в случае с интерфейсом обязанности сторон четкие и понятные: интерфейс просто объявляет контракт, а конкретный класс его реализует.
Так вроде выглядит неплохо:
ОтветитьУдалить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();
}
}
@Vasiliy: да, такой вариант, конечно, работает. Смысл в том, что нельзя, чтобы в обоих случаях (в базовом и производном классах) было именно field-like событие, что не совсем очевидно с первого взгляда.
ОтветитьУдалитьА хоть один юз-кейс этого безобразия придумать-то удалось? В какой задаче витртуальность события былда бы незаменимой или просто очень удобной фичей?
ОтветитьУдалить@_FRED_: неа, не удалось. Кроме того, даже если и появится подобный юз-кейс (например, добавить процесс логирования в классы наследники о подписке на события в базовом классе), то всегда можно сделать это явно - добавить виртуальную функцию, с нормальным именем, которая бы точнее говорила о контракте между базовым классом и наследником.
ОтветитьУдалитьИ я кстати, не уверен, что сами разработчики этой фичи знали хоть один нормальный кейс применения. Как по мне, так возможность сделать событие виртуальным - это вопрос согласованности, а не возможности как таковой.
Вот в ObservableCollection<> чудаки такие события заиспользовали http://msdn.microsoft.com/en-us/library/ms668610.aspx Долго мидитировал, но не понял - зачем.
ОтветитьУдалитьПо сути единственный бенефит - возможность переопределить в наследнике логику подписки\отписки. Но вот где и как это можно было бы использовать - ума не приложу. А вот разложенные таким образом грабли встречать приходилось.
@_FRED_: ах ты ж елки палки!!! Там правда виртуальное событие!!!! Да, и оно перестает файрится при переопределении его как field-like событие (что неудивительно).
ОтветитьУдалитьКстати, другим кейсом применени виртуального события - это возможность отключения возбуждения этого события базового класса. Правда, опять же, мне такие кейсы в реальности не попадались, да и в этом случае нужно несколько страниц комментариев в коде, объясняющих что именно ты сделал, как это работает, и что это ты сделал намеренно.
А как на счет такого юз-кейса - поддержка потокобезопасности? Кажется, что вполне применимо.
ОтветитьУдалить@ak: что имелось ввиду по поводу юз-кейса и потокобезопасности? Не понял, если честно.
ОтветитьУдалитьПотокобезопасность там есть, точнее, она была в field-like евентах до 4-го фреймворка в одном виде, а потом в другом. Т.е. подписка и отписка потокобезопасны...
В общем, не понял я.
Мне приходилось использовать виртуальные события в очень, скажем так, специфическом сценарии. Нужно было вызывать подписанные события параллельно (а не последовательно, как это делается в multicast-делегатах - к тому же, в случае экзепшена в одном из них, дальнейшая обработка событий идёт лесом ЕМНИП). В результате там было поле List> (соответственно, ) и в методе Invoke... стартовали потоки на каждое из событий листа (+ некий код, обеспечивающий потокобезопасность самой подписки и вызова событий с минимальным временем локов, но это неважно).
ОтветитьУдалитьВ общем, хочу сказать, что само определение "события" в .NET многими программистами понимается несколько неверно. Событие - это такой же "чёрный ящик", как и property (и не нужно делать никаких предположений о том, как оно вызывается и как вообще там хранятся делегаты), и стандартное field like поведение - это примерно то же самое, что и auto property, т.е. базовая реализация для очевидных сценариев.
А вообще, зло - это не виртуальные события как таковые. Зло - это виртуальные события + field like реализация в базовом классе. Т.е. если делаешь событие виртуальным, то обеспечь своё собственное хранилище для делегатов. Или делай событие абстрактным (и так же, абстрактный метод Invoke, что и было в моём случае с параллельным вызовом событий).
Примерно так.
Да, кстати, концептуальная ошибка в приведённом примере кроется не в том, что field like event нельзя использовать для виртуального события, а в том, что InvokeSomeEvent() "завязано" на конкретную реализацию в базовом классе - это примерно то же самое, как из метода использовать приватное поле, которое наружу торчит в виде виртуального проперти. И потом никто не мешает потомку переопределить проперти (без вызова базовых сеттеров и геттеров), в результате чудесным образом логика в методах предка будет сломана нахрен.
ОтветитьУдалитьСпасибо, отличные примеры!
ОтветитьУдалитьДа, конечно, проблемы с виртуальными событиями будут только с виртуальным field-like событием в базовом классе.
Но даже, если бы мне понадобилось виртуальное не field-like событие, я бы предпочел добавить более информативные виртуальные методы, нежели методы add и remove.
Я правильно понял насчёт примера со свойствами?
ОтветитьУдалить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(); // тут уже будет пусто...логика поломалась
}
Да. Правильно.
Удалить