Comments 39
Те системы событий, с которыми я сталкивался
Огласите весь список, пожалуйста.
Я и огласил вобщем-то) Все с чем сталкивался подпадает под 3 группы
Я и огласил вобщем-то
Вы привели свою классификацию но не названия конкретных инструментов. Можно предположить, что среди них был Qt, но по остальным остается только гадать.
Это те, с которыми я сталкивался в разных проектах. Если у них были названия, то я их не знаю)
Могу я предположить, что из известных был Qt, а остальное было что-то доморощенное, сделанное под конкретный проект (а не что-то из Boost-а или еще чего-то OpenSource-ного)?
Предположить можете, но подтвердить это я не смогу.
но подтвердить это я не смогу.
Для человека, который решил вынести свои идеи и свое творение на публику вы что-то слишком уж загадочны. Если это были внутренние закрытые разработки, то можно же так и сказать -- внутренние, закрытые. В общем, странное поведение.
Из описанного вами более-менее понятно чем не устраивает подход из Qt. Но чем, например, не подошли Boost.Signals2?
Что-то подобное мне знакомо. Я бы отнес бустовые сигналы к пункту 1. Это универсальный механизм на все случаи жизни да еще и с синхронизацией межпоточной за, что придется заплатить.
По важности, что не устраивает:
1. Необходимость хранить connection для отсоединения - это то, что я не хотел делать обязательным.
2. Синтаксис - сигнатура функции с возможностью возвращать значение и требованием std::bind для функций-членов - универсально, но в EventSystem не нужно - синтаксис чище.
Это так на вскидку.
Signals универсальный под любые задачи, мой под ограниченную однопоточную архитектуру, простой и понятный.
Спасибо, понятно.
Всегда рад пообщаться)
Для человека, который решил вынести свои идеи и свое творение на публику вы что-то слишком уж загадочны. Если это были внутренние закрытые разработки, то можно же так и сказать -- внутренние, закрытые. В общем, странное поведение.
Я не могу сказать не потому, что это секретная инфа, а потому что если у них названия и есть я их просто не знаю.
Не пробовали смотреть в сторону рефлексии, которая скоро станет частью стандарта C++ 26.
Получите аналог Qt signal-slot, но не связанный с инфраструктурой.
В ROOT https://habr.com/ru/companies/nic_ct/articles/921676/ рефлексия была изначально, поэтому когда-то давно я написал аналог Qt signal-slot.
Предполагаю, что как только рефлексия станет частью стандарта C++ 26, сразу же появятся обработчики событий на ее основе.
Если будет интересно в следующем посте напишу:
ScopedEventHandler
Реализацию без virtual
Так это еще не production ready реализация, а только прототип?
В своем проекте тоже столкнулся с надобностью ивентной системы и она у меня уже прошла несколько итераций. В общем идеи все те же что и в этой статье. Тоже может статью написать?
Для меня неожиданными моментами стали
- время жизни подписчика событие.
подписчик не должен переживать события и для этого нужна отписка в деструкторе. У меня для этого RAII объект Subscription.
- время жизни события.
при отписке от события нужно учитывать, что само событие может быть уже и не живо.
- во время диспатчинга события, уничтожается само событие.
У меня есть логика, которая по событию уничтожает и пересоздает объект владелец события. То есть диспатчинг события должен переживать само событие.
- рекурсия - во время обработки события, заэмиттили то же самое событие.
- ну про многопоточку понятно - если подписываться или отписываться во время диспатчинга события, может быть не очень хорошо.
Ну и по поводу реализации без virtual. Я по разному экспериментировал и пришел к тому, что у меня обработчик хранится в std::function. Можно искать и другие реализации, возможно более эффективные но скорее всего со своими ограничениями.
Я использую эту версию(ну может немножко модифицированную уже) в production. RAII мне не нужно, так как принцип построения системы подразумевает контроль за лайфтаймом. Рекурсию только если спецом написать - ошибка проектирования.
Без virtual это (спойлер) шаблоны и void*. Интерфейс остается прежним.
C++ Event System от идеи до реализации
Вот у меня есть (неопубликованная) программа «МедиаТекст» (см. скриншот: http://scholium.webservis.ru/Pics/MediaText.png ) на C++ / WTL (опенсорсный файл FFPlay.c, для поддержки медиа-файлов, переделан в классы С++). Она использует:
Менеджер потоков
Менеджер видов (дочерних окон)
Менеджер событий
Думал, найти что-то полезное в ваших идеях, но не нашел. Это баг или фича?
Тут я ваще не понял контекста вопроса
Тут я ваще не понял контекста вопроса
Ну, примерно, как я «ваще» не понял контекста статьи. Вроде слова знакомые, у вас: «С++», «Система событий». У меня: «С++», «Менеджер событий», а понимаем мы совершенно непересекающиеся веши.
Главные вопросы: Для кого ваша статья написана? Какие задачи решает? Что можно «пощупать»? Не код, которого кругом много и в который вникаешь только тогда, когда видишь в нем смысл, даже если его пару килобайт всего. А смысла вникать в ваш код я не увидел. Как говорится: «Лучше один раз потрогать, чем сто раз увидеть».
Со своей стороны я показал картинку своей программы, чтобы получить представление о роли моего «менеджера событий», в смысле, для какой задачи он используется.
Если я правильно понял, ваш код ориентирован на чистый С++, ориентированный только на консольный режим, поэтому никакой графики не продемонстрировано. Кому это надо – непонятно? И что это может добавить в WTL, я тоже не понял, тем более что, эта легкая библиотека классов решает все мыслимые задачи, которые могут стоять, допустим, лично передо мной.
Вообще, я бы порекомендовал показывать меньше кода в статье, в крайнем случае, забрасывать его под кат. И побольше давать картинок. Ну, и писать стандартные разделы: результаты, для кого предназначена, выводы и краткое введение в тему ибо то что вам очевидно, другим надо тратить несколько минут на вникание, а для этого не всегда виден смысл…
Если про комментарии и критику (которая приветствуется):
в файле Event.h исправить details.inl -> detail.inl;
указать, что код написан под C++20 (под меньшими версиями даёт ошибки). Или (более предпочтительно) приложить рабочий CMakeList.txt для example.cpp;
добавить в Event.h #include <typeindex> (хорошо бы и #include <vector>);
продумать (или запретить) ситуацию с константностью: если в AddHandler передать ссылку на константный объект - будет ошибка; если передать константный метод - тоже будет ошибка (и если метод static - туда же);
Идея с хэшами как-то странно выглядит:
-- Во-первых, есть ненулевой шанс, что произойдёт коллизия хэшей. Вероятность маленькая, но если у вас вдруг не будет вызываться обработчик - это будет очень странно и "замучаешься искать";
-- Во-вторых, читать исполняемую память (где код) - ну это как-бы нехорошо (и в какой-то момент может привести к крэшу, если условный сегмент разрешат на выполнение и запретят на чтение (ну вдруг защиты усилят, будущее оно неизвестное));
-- В-третьих, вы читаете исполняемой памяти больше, чем занимает сам метод (округляете вверх до размера std::size_t), т.е. вы можете читнуть даже не свою память! Опять нехорошо;
Гарантии исключений? Что ловить, std::bad_alloc?
имя пространства имён detail в глобальном пространстве; класс Event? Может всё (и допклассы в том числе) упаковать в своё пространство имён?
Это можно да, но мелочь, всегда 2 файла каждый может сделать с ними что хочет)
Под С++17
Зачем? что-то не компилируется где-то?
Шанс 1 делить на 2 в 62 степени, вроде как-то так
Где чтение исполняемой памяти? я беру sizeof(MemberPtr) и по нему хеш составляю на случай если он не 8 байт, а 16.
Для игр - без исключений.
Я думал убрать в неймспейс какой-то. Но какой? Проще если тот, кто будет юзать сам обернет в неймспейс который ему нравится.
Описанное в статье не имеет ничего относящегося к Event System, а является некоторой скажем прямо не очень удачной реализацией механизма signal slot взаииодействия. О чем уже намекали в нескольких комментариях. Как реализация для изучения принципов взаимодействия подойдет, как инструмент для серьезной разработки - нет.
Возможно вы понимаете что-то другое под понятием Event System?)
Я как раз не пытался реализовать signal slot как в qt или boost.
1. Все же интересно что, по вашему, тут неудачно?
2. Почему не подходит для "серьезной"(еще бы пояснить что вы под этим подразумеваете) разработки?
Возможно вы видите какие-то явные проблемы архитектуры, функционала, логические проблемы или проблемы производительности?
Когда встречается сочетание слов Event System первое что приходит на ум Event-driven architecture (EDA) и Event-driven programming. Ключевым моментом является наличие общего понятия Event - "сообщения, которое возникает в различных точках исполняемого кода при выполнении определённых условий". То есть Event - это не сущность вызывающая колбеки по подписке, а некоторая структура данных передаваемая между поведенческими сущностями для оповещении о любом событии, произошедшем с ними.
Структура события, как правило, состоит из двух частей. Первая содержит информацию для идентификации самого типа события, вторая - данные относительно самого факта события.
Можно использовать иерархию типов событий
struct Event {
virtual ~Event() = default;
virtual Type type () const = 0;
};
struct ConcreteEvent : Event
{
// ...
};
Но тогда система событий будет ограничена только этой иерархией.
Либо в качестве события использовать std::any
, тогда можно обмениваться более широким набором данных.
Для реализации событийной модели используется паттерн Observer (Publisher/Subscriber)
Простейшая реализация:
struct ISubsriber {
using AnyEvent = std::any;
virtual ~ISubsriber() = default;
virtual onEvent (AnyEvent const & event) = 0;
};
struct Publisher {
void addSubscriber(ISubscriber*);
void removeSubscriber(ISubscriber*);
void notify(AnyEvent const & event) const
{
for (auto & subscriber : subscribers_)
subscriber->onEvent(event);
}
};
Вся прелесть EDA в том, что компоненты на столько слабо связаны, что их можно соединять в любом сочетании, так как они используют обобщенное понятие Event.
В случае, когда используются колбеки с определенным интерфейсом, как здесь, такие сущности традиционно называют сигналами, а их вызов активацией сигнала. Системы сигналов в своей сути тоже реализует паттерн Observer.
По самой реализации отвечу в другом комментарии.
Вот несколько проблем, которые находятся на поверхности:
1. Привязка к адресу метода
В разных единицах компиляции функции и методы могут иметь разные адреса по разным причинам static, inline, специализация шаблонов и др. Проверка заняла 5 минут, ваш подход не работает.
Виртуальные методы не подерживаются.
Лямбды не поддерживаются.
Привязка методов с меньшим количеством параметров не поддерживается.
Использование std::bind невозможно.
2. Проблемы с производительностью
Многое можно перевести в constexpr.
Использование для активации сигнала virtual метода.
Лишние выделения памяти с помощью std::unique_ptr
3. Ошибки в коде
Отсутствие typename
Arg &&
Разная логика при удалении функции при активном и пассивном состоянии.
Когда-то писал нечто подобное которое потом переросло в серьезную систему событий на проде, со всеми плюшками. Но начинал с подобного класса.
Вот несколько советов, что можно улучшить:
- Хеши считать вовсе не обязательно, если нужно сравнить два хендлера достаточно сравнить объект и указатель на метод или только указатель на функцию если это не метод класса.
- std::function слишком тяжелая и может аллоцировать. В данном случае подойдет более специализированная имплементация функтора. Раз нам известны ограничения, это открывает некоторые оптимизации по вызову и памяти.
- Можно избежать виртуальных вызовов в хендлере.
- std::unique_ptr для маленького класса хендлера это большие расходы по перформансу, не только при вставке и удалении такового из контейнера, +1 переход по указателю при итерации и отсутствие дружбы с кешем.
- Инвалидация хендлеров прямо во время dispatch может привести к не детерминированному поведению. Например представьте что у вас два хендлера, и один отписывает другого. Если первый выполнится раньше, то вызов второго не произойдет. Но если порядок вызова изменится (потому что они подписались в другом порядке) то оба будут вызваны. То есть поведение зависит от очередности подписки. А еще надо два bool и доп вектор что бы все это поддерживать. Гораздо проще в disptach делать копию вектора хендлеров и уже для нее вызывать всех по очереди, так вы можете даже во время диспатча делать что угодно с подписками, не боясь сломать текущий диспатч лист.
- Всякий RAII сахарок это неплохо конечно, но больше всего необходимо такой системе некий Event Dispatcher который был бы посредником между ивентом и подписчиками, которые хотели бы потокобезопасно работать с ивентом и получать от него события в своих средах выполнения где можно безопасно обработать это событие.
Спасибо за подробный коммент. По пунктам:
Я решил отказаться от этого для скорости - хеш посчитали 1 раз без виртуальных IsEqual и каста.
У меня вроде нет std::function
Согласен можно, но это будет сложный для восприятия код и я написал что такой вариант покажу в другой статье если будет интересно)
Действительно прям большие? По моим оценкам были мизерные, но может я и ошибаюсь. Хранение сырых указателей даст заметный прирост производительности?
Такова и была архитектурная идея моя. Ты отписываешься мгновенно, а подписываешься отложено. Это осознанное решение и я на этом сделал акцент. Копия вектора это оверхед, как мне кажется, недопустимый.
Если RemoveHandler тягается в уничтожении объекта, а реально из Event он удален не будет и будет вызван в этой итерации будет UB согласны? Нельзя так просто взять и сделать копию.EventDispatcher подразумевает EventListener)
Пункты с 1 по 4 по сути связаны. У вас там наследование и виртуальные методы, разные размеры объектов и т.д. Если все это упаковать в один правильный класс, там отпадет необходимость считать хеши, использовать наследование и виртуальные вызовы, как следствие отпадет необходимость вообще хранить объект на куче и заворачивать в умные указатели (у меня такой вместился в 32b).
Копия вектора только звучит как большой оверхед, в реальности в среднем у событий не так много подписчиков, можно использовать inplace вектор для копии на стеке. У нас в среднем выходило не более 10 подписчиков на ивент. Скопировать ~10 елементов по 32b на стек не стоит ничего. Если так вышло что подписчиков тысячи, то скорее всего это одни и те же типы объектов и стоит пересмотреть использование ивентов для них и лучше вызывать для них методы напрямую.
По хорошему подписчик сам должен удостоверится что он отписался до того, как был разрушен. То что вы написали это правильно, но ваша архитектура никак от этого не защищает, подписчик так же может быть разрушен кем-то до того, как отпишется или просто забыли отписаться. Всякое бывает и тут ничего не поделаешь, ивент не контролирует lifetime своих подписчиков. Я говорил о другом, что у вас поведение может быть не детерминированным и зависеть от очередности подписки что может привести к трудноуловимым багам.
EventDispatcher подразумевает EventListener
- естественно.
Давайте рассмотрим ситуацию с копией на примере, для лучшего понимания:
Есть 3 объекта A,B,C.
Объект А содержит Event<>.
Объект В содержит в себе объект C.
При создании объекта B создает объект С.
Затем объект B подписывает себя на объект Event объекта А.
Потом объект B подписывает объект С на событие А.
Объект В получает событие, отписывает объект С и удаляет объект С
Если будет копия, то отписка объекта С на копию никак не повлияет и после выхода из обработки в В будет вызван обработчик С, который уже умер, хотя выполнил свой контракт и отписался.
Я понимаю вашу идею и прекрасно понимаю недостатки с копированием. Ну а пример, который вы привели довольно странный, если В содержит в себе C и уже подписан на Event, зачем подписывать еще и C? В и так может вызвать для C нужный метод когда сработает обработчик (ведь он публичный, если B смог его подписать). Тут скорее проблема использования, когда один объект подписывает другой, это очень плохой подход в целом, потому что нарушает несколько фундаментальных принципов.
И снова повторюсь, вовремя отписаться до разрушения это ответственность подписчика и задача пользователя в этом убедиться. Я могу привести много примеров где оба подхода ничего не смогут поделать если кто-то извне уничтожает подписчика до того, как он отписался. Вы все равно никак не решите эту проблему на стороне ивента, пока сам пользователь контролирует подписку\отписку и уничтожение.
Согласен, пример синтетический. Я им хотел показать, что при выполнении контракта подписчиком все равно произойдет падение.
Ответственность подписчика это, действительно, основа моей идеи.
Расскажете как у вас с копией реализуется кейс, когда объект отписывается от события при обработке события в другом объекте(пускай не напрямую как в моем синт. примере, а через какие-то вложенные обработки, или идет последовательный диспатчинг и тд)? Такое же вполне возможно и не может быть запрещено. Или, так на вскидку, слишком уж сложно контролируемо.
Ну в теории всякое возможно, на практике если избегать подписки или отписки чужих объектов, то таких ситуаций практически не возникает, потому что в обработчиках событий редко бывает непосредственно код который каким-то образом влияет на время жизни объектов того же уровня абстракций. В основном при получении события меняется только состояние объекта (принцип единственной ответственности). Управляющий код не должен быть реализован через ивенты, хоть и возможно накрутить менеджерам кучу ивентов типа OnInit, OnUpdate, OnDestroy и т.д на практике это выливается в проблемы когда важен порядок этих операций и он легко может быть нарушен одной подпиской\отпиской.
Отличный материал! В тех реализациях, которые я видел, проблема изменения списка подписчиков во время рассылки решалась созданием копии списка перед запуском рассылки. Если подписчиков у события не тысячи, скопировать список указателей эффективнее, чем городить m_added_handlers, watHotRemoved и т.п.
Спасибо.
А как вы решали проблему hot отписки и уничтожения подписчика? Он останется в скопированном списке и вызовется.
Не представляю реальную ситуацию, где это может выстрелить.
Если подписчик отписывается сам в обработчике события, то он уже получил событие и в этой рассылке больше его не получит. Если же какой-то другой подписчик отписывает своего "соседа", это какое-то нарушение субординации. Звоночек к фиксу архитектуры.
Вспомнил ещё одну причину копировать список. Если делать механизм потокобезопасным, то при итерации по списку надо накладывать блокировку, а это приведёт к дедлоку при попытке добавить подписчика. Поэтому копирование.
C++ Event System от идеи до реализации