Historia Wyrażeń Lambda: od C++03 do C++20, część II
W pierwszej części serii przeszliśmy przez lambdy w perspektywie od C++03, przez C++11 aż do C++14. Opisałem motywację do wprowadzenia tej potężnej funkcjonalności języka C++, podstawowe scenariusze użycia, składnię oraz jej udoskonalenia, które pojawiały się z każdą kolejną wersją standardu. Wspomniałem również o kilku scenariuszach brzegowych.
Teraz pora na przeniesienie się do standardu C++17 oraz spojrzenie w przyszłość (całkiem bliską!): C++20.
Wstęp
Jako słowo wstępu wspomnę, że pomysł na tę serię powstał na jednym z ostatnich spotkań naszej lokalnej grupy C++ w Krakowie. Mieliśmy krótką sesję live coding związaną z historią wyrażeń lambda. Spotkanie było prowadzone przez Eksperta C++, Tomasza Kamińskiego (zachęcam do odwiedzenia jego profilu na LinkedIn). Link do naszego eventu:
Lambdy: Od C++11 do C++20 - C++ User Group Krakow
Zdecydowałem, że wezmę kod Tomka (za jego zgodą!) opiszę i opublikuję jako wpis na blogu.
Jak dotąd, w pierwszym wpisie opisałem następujące elementy wyrażeń lambda:
- Podstawowa składnia
- Typ lambda
- Operator wywoływania
- Przechwytywanie (
mutable
, zmienne globalne, zmienne statyczne, właściwości klasy oraz wskaźnikthis
, obiekty typu move-able-only, zachowywanie stałych) - Zwracany typ
- IIFE - Immediately Invoked Function Expression
- Konwersja do wskaźnika na funkcję
- Udoskonalenia standardu C++14
- Dedukcja zwracanego typu
- Przechwytywanie z inicjalizerem
- Przechwytywanie właściwości klas
- Lambdy generyczne
Powyższa lista to tylko mała część całej historii o lambdach!
Sprawdźmy teraz, co w ich kontekście zmienił standard C++17 oraz co przyniesie nam standard C++20?
Usprawnienia w C++17
Link do standardu C++17 (ostatnia wersja draftu przed publikacją) N659 oraz do sekcji z opisem lambd: [expr.prim.lambda].
C++17 dodało dwa znaczące ulepszenia do wyrażeń lambda:
- lambdy
constexpr
- Przechwytywanie
*this
Jakie to ma dla nas znaczenie? Zapraszam do dalszej lektury.
Lambdy constexpr
Od C++17, jeśli lambda spełnia pewne wymagania, standard definiuje operator()
jako constexpr
.
Cytat z expr.prim.lambda #4:
The function call operator is a
constexpr
function if either the corresponding lambda-expression’s parameter-declaration-clause is followed byconstexpr
, or it satisfies the requirements for aconstexpr
function..
Na przykład:
constexpr auto Square = [] (int n) { return n*n; }; // niejawny constexpr
static_assert(Square(2) == 4);
Przypomnijmy sobie wszystkie reguły, które dla funkcji constexpr
wprowadza standard C++17:
- nie powinna być wirtualna;
- typ przez nią zwracany powinien być literałem;
- każdy z jej parametrów powinien być typu literalnego;
- jej ciało powinno być
= delete
,= default
, lub instrukcją złożoną, która nie zawiera:
- definicji
asm
,- instrukcji
goto
,- identyfikatora,
- bloku try-catch lub
- definicji zmiennej typu nieliteralnego, statycznej, o czasie życia wątku lub dla której żadna inicjalizacja nie została wykonana.
Co powiecie na praktyczny przykład?
template<typename Range, typename Func, typename T>
constexpr T SimpleAccumulate(const Range& range, Func func, T init) {
for (auto &&elem: range) {
init += func(elem);
}
return init;
}
int main() {
constexpr std::array arr{ 1, 2, 3 };
static_assert(SimpleAccumulate(arr, [](int i) {
return i * i;
}, 0) == 14);
}
Uruchom ten kod na @Wandbox
Kod ten wykorzystuje lambdę constexpr
, która następnie jest przekazana do algorytmu SimpleAccumulate
. Algorytm ten również wykorzystuje kilka elementów C++17: dodatek constexpr
dla std::array
, std::begin
oraz std::end
(wykorzystywane w pętli for). Dzięki temu, że wszystkie te elementy są typu constexpr
cały kod może zostać uruchomiony w trakcie procesu kompilacji.
Oczywiście, to nie wszystko.
Możemy również przechwytywać zmienne (zakładając, że są one także wyrażeniami stałymi):
constexpr int add(int const& t, int const& u) {
return t + u;
}
int main() {
constexpr int x = 0;
constexpr auto lam = [x](int n) { return add(x, n); };
static_assert(lam(10) == 10);
}
Jest jeden bardzo interesujący przypadek, kiedy nie możemy przechwycić takiej zmiennej:
constexpr int x = 0;
constexpr auto lam = [x](int n) { return n + x };
W tym przypadku kompilator Clang powinien zwrócić nam ostrzeżenie:
warning: lambda capture 'x' is not required to be captured for this use
Dzieje się tak najprawdopodobniej dlatego, że zmienna x
zostaje zastąpiona w locie przy każdym jej użyciu (dopóki nie przekażecie jej dalej lub pobierzecie jej adresu).
Jednakże, dajcie mi znać, jeśli znacie przyczyny takiego zachowania. Ja znalazłem jedynie informację (na cppreference), ale nie mogłem nigdzie tego znaleźć w drafcie…
A lambda expression can read the value of a variable without capturing it if the variable
- has const non-volatile integral or enumeration type and has been initialised with a constant expression, or
- is constexpr and has no mutable members.
Bądźcie przygotowani na niedaleką przyszłość:
W C++20 będziemy mieli standardowe algorytmy (oraz możliwe, że niektóre z kontenerów), które również będą constexpr
. Dzięki temu constexpr
lambdy będą bardzo poręczne. Twój kod będzie wyglądał identycznie w wersji runtime jak i w wersji compilation time!
W skrócie:
Lambdy constexpr
lepiej pracują z programowaniem szablonowym, dając możliwie jak najkrótszy kod wynikowy.
Przejdźmy teraz do drugiej ważnej nowości dostępnej od C++17:
Przechwytywanie *this
Czy pamiętacie problem, kiedy chcieliśmy przechwycić właściwość klasy?
Domyślnie, mogliśmy przechwytujemy this
(jako wskaźnik) i to właśnie dlatego mogliśmy popadać w kłopoty, kiedy obiekty tymczasowe zakończyły swój cykl życiowy… Możemy poprawić to wykorzystując przechwytywanie z inicjatorem (zobacz w pierwszej części wpisu).
Teraz, od C++17 mamy inną możliwość. Możemy wrapować kopię *this
:
#include <iostream>
struct Baz {
auto foo() {
return [*this] { std::cout << s << std::endl; };
}
std::string s;
};
int main() {
auto f1 = Baz{"ala"}.foo();
auto f2 = Baz{"ula"}.foo();
f1();
f2();
}
Uruchom ten kod na @Wandbox
Przechwytywanie wymaganej właściwości chroni nas przed potencjalnymi błędami związanymi z wartościami tymczasowymi, ale nie możemy zrobić tego samego z wywoływaniem metody znajdującej się wewnątrz typu:
Na przykład:
struct Baz {
auto foo() {
return [this] { print(); };
}
void print() const { std::cout << s << '\n'; }
std::string s;
};
W C++14 jedynym wyjściem gwarantującym nam bezpieczeństwo było przechwycenie this
:
auto foo() {
return [self=*this] { self.print(); };
}
Ale w C++17 możemy zrobić to samo, o wiele ładniej:
auto foo() {
return [*this] { print(); };
}
Jeszcze jedna rzecz:
Zwróćcie uwagę, że jeżeli przechwycicie “wszystko” używając [=]
w metodzie wewnątrz klasy, wtedy this
również zostanie (niejawnie) przechwycony! Może to powodować w przyszłości błędy… dlatego zostanie to oznaczone jako deprecated w C++20.
Tym oto akcentem przenosimy się do kolejnej sekcji: przyszłości.
Przyszłość z C++20
Wraz z C++20 przychodzą następujące nowości:
- Zezwalanie na
[=, this]
przy przechwytywaniu - P0409R2 oraz deprecate domniemanego przechwytywania this przy[=]
- P0806 - Pakowanie w liście przechwytywania wewnątrz lambdy:
...args = std::move(args)](){}
- P0780 static
,thread_local
oraz przechwytywanie dla structured binding - P1091- lambdy szablonowe (wraz z konceptami) - P0428R2
- Uproszczenia dla przechwytywania domniemanego - P0588R1
- Domyślnie konstuowalne oraz przypisywalne bezstanowe lambdy - P0624R2
- Lambdy w unevaluated contexts - P0315R4
W większości przypadków nowo dodane funkcjonalności trochę “oczyszczają” lambdy oraz zezwalają na kilka bardziej zaawansowanych przypadków użycia.
Na przykład wraz z P1091 możemy przechwytywać structured bindings.
Mamy również wyjaśnienia w sprawie przechwytywania this
. W C++20 będziesz otrzymwał ostrzeżenie, jeżli użyjesz przechwytywania [=]
wewnątrz metody:
struct Baz {
auto foo() {
return [=] { std::cout << s << std::endl; };
}
std::string s;
};
Ostrzeżenie wygenerowane przez kompilator GCC 9:
warning: implicit capture of 'this' via '[=]' is deprecated in C++20
Uruchom ten kod na @Wandbox
Jeżeli na prawdę potrzebujecie przechwycić this
, powinniście korzystać z [=, this]
.
Mamy również zmiany związane z zaawansowanymi użyciami takimi jak unevaluated contexts oraz domyślnie konstruktowalne lambdy bezstanowe.
Dzięki tym dwóm zmianom będziemy mogli napisać:
std::map<int, int, decltype([](int x, int y) { return x > y; })> map;
Ale jest jeszcze jedna nowa, bardzo interesująca funkcja: lambdy szablonowe.
Lambdy szablonowe
Wraz z C++14 otrzymaliśmy lambdy generyczne, co oznacza, że parametry zadeklarowane jako auto
zachowują się jako parametry szablonowe.
Przykładowo, dla lambdy:
[](auto x) { x; }
kompilator wygeneruje operator wywołania, który odpowiada metodzie szablonowej:
template<typename T>
void operator(T x) { x; }
Niestety, nie mamy możliwości zmiany typu tego parametru, wykorzystując typy szablonowe. Jednak z C++20 będzie to możliwe.
Na przykład, w jaki sposób możemy ograniczyć naszą lambdę do działania jedynie z wektorami określonego typu?
Możemy stworzyć lambdę generyczną:
auto foo = []<typename T>(const auto& vec) {
std::cout<< std::size(vec) << '\n';
std::cout<< vec.capacity() << '\n';
};
Jeżeli wywołamy ją z parametrem typu int
(np. foo(10);
), to otrzymamy trudny do zrozumienia komunikat o błędzie:
prog.cc: In instantiation of 'main()::<lambda(const auto:1&)> [with auto:1 = int]':
prog.cc:16:11: required from here
prog.cc:11:30: error: no matching function for call to 'size(const int&)'
11 | std::cout<< std::size(vec) << '\n';
W C++20 będziemy mogli napisać:
auto foo = []<typename T>(std::vector<T> const& vec) {
std::cout<< std::size(vec) << '\n';
std::cout<< vec.capacity() << '\n';
};
Powyższa lambda rozwija się do następującego operatora:
<typename T>
void operator(std::vector<T> const& s) { ... }
Parametr szablonowy pojawi się za listą przechwytywania []
.
Jeśli wywołacie tą lambdę z parametrem int
(foo(10);
), to otrzymacie ładniejszy komunikat:
note: mismatched types 'const std::vector<T>' and 'int'
Uruchom ten kod na@Wandbox
W powyższym przykładzie kompilator ostrzeże nas o niedopasowaniu interfejsu lambdy niż o błędach pojawiających się w jej ciele.
Innym ważnym aspektem związanym z lambdami generycznymi jest to, że mamy do czynienia ze zwykłą zmienną, której typ nie jest typem szablonowym. Zatem, jeśli chcemy mieć dostęp do typu szablonowego, musimy skorzystać z decltype(x)
(dla lambdy z argumentem (auto x)
). Spowoduje to, że nasz kod będzie nieco bardziej rozwlekły i skomplikowany.
Na przykład (korzystamy z kodu z P0428):
auto f = [](auto const& x) {
using T = std::decay_t<decltype(x)>;
T copy = x;
T::static_function();
using Iterator = typename T::iterator;
}
Możemy teraz napisać to samo, ale w odrobinę prostszy sposób:
auto f = []<typename T>(T const& x) {
T::static_function();
T copy = x;
using Iterator = typename T::iterator;
}
W tej sekcji zrobiliśmy mały przegląd standardu C++20, ale mam jeden dodatkowy scenariusz dla Was. Ta technika jest dostępna nawet w C++14. Czytajcie zatem dalej.
Bonus - LIFTing z lambdami
Obecnie mamy problem, kiedy próbujemy przeładować funkcję, do której chcemy przekazać algorytm standardowy (lub cokolwiek innego, co wymaga obiektu callable):
// dwa przeładowania:
void foo(int) {}
void foo(float) {}
int main()
{
std::vector<int> vi;
std::for_each(vi.begin(), vi.end(), foo);
}
W GCC9 (trunk) pojawi się następujący komunikat o błędzie:
error: no matching function for call to
for_each(std::vector<int>::iterator, std::vector<int>::iterator,
<unresolved overloaded function type>)
std::for_each(vi.begin(), vi.end(), foo);
^^^^^
Jest jednakże jeden trick, dzięki któremu możemy użyć lambdy, a następnie wywołać żądane przeładowanie.
W podstawowej postaci, dla prostych typów, nasze dwie funkcje mogą zostać zapisane następująco:
std::for_each(vi.begin(), vi.end(), [](auto x) { return foo(x); });
Czyli pakujemy wywołanie naszych funkcji w generyczną lambdę.
W najbardziej generycznej postaci potrzebujemy odrobinę więcej typowania:
#define LIFT(foo) \
[](auto&&... x) \
noexcept(noexcept(foo(std::forward<decltype(x)>(x)...))) \
-> decltype(foo(std::forward<decltype(x)>(x)...)) \
{ return foo(std::forward<decltype(x)>(x)...); }
Skomplikowane, co nie? :)
Spróbujmy to rozszyfrować:
Tworzymy generyczną lambdę, a następnie przekazujemy do niej wszystkie argumenty. Aby zdefiniować ją poprawnie, potrzebujemy określić ją jako noexcept
i zwrócić wartość, która określi jej typ. To dlatego potrzebujemy zduplikować kod wywołania: aby dostać się do odpowiedniego typu.
To makro nazywane LIFT
działa na każdym kompilatorze, który wspiera C++14.
Uruchom ten kod na @Wandbox
Podsumowanie
W tym wpisie poznaliśmy znaczące zmiany w C++17 oraz zobiliśmy przegląd nowych funkcjonalności w C++20.
Mogliśmy zaobserwować, że każda iteracja języka łączy lambdy z pozostałymi elementami C++. Na przkład, przed C++17 nie mogliśmy wykorzystać ich w kontekście constexpr
. Teraz jest to już możliwe. Podobnie z lambdami generycznymi z C++14, oraz ich ewolucją w C++20 w postaci lambd szablonowych.
Czy coś pominąłem?
Może macie jakieś ciekawe przykłady do udostępnienia?
Dajcie mi znać w komentarzach!
Referencje
- orginał: Bartek’s coding blog: Lambdas: From C++11 to C++20, Part 1
- orginał: Bartek’s coding blog: Lambdas: From C++11 to C++20, Part 2
- C++11 - [expr.prim.lambda]
- C++14 - [expr.prim.lambda]
- C++17 - [expr.prim.lambda]
- Lambda Expressions in C++ | Microsoft Docs
- Simon Brand - Passing overload sets to functions
- Jason Turner - C++ Weekly - Ep 128 - C++20’s Template Syntax For Lambdas
- Jason Turner - C++ Weekly - Ep 41 - C++17’s constexpr Lambda Support