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

  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

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


  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. stepsoft
        02.07.2025 06:27

        Когда встречается сочетание слов 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. AngryKid Автор
          02.07.2025 06:27

          Я понял вас. Да у меня не готовая EDA и не предлагает такую схему работы. Я понимаю event как "уведомление", тоесть задача "как уведомить кого-то о чем-то".
          Будет интересно почитать ваши замечания по реализации.


  1. Ivaneo
    02.07.2025 06:27

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

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

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

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

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


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

      Спасибо за подробный коммент. По пунктам:

      1. Я решил отказаться от этого для скорости - хеш посчитали 1 раз без виртуальных IsEqual и каста.

      2. У меня вроде нет std::function

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

      4. Действительно прям большие? По моим оценкам были мизерные, но может я и ошибаюсь. Хранение сырых указателей даст заметный прирост производительности?

      5. Такова и была архитектурная идея моя. Ты отписываешься мгновенно, а подписываешься отложено. Это осознанное решение и я на этом сделал акцент. Копия вектора это оверхед, как мне кажется, недопустимый.
        Если RemoveHandler тягается в уничтожении объекта, а реально из Event он удален не будет и будет вызван в этой итерации будет UB согласны? Нельзя так просто взять и сделать копию.

      6. EventDispatcher подразумевает EventListener)


      1. Ivaneo
        02.07.2025 06:27

        Пункты с 1 по 4 по сути связаны. У вас там наследование и виртуальные методы, разные размеры объектов и т.д. Если все это упаковать в один правильный класс, там отпадет необходимость считать хеши, использовать наследование и виртуальные вызовы, как следствие отпадет необходимость вообще хранить объект на куче и заворачивать в умные указатели (у меня такой вместился в 32b).

        Копия вектора только звучит как большой оверхед, в реальности в среднем у событий не так много подписчиков, можно использовать inplace вектор для копии на стеке. У нас в среднем выходило не более 10 подписчиков на ивент. Скопировать ~10 елементов по 32b на стек не стоит ничего. Если так вышло что подписчиков тысячи, то скорее всего это одни и те же типы объектов и стоит пересмотреть использование ивентов для них и лучше вызывать для них методы напрямую.

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

        EventDispatcher подразумевает EventListener - естественно.


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

          Давайте рассмотрим ситуацию с копией на примере, для лучшего понимания:

          • Есть 3 объекта A,B,C.

          • Объект А содержит Event<>.

          • Объект В содержит в себе объект C.

          • При создании объекта B создает объект С.

          • Затем объект B подписывает себя на объект Event объекта А.

          • Потом объект B подписывает объект С на событие А.

          • Объект В получает событие, отписывает объект С и удаляет объект С

          Если будет копия, то отписка объекта С на копию никак не повлияет и после выхода из обработки в В будет вызван обработчик С, который уже умер, хотя выполнил свой контракт и отписался.


          1. Ivaneo
            02.07.2025 06:27

            Я понимаю вашу идею и прекрасно понимаю недостатки с копированием. Ну а пример, который вы привели довольно странный, если В содержит в себе C и уже подписан на Event, зачем подписывать еще и C? В и так может вызвать для C нужный метод когда сработает обработчик (ведь он публичный, если B смог его подписать). Тут скорее проблема использования, когда один объект подписывает другой, это очень плохой подход в целом, потому что нарушает несколько фундаментальных принципов.

            И снова повторюсь, вовремя отписаться до разрушения это ответственность подписчика и задача пользователя в этом убедиться. Я могу привести много примеров где оба подхода ничего не смогут поделать если кто-то извне уничтожает подписчика до того, как он отписался. Вы все равно никак не решите эту проблему на стороне ивента, пока сам пользователь контролирует подписку\отписку и уничтожение.


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

              Согласен, пример синтетический. Я им хотел показать, что при выполнении контракта подписчиком все равно произойдет падение.
              Ответственность подписчика это, действительно, основа моей идеи.

              Расскажете как у вас с копией реализуется кейс, когда объект отписывается от события при обработке события в другом объекте(пускай не напрямую как в моем синт. примере, а через какие-то вложенные обработки, или идет последовательный диспатчинг и тд)? Такое же вполне возможно и не может быть запрещено. Или, так на вскидку, слишком уж сложно контролируемо.


              1. Ivaneo
                02.07.2025 06:27

                Ну в теории всякое возможно, на практике если избегать подписки или отписки чужих объектов, то таких ситуаций практически не возникает, потому что в обработчиках событий редко бывает непосредственно код который каким-то образом влияет на время жизни объектов того же уровня абстракций. В основном при получении события меняется только состояние объекта (принцип единственной ответственности). Управляющий код не должен быть реализован через ивенты, хоть и возможно накрутить менеджерам кучу ивентов типа OnInit, OnUpdate, OnDestroy и т.д на практике это выливается в проблемы когда важен порядок этих операций и он легко может быть нарушен одной подпиской\отпиской.


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

                  Раз вы уверены в контроле над этим значит у вас очень четкая архитектура, документация и ревью. Круто конечно, но я на такое рассчитывать не могу)