Лямбда-вирази в С++

4 хв. читання

В стандарті С++11 існує поняття лямбда-вираз. Тим, хто читає документацію та статті в інтернеті про лямбда-вирази, важко розібратися, що це за вирази, як їх використовувати та які їхні особливості. Одні джерела поверхово описують тему, а інші навпаки – занадто складно. На мою думку, це предмет вивчення не для новачків. Але в цій статті я постараюся максимально доступно пояснити, що таке лямбда-вирази.

В математиці лямбда-численням називають систему для дослідження визначення та застосування функції. Таким чином математики спростили формат запису функції і намагалися формалізувати обчислення. В нас є такий вираз:

λх.х

Де λ означає, що це лямбда-функція. Після цього перша «х» - список аргументів, в яких може бути інша лямбда-функція. «х» після крапки – тіло функції, а вже після нього іде аргумент, який передається. Наприклад,

λх.х+2 5

В результаті повернеться 7, оскільки 5 передається в якості аргументу, тому 5+2 = 7. З цього можна зробити висновок, що будь-яка змінна – лямбда-вираз. Детальніше у Вікіпедії.

Перед тим як почати розбір лямбда-виразу в програмуванні, давайте згадаємо що таке функтор. В різних мовах програмування цей термін може мати різне значення. В мові С++ функтор – скорочена назва від «функціональний об'єкт». Функтор являє собою екземпляр, в класі якого визначено перевантажений operator() . Розглянемо приклад:

class Functor
{
public:
	Functor();
	void operator()() const;
};

Functor::Functor()
{
}

void Functor::operator()() const
{
	cout << "Hi, I am functor." << endl;
}

int main()
{
	Functor funObj;
	funObj();
}

У класі Functor оголошений перевантажений operator() . В функції main() створюється об'єкт класа Functor з іменем funObj . Цей об'єкт і є функтором. Після створення об'єкта ви бачите вираз funObj() – використання функтора. В результаті на вивід ми отримаємо повідомлення > «Hi, I am functor.».

Що ж, з функтором розібралися. Мабуть, у вас виникло запитання, при чому функтор і лямбда-вирази? Відповідь проста.

Лямбда-вирази в С++

Іншими словами лямбда-вираз – безіменна локальна функція. І щоб краще зрозуміти значення «безіменна функція» та «анонімний функтор», розглянемо наступний код.

void output(int i) // функція output() не може бути перевантажена, оскільки виникне помилка в for_each
{
	cout << i << " ";
}

void outputVec(vector<int>& vec)
{
	for (vector<int>::iterator iter = vec.begin(); iter != vec.end(); ++iter)
		cout << *iter << " ";
	cout << endl;
}

int main()
{
	vector<int> myVector;

	for (int i = 0; i < 10; ++i)
		myVector.push_back(i);

	outputVec(myVector); // вивід за допомогою звичайної функції

	for_each(myVector.begin(), myVector.end(), output);  // 1
	cout << endl;
	for_each(myVector.begin(), myVector.end(), [](int i) //2
												{
													cout << i << " ";
												}
			);
	
	cout << endl;

	return 0;
}

В цій програмі створюється вектор і заповнюється числами від 0 до 9. В рядку 1 в for_each() викликається функція з іменем output, яка виводить вміст вектора. В рядку 2 використовується функція for_each(), яка приймає посилання на безіменну функцію, тобто лямбда-вираз. В усіх випадках ми отримуємо однаковий результат: 0 1 2 3 4 5 6 7 8 9.

Як вже було згадано, функтор є екземпляром класу, в якому перевантажений operator(), а лямбда-вираз – скорочена форма анонімного функтора. В наступному прикладі представлене порівняння:

class Anonim
{
public:
	void operator()(int _value) const;
};

void Anonim::operator()(int _value) const
{
	cout << _value << " ";
}

int main()
{
	vector<int> myVector;

	for (int i = 0; i < 10; ++i)
		myVector.push_back(i);

	for_each(myVector.begin(), myVector.end(), Anonim());  // 1
	cout << endl;
	for_each(myVector.begin(), myVector.end(), [](int _value) //2
												{
													cout << _value << " ";
												}
			);

	cout << endl;

	return 0;
}

В рядку 1 у функцію передається анонімний функтор класу AnonimAnonim(), який виводить елементи вектора. В рядку 2 передається лямбда-вираз, який є скороченою формою запису анонімного функтора Anonim() і теж виводить елементи вектора. Такий підхід з лямбда-виразами дозволяє уникати створення класів і спрощує процес написання коду.

Структура лямбда-виразів

Зазвичай, лямбда-вираз має таку структуру:

Лямбда-вирази в С++

По замовчуванню функція лямбда-виразу повертає тип void. Тобто, дану вище структуру можна записати так:

[]()->void {}

Це означає, що ми можемо явно вказувати тип повернення за допомогою такого синтаксису:

[]()-><тип> {}

Розглянемо приклад:

int main()
{
	vector<int> myVector(10, 5);

	for_each(myVector.begin(), myVector.end(), [](int _value) 
												{
													cout << _value << " ";
												}
			);

	cout << endl;

	return 0;
}

В прикладі створюється вектор і одразу ініціалізується 10-ма значеннями «5». Після цього виконується фукція for_each і, як ви побачили, використвується лямбда-вираз. Список захоплення [ ] порожній (його розглянемо пізніше), а от в списку параметрів приймаєтся _value, що є елементом вектору. Компілятор розуміє, що функція повертає тип void, оскільки ми не вказали явно інший тип і не використовуємо оператори return. Результат програми – 5 5 5 5 5 5 5 5 5 5.

Ми можемо не вказувати тип повернення функції лямбда-виразу, якщо він void або якщо функція використовує один оператор return – тоді компілятор сам визначає який тип повертати.

int main()
{
	vector<int> myVector(10, 5);

	bool sum;
	sum = count_if(myVector.begin(), myVector.end(), [](int _value)
													{
														return (_value + 3) == 8;
													}
				  );
	cout << sum << endl;

	return 0;
}

В програмі заповнюється вектор 10-ма елементами із значенням 5. В функцію count_if() посилається лямбда-вираз, який має один оператор return, в якому перевіряється умова чи додавання до елемента вектора значення 3 буде рівно 8. В цьому випадку компілятор визначить тип повернення як bool, що є аналогічним такому запису:

int main()
{
	vector<int> myVector(10, 5);

	bool sum;
	sum = count_if(myVector.begin(), myVector.end(), [](int _value)-> bool
													{
														return (_value + 3) == 8;
													}
				  );
	cout << sum << endl;

	return 0;
}

Якщо ж кількість операторів return 2 і більше, то тип необхідно вказати явно, адже в іншому випадку компілятор не зможе інтерпретувати який тип повертати. Явне вказування типів, як ви вже знаєте, має вигляд:

[]()->bool {}
[]()->int {}
[]()->double {}
	...

Список захоплення [ ]

Список захоплення призначений для захоплення змінної, яка існує в тій же зоні видимості, що і лямбда-вираз. Наприклад:

int main()
{
	vector<int> myVector(10, 5);

	int number1, number2;
	cin >> number1;
	cin >> number2;

	bool sum;
	sum = count_if(myVector.begin(), myVector.end(), [number1, number2](int _value)-> bool
													{
														return _value == (number1 + number2);
													}
				  );
	cout << sum << endl;

	return 0;
}

Як бачимо, в [ ] захоплюються змінні number1 і number2 з зовнішнього контексту, які можна тепер використовувати в лямбда-виразі. Слід зауважити, що у лямбда-виразі використовуются копії цих змінних, а не їх оригінали. Для роботи з оригіналами використовується захоплення по посиланню [&], яке ми розглянемо трохи пізніше.

Лямбда-вирази в С++

Тому для того, щоб мати змогу редагувати захвачені змінні слід використовувати специфікатор mutable.

int main()
{
	vector<int> myVector(10, 5);

	int number1, number2;
	cin >> number1;
	cin >> number2;

	bool sum;
	sum = count_if(myVector.begin(), myVector.end(), [number1, number2](int _value) mutable -> bool
													{
														number1 = number2 + number1;
														return _value == (number1 + number2);
													}
				  );
	cout << sum << endl;

	return 0;
}

Тепер, коли в цьому коді використовується специфікатор mutable, ми можемо переініціалізувати зовнішні змінні number1, number2 та виконувати операції над ними.

Крім того, ми маємо можливість спростити власне життя, використавши режими захвату. Є два режими:

[=]() {};
[&]() {};

[=] використовується для захоплення змінних по значенню. Всі змінні з зовнішнього контексту, які використовуються в тілі лямбда, автоматично захоплюються. Точніше захоплюється їхнє значення.

int main()
{
	vector<int> myVector(10, 5);

	int number1, number2;
	cin >> number1;
	cin >> number2;

	bool sum;
	sum = count_if(myVector.begin(), myVector.end(), [=](int _value) mutable -> bool
													{
														number1 = number2 + number1;
														return _value == (number1 + number2);
													}
				  );
	cout << sum << endl;

	return 0;
}

Це значно спрощує написання коду, оскільки при великій кількості захоплених змінних необхідно все прописувати вручну і лямбда-вираз стає дещо громіздким. Або ж виникає необхідність динамічного додавання змінної в тіло лямбди. Через це також існує ризик захопити непотрібну змінну.

	[var1, var2, var3]()                            [=]()
	{												{
		var1;											var1;
		var2;					==						var2;
		var3;											var3;
	}												}

[&] використовується аналогічно до [=] за винятком, що це захоплення за посиланням.

	[&]()    
	{		
		var1;
		var2;
		var3;
	}

Лямбда-вирази в С++

Якщо ми вказуємо [&], то зміна значення змінної в зовнішньому контексті відобразиться в тілі лямбда і навпаки, якщо ми змінюємо значення змінної в тілі лямбди - зміна відбудеться і в зовнішньому контексті. Слід зауважити, специфікатор mutable не обов'язково використовувати при операції над зовнішніми змінними в тілі лямбди.

При використанні [=] (змінити значення без mutable не можна) і зміні значень зовнішніх змінних в тілі лямбди ці змінні не змінюватимуться в зовнішньому контексті. Тобто, операції над зовнішніми змінними в тілі лямбди при захопленні по значенню відбуватимуться над копіями цих змінних, в той час як [&] працює з оригіналами.

Є такі варіанти використання режимів захоплення:

	[]() {};				// без захоплення
	[=]() {};				// всі змінні захоплюються по значенню
	[&]() {};				// всі змінні захоплюються по посиланню
	[var1, var2]() {};		// захоплення var1 і var2 по значенню
	[&var1, &var2]() {};	// захоплення var1 і var2 по посиланню
	[var1, &var2]() {};		// захоплення var1 по значенню, а var2 по посиланню
	[=, &var1, &var2]() {};	// всі змінні захоплюються по значенню
							// крім var1 і var2, які захоплюються по посиланню
	[&, var1, var2]() {};	// всі змінні захоплюються по посиланню
							// крім var1 і var2, які захоплюються по значенню

При такому виразі:

[=](int& _value) mutable -> {...};

У змінних, які захоплюються по значенню, буде мінятися їхня внутрішня копія, а от параметр _value передається по посиланню, тобто оригінал теж змінюватиметься.

Особливості лямбда-виразів

Розглянемо деякі особливості лямбда-виразів. Однією з особливостей є те, що лямбда-вираз не може захопити змінну у список захоплення, якщо ця змінна не просторі видимості. Цілком загальне правило, яке стосується не тільки лямбда-виразів.

													int var1, var2, var3;
	for (;;)										for (;;)
		int var1, var2, var3;						var1, var2, var3;
	[=]() mutable									[=]() mutable
	{												{
		var1;											var1;
		var2;											var2;
		var3;											var3;
	}												}
	  // wrong										   // right

Однак, якщо в нас є наступний випадок:

class Example
{
public:
	Example(int value = 0);
	void Output();
private:
	int m_value;
};

Example::Example(int value) : m_value(value)
{
}

void Example::Output()
{
	function<int(int)> lambdaSave = [m_value](int& _val) mutable -> int // error
	{
		_val = m_value;
		return _val;
	};
}

Тоді компілятор не допускає використання змінної m_value в лямбда-виразі, оскільки вона є членом даних (неважливо закритим, захищеним чи відкритим) і знаходиться поза зоною видимості. Для рішення цієї проблеми використовується вказівник this. Тепер даний код можна переписати таким чином:

class Example
{
public:
	Example(int value = 0);
	void Output();
private:
	int m_value;
};

Example::Example(int value) : m_value(value)
{
}

void Example::Output()
{
	auto lambdaSave = [this](int& _val) mutable -> int // error
	{
		_val = m_value;
		return _val;
	};
}

Зверніть увагу на вираз function<int(int)> lambdaSave і auto в прикладах. Незабаром ми до цього повернемося.

Лямбда-вирази в С++

Генерація лямбда-виразів

Як ви помітили, в останньому прикладі використовується такий вираз - function<int(int)> lambdaSave. Цей вираз зберігає стан лямбда-виразу.

Для цього небхідно підключити відповідну бібліотеку:

#include <functional>

За допомогою цієї бібліотеки можна писати функції, які генерують лямбда-вирази і навіть лямбда-вирази, що також генерують лямбда-вирази. Звучить складно, але зараз ви все зрозумієте.

Структура такої функції має вигляд:

Лямбда-вирази в С++

Приклад:

function<function<int(int)> (int)> Generation = [](int _value) -> function<int(int)>
													{
														return [_value](int _secondValue) -> int
																{
																	return _value + _secondValue;
																};
													};

Тепер пояснення:

Лямбда-вирази в С++

Лямбда-вирази в С++

В результаті Generation зберігає стан _secondValue + _value. Це все досить складно і на практиці навряд чи використовуватиметься. Наведено як приклад, щоб ви мали уявлення.

Крім того, ми згадували auto. Ключове слово auto дозволяє автоматично визначати тип. Наприклад:

auto a = 5;

Тут компілятор автоматично визначить тип змінної aint. Однак змінити тип в подальшому не можна. Тобто якщо переініціалізувати змінну а = 3.56, компілятор виведе число 3, так як він попередньо визначив тип а як int, а не float. За допомогою auto попередній код можна сильно спростити.

auto Generation = [](int _value) -> function<int(int)>
										{
											return [_value](int _secondValue) -> int
													{
														return _value + _secondValue;
													};
										};

Такий підхід реалізовує абстрагування і є практичним, оскільки програмістові не доведеться самому визначати типи.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.6K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація