Обмен сообщениями достаточно фундаментальная вещь в науке Computer Science. Будем рассматривать её в приближении к событийно-ориентированному программированию (event-driven). Терминология, возможности и реализации могут отличаться: события (events), сообщения (messages), сигналы/слоты (signals/slots) и callbacks. В целом суть, что с приходом события запускается ответная реакция.
Сама система обмена сообщениями в статье послужила демонстрацией вольной, но допустимой интерпретации ссылок/указателей, упрощающей код. Получившаяся система тривиальна и умеет только регистрировать обработчик на определённый код сообщения и посылать сообщения с таким кодом.
Допустим что обработчики нетривиальные, а сообщений немного. И что мы сами генерируем сообщения и они не приходят нам по сети, например. В таком случае хочется иметь что-то более удобное с явными объявлениями переменных в сообщении. Например, нечто подобное:
Но хочется убрать проверочный код, не имеющий отношения к логике работы, под капот. Заменим поэтому указатель на ссылку, показав что в обработчик точно приходит объект, а неNULL nullptr. И пусть обработчик сразу принимает требуемый им тип сообщения.
Как осуществить задуманное и поддержать другие возможные классы сообщений?
Идея проста. Во время регистрации обработчика узнаем тип аргумента, который он принимает и запишем его. А при отсылке сообщения проверим, что тип сообщения совпадает с типом аргумента обработчика. Для каждого нового типа сообщения пронаследуемся от базового класса сообщения
Старые добрые делегаты работают в С++03. Один из примеров реализации описан на Хабре здесь. Делегаты в данном случае это только функциональная обёртка над функциями-членами. Так выглядит подписка обработчика.
Корректность. Как только устройство производного класса сообщения становится менее тривиальным, появляется проблема среза объектов. При входе в метод send объект срезается до базового типа, сдвинув передаваемую ссылку на базовый объект. Обработчик не узнает об этом и воспользуется невалидной ссылкой. Проинформируем, если нам встретится такой объект.
Но лучше всего написать проверку времени компиляции. Компилятор сделает срез базового типа по отнаследованному. И если указатель увеличился с 1, значит объект был срезан.
К сожалению, компилятор MSVS 2013 не справляется с компиляцией условия, но gcc-4.8.1 вполне.
Отправление сообщения делаем просто. Проверяем, что сообщение не срезается. Пробегаем по всем обработчикам. Если коды сообщения и обработчика совпадают, то проверяем типы на соответствие. Если всё совпало, то вызываем обработчик.
Важно не забыть добавить проверку, что
Пример с делегатом простой. Класс обработчика, подписка делегата и отправление сообщения:
Код с делегатами
В C++11 появились лямбды. Наша цель, чтобы процесс подписки выглядел очень просто:
Лямбду можно обернуть в
Код по смыслу аналогичен варианту с делегатами. Усложняется лишь логика работы с лямбдой.
Код отправки почти дословно повторяет разобранный код с делегатами. Весь код с лямбдами.
Вот финал нашего труда. Можно просто зарегистрироваться, послать сообщение и обработать его.
Приведу также ссылку на статью с более общей реализацией обратного вызова (callback) для нескольких аргументов.
Посмотрим насколько просели по производительности. Возьмём только по одному обработчику для двух мессенджеров, один из которых наш и может принимать любой унаследованный отмного 500 000 000 раз.
В 12 раз медленнее. Вся разница уходит на взятие
Код замера
В итоге мы получили простую и вполне удобный прототип системы обмена сообщений. Весь код доступен на GitHub.
Сама система обмена сообщениями в статье послужила демонстрацией вольной, но допустимой интерпретации ссылок/указателей, упрощающей код. Получившаяся система тривиальна и умеет только регистрировать обработчик на определённый код сообщения и посылать сообщения с таким кодом.
Допустим что обработчики нетривиальные, а сообщений немного. И что мы сами генерируем сообщения и они не приходят нам по сети, например. В таком случае хочется иметь что-то более удобное с явными объявлениями переменных в сообщении. Например, нечто подобное:
StringMessage* str_message = ...;
send(my_message);
...
void handle_message(const Message* message) {
assert(message);
const StringMessage* str_message = dynamic_cast<const StringMessage*>(message);
assert(str_message);
std::cout << str_message->message ...
}
Но хочется убрать проверочный код, не имеющий отношения к логике работы, под капот. Заменим поэтому указатель на ссылку, показав что в обработчик точно приходит объект, а не
void handle_message(const StringMessage& message) {
...
}
Как осуществить задуманное и поддержать другие возможные классы сообщений?
Идея проста. Во время регистрации обработчика узнаем тип аргумента, который он принимает и запишем его. А при отсылке сообщения проверим, что тип сообщения совпадает с типом аргумента обработчика. Для каждого нового типа сообщения пронаследуемся от базового класса сообщения
Message
.class Message{
public:
Message(unsigned code) : code(code) {}
virtual ~Message() {}
const unsigned code;
};
enum Code {
STRING = 1
};
class StringMessage : public Message {
public:
StringMessage(const std::string& msg) : Message(STRING), message(msg) {}
const std::string message;
};
Решение с делегатами
Старые добрые делегаты работают в С++03. Один из примеров реализации описан на Хабре здесь. Делегаты в данном случае это только функциональная обёртка над функциями-членами. Так выглядит подписка обработчика.
class Messenger {
...
template <class T, class MessageDerived>
void subscribe(int code, T* object, void (T::* method)(const MessageDerived&)) {
// Сохраняем тип аргумента, который действительно принимает функция-член класса
const std::type_index& arg_type = typeid(const MessageDerived);
// Преобразуем указатель функцию, как будто он принимает просто (const Message&)
void (T::* sign)(const Message&) = (void (T::*)(const MessageDerived&)) method;
// Добавляем нового подписчика
subscribers_.push_back(Subscriber(code, object, NewDelegate(object, sign), arg_type));
}
}
Корректность. Как только устройство производного класса сообщения становится менее тривиальным, появляется проблема среза объектов. При входе в метод send объект срезается до базового типа, сдвинув передаваемую ссылку на базовый объект. Обработчик не узнает об этом и воспользуется невалидной ссылкой. Проинформируем, если нам встретится такой объект.
template <class Base, class Derived>
bool is_sliced(const Derived* der) {
return (void*) der != (const Base*) der;
}
Но лучше всего написать проверку времени компиляции. Компилятор сделает срез базового типа по отнаследованному. И если указатель увеличился с 1, значит объект был срезан.
template <class Base, class Derived>
struct is_sliced2 : public std::integral_constant<bool,
((void*)((Base*)((Derived*) 1))) != (void*)1> {};
...
static_assert(!is_sliced2<Message, Arg>::value, "Message object should not be sliced");
К сожалению, компилятор MSVS 2013 не справляется с компиляцией условия, но gcc-4.8.1 вполне.
Отправление сообщения делаем просто. Проверяем, что сообщение не срезается. Пробегаем по всем обработчикам. Если коды сообщения и обработчика совпадают, то проверяем типы на соответствие. Если всё совпало, то вызываем обработчик.
Отправка сообщения
class Messenger {
...
template <class LikeMessage>
void send(const LikeMessage& msg) {
assert((!is_sliced<Message, LikeMessage>(&msg)));
send_impl(msg);
}
private:
void send_impl(const Message& msg) {
const std::type_info& arg = typeid(msg); // Кешируем настоящий тип сообщения
for (SubscribersCI i = subscribers_.begin(); i != subscribers_.end(); ++i) {
if (i->code == msg.code) { // Нашли требуемый код
if (arg != i->arg_type) // Плохо, если не совпали типы аргумента и делегата
throw std::logic_error("Bad message cast");
i->method->call(msg); // Вызывается ф-я член
}
}
}
}
Важно не забыть добавить проверку, что
MessageDerived
действительно унаследован от Message
. В С++11 в файле <type_traits>
есть std::is_base_of
. В С++03 проверку времени компиляции придётся писать руками.Пример с делегатом простой. Класс обработчика, подписка делегата и отправление сообщения:
class Printer {
public:
void print(const StringMessage& msg) {
std::cout << "Printer received: " << msg.message << std::endl;
}
};
int main() {
Messenger messenger;
Printer print;
messenger.subscribe(STRING, &print, &Printer::print);
messenger.send(StringMessage("Hello, messages!"));
return 0;
}
Код с делегатами
C++11
В C++11 появились лямбды. Наша цель, чтобы процесс подписки выглядел очень просто:
messenger.subscribe(STRING, [](const StringMessage& msg) {...});
Лямбду можно обернуть в
std::function
, но для этого нужно знать тип лямбды, не потеряв тип входного аргумента. А затем сконвертировать лямбду во что-то универсальное вроде std::function<void (const Message&)>
. Но нельзя просто так взять и узнать тип С++ лямбды.Выяснить тип лямбды
Позаимствовано отсюда. Непонятная, рекурсивно наследующаяся штука, да ещё и с частичной специализацией! Но смысл в том, что каждая лямбда имеет
template <typename Function>
struct function_traits
: public function_traits<decltype(&Function::operator())> {};
template <typename ClassType, typename ReturnType, typename... Args>
struct function_traits<ReturnType(ClassType::*)(Args...) const> {
typedef ReturnType (*pointer)(Args...);
typedef std::function<ReturnType(Args...)> function;
};
Позаимствовано отсюда. Непонятная, рекурсивно наследующаяся штука, да ещё и с частичной специализацией! Но смысл в том, что каждая лямбда имеет
operator()
, который и используется для вызова. decltype(&Function::operator())
разворачивает это в тип функции-члена, соответствующей лямбде. Аргументы передаются в частично-специализированный шаблон, где и устанавливаются соответствующие синонимы для типа указателя на функцию и std::function
для указателя на функцию.Код по смыслу аналогичен варианту с делегатами. Усложняется лишь логика работы с лямбдой.
template <typename Function>
class Messenger {
...
void subscribe(int code, Function func) {
// Узнаем тип функции с помощью function_traits
typedef typename function_traits<Function>::function FType;
// У std::function есть синоним аргумента argument_type (если аргумент единственный)
typedef typename FType::argument_type Arg;
// Сохраним typeid аргумента
auto& arg_type = typeid(Arg);
// Проверим, что сообщение пронаследовано от Message
// Тип Arg является ссылкой. Для проверки типа, ссылку нужно убрать из типа.
typedef std::remove_reference<Arg>::type ArgNoRef;
// Проверка на наследственность
static_assert(std::is_base_of<Message, ArgNoRef>::value,
"Argument type not derived from base Message");
// Преобразуем лямбду в соответствующий ей указатель на функцию
auto ptr = to_function_pointer(func);
// И тут же меняем на нужный тип указателя, который и сохраняем
auto pass = (void(*) (const Message&)) ptr;
subscribers_.emplace_back(std::move(Subscriber(code, pass, arg_type)));
}
}
Что внутри to_function_pointer?
Лямбда статически преобразуется к типу указателя на функцию соответствующего типа.
template <typename Function>
typename function_traits<Function>::pointer
to_function_pointer(Function& lambda) {
return static_cast<typename function_traits<Function>::pointer>(lambda);
}
На заметку
Стоит заметить что сделать приведение в обратную сторону гораздо проще.
Это логичное поведение, потому что публичное наследование (public inheritance) является реализацией отношения «является» (is a). Конкретно
std::function<void (const Message&)> msg_func = ...;
std::function<void (const StringMessage&)> str_func = msg_func; // И всё
Это логичное поведение, потому что публичное наследование (public inheritance) является реализацией отношения «является» (is a). Конкретно
StringMessage
является Message
. Но не наоборот.Код отправки почти дословно повторяет разобранный код с делегатами. Весь код с лямбдами.
Вот финал нашего труда. Можно просто зарегистрироваться, послать сообщение и обработать его.
int main() {
Messenger messenger;
messenger.subscribe(STRING, [](const StringMessage& msg) {
std::cout << "Received: " << msg.message << std::endl;
});
messenger.send(StringMessage("Hello, messages!"));
return 0;
}
Приведу также ссылку на статью с более общей реализацией обратного вызова (callback) для нескольких аргументов.
Просадка производительности
Посмотрим насколько просели по производительности. Возьмём только по одному обработчику для двух мессенджеров, один из которых наш и может принимать любой унаследованный от
Message
тип. И второй, который умеет принимать только сообщение со строкой StringMessage
. Будем посылать одно установленное сообщение Msg: 13955ms
Str: 1176ms
Ratio: 12.0
В 12 раз медленнее. Вся разница уходит на взятие
typeid
типа аргумента при отправлении на одно сообщение и проверку на совпадение типов. Цифра удручающая, будем помнить о ней, но всё-таки не самая важная. Потому что скорее всего в программе возникнет узкое место не в процессе отправки сообщений, а в их обработке. И в самом крайнем случае можно убрать проверку на тип в релизном режиме, выровняв производительность.Код замера
О чём я умолчал
Я не затронул вопросы удаления лямбд. В версии с делегатами мы сохраняли у себя указатель на объект и при удалении объекта мы сможем удалить всю информацию о подписчике. Здесь я не вижу других вариантов решения, кроме как поступить также и добавить в метод подписки ещё аргумент указателя на объект.
Итоги
В итоге мы получили простую и вполне удобный прототип системы обмена сообщений. Весь код доступен на GitHub.