Nie ulega wątpliwością, że standard C++ 11 był prawdziwą rewolucją w programowaniu w tym języku. Jedną z najbardziej oczekiwanych zmian było wprowadzenie obsługi wyrażeń lambda w C++. W poniższym wpisie postaram się przybliżyć wam specyficzną składnię oraz przykłady użycia wyrażeń lambda w C++.
Wyrażenia lambda w C++ pozwalają nam na pisanie krótkich, anonimowych funkcji często nieprzeznaczonych do dalszego użytkowania w kodzie. Niewartych nawet nazywania. Ot takie jednolinijkowce pozwalające na skrócenie kodu i uproszczeniu zapisu funkcji.
Wyrażenia lambda C++ – podstawowa składnia
Chcąc skonstruować swoje pierwsze użyteczne wyrażenie lambda powinniśmy zacząć od zapoznania się ze składnią jednej z najmniej skomplikowanych wariacji wyrażeń lambda. Jak wspomniałem są to funkcje z tym, że anonimowe – nie posiadają nazwy.
Chcąc zdefiniować lambdę w kodzie zaczniemy od napisania początkowo pustych (spokojnie wrócimy do tego) nawiasów kwadratowych. Na dobry początek wystarczy Ci wiedza, że bez [] nie ma wyrażenia lambda. Następnie w okrągłych nawiasach, podobnie jak ma to miejsce w przypadku zwykłych funkcji, zapiszemy parametry funkcji, które będzie można doń przekazać. W nawiasach klamrowych umieszczamy ciało funkcji.
Klasyczny już przykład użycia wyrażenia lambda:
1 2 3 4 5 6 7 8 9 10 |
vector <int> model = {4,3,1,7,11,0}; sort(model.begin(), model.end( ), [ ]( const int a, const int b ) { return a > b; }); for(vector<int>::iterator it = model.begin() ; it != model.end(); ++it) cout << *it << ' '; //11 7 4 3 1 0 |
Funkcja sort z biblioteki algorithm domyślnie sortuje przekazane dane od najmniejszej wartości do największej. Gdybyśmy chcieli posortować dane w odwrotnej kolejności to musielibyśmy przekazać do funkcji warunek porównania, czyli funkcję tego dokonującą. Wiązałoby się to z koniecznością stworzenia specjalnej funkcji przeznaczonej jedynie do tak dennego zadania. Zamiast tego można skorzystać z wyrażenia lambda. Do funkcji sort przekazaliśmy wyrażenie przyjmujące dwa parametry typu int. Natomiast w ciele funkcji dochodzi do porównania ich wartości. W efekcie zwrócona zostaje wartość typu bool, a na jej podstawie funkcja sort wie, które wartości w wektorze ze sobą zamienić. W efekcie mamy dane posortowane malejąco.
Wywoływanie wyrażeń lambda z argumentami
Co jeżeli chcemy mieć kontrolę nad argumentami przekazywanymi do wyrażenia lambda? Wtedy skorzystamy z opcji wywołania lambdy razem z przekazanymi argumentami. Spójrz proszę na poniższy zapis:
1 2 3 |
cout << [](const int a, const int b){ return a<b; } (3,5) << endl; cout << [](const int a, const int b){ return a<b; } (5,3) << endl; |
W obu przypadkach wewnątrz funkcji dojdzie do porównania wartości przekazanych parametrów.
Wyrażenia lambda C++ – typ zwracany
Kompilator powinien automatycznie domyślić się jaki typ ma zwracać wyrażenie lambda. Dobrze to ukazuje poniższy przykład:
1 2 3 4 5 |
cout << typeid( [](auto a) { return (float)a*3; } (3) ).name() << endl; //f cout << typeid( [](auto a) { return a*3; } (3) ).name() << endl;//i cout << typeid( [](auto a) { return a*3; } (0.5) ).name() << endl;//d |
Przy każdym wywołaniu na własną rękę zinterpretował jaki ma być typ zwracany. Ale co w przypadku, gdy funkcja ma więcej niż jedną ścieżkę kończącą i w rezultacie niejedno (lub niejednoznaczne) wystąpienie słowa kluczowego return.
1 2 3 4 5 6 7 |
[](auto a) { if(a>3) return 0; else return 0.43 } (4); |
Zatem taki zapis jak powyższy się nie skompiluje, ponieważ kompilator nie może zdecydować przed rozpoczęciem programu jaki będzie zwracany typ. Co zatem możemy z tym fantem zrobić? Albo po chamsku rzutujemy zwracaną wartość na interesujący nas typ przy każdym wystąpieniu słowa return, albo bezpośrednio przed ciałem funkcji zapisujemy jaki zwracany typ nas interesuje. Taktykę tą przedstawia przykład poniżej:
1 |
cout << typeid([](auto a)->double { if(a>3) return 0; else return 0.43; } (4) ).name() << endl; //d |
Typ wyrażenia lambda
Ciekawym rozwiązaniem jest udostępnienie możliwości tworzenia zmiennych typu wyrażenie lambda. Nie wiem czy można to tak ująć, ale takie sformułowanie przyszło mi do głowy. Spójrzcie sami:
1 2 3 4 5 6 7 8 9 |
auto licz = [] (const int a, const int b) { return a^b; }; int var = licz(5,7); cout << var << endl;//2 var = licz(13,23); cout << var << endl;//26 |
Po utworzeniu zmiennej licz i zainicjalizowaniu jej wyrażeniem lambda bez podania konkretnych argumentów możemy używać jej identycznie jak zwykłych funkcji używając ich do realizacji szybkich obliczeń.
Capture, czyli przechwytywanie nazw
Składnia wyrażeń lambda umożliwia nam korzystanie ze zmiennych użytych wcześniej w kodzie poprzez przechwycenie ich nazw. Dzięki temu wewnątrz ciała wyrażenia lambda będzie można śmiało z nich korzystać bez konieczności przekazywania ich jako argumenty.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
int a=0, b=10; //[]() { cout << "Nie mam dostepu żadnej zmiennej " << a << ' ' << b << endl; }();//blad kompilacji [=]() { cout << "Mam dostęp do wartości zmiennych " << a << ' ' << b << endl; }(); [&]() { cout << "Mam dostęp do zmiennych przez referencję" << endl; ++a; ++b; }(); cout << a << ' ' << b << endl; // 1 11 [&b, a]() { cout << "Mam dostęp do wartości zmiennej a i referencji b: " << a << ' ' << ++b << endl; }();//1 12 [=, &a]() { cout << "Wszystkie przez wartość, ale akurat a przez referencje: " << ++a << ' ' << b << endl; }();//2 12 [&, b]() { cout << "Wszystkie przez referencje, ale akurat b przez wartość: " << ++a << ' ' << b << endl; }(); //3 12 //[&, &b]() {}( cout << "Błąd kompilacji -> referencja jest domyślna"; ); //[=, a]() {}( cout << "Błąd kompilacji -> domyślne przechwytywanie przez wartość"; ); |
Mamy kilka wariacji dotyczących przechwytywania nazw. Możemy przechwytywać je uzyskując jedynie dostęp do ich wartości (przez wartość) lub też uzyskując dostęp do oryginałów zmiennych z możliwością ich edycji (przez referencję).
Zostawiając puste nawiasy domyślnie nie przechwycimy żadnej nazwy. Natomiast korzystając z modyfikatorów „=” oraz „&” uzyskamy dostęp do wszystkich nazw przez wartość w pierwszym przypadku lub przez referencję w drugim. Nic nie stoi na przeszkodzie, aby jawnie przechwytywać tylko konkretne zmienne poprzez wpisywanie ich nazw pomiędzy nawiasy kwadratowe. Pisząc samą nazwę zmiennej uzyskamy dostęp do wartości zmiennej, a ona sama zostanie potraktowana jako stała. Natomiast dodając przed nazwą „&” uzyskasz dostęp do oryginału zmiennej.
Specyfikator mutable
Za pomocą specyfikatora mutable umieszczanego zaraz po liście parametrów lambdy możemy zmusić nieco kompilator do współpracy. Stosując taki zapis:
1 2 3 4 5 |
int a=0; [a]() mutable { cout << "a w lambdzie: " << ++a << endl; }(); cout << "a poza lambdą: " << a; |
Stworzymy lokalną kopię przechwyconej zmiennej. Będziemy mogli korzystać z jej nazwy wewnątrz ciała lambdy, ale prawdziwa zawartość zmiennej a pozostanie bez zmian.
Od standardu C++ 14 istnieje możliwość utworzenia zmiennej o ustalonej wartości w obrębie lambdy tak, aby nie trzeba było specjalnie tworzyć zmiennej, której nazwę chcielibyśmy przechwycić. Jeżeli dodatkowo dodamy specyfikator mutable to będziemy mieli możliwość zmiany wartości tejże zmiennej.
1 2 3 4 5 6 7 8 9 |
auto licznik = [ i = 0]() mutable { cout << "To jest " << ++i << " wywołanie licznika" << endl;}; licznik();//To jest 1 wywołanie licznika licznik();//To jest 2 wywołanie licznika licznik();//To jest 3 wywołanie licznika licznik();//To jest 4 wywołanie licznika |
Podsumowanie
Zachęcam do eksperymentowania z lambdami na własną rękę i poszukiwania wiedzy w najlepszym możliwym miejscu, czyli w dokumentacji C++ 😀