Ну в теории всякое возможно, на практике если избегать подписки или отписки чужих объектов, то таких ситуаций практически не возникает, потому что в обработчиках событий редко бывает непосредственно код который каким-то образом влияет на время жизни объектов того же уровня абстракций. В основном при получении события меняется только состояние объекта (принцип единственной ответственности). Управляющий код не должен быть реализован через ивенты, хоть и возможно накрутить менеджерам кучу ивентов типа OnInit, OnUpdate, OnDestroy и т.д на практике это выливается в проблемы когда важен порядок этих операций и он легко может быть нарушен одной подпиской\отпиской.
Я понимаю вашу идею и прекрасно понимаю недостатки с копированием. Ну а пример, который вы привели довольно странный, если В содержит в себе C и уже подписан на Event, зачем подписывать еще и C? В и так может вызвать для C нужный метод когда сработает обработчик (ведь он публичный, если B смог его подписать). Тут скорее проблема использования, когда один объект подписывает другой, это очень плохой подход в целом, потому что нарушает несколько фундаментальных принципов.
И снова повторюсь, вовремя отписаться до разрушения это ответственность подписчика и задача пользователя в этом убедиться. Я могу привести много примеров где оба подхода ничего не смогут поделать если кто-то извне уничтожает подписчика до того, как он отписался. Вы все равно никак не решите эту проблему на стороне ивента, пока сам пользователь контролирует подписку\отписку и уничтожение.
Пункты с 1 по 4 по сути связаны. У вас там наследование и виртуальные методы, разные размеры объектов и т.д. Если все это упаковать в один правильный класс, там отпадет необходимость считать хеши, использовать наследование и виртуальные вызовы, как следствие отпадет необходимость вообще хранить объект на куче и заворачивать в умные указатели (у меня такой вместился в 32b).
Копия вектора только звучит как большой оверхед, в реальности в среднем у событий не так много подписчиков, можно использовать inplace вектор для копии на стеке. У нас в среднем выходило не более 10 подписчиков на ивент. Скопировать ~10 елементов по 32b на стек не стоит ничего. Если так вышло что подписчиков тысячи, то скорее всего это одни и те же типы объектов и стоит пересмотреть использование ивентов для них и лучше вызывать для них методы напрямую.
По хорошему подписчик сам должен удостоверится что он отписался до того, как был разрушен. То что вы написали это правильно, но ваша архитектура никак от этого не защищает, подписчик так же может быть разрушен кем-то до того, как отпишется или просто забыли отписаться. Всякое бывает и тут ничего не поделаешь, ивент не контролирует lifetime своих подписчиков. Я говорил о другом, что у вас поведение может быть не детерминированным и зависеть от очередности подписки что может привести к трудноуловимым багам.
Когда-то писал нечто подобное которое потом переросло в серьезную систему событий на проде, со всеми плюшками. Но начинал с подобного класса. Вот несколько советов, что можно улучшить: - Хеши считать вовсе не обязательно, если нужно сравнить два хендлера достаточно сравнить объект и указатель на метод или только указатель на функцию если это не метод класса. - std::function слишком тяжелая и может аллоцировать. В данном случае подойдет более специализированная имплементация функтора. Раз нам известны ограничения, это открывает некоторые оптимизации по вызову и памяти.
- Можно избежать виртуальных вызовов в хендлере.
- std::unique_ptr для маленького класса хендлера это большие расходы по перформансу, не только при вставке и удалении такового из контейнера, +1 переход по указателю при итерации и отсутствие дружбы с кешем.
- Инвалидация хендлеров прямо во время dispatch может привести к не детерминированному поведению. Например представьте что у вас два хендлера, и один отписывает другого. Если первый выполнится раньше, то вызов второго не произойдет. Но если порядок вызова изменится (потому что они подписались в другом порядке) то оба будут вызваны. То есть поведение зависит от очередности подписки. А еще надо два bool и доп вектор что бы все это поддерживать. Гораздо проще в disptach делать копию вектора хендлеров и уже для нее вызывать всех по очереди, так вы можете даже во время диспатча делать что угодно с подписками, не боясь сломать текущий диспатч лист.
- Всякий RAII сахарок это неплохо конечно, но больше всего необходимо такой системе некий Event Dispatcher который был бы посредником между ивентом и подписчиками, которые хотели бы потокобезопасно работать с ивентом и получать от него события в своих средах выполнения где можно безопасно обработать это событие.
Ну так в статье та же куча предлагается как решение. Разница только в том, какой аллокатор будет эту память делить и отдавать при запросах malloc/free new/delete. Тот же TLSF в стандартной реализации работает с фиксированным буфером который ему дали, где эта память лежит ему наплевать, это может быть стек, куча, GPU или даже файл. Это просто эффективный алгоритм (и один из лучших на данный момент). Но если вся проблема только в объеме памяти то должны быть известны максимальные размеры всего что может быть выделено. В таком случаях подойдут фиксированных размеров пул аллокторы, монотонные или стек аллокаторы для временных объектов на стеке. Но опять же зависит от объема памяти, если нет очень больших по размеру аллокаций относительно общего объема и на пике будет использовано не более 70-80% от общего объема то TLSF справится. Там каждое освобождение памяти гарантированно склеивает соседние пустые блоки в один большой.
Что мешает использовать например TLSF аллокатор? Можно в связке с stl, pmr или без не важно. Детерминированное время О(1) на все операции, низкая фрагментация.
Это решение не переносимое, завязано на один конкретный компилятор и его внутреннюю реализацию stl, которая в следующей версии может измениться и код не будет работать.
буквально любая функция, так как void* можно реинтерпретировать как что угодно
void* нельзя привести к указателю на функцию, как минимум потому что размер указателя на функцию может иметь другой размер. См https://godbolt.org/z/nhxjT9r5W
Как я уже написал выше, я не добавлял каких то проверок и не тестировал на всевозможных аргументах, цель этого кода показать принцип решения где используя tuple и index sequence мы простым перебором находим нужные индексы аргументов и затем передаем их в apply. Что касается ошибки в приведенных вами тестах, фикситься одной строчкой. using SourceArgs = std::tuple<std::remove_reference_t<T>...>;
Не исключаю, что могут быть и другие ошибки если использовать всевозможные ссылочные типы, const, volatile в качестве аргументов, но опять же это все можно пофиксить.
Реализация для любой функции с любым количеством аргументов (*больше 1) и perfect forwarding. Я не добавлял дополнительные проверки на количество аргументов и т.д. что бы сохранить читабельность кода, при желании можно их добавить.
на данный момент альтернатив CMake просто не существует.
Возможно Вам стоит взглянуть на Sharpmake, продукт написанный и используемый внутри Ubisoft, недавно был опубликован на github. Не знаю можно ли тут выкладывать ссылку, легко гуглится. Хоть это и урезанная версия, например не содержит готовых настроек для консолей, но это из-за требований first party, что в прочем не мешает вам их добавить. В целом позволяет очень быстро генерировать огромные проекты под Visual Studio, XCode или makefile's, а главное очень гибкая и простая настройка т.к. все написано на C#, вы можете включить в генератор абсолютно любую логику, платформы и сборки.
Например на проекте в несколько миллионов строк кода, над которым я сейчас работаю, Sharpmake генерирует solution для Visual Studio под 5 различных платформ, debug|release|profile|retail + blob + fastbuild меньше чем за минуту на 8ми ядерном процессоре.
В C++ не хватает такой сущности как interface, — структурой в которой такая оптимизация была бы включена по умолчанию и компилятор, который выдавал ошибку если в интерфейс добавить что либо кроме виртуальных функций.
Условие «реализация функции вывода на печать не в счет» подразумевает, что ее возвращаемое значение тоже часть реализации и можно использовать саму функцию printf как условие.
int main()
{
int n = 9;
while (printf("%.d", n--));
printf("0");
return 0;
}
Однако тут используется цикл с условием. Если брать в широком смысле, то так или иначе задача не решаема без цикла или рекурсии, но рекурсия ограничена. Если исключить любое сравнение то подходит только бесконечный цикл.
Прервать его можно следующими способами:
1. break
2. return из main фунции
3. Бросить исключение
4. goto
5. Функция exit() или ее аналоги
Первые четыре варианта требуют какого то условия что бы не выйти из цикла раньше чем нужно, а это неизбежное сравнение. Остается только четвертый вариант, в комментариях уже оставляли подобный пример, вот мой:
#include <cstdio>
#include <stdlib.h>
void print(int n) { printf("%d\n", n); }
void printAndExit(int n) { print(n); exit(0); };
void(*f[])(int) = { printAndExit, print };
int main()
{
int n = 9;
for (;;)
{
f[bool(n)](n);
--n;
}
return 0;
}
Много текста и страшных преобразований, которые сводятся к одной простой сути — массивы в стеке и на куче работают по разному. Если с одномерными массивами особо проблем нету и они легко преобразуются к указателю, то с многомерными надо помнить что «Массив массивов» и «указатель на указатель» — это разные сущности. Можно легко наступить на грабли если не знать этих особенностей и потому лучше избегать использования двумерных статических массивов.
Ну в теории всякое возможно, на практике если избегать подписки или отписки чужих объектов, то таких ситуаций практически не возникает, потому что в обработчиках событий редко бывает непосредственно код который каким-то образом влияет на время жизни объектов того же уровня абстракций. В основном при получении события меняется только состояние объекта (принцип единственной ответственности). Управляющий код не должен быть реализован через ивенты, хоть и возможно накрутить менеджерам кучу ивентов типа OnInit, OnUpdate, OnDestroy и т.д на практике это выливается в проблемы когда важен порядок этих операций и он легко может быть нарушен одной подпиской\отпиской.
Я понимаю вашу идею и прекрасно понимаю недостатки с копированием. Ну а пример, который вы привели довольно странный, если В содержит в себе C и уже подписан на Event, зачем подписывать еще и C? В и так может вызвать для C нужный метод когда сработает обработчик (ведь он публичный, если B смог его подписать). Тут скорее проблема использования, когда один объект подписывает другой, это очень плохой подход в целом, потому что нарушает несколько фундаментальных принципов.
И снова повторюсь, вовремя отписаться до разрушения это ответственность подписчика и задача пользователя в этом убедиться. Я могу привести много примеров где оба подхода ничего не смогут поделать если кто-то извне уничтожает подписчика до того, как он отписался. Вы все равно никак не решите эту проблему на стороне ивента, пока сам пользователь контролирует подписку\отписку и уничтожение.
Пункты с 1 по 4 по сути связаны. У вас там наследование и виртуальные методы, разные размеры объектов и т.д. Если все это упаковать в один правильный класс, там отпадет необходимость считать хеши, использовать наследование и виртуальные вызовы, как следствие отпадет необходимость вообще хранить объект на куче и заворачивать в умные указатели (у меня такой вместился в 32b).
Копия вектора только звучит как большой оверхед, в реальности в среднем у событий не так много подписчиков, можно использовать inplace вектор для копии на стеке. У нас в среднем выходило не более 10 подписчиков на ивент. Скопировать ~10 елементов по 32b на стек не стоит ничего. Если так вышло что подписчиков тысячи, то скорее всего это одни и те же типы объектов и стоит пересмотреть использование ивентов для них и лучше вызывать для них методы напрямую.
По хорошему подписчик сам должен удостоверится что он отписался до того, как был разрушен. То что вы написали это правильно, но ваша архитектура никак от этого не защищает, подписчик так же может быть разрушен кем-то до того, как отпишется или просто забыли отписаться. Всякое бывает и тут ничего не поделаешь, ивент не контролирует lifetime своих подписчиков. Я говорил о другом, что у вас поведение может быть не детерминированным и зависеть от очередности подписки что может привести к трудноуловимым багам.
EventDispatcher подразумевает EventListener
- естественно.Когда-то писал нечто подобное которое потом переросло в серьезную систему событий на проде, со всеми плюшками. Но начинал с подобного класса.
Вот несколько советов, что можно улучшить:
- Хеши считать вовсе не обязательно, если нужно сравнить два хендлера достаточно сравнить объект и указатель на метод или только указатель на функцию если это не метод класса.
- std::function слишком тяжелая и может аллоцировать. В данном случае подойдет более специализированная имплементация функтора. Раз нам известны ограничения, это открывает некоторые оптимизации по вызову и памяти.
- Можно избежать виртуальных вызовов в хендлере.
- std::unique_ptr для маленького класса хендлера это большие расходы по перформансу, не только при вставке и удалении такового из контейнера, +1 переход по указателю при итерации и отсутствие дружбы с кешем.
- Инвалидация хендлеров прямо во время dispatch может привести к не детерминированному поведению. Например представьте что у вас два хендлера, и один отписывает другого. Если первый выполнится раньше, то вызов второго не произойдет. Но если порядок вызова изменится (потому что они подписались в другом порядке) то оба будут вызваны. То есть поведение зависит от очередности подписки. А еще надо два bool и доп вектор что бы все это поддерживать. Гораздо проще в disptach делать копию вектора хендлеров и уже для нее вызывать всех по очереди, так вы можете даже во время диспатча делать что угодно с подписками, не боясь сломать текущий диспатч лист.
- Всякий RAII сахарок это неплохо конечно, но больше всего необходимо такой системе некий Event Dispatcher который был бы посредником между ивентом и подписчиками, которые хотели бы потокобезопасно работать с ивентом и получать от него события в своих средах выполнения где можно безопасно обработать это событие.
Ну так в статье та же куча предлагается как решение. Разница только в том, какой аллокатор будет эту память делить и отдавать при запросах malloc/free new/delete. Тот же TLSF в стандартной реализации работает с фиксированным буфером который ему дали, где эта память лежит ему наплевать, это может быть стек, куча, GPU или даже файл. Это просто эффективный алгоритм (и один из лучших на данный момент).
Но если вся проблема только в объеме памяти то должны быть известны максимальные размеры всего что может быть выделено. В таком случаях подойдут фиксированных размеров пул аллокторы, монотонные или стек аллокаторы для временных объектов на стеке. Но опять же зависит от объема памяти, если нет очень больших по размеру аллокаций относительно общего объема и на пике будет использовано не более 70-80% от общего объема то TLSF справится. Там каждое освобождение памяти гарантированно склеивает соседние пустые блоки в один большой.
Что мешает использовать например TLSF аллокатор? Можно в связке с stl, pmr или без не важно. Детерминированное время О(1) на все операции, низкая фрагментация.
Это решение не переносимое, завязано на один конкретный компилятор и его внутреннюю реализацию stl, которая в следующей версии может измениться и код не будет работать.
void* нельзя привести к указателю на функцию, как минимум потому что размер указателя на функцию может иметь другой размер. См https://godbolt.org/z/nhxjT9r5W
Как я уже написал выше, я не добавлял каких то проверок и не тестировал на всевозможных аргументах, цель этого кода показать принцип решения где используя tuple и index sequence мы простым перебором находим нужные индексы аргументов и затем передаем их в apply. Что касается ошибки в приведенных вами тестах, фикситься одной строчкой. using SourceArgs = std::tuple<std::remove_reference_t<T>...>;
https://godbolt.org/z/YfK11hG5G
Не исключаю, что могут быть и другие ошибки если использовать всевозможные ссылочные типы, const, volatile в качестве аргументов, но опять же это все можно пофиксить.
Реализация для любой функции с любым количеством аргументов (*больше 1) и perfect forwarding. Я не добавлял дополнительные проверки на количество аргументов и т.д. что бы сохранить читабельность кода, при желании можно их добавить.
Возможно Вам стоит взглянуть на Sharpmake, продукт написанный и используемый внутри Ubisoft, недавно был опубликован на github. Не знаю можно ли тут выкладывать ссылку, легко гуглится. Хоть это и урезанная версия, например не содержит готовых настроек для консолей, но это из-за требований first party, что в прочем не мешает вам их добавить. В целом позволяет очень быстро генерировать огромные проекты под Visual Studio, XCode или makefile's, а главное очень гибкая и простая настройка т.к. все написано на C#, вы можете включить в генератор абсолютно любую логику, платформы и сборки.
Например на проекте в несколько миллионов строк кода, над которым я сейчас работаю, Sharpmake генерирует solution для Visual Studio под 5 различных платформ, debug|release|profile|retail + blob + fastbuild меньше чем за минуту на 8ми ядерном процессоре.
Однако тут используется цикл с условием. Если брать в широком смысле, то так или иначе задача не решаема без цикла или рекурсии, но рекурсия ограничена. Если исключить любое сравнение то подходит только бесконечный цикл.
Прервать его можно следующими способами:
1. break
2. return из main фунции
3. Бросить исключение
4. goto
5. Функция exit() или ее аналоги
Первые четыре варианта требуют какого то условия что бы не выйти из цикла раньше чем нужно, а это неизбежное сравнение. Остается только четвертый вариант, в комментариях уже оставляли подобный пример, вот мой: