Те системы событий, с которыми я сталкивался, страдали от таких проблем:

  1. Перегруженность интерфейса — макросы, громоздкие шаблоны, неочевидный синтаксис, множественная параметризация;

  2. Broadcast — каждое событие отправляется всем слушателям, а они сами решают, нужно ли им реагировать. Это просто, но дорого;

  3. Signal/Slot архитектура, как в Qt — требует кодогенерации и тяжело отделяется от инфраструктуры.

Я захотел реализовать собственную систему событий, которая была бы:

  • простой в использовании;

  • понятной в коде;

  • симметричной — добавление и удаление обработчиков по одинаковому интерфейсу;

  • легкой — минимум кода;

  • самодостаточной — без макросов, фреймворков, кодогенерации или внешних зависимостей;

Пример использования

class SomeClass
{
public:
	Event<> someEvent;
	Event<int, SomeClass*> otherEvent;
private:
	void dispatchSomeEvent()
	{
		someEvent();
	}

    void dispatchOtherMethod()
    {
        otherEvent(5, this);
    }
};

class SomeOtherClass
{
public:
    ~SomeOtherClass()
    {
		m_someClass->someEvent.RemoveHandler(*this, &SomeOtherClass::onSomeEvent);
		m_someClass->otherEvent.RemoveHandler(*this, &SomeOtherClass::onSomeOtherEvent);
    }
	void onSomeClassCreated(SomeClass* someClass)
	{
		m_someClass = someClass;
		m_someClass->someEvent.AddHandler(*this, &SomeOtherClass::onSomeEvent);
		m_someClass->otherEvent.AddHandler(*this, &SomeOtherClass::onSomeOtherEvent);
	}
	void onSomeEvent()
	{
		//do something and unsubscribe
		m_someClass->someEvent.RemoveHandler(*this, &SomeOtherClass::onSomeEvent);
	}

	void onSomeOtherEvent(int val, SomeClass* obj)
	{
	}
private:
	SomeClass* m_someClass;
};

Реализация

details.inl

  1. Файл details.inl

namespace detail
{
	inline std::size_t hash_combine(std::size_t seed, std::size_t value) noexcept
	{
		return seed ^ (value + 0x9e3779b97f4a7c15ULL + (seed << 6) + (seed >> 2));
	}

	template<typename... Args>
	inline std::size_t GenerateID(void (*func)(Args...))
	{
		return std::hash<void*>()(reinterpret_cast<void*>(func));
	}

	template<typename Obj, typename Meth>
	inline std::size_t GenerateID(Obj* obj, Meth method)
	{
		constexpr std::size_t N = sizeof(Meth);
		constexpr std::size_t W = sizeof(std::size_t);
		constexpr std::size_t CHUNKS = (N + W - 1) / W;

		std::size_t pieces[CHUNKS]{};
		std::memcpy(pieces, &method, N);

		std::size_t h = std::hash<std::type_index>{}(typeid(Obj));
		for (std::size_t v : pieces)
			h = hash_combine(h, std::hash<std::size_t>{}(v));

		h = hash_combine(h, std::hash<void*>{}(static_cast<void*>(obj)));
		return h;
	}
}

Просто генерация хеша для разных типов хендлеров. Внимания стоит только получение хеша функции-метода. Из-за возможного выхода размера указателя метода за 8 байт пришлось сделать функцию чуть более сложной.

EventHandler

Посмотрим как реализуется базовый EventHandler:

template<typename ...Args>
class EventHandler
{
public:
	virtual void call(Args&&... args) = 0;

	size_t GetHandlerID() const { return m_handlerID; }

	void Invalidate() { m_valid = false; }
	bool IsValid() const { return m_valid; }

protected:
	size_t m_handlerID;
private:
	bool m_valid = true;
};

Это база для разных видов хендлеров. Чистая функция вызова.
Тут же реализована инвалидация хендлера для "горячего" удаления.

MethodEventHandler

Наследник для функций-членов:

template<typename Object, typename ...Args>
class MethodEventHandler final : public EventHandler<Args...>
{
public:
	using MethodType = void(Object::*)(Args...);
public:
	MethodEventHandler(Object& object, MethodType method) : m_object(object), m_method(method)
	{
		this->m_handlerID = detail::GenerateID(&object, method);
	}

	void call(Args&&... args) override { (m_object.*m_method)(std::forward<Args>(args)...); }

private:
	Object& m_object;
	MethodType m_method;
};
  • Хранит ссылку на объект и указатель на функцию.

  • Переопределяет call, так как синтаксис вызова специфический.

  • Генерируется айди(хеш) на базе объекта и функции-члена.

FunctionEventHandler

Наследник для функции:

template<typename ...Args>
class FunctionEventHandler : public EventHandler<Args...>
{
public:
	using FunctionType = void(*)(Args...);
public:
	FunctionEventHandler(FunctionType function) : m_function(function)
	{
		this->m_handlerID = detail::GenerateID(function);
	}

	void call(Args&&... args) override { (*m_function)(std::forward<Args>(args)...); }

private:
	FunctionType m_function;
};

Здесь — тот же подход, только для функций.

Event

Реализация самого Event:

template<typename ...Args>
class Event
{
	using HandlerType = EventHandler<Args...>;

public:
	//Защитимся от копирования и переноса - это недопустимая операция(во всяком случае пока)
	Event() = default;
	Event(const Event&) = delete;
	Event& operator=(const Event&) = delete;
	Event(Event&&) = delete;
	Event& operator=(Event&&) = delete;

	template<typename Object>
	void AddHandler(Object& object, MethodEventHandler<Object, Args...>::MethodType method)
	{
		if (HasId(detail::GenerateID(&object, method)))
			return;

		(m_dispatching ? m_added_handlers : m_handlers).emplace_back(std::make_unique<MethodEventHandler<Object, Args...>>(object, method));
	}

	void AddHandler(FunctionEventHandler<Args...>::FunctionType function)
	{
		if (HasId(detail::GenerateID(function)))
			return;

		(m_dispatching ? m_added_handlers : m_handlers).emplace_back(std::make_unique<FunctionEventHandler<Args...>>(function));
	}

	template<class F>
	void AddHandler(F) = delete; // Лямбды и функторы не поддерживаются — нельзя безопасно отписаться

	template<typename Object>
	void RemoveHandler(Object& object, MethodEventHandler<Object, Args...>::MethodType method)
	{
		RemoveById(detail::GenerateID(&object, method));
	}

	void RemoveHandler(FunctionEventHandler<Args...>::FunctionType function)
	{
		RemoveById(detail::GenerateID(function));
	}
	
	template<class F>
	void RemoveHandler(F) = delete; //Симметрия

	void operator()(Args... args)
	{
		m_dispatching = true;
		for (auto& handler : m_handlers)
			if (handler->IsValid())
				handler->call(std::forward<Args>(args)...);
		m_dispatching = false;

		// Удаляем невалидные обработчики после завершения вызовов
        if (m_wasHotRemoved)
        {
    		m_handlers.erase(std::remove_if(m_handlers.begin(), m_handlers.end(), [](const auto& handler) { return !handler->IsValid(); }), m_handlers.end());
            m_wasHotRemoved = false;
        }
		for (auto& handler : m_added_handlers)
			m_handlers.push_back(std::move(handler));
		m_added_handlers.clear();
	}

private:
	std::vector<std::unique_ptr<HandlerType>> m_handlers;
	std::vector<std::unique_ptr<HandlerType>> m_added_handlers;

	bool m_dispatching = false;
    bool m_wasHotRemoved = false;

	bool HasId(std::size_t id) const
	{
		auto pred = [&](const auto& handler) { return handler->GetHandlerID() == id; };
		return std::any_of(m_handlers.begin(), m_handlers.end(), pred) || std::any_of(m_added_handlers.begin(), m_added_handlers.end(), pred);
	}

	void RemoveById(std::size_t id)
	{
		auto pred = [&](const auto& handler) { return handler->GetHandlerID() == id; };

		if (m_dispatching)
		{
			if (auto it = std::find_if(m_handlers.begin(), m_handlers.end(), pred); it != m_handlers.end())
			{	
                (*it)->Invalidate();
                m_wasHotRemoved = true;
            }
		}
		else
		{
			m_handlers.erase(std::remove_if(m_handlers.begin(), m_handlers.end(), pred), m_handlers.end());
		}

		m_added_handlers.erase(std::remove_if(m_added_handlers.begin(), m_added_handlers.end(), pred), m_added_handlers.end());
	}
};
  • Предлагается 2 функции добавления и 2 функции удаления хендлеров в зависимости от типа колбека.

  • За счет возможности инвалидировать хендлер можно удалять во время диспатчинга события.

  • Работа с ID(хешом) - скрытая реализация, по этому все убрано в private зону и унифицировано для разных типов хендлеров.

  • Специальный флажок m_dispatching, чтобы определить "горячая фаза" или нет. Если фаза не горячая, то и добавлять/удалять можно напрямую.

Некоторые тонкости горячей фазы

Добавление/Удаление хендлеров во время диспатчинга:

  • Добавленый хендлер - не будет вызван в текущем диспатчинге.

  • Удаленный хендлер - будет помечен как невалидный и не будет вызван в текущем диспатчинге.

Удаление мгновенное, а добавление отложенное.

Проблемы и ограничения

  1. Можно забыть отписаться. Если объект будет уничтожен и не отпишется от события, возникнет UB из-за висячего указателя.

  2. Лямбды запрещены, поскольку их невозможно отписать.

  3. Не thread-safe.

Для чего эта система не подойдет

  1. «Я хочу слушать событие, но я не знаю кто его отправляет».

  2. «Я не знаю(или не хочу знать) время жизни объекта с событием».

Для чего эта система подойдет

  1. Вы имеете доступ к источнику события.

  2. Вы точно знаете время жизни источника события(или можете убедиться, что источник жив)

Возможные улучшения

  1. ScopedEventHandler — RAII‑механизм, отписка по разрушению. Решит проблему забывчивости, а так же даст возможность подписывать лямбды и функторы.

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

Заключение

Решение уровня «добавил и поехало». Требует внимательности, поскольку, забыв отписаться, можно получить UB. Подходит для однопоточной архитектуры, где явно известны источники событий и их жизненный цикл.

Если будет интересно в следующем посте напишу:

  1. ScopedEventHandler

  2. Реализацию без virtual

Комментарии и конструктивная критика приветствуется. Будет интересно мнение экспертов.

GitHub

Комментарии (24)


  1. eao197
    02.07.2025 06:27

    Те системы событий, с которыми я сталкивался

    Огласите весь список, пожалуйста.


    1. AngryKid Автор
      02.07.2025 06:27

      Я и огласил вобщем-то) Все с чем сталкивался подпадает под 3 группы


      1. eao197
        02.07.2025 06:27

        Я и огласил вобщем-то

        Вы привели свою классификацию но не названия конкретных инструментов. Можно предположить, что среди них был Qt, но по остальным остается только гадать.


        1. AngryKid Автор
          02.07.2025 06:27

          Это те, с которыми я сталкивался в разных проектах. Если у них были названия, то я их не знаю)


          1. eao197
            02.07.2025 06:27

            Могу я предположить, что из известных был Qt, а остальное было что-то доморощенное, сделанное под конкретный проект (а не что-то из Boost-а или еще чего-то OpenSource-ного)?


            1. AngryKid Автор
              02.07.2025 06:27

              Предположить можете, но подтвердить это я не смогу.


              1. eao197
                02.07.2025 06:27

                но подтвердить это я не смогу.

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

                Из описанного вами более-менее понятно чем не устраивает подход из Qt. Но чем, например, не подошли Boost.Signals2?


                1. AngryKid Автор
                  02.07.2025 06:27

                  Что-то подобное мне знакомо. Я бы отнес бустовые сигналы к пункту 1. Это универсальный механизм на все случаи жизни да еще и с синхронизацией межпоточной за, что придется заплатить.
                  По важности, что не устраивает:
                  1. Необходимость хранить connection для отсоединения - это то, что я не хотел делать обязательным.
                  2. Синтаксис - сигнатура функции с возможностью возвращать значение и требованием std::bind для функций-членов - универсально, но в EventSystem не нужно - синтаксис чище.

                  Это так на вскидку.

                  Signals универсальный под любые задачи, мой под ограниченную однопоточную архитектуру, простой и понятный.


                  1. eao197
                    02.07.2025 06:27

                    Спасибо, понятно.


                    1. AngryKid Автор
                      02.07.2025 06:27

                      Всегда рад пообщаться)


                    1. AngryKid Автор
                      02.07.2025 06:27

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

                      Я не могу сказать не потому, что это секретная инфа, а потому что если у них названия и есть я их просто не знаю.


  1. x2v0
    02.07.2025 06:27

    Не пробовали смотреть в сторону рефлексии, которая скоро станет частью стандарта C++ 26.
    Получите аналог Qt signal-slot, но не связанный с инфраструктурой.
    В ROOT https://habr.com/ru/companies/nic_ct/articles/921676/ рефлексия была изначально, поэтому когда-то давно я написал аналог Qt signal-slot.
    Предполагаю, что как только рефлексия станет частью стандарта C++ 26, сразу же появятся обработчики событий на ее основе.


    1. AngryKid Автор
      02.07.2025 06:27

      Идея была сделать легкое, под конкретные задачи и без кодогенерации.


  1. Notevil
    02.07.2025 06:27

    Если будет интересно в следующем посте напишу:

    1. ScopedEventHandler

    2. Реализацию без virtual

    Так это еще не production ready реализация, а только прототип?

    В своем проекте тоже столкнулся с надобностью ивентной системы и она у меня уже прошла несколько итераций. В общем идеи все те же что и в этой статье. Тоже может статью написать?
    Для меня неожиданными моментами стали
    - время жизни подписчика событие.
    подписчик не должен переживать события и для этого нужна отписка в деструкторе. У меня для этого RAII объект Subscription.
    - время жизни события.
    при отписке от события нужно учитывать, что само событие может быть уже и не живо.
    - во время диспатчинга события, уничтожается само событие.
    У меня есть логика, которая по событию уничтожает и пересоздает объект владелец события. То есть диспатчинг события должен переживать само событие.
    - рекурсия - во время обработки события, заэмиттили то же самое событие.
    - ну про многопоточку понятно - если подписываться или отписываться во время диспатчинга события, может быть не очень хорошо.

    Ну и по поводу реализации без virtual. Я по разному экспериментировал и пришел к тому, что у меня обработчик хранится в std::function. Можно искать и другие реализации, возможно более эффективные но скорее всего со своими ограничениями.


    1. AngryKid Автор
      02.07.2025 06:27

      Я использую эту версию(ну может немножко модифицированную уже) в production. RAII мне не нужно, так как принцип построения системы подразумевает контроль за лайфтаймом. Рекурсию только если спецом написать - ошибка проектирования.
      Без virtual это (спойлер) шаблоны и void*. Интерфейс остается прежним.


  1. Emelian
    02.07.2025 06:27

    C++ Event System от идеи до реализации

    Вот у меня есть (неопубликованная) программа «МедиаТекст» (см. скриншот: http://scholium.webservis.ru/Pics/MediaText.png ) на C++ / WTL (опенсорсный файл FFPlay.c, для поддержки медиа-файлов, переделан в классы С++). Она использует:

    1. Менеджер потоков

    2. Менеджер видов (дочерних окон)

    3. Менеджер событий

    Думал, найти что-то полезное в ваших идеях, но не нашел. Это баг или фича?


    1. AngryKid Автор
      02.07.2025 06:27

      Тут я ваще не понял контекста вопроса


      1. Emelian
        02.07.2025 06:27

        Тут я ваще не понял контекста вопроса

        Ну, примерно, как я «ваще» не понял контекста статьи. Вроде слова знакомые, у вас: «С++», «Система событий». У меня: «С++», «Менеджер событий», а понимаем мы совершенно непересекающиеся веши.

        Главные вопросы: Для кого ваша статья написана? Какие задачи решает? Что можно «пощупать»? Не код, которого кругом много и в который вникаешь только тогда, когда видишь в нем смысл, даже если его пару килобайт всего. А смысла вникать в ваш код я не увидел. Как говорится: «Лучше один раз потрогать, чем сто раз увидеть».

        Со своей стороны я показал картинку своей программы, чтобы получить представление о роли моего «менеджера событий», в смысле, для какой задачи он используется.

        Если я правильно понял, ваш код ориентирован на чистый С++, ориентированный только на консольный режим, поэтому никакой графики не продемонстрировано. Кому это надо – непонятно? И что это может добавить в WTL, я тоже не понял, тем более что, эта легкая библиотека классов решает все мыслимые задачи, которые могут стоять, допустим, лично передо мной.

        Вообще, я бы порекомендовал показывать меньше кода в статье, в крайнем случае, забрасывать его под кат. И побольше давать картинок. Ну, и писать стандартные разделы: результаты, для кого предназначена, выводы и краткое введение в тему ибо то что вам очевидно, другим надо тратить несколько минут на вникание, а для этого не всегда виден смысл…


        1. AngryKid Автор
          02.07.2025 06:27

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


  1. Apoheliy
    02.07.2025 06:27

    Если про комментарии и критику (которая приветствуется):

    • в файле 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? Может всё (и допклассы в том числе) упаковать в своё пространство имён?


    1. AngryKid Автор
      02.07.2025 06:27

      1. Это можно да, но мелочь, всегда 2 файла каждый может сделать с ними что хочет)

      2. Под С++17

      3. Зачем? что-то не компилируется где-то?

      4. Шанс 1 делить на 2 в 62 степени, вроде как-то так

      5. Где чтение исполняемой памяти? я беру sizeof(MemberPtr) и по нему хеш составляю на случай если он не 8 байт, а 16.

      6. Для игр - без исключений.

      7. Я думал убрать в неймспейс какой-то. Но какой? Проще если тот, кто будет юзать сам обернет в неймспейс который ему нравится.


  1. stepsoft
    02.07.2025 06:27

    Описанное в статье не имеет ничего относящегося к Event System, а является некоторой скажем прямо не очень удачной реализацией механизма signal slot взаииодействия. О чем уже намекали в нескольких комментариях. Как реализация для изучения принципов взаимодействия подойдет, как инструмент для серьезной разработки - нет.


    1. AngryKid Автор
      02.07.2025 06:27

      Возможно вы понимаете что-то другое под понятием Event System?)
      Я как раз не пытался реализовать signal slot как в qt или boost.

      1. Все же интересно что, по вашему, тут неудачно?
      2. Почему не подходит для "серьезной"(еще бы пояснить что вы под этим подразумеваете) разработки?

      Возможно вы видите какие-то явные проблемы архитектуры, функционала, логические проблемы или проблемы производительности?


  1. Ivaneo
    02.07.2025 06:27

    Когда-то писал нечто подобное которое потом переросло в серьезную систему событий на проде, со всеми плюшками. Но начинал с подобного класса.
    Вот несколько советов, что можно улучшить:
    - Хеши считать вовсе не обязательно, если нужно сравнить два хендлера достаточно сравнить объект и указатель на метод или только указатель на функцию если это не метод класса.
    - std::function слишком тяжелая и может аллоцировать. В данном случае подойдет более специализированная имплементация функтора. Раз нам известны ограничения, это открывает некоторые оптимизации по вызову и памяти.

    - Можно избежать виртуальных вызовов в хендлере.

    - std::unique_ptr для маленького класса хендлера это большие расходы по перформансу, не только при вставке и удалении такового из контейнера, +1 переход по указателю при итерации и отсутствие дружбы с кешем.

    - Инвалидация хендлеров прямо во время dispatch может привести к не детерминированному поведению. Например представьте что у вас два хендлера, и один отписывает другого. Если первый выполнится раньше, то вызов второго не произойдет. Но если порядок вызова изменится (потому что они подписались в другом порядке) то оба будут вызваны. То есть поведение зависит от очередности подписки. А еще надо два bool и доп вектор что бы все это поддерживать. Гораздо проще в disptach делать копию вектора хендлеров и уже для нее вызывать всех по очереди, так вы можете даже во время диспатча делать что угодно с подписками, не боясь сломать текущий диспатч лист.

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