Лямбда-функции и реализация удобного механизма Callback-ов на C++

в 9:11, , рубрики: c++, c++0x, c++11, callback, callbacks, С++, метки: , , , ,

В этой статье на примере реализации механизма обратного вызова будет рассмотрена возможности применения лямбда функций в удобной и быстрой форме.

Постановка задачи

Необходимо реализовать удобный и быстрый механизм сохранения «указателя» на произвольную функцию и последующего его вызова с передачей аргумента (для примера возьмём тип char*).

Метод 1 – на классическом «Си»

Решая задачу «в лоб» можно получить что-то вроде такого:

//Определение нашей функции
static void MyFunction(char *s){
	puts(s);
}

int main(){
	//Переменная, хранящая указатель на функцию
	void (*MyCallback)(char *argument);

	//Сохранение указателя на нашу функцию
	MyCallback=MyFunction;

	//Вызов функции по указателю
	MyCallback("123");

	return 0;
}

Механизм очень простой и часто используемый. Но при большом количестве обратных вызовов их объявление становиться не очень удобным.

Лямбда-функции в С++

Для тех кто не слышал про С++11 (или С++0x) или пока ещё не коснулся его, расскажу про некоторые нововведения из этого стандарта. В С++11 появилось ключевое слово auto, которое может ставиться вместо типа при объявлении переменной с инициализацией. При этом тип переменной будет идентичен типу, казанному после «=». Например:

	auto a=1;     // тоже самое что  int         a=1;
	auto b="";    // тоже самое что  const char* b=1;
	auto c=1.2;   // тоже самое что  double      c=1;
	auto d;	// ошибка! невозможно определить тип переменной d

Но самое интересное это лямбда-функции. В принципе, это обычные функции, но которые могут быть объявлены прямо в выражении:

[](int a,int b) -> bool //лямбда функция с двумя аргументами, возвращает bool
{
	return a>b;
}

Синтаксис лямбда функции таков:

[захватываемые переменные](аргументы)->возвращаемый тип{ тело функции }

Кусок «->возвращаемый тип» может отсутствовать. Тогда подразумевается «->void». Ещё пример использования:

int main(int argc,char *argv[]){

	//функция, аналогичная abs(int)
	auto f1=[](int a)->int{
		return (a>0)?(a):(-a);
	};

	//функция, возвращающая случайное значение от 0.0 до 1.0
	auto f2=[]()->float{
		return float(rand())/RAND_MAX;
	};

	//функция, ожидающая нажатия enter
	auto f3=[](){
		puts("Press enter to continue...");
		getchar();
	};

	printf("%d %dn",f1(5),f1(-10));
	printf("%f %fn",f2(),f2());
	f3();

	return 0;
}

Данная программа выведет:

5 10
0.563585 0.001251
Press enter to continue...

В этом примере были объявлены и проинициализированы три переменные (f1,f2 и f3) типа auto, следовательно тип которых соответствует типу стоящему справа – типу лямбда функций.
Лямбда функция, сама по себе, не является указателем на функцию (хотя в ряде случаев может бытьприведена к нему). Компилятор вызывает функцию не по адресу а по её типу – именно поэтому у каждой лямбда функции свой тип, например «<lambda_a48784a181f11f18d942adab3de2ffca>». Такой тип невозможно указать, поэтому его можно использовать только в связке с auto или шаблонами (там тип тоже может автоматически быть определён).
Стандарт так же допускает преобразование от типа лямбда к типу указателя на функцию, в случае отсутствия захватываемых переменных:

void(*func)(int arg);
func= [](int arg){ ... }; // была лямбда, стала указатель

Захватываемые переменные это те переменные, которые «попадают внутрь» лямбда функции при её указании:

int main(int argc,char *argv[]){
	auto f=[argc,&argv](char *s){
		puts(s);
		for(int c=0;c<argc;c++){
			puts(argv[c]);
		}
	};

	f("123");

	return 0;
}

Эти параметры, фактически и сохраняются (копируются по значению) в переменной f.
Если указать знак & перед именем, то параметр будет передан по ссылке, а не по значению.
Адрес самой функции по-прежнему нигде не хранится.

Метод 2 – Реализация на С++

Заменив статическую функцию на лямбду можно упростить наш пример:

int main(){
	void (*MyCallback)(char *argument);

	//Теперь функция может быть определена прямо здесь!
	MyCallback=[](char *s){
		puts(s);
	};

	MyCallback("123");

	return 0;
}

Вот так немножко добавив «плюсов» можно сильно упростить жизнь, главное не переборщить, чем мы сейчас и попробуем заняться. В этом примере такая конструкция будет работать, пока нам не захочется «захватить» переменные в лямбда функции. Тогда компилятор не сможет преобразовать лямбду в указатель. Вот тут, используя С++ можно сделать так:

class Callback{
private:

	// Класс, обеспечивающий вызов функций с их особенностями
	class FuncClass{
	public:
		// Переопределяемая функция
		virtual void Call(char*)=0;
	};

	// Указатель на сохранённый класс
	FuncClass *function;

public:

	Callback(){
		function=0;
	}

	~Callback(){
		if(function) delete function;
	}



	template<class T>
	void operator=(T func){
		if(function) delete function;

		// Класс с переопределённой функцией Call, вызывающей func
		class NewFuncClass:public FuncClass{
		public:
			T func;

			NewFuncClass(T f):func(f){
			}

			void Call(char* d){
				func(d);
			}
		};

		// Создаём экземпляр класса и сохраняем его
		function=new NewFuncClass(func);
	}

	void operator()(char* d){
		if(function) function->Call(d);
	}
};

int main(){

	Callback MyCallback;

	MyCallback=[](char *s){
		puts(s);
	};

	MyCallback("123");

	return 0;
}

Вот так. Чуть-чуть плюсов и код в несколько раз больше. Громоздкая реализация, а ведь здесь ещё и не учтена возможность копирования экземпляров Callback. Но удобство использования на высоте. Так же за скромной операцией «=» прячется выделение динамической памяти, да ещё конструктор – явно не вписывается в концепцию наглядности кода широко любимую верных классическому «Си» программистам.

Попробуем это исправить и максимально ускорить и упростить реализацию, не потеряв удобство.

Метод 3 – Что-то среднее

Реализация:

class Callback{
private:
	void (*function)(char*,void*);
	void  *parameters[4];

public:
	Callback(){
		function=[](char*,void*){
		};
	}

	template<class T>
	void operator=(T func){
		// Вот так мы убедимся, что  sizeof(T) <= sizeof(parameters)
		// Если это не выполняется, то будет compile-time ошибка, т.к.
		// нельзя указывать отрицательный размер массива
		sizeof(int[ sizeof(parameters)-sizeof(T) ]);

		// Сохраняем указатель на функцию, которая вызывает переданную функцию func
		function=[](char* arg,void *param){
			(*(T*)param)(arg);
		};

		// Копируем значение в переменной func в parameters
		memcpy(parameters,&func,sizeof(T));
	}

	void operator()(char* d){
		// Вызываем функцию по указателю function, передав ещё и parameters
		function(d,parameters);
	}
};

int main(){

	Callback MyCallback;

	MyCallback=[](char *s){
		puts(s);
	};

	MyCallback("123");

	return 0;
}

Во первых: мы убрали большой кусок связанный с виртуальными функциями и выделением памяти. Сохранение просиходит на скорости копирования нескольких байт.

Вызов тоже быстрый – от вызова двух вложенных функций (вспомогательная и сохранённая) до одной, когда компилятор встраивает одну в другую – почти идеальный вариант (от идеала отделяет один лишний аргумент «parameters»).

У такой реализации единственное ограничение – это максимальный размер захватываемых в лямбда функциях переменных. Но обычно требуется передать не так много дополнительных параметров. А при большом количестве можно и использовать динамическую память в ущерб скорости.

Итог

Удобство и функционал передачи функции как указателя был доведён до высокого уровня удобства без особого увеличения ресурсоёмкости. Что касается функционала, то простора для творчества ещё предостаточно: создание очереди с приоритетами (потока событий), шаблона для разных типов аргумента и т.д.

Автор: DreamNik

Источник


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


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