Делегаты, сигналы и слоты.

Как известно, в C++ делегатов нет. А жаль.
Впринципе, в интернете много делегатов. И слотов много. Но, я зачем-то решил написать свои.

Благодаря механикам: официальной — variadic templates и неофициальной — horrible_cast реализация делегатов оказалось компактной, хотя, у нее, возможно и будут проблемы с переносимостью (не зря же у нормальных людей в реализациях трёхэтажная шаблонная магия и двухэтажная препроцессорная).

Платформа на которой всё запустилось — avr2560 в виде Ардуино. К статье прилагаю сию работу в виде Ардуино библиотеки.

Значительно упрощает реализацию делегата отказ от использования множества различных сигнатур применительно к одному делегату. (Хоть убейте, не знаю, зачем оно людям нужно). Действительно, если объявить делегат шаблонным с самого начала и явно прописать его сигнатуру, не приходится изобретать хитрых методов востановления сигнатуры.

Механика сохранения информации делегата у нас следующая следующая.
1. Делегат инициализируется парой указатель на объект и указатель на метод класса.
2. Указатель на объект приводится к указателю на тип Abstract.
3. Указатель на метод класса приводится к указателю на метод класса Abstract (спасибо horrible_cast)
4. Полученные указатели сохраняются в отведённых полях.

В этом процессе мы теряем информацю о типе объекта, но оказывается, что для корректного вызова метода тип знать совсем не обязательно.

Механика вызова делегата и того проще. Мы просто вызываем с передаваемыми оператору вызова параметрами метод, якобы, Abstract класса по сохранённому указателю на метод и сохранённому указателю на объект. Удивительно, но это работает.

Собственно, вот что из себя представляет основа делегата:

template<typename OutputType ,typename ... VarTypes>	
	class delegate
	{public:
		
		class Abstract{};
		
		typedef Abstract* delegate_obj_t;			
		typedef OutputType (Abstract::*delegate_mtd_t)(VarTypes ...);
		
		delegate_obj_t object;
		delegate_mtd_t method;
		
	....

Вот типичный конструктор:

//Конструктор. Делегат метода класса. 
		//@1 указатель на объект, метод которого вызывается.
		//@2 указатель на метод.
		//Пример delegate<void, int> d(&a, &A::func);
		template<typename T1, typename T2>
		delegate(T1* ptr_obj, OutputType(T2::*mtd)(VarTypes ...))  : function(0)
		{
			object = reinterpret_cast <delegate_obj_t> (ptr_obj);
			method = horrible_cast<delegate_mtd_t, OutputType(T2::*)(VarTypes ...)>(mtd);
		};	

Вот вызов:

OutputType operator()(VarTypes ... arg) {
				return (object->*method)(arg ...);
			};

Всё… Если вы откроете файл delegate.h в выложенной мной библиотеке, вы увидите, что реализация немного сложнее из-за некоторых дополнительных механизмов инициализации и из-за добавления возможности вызова обычных, не являющихся членами класса функций. У них точно такая же сигнатура, так что проблем с этим не возникает.

Использование:
class A
{
void mtd(int){};
} a;

void fnc(int){};
void fnc2(int){};

delegate<void, int> d1 = fnc;
d1(3);
d1 = fnc2;
d1(4);
delegate<void, int> d2(&a, &A::mtd);
delegate<void, int> d3 = method(a, A::mtd);


Первый параметр шаблона — возвращаемое значение. Дальше типы параметров в любом количестве.
Макрос method введён специально для передачи значений в конструктор через знак присваивания.
(операторы присваивания неопределены, а потому присваивание вызывает конструктор).

Пожалуй, здесь всё.

На основе данной реализации делегатов и механики monolist строится sigslot библиотека. Класс sigslot — класс сигнала. В качестве слота выступает любая функция или метод с такой же сигнатурой.
Вызов sigslot через operator() приводит к последовательному выполнению всех находящихся в списке делегатов (с переданными параметрами, разумеется).

Библиотека использует динамическую память для хранения делегатов. Алгоритм работы, вероятно, медленней других реализаций, особенно разворачивающихся на этапе компиляции. В данной реализации вся работа выполняется в реалтайме.

Библиотека перегружает для класса sigslot операторы

+= — добавление метода или функции.
!= — приоритетное добавление метода.
-= — удаление метода.

По умолчанию последний добавленный будет последним же исполняться. Приоритетное добавление ставит добавляемую функцию в начала списка. Такая функция исполняется первой.
Удаление метода заключается к поиску в списке записи, аналогичной переданной функции и удалению ее.
При вызове сигнал возвращает то, что вернул последний объект. Не знаю, зачем…

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

s += func;
  s += method(a, A::mtd);
  s(3, 0.97);

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

Если вы до сюда дочитали, спасибо.

Прилагаю Ардуино библиотеку с реализацией. В файлах приводится довольно подробный комментарий о том, что зачем и с какими параметрами вызывается.
  • +3
  • 09 декабря 2015, 11:48
  • Mirmik
  • 1
Файлы в топике: sigslot.zip

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

RSS свернуть / развернуть
Спасибо за статью. Неплохо было бы расширить функциональность, чтобы делегат мог инкапсулировать не только экземпляр метод но статический, простую функцию, лямбду.
0
  • avatar
  • e_mc2
  • 09 декабря 2015, 18:55
не только экземпляр метод но статический, простую функцию, лямбду.
Упс, что-то я протормозил, это реализованно.
0
Я тоже использую подобные делегаты. Несколько рекомендаций:
1. Делегат инициализированный по умолчанию нельзя вызавать — будет доступ по нулевому указателю. Лучше по умолчанию инициализировать делегат пустой функцией, тогда и необходимость проверки отпадет.
2. Нет способа проверить, что делегат пустой и его нельзя вызывать.
3. Вызов функции члена класса по указателю не очень эффективен, порядка 15-30 тактов если без параметров.
4. Чтобы вызвать такой делегат из старого Си-шного кода (а иногда такое надо), придется вручную делать фукнцию обертку.

Есть способ лучше:
#include <iostream>
template<class Result>
class Delegate
{
public:
	typedef Result (*InvokeT)(void *);		// указатель на функцию (Invoke())
	static  Result VoidFunc() { return Result(); }
private:
	void *_object;
	InvokeT _callback;
	template<class ObjectT, Result (ObjectT::* Func)()>
	static Result InvokeObj(void *object){
		return (static_cast<ObjectT*>(object)->* Func)();
	}
	template<Result (* Func)()>
	static Result InvokeFunc(void *){
		return Func();
	}
	Delegate(void *obj, InvokeT func)
		: _object(obj), _callback(func) { }
public:

	Delegate()
		: _object(0), _callback(&InvokeFunc<VoidFunc>)	{ }
	// вызывает конструктор, 
	// для каждой функции класса создаёт свой Invoke (т.к. специализация шаблона!)
	// и передаёт указатель на Invoke как второй параметр ctor"а.
	template<class ObjectT, Result (ObjectT::* Func)()> static Delegate Make(ObjectT * object){
		return Delegate(object, &InvokeObj<ObjectT, Func>);
	}
	template<Result (* Func)()> static Delegate MakeFree(){
		return Delegate(0, &InvokeFunc<Func>);
	}
	// вызов Invoke и передача ему объекта для которого он был создан
	inline Result operator()()const{
		return _callback(_object);
	}
	InvokeT ToCFunction()const{
		return _callback;
	}
	void* Object()const{
		return _object;
	}
};

class Foo
{
public:
	bool Bar(){ std::cout << "Hello" << std::endl; return true;}
};

int main()
{
	Foo foo;
	Delegate<bool> d;
	d();
	d = Delegate<bool>::Make<Foo, &Foo::Bar>(&foo);
	d();
	return 0;
}
+2
У этих делегатов есть нехороший недостаток. Шаблоны не могут разрешить ситуацию, возникающую, когда делегат инициализируется указателем на функцию внутри функции, получившей этот указатель в качестве параметра.
0
А зачем передавать указатель на ФЧК в качестве параметра, если можно передавать уже готовый делегат? Это вопрос дизайна интерфейсов.
0
А зачем это надо, никто не расскажет?
0
Удобный механизм для передачи экземплярчик методов (и не тольео) в качестве функции обратного вызова.
0
e_mc2
3. Вызов функции члена класса по указателю не очень эффективен, порядка 15-30 тактов если без параметров.
Так вы же тоже вызываете метод по указателю на метод 0_о… Или я что-то недопонял понял.<br /

1. Делегат инициализированный по умолчанию нельзя вызавать — будет доступ по нулевому указателю. Лучше по умолчанию инициализировать делегат пустой функцией, тогда и необходимость проверки отпадет.
Да и фиг с ним, честно говорю… Но, это да, пожалуй надоработка.

0
Одно дело вызывать функцию по обычному указателю на функцию, и совсем другое вызывать функцию член класса(методов в С++ таки нет) по указателю на нее.
У меня в функцию-обёртку InvokeObj указатель на функцию член класса передается как параметр шаблона и известен на этапе компановки. В худшем случае такой вызов будет обычным вызовом по известному адресу, в лучшем — вызов вообще заинлайнится.
+1
Хм… Согласен. Попробую это адаптировать.
0
функцию член класса(методов в С++ таки нет)
Гм, а что такое тогда метод?
0
В теории ООП обычно говорят о «данных» и «методах обработки данных». В стандарте С++ нет понятия «метод», там есть Member functions (и т. д.), хотя в разговорной речи намного чаще употребляют «метод». Точно также, как часто путают класс и объект (экземпляр класса), когда говорят «создаем класс» хотя создают его экземпляр. В общем (ИМХО), это тонкости из серии «нажал на курок» (хотя на самом деле на спусковой крючок), «просверлил дырку» (отверстие) и т. д.
0
Это в теории, а есть какие-то признаки, чтобы определить — труЪ это методы или не труЪ? Дельфи, например, эти member functions методами и зовет.
0
P.S. Или это чисто вопрос принятой в С++ терминологии?
0
Да, в данном случае, это именно вопрос в официальной терминологии.
0
Есть мнение, что true метод должен быть «объектом первого класса». Хотя в терминологии как всегда разброд и шатание и где-то исторически сложилось, что функции зовут методами, а где-то наоборот.
0
Есть мнение, что true метод должен быть «объектом первого класса».
А подскажи источник, где можно на эту тему почитать. Вики трактует методы как функции класса.
+1
Присоединяюсь к вопросу коллеги Vga

Я нигде не встречал подобного мнения. Хотя действительно теория ООП дает весьма расплывчатые термины и их часто трактуют по разному.
0
Я не смог найти документального подтверждения этой версии, возможно оно из какого-то кухонного обсуждения с коллегами.
0
Вообще, «функции как объекты первого класса» все же больше вотчина ФП, а не ООП, как мне кажется.
0
Ну почему, во многих скриптовых языках, взять хоть Python или JS — методы там вполне себе объекты первого класса.
0
В оных языках обычно сильно заметно влияние ФП. Да и в новых дельфи, кажется, прокачали функции до объектов первого класса. Так что грань стирается, нынче все мультипарадигменное. Но корни этого все же из ФП растут.
0
Одно дело вызывать функцию по обычному указателю на функцию, и совсем другое вызывать функцию член класса(методов в С++ таки нет) по указателю на нее.
А в чем разница, кстати? Вроде ж ФЧК отличается только передачей дополнительного параметра — this.
0
Разница в том, что когда вызов идет по указателю на ФЧК, в общем случае не известно что за функция вызывается. Это может быть обычная ФЧК, может виртуальная виртуальная, плюс надо учитывать наследование, множественное и виртуальное. Указатель на ФЧК — это хитрая структура с несколькими полями и флагами, размером с два обычных указамеля для GCC и даже больше для некоторых других компиляторов.
Есть неплохая статьяпро указатели на ФЧК и делегаты, где-то были её переводы на русский, если надо.
+2
^^

OutputType operator()(VarTypes ... arg) {
			uint8_t type = (object == (delegate_obj_t) -1) ? FUNCTION : METHOD; 
			switch (type)
			{
				case METHOD: 
				//return (object->*method)(arg ...);
				return (reinterpret_cast<OutputType(*)(void*,VarTypes ... arg)> (method))(object, arg ...);
				
				case FUNCTION: 
				return (function)(arg ...);
			};
		};


Правда с виртуальными функциями мы больше не работаем.
0
Это очень не надежно. На x86 такое уже не будет работать. Да и на АВР может сломаться в зависимости от настроек компилятора.
+1
А разве на x86 указатели на объект передаются подругому?
0
Это зависит от calling convention. x86 this обычно передается в регистре ECX (thiscall), а остальные аргументы в стеке, но это зависит от компилятора и его настроек. Если вот так в лоб передать this как обычный аргумент функции, то он вероятно попадет с стек (для stdcall соглашения), а не в ECX.
0
В принципе, это можно выличить с использованием конструкций типа
void(*ptr)(void)__attribute((regparm(1)));


:)
Не вижу ничего плохого в аппаратно зависимой библиотеке. Пусть она будет аппаратнозависимая, зато простая.
0
В G++
0
Забавно. В дельфи проще, там указатель на метод — просто указатель. Не знаю, правда, как оно обрабатывает виртуальные и динамические методы — вероятно, через функцию-хелпер. А для встроенных делегатов используется структурка из двух полей, адрес метода и self (указатель на экземпляр).
0
amaora
Построение универсального интерфейса без применения наследования.
По сути мы оборачиваем объект и метод любого типа в обертку делегата. Дальше мы можем работать просто с делегатом, не особо задумываясь о многообразии типов.

Пример — класс таймеров. Функционал — запуск функций и методов через некоторое время после запуска таймера.
Мы хотим создать универсальный класс, чтобы он мог работать с любыми классами и функциями. В частности, мы не можем позволить себе решить эту проблему наследованием от некоего класса Timered. Это неэстетично. Чтобы создать универсальный таймер необходимо нечто, что поможет его работать с объектами разных типов. То есть, делегат.

Что касается сигналов, это тоже средство, позволяющее уменьшить связность программы. Вместо того, чтобы прописывать в классах какие-то левые взаимосвязи, мы объявляем сигнал и позволяем любым сторонним классам к нему присоединиться.
0
Хочется понять, много ли я потерял, реализовав эти же таймеры на C, используя callback на голые функции.
0
Ничего не потеряли. А некоторые (например, Линус Торвальдс) наоборот скажут, что Вы только выиграли реализовав таймеры на С :) С таким же успехом можно спросить: а что я потеряю реализовав таймеры с обратным вызовом на ASM? Просто разные парадигмы… В С для обратного вызова достаточно знать адрес функции (ну и желательно сигнатуру :), поэтому там смысла в таких обертках нет.
0
А вы не пробовали как-нибудь упростить синтаксис…
Неплохо, пока параметр 1.

Но, всё-таки…
delegate2<void, int, float, double, double> d3 = delegate2<void, int, float, double, double>::Method<A, &A::task3>(&a);
Это не гуд.
0
Мне кажетеся Вы меня путаете с коллегой neiver и вопрос адресован ему?

У меня нет идей как упростить синтаксис, насколько я понимаю С++ не сможет автоматически вывести типы шаблона на основании сигнатуры функции. Поэтому, можно разве что в правой части использовать auto для сокращения синтаксиса. Хотя возможно я ошибаюсь, я больше пишу на С# (там выведение типов для обобщений работает немного по другому), С++ я, увы, стал постепенно забывать.
0
 что в правой части использовать auto
Упс, естественно в левой части выражения…
0
Ох… Непривычная организация форума. Виноват.
0
Не стоит путать холодное с мягким. Делегаты решают проблемы человека живущего в с++. Иначе говоря, делегаты ради с++, а не с++ ради делегатов. Это не какой-то страшно крутой функционал, а некий аналог callback для классов.
+2
Еще такой момент: сугубо имхо, но идея перегрузки оператора != для sigslot — не самая лучшая, ибо ломается привычная логика — оператор неравенства используется для добавления. Интуитивно понять, что выражение
somevar != foo();

добавляет функцию в список вызова (а не выполняет проверку) невозможно.

В отличии от перегрузки += -=, использование которых мне кажется интуитивно понятным (хотя возможно мне это кажется понятным из-за знания С#)
0
  • avatar
  • e_mc2
  • 09 декабря 2015, 21:48
В языке не так уж много операторов. Этот был как-то ближе всего.
0
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.