О вольностях в ссылках или простейший обмен сообщениями

в 11:25, , рубрики: c++, c++11, callback, callbacks, event-driven, С++, сообщения, метки: , , , , ,

О вольностях в ссылках или простейший обмен сообщениямиОбмен сообщениями достаточно фундаментальная вещь в науке Computer Science. Будем рассматривать её в приближении к событийно-ориентированному программированию (event-driven). Терминология, возможности и реализации могут отличаться: события (events), сообщения (messages), сигналы/слоты (signals/slots) и callbacks. В целом суть, что с приходом события запускается ответная реакция.
Сама система обмена сообщениями в статье послужила демонстрацией вольной, но допустимой интерпретации ссылок/указателей, упрощающей код. Получившаяся система тривиальна и умеет только регистрировать обработчик на определённый код сообщения и посылать сообщения с таким кодом.
Допустим что обработчики нетривиальные, а сообщений немного. И что мы сами генерируем сообщения и они не приходят нам по сети, например. В таком случае хочется иметь что-то более удобное с явными объявлениями переменных в сообщении. Например, нечто подобное:

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 ...
}

Но хочется убрать проверочный код, не имеющий отношения к логике работы, под капот. Заменим поэтому указатель на ссылку, показав что в обработчик точно приходит объект, а не NULL nullptr. И пусть обработчик сразу принимает требуемый им тип сообщения.

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);
}

На заметку

Стоит заметить что сделать приведение в обратную сторону гораздо проще.

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. Будем посылать одно установленное сообщение много 500 000 000 раз.

Msg: 13955ms 
Str:  1176ms
Ratio:  12.0

В 12 раз медленнее. Вся разница уходит на взятие typeid типа аргумента при отправлении на одно сообщение и проверку на совпадение типов. Цифра удручающая, будем помнить о ней, но всё-таки не самая важная. Потому что скорее всего в программе возникнет узкое место не в процессе отправки сообщений, а в их обработке. И в самом крайнем случае можно убрать проверку на тип в релизном режиме, выровняв производительность.
Код замера

О чём я умолчал

Я не затронул вопросы удаления лямбд. В версии с делегатами мы сохраняли у себя указатель на объект и при удалении объекта мы сможем удалить всю информацию о подписчике. Здесь я не вижу других вариантов решения, кроме как поступить также и добавить в метод подписки ещё аргумент указателя на объект.

Итоги

В итоге мы получили простую и вполне удобный прототип системы обмена сообщений. Весь код доступен на GitHub.

Автор: Parander

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js