Historia Wyrażeń Lambda: od C++03 do C++20, część I
Wyrażenia lambda są jednym z najpotężniejszych dodatków do C++11, które w dodatku ewoluują z każdą nową wersją Standardu C++. W tym wpisie przejdziemy przez ich historę, pokazując kluczową funkcję nowoczesnego C++.
Wstęp
Na jednym ze spotkań naszej lokalnej grupy C++ w Krakowie miała miejsce sesja live coding, ukazująca “historię” wyrażeń lambda. Spotkanie było prowadzone przez eksperta C++, Tomasza Kamińskiego (tutaj jego profil na linkedin). Link do naszego eventu:
Lambdy: Od C++11 do C++20 - C++ User Group Kraków
Zdecydowałem, że wezmę kod Tomka (za jego zgodą!) opiszę i opublikuję jako wpis na blogu.
Zaczniemy od standardu C++03 oraz potrzeby posiadania kompaktowych, lokalnych wyrażeń funkcyjnych. Następnie przejdziemy przez standardy C++11 oraz C++14. W drugiej części wpisu sprawdzimy, jakie zmiany wnosi ze sobą standard C++17, oraz zobaczymy, co nowego przyniesie standard C++20.
“Lambdy” w C++03
Od pierwszych dni STL algorytmy takie jak std::sort
mogły przyjąć obiekt typu callable
i wywołać go na elementach znajdujących się wewnątrz kontenera. Jednakże, w C++03 nie oznaczało to nic poza wskaźnikami na funkcję oraz funktorami.
Na przykład:
#include <iostream>
#include <algorithm>
#include <vector>
struct PrintFunctor {
void operator()(int x) const {
std::cout << x << std::endl;
}
};
int main() {
std::vector<int> v;
v.push_back(1);
v.push_back(2);
std::for_each(v.begin(), v.end(), PrintFunctor());
}
Klikalny przykład na Wandbox
Problemem jest tutaj niestety to, że byliśmy zmuszeni do utworzenia osobnej funkcji lub funktora, który znajdował się w całkiem innym zasięgu, niż implementacja algorytmu.
Jako potencjalne rozwiązanie można było utworzyć lokalną klasę funktora - ponieważ C++ zawsze wspierał tą składnię. Niestety, ale to nie działało…
Zobaczcie kod:
int main() {
struct PrintFunctor {
void operator()(int x) const {
std::cout << x << std::endl;
}
};
std::vector<int> v;
v.push_back(1);
v.push_back(2);
std::for_each(v.begin(), v.end(), PrintFunctor());
}
Spróbujcie skompilować to z flagą -std=c++98
. Pokaże Wam się następujący błąd (GCC):
error: template argument for
'template<class _IIter, class _Funct> _Funct
std::for_each(_IIter, _IIter, _Funct)'
uses local type 'main()::PrintFunctor'
Zasadniczo, to w C++98/03 nie mogliśmy utworzyć instancji typu szablonowego dla lokalnego typu.
Z powodu tych wszystkich ograniczeń komitet standaryzacyjny rozpoczął prace nad projektem nowej funkcjonalności: czegoś, co mogliśmy utworzyć i wywołać “w miejscu”… “wyrażeń lambda”!
Jeśli popatrzymy na N3337 - ostateczny szkic standardu C++11, to zobaczymy całkiem nową, osobną sekcję poświęconą lambdom: [expr.prim.lambda].
Przenosimy się do C++11
Myślę, że lambdy zostały dodane do języka w bardzo mądry sposób. Korzystają one z nowej składni, ale kompilator rozszerza je do postaci prawdziwych klas. To rozwiązanie ma wszystkie zalety (oraz niektóre wady) rodem z prawdziwie silnie typowanego języka.
Tutaj jest prosty kod, który również pokazuje nam lokalny obiekt funktora:
#include <iostream>
#include <algorithm>
#include <vector>
int main() {
struct {
void operator()(int x) const {
std::cout << x << '\n';
}
} someInstance;
std::vector<int> v;
v.push_back(1);
v.push_back(2);
std::for_each(v.begin(), v.end(), someInstance);
std::for_each(v.begin(), v.end(), [] (int x) {
std::cout << x << '\n';
}
);
}
Żywy przykład na WandBox
Możecie również sprawdzić CppInsights, który pokazuje, do jakiej postaci kompilator rozwija nasz kod:
Zobacz przykład na: CppInsighs: testy funkcji lambda
W powyższym przykładzie kompilator rozwinie kod:
[] (int x) { std::cout << x << '\n'; }
do postaci:
struct {
void operator()(int x) const {
std::cout << x << '\n';
}
} someInstance;
Składnia wyrażeń lambda:
[] () { code; }
^ ^ ^
| | |
| | opcjonalnie: mutable, exception, trailing return, ...
| |
| lista parametrów (opcjonalnie)
|
lista przechwytywania
Zanim zaczniemy, przejdźmy przez kilka definicji:
Cytowane ze standardu: [expr.prim.lambda#2]:
The evaluation of a lambda-expression results in a prvalue temporary. This temporary is called the closure object.
Cytowane ze standardu: [expr.prim.lambda#3]:
The type of the lambda-expression (which is also the type of the closure object) is a unique, unnamed non-union class type — called the closure type.
Kilka przykładów wyrażeń lambda:
[]{} // najprostsza lambda!
[](float f, int a) { return a*f; }
[](MyClass t) -> int { auto a = t.compute(); return a; }
[](int a, int b) { return a < b; }
Typy wyrażeń lambda
Niestety, ale nie ma sposobu, aby poznać nazwę lambdy przed momentem kompilacji, ponieważ kompilator generuje unikalną nazwę dla każdej lambdy z osobna.
To właśnie dlatego powinniśmy korzystać ze słowa kluczowego auto
(lub decltype
), kiedy chcemy wydedukować jej typ.
auto myLambda = [](int a) -> double { return 2.0 * a; }
Co więcej: [expr.prim.lambda]:
The closure type associated with a lambda-expression has a deleted ([dcl.fct.def.delete]) default constructor and a deleted copy assignment operator.
To dlatego nie możemy napisać:
auto foo = [&x, &y]() { ++x; ++y; };
decltype(foo) fooCopy;
Dostaniemy wtedy komunikat o błędzie (GCC):
error: use of deleted function 'main()::<lambda()>::<lambda>()'
decltype(foo) fooCopy;
^~~~~~~
note: a lambda closure type has a deleted default constructor
Operator wywoływania
Kod, który znajduje się w wyrażeniu lambda jest “tłumaczony” do postaci kodu wewnątrz metody operator()
odpowiedniej dla typu closure.
Domyślnie, jest to metoda typu const inline
. Możemy zmienić to, zamieszczając specyfikator mutable
za listą deklaracji parametrów:
auto myLambda = [](int a) mutable { std::cout << a; }
Dopóki stała metoda nie jest “problemem” dla wyrażeń lambda z pustą listą przechwytywania… to robi to różnicę, kiedy chcemy przechwycić coś z zewnątrz kodu znajdującego się wewnątrz lambdy.
Przechwycanie
Występujący w konstrukcji lambdy operator []
służy do przechwytywania listy zmiennych. Jest on nazywany “capture clause”.
Przez przechwytywanie zmiennej, mam na myśli kopię tej zmiennej wewnątrz typu closure
. Po przechwyceniu takiej zmiennej możemy korzystać z niej wewnątrz ciała lambdy.
Podstawowa składnia:
[&]
- przechwytywanie przez referencję wszystkich zmiennych automatycznych dostępnych w obecnym zasięgu[=]
- przechwytywanie przez wartość (wartość jest kopiowana)[x, &y]
- przechwytywanie zmiennejx
przez wartość oraz zmiennejy
przez referencję
Na przykład:
int x = 1, y = 1;
{
std::cout << x << " " << y << std::endl;
auto foo = [&x, &y]() { ++x; ++y; };
foo();
std::cout << x << " " << y << std::endl;
}
Możesz pobawić się pełnym przykładem na Wandbox.
O ile korzystanie z opcji [=]
oraz [&]
może być wygodne - w końcu przechwytujemy wszystkie zmienne z zasięgu, to o wiele ładniejszym rozwiązaniem jest wyraźne przechwytywanie konkretnych zmiennych. Dzięki temu kompilator może ostrzec nas przed niechcianymi efektami (na przykład zmienne globalne i statyczne, o których wspominam dalej).
Możemy również czytać o tym więcej w 31 rozdziale książki “Effective Modern C++” autorstwa Scott Meyers’a: “Avoid default capture modes.”.
Ważny cytat:
The C++ closures do not extend the lifetimes of the captured references.
Słowo kluczowe Mutable
Domyślnie operator operator()
generowany przez typ closure
jest typu const
, co oznacza, że nie możemy modyfikować przechwytywanych zmiennych wewnątrz ciała lambdy.
Jeżeli chcesz zmienić to zachowanie, potrzebujesz użyć słowa kluczowego mutable
zaraz za listą parametrów:
int x = 1, y = 1;
std::cout << x << " " << y << std::endl;
auto foo = [x, y]() mutable { ++x; ++y; };
foo();
std::cout << x << " " << y << std::endl;
W tym przykładzie możemy zmienić wartości zmiennych x
oraz y
… ale pamiętajmy o tym, że ciągle są to kopie zmiennych, które przekazywane są w zakresie obejmującym lambdę.
Przechwytywanie zmiennych globalnych
Kiedy mamy dostępną zmienną globalną, a następnie przechwycimy wszystkie zmienne w zasięgu ([=]
), możemy pomyśleć, że zmienna ta również będzie przecwycona przez wartość… ale tak nie jest.
int global = 10;
int main()
{
std::cout << global << std::endl;
auto foo = [=] () mutable { ++global; };
foo();
std::cout << global << std::endl;
[] { ++global; } ();
std::cout << global << std::endl;
[global] { ++global; } ();
}
Uruchom ten kod na Wandbox
Tylko zmienne automatyczne zostają przechwycone. Kompilator GCC dodatkowo może zwrócić ostrzeżenie:
warning: capture of variable 'global' with non-automatic storage duration
To ostrzeżenie pojawi się tylko wtedy, kiedy jawnie będziemy próbowali przechwycić globalną zmienną. Zatem jeżeli korzystamy z [=]
, kompilator nie pomoże nam.
Kompilator Clang jest trochę bardziej pomocny, ponieważ wygeneruje nam komunikat o błędzie:
error: 'global' cannot be captured because it does not have automatic storage duration
Zobacz na Wandbox
Przechwytywanie zmiennych statycznych
Podobnie do przechwytywania zmiennych globalnych, osiągniemy ten sam efekt ze zmiennymi statycznymi:
#include <iostream>
void bar()
{
static int static_int = 10;
std::cout << static_int << std::endl;
auto foo = [=] () mutable { ++static_int; };
foo();
std::cout << static_int << std::endl;
[] { ++static_int; } ();
std::cout << static_int << std::endl;
[static_int] { ++static_int; } ();
}
int main()
{
bar();
}
Uruchom ten kod na Wandbox
Wyjście programu:
10
11
12
I znowu, komunikat ostrzeżenia pojawi się tylko wtedy, kiedy jawnie będziemy chcieli przechwycić zmienną statyczną, zatem jeżeli wykorzystamy opcję [=]
, to kompilator nie ostrzeże nas o potencjalnym błędzie.
Przechwytywanie właściwości klasy
Czy wiecie, co stanie się w następującym kodzie:
#include <iostream>
#include <functional>
struct Baz
{
std::function<void()> foo()
{
return [=] { std::cout << s << std::endl; };
}
std::string s;
};
int main()
{
auto f1 = Baz{"ala"}.foo();
auto f2 = Baz{"ula"}.foo();
f1();
f2();
}
W powyższym kodzie deklarujemy obiekt klasy Baz
, który wywołuje metodę foo()
. Zwróćmy uwagę, że metoda foo()
zwraca lambdę (typ std::function
), która przechwytuje właściwość klasy.
Odkąd wykorzystujemy obiekty tymczasowe, nie możemy być pewni, co dokładnie wydarzy się, kiedy wywołamu f1
oraz f2
. Jest to problem nazywany jako dangling reference problem
, który generuje Zachowanie Niezdefiniowane.
Jest to podobne zachowanie do:
struct Bar {
std::string const& foo() const { return s; };
std::string s;
};
auto&& f1 = Bar{"ala"}.foo(); // dangling reference
Pobaw się tym kodem na Wandbox
Ponownie, jeśli jawnie określimy przechwytywanie ([s]
):
std::function<void()> foo()
{
return [s] { std::cout << s << std::endl; };
}
Kompilator uniemożliwi nam popełnienie tej pomyłki przez emisję komunikatu o błędzie:
In member function 'std::function<void()> Baz::foo()':
error: capture of non-variable 'Baz::s'
error: 'this' was not captured for this lambda function
...
Zobacz przykład na Wandbox
Obiekty typu Move-able-only
Jeśli działamy na obiecie, o którym mówi się, że jest “movable only” (na przykład unique_ptr
), to nie możemy przenieść go do wewnątrz lambdy jako przechwyconą zmienną. Przechwytywanie przez wartość nie zadziała, zatem będziemy mogli jedynie łapać ją przez referencję… jednakże to nie przeniesie jego prawa własności, a to prawdopodobnie nie jest tym, czego chcieliśmy dokonać.
std::unique_ptr<int> p(new int{10});
auto foo = [p] () {}; // nie skompiluje się....
Zachowywanie stałych
Jeżeli przechwycimy stałą, to w dalszym ciągu pozostaje ona niezmienna:
int const x = 10;
auto foo = [x] () mutable {
std::cout << std::is_const<decltype(x)>::value << std::endl;
x = 11;
};
foo();
Przetestuj ten kod na Wandbox
Zwracany typ
W C++11 możemy pominąć typ zwracany przez lambdę. Wtedy kompilator wydedukuje ten typ za nas.
Początkowo, dedukcja zwracanego typu była ograniczona do lambd posiadających tylko jedno słowo return
, ale szybko zostało to zniesione, ponieważ nie znaleziono problemów podczas implementacji wygodniejszej wersji.
Zobaczcie link C++ Standard Core Language Defect Reports and Accepted Issues (dzięki Tomek za znalezienie dobrego linku!)
Zatem, od C++11 kompilator może wydedukować zwracany typ, o ile nasze wyrażenia return
są tego samego typu.
if all return statements return an expression and the types of the returned expressions after lvalue-to-rvalue conversion (7.1 [conv.lval]), array-to-pointer conversion (7.2 [conv.array]), and function-to-pointer conversion (7.3 [conv.func]) are the same, that common type;
auto baz = [] () {
int x = 10;
if ( x < 20)
return x * 1.1;
else
return x * 2.1;
};
Pobaw się tym kodem na Wandbox
W powyższej lambdzie mamy dwa wyrażenia zwracające, które odnoszą się do typu double
, zatem kompilator będzie mógł wydedukować za nas typ wyrażenia lambda.
IIFE - Immediately Invoked Function Expression
W naszych przykładach definiowaliśmy lambdy, które następnie wywoływaliśmy korzystając z obiektu typu closure
… ale przecież możemy wywołać ją natychmiast:
int x = 1, y = 1;
[&]() { ++x; ++y; }(); // <-- wywołanie ()
std::cout << x << " " << y << std::endl;
Tego typu wyrażenie może się przydać, kiedy mamy do czynienia z kompleksową inicjalizacją obiektu stałego.
const auto val = []() { /* kilka linijek kodu... */ }();
Napisałem o tym więcej na swoim blogu: IIFE for Complex Initialization.
Konwersja do wskaźnika na funkcję
The closure type for a lambda-expression with no lambda-capture has a public non-virtual non-explicit const conversion function to pointer to function having the same parameter and return types as the closure type’s function call operator. The value returned by this conversion function shall be the address of a function that, when invoked, has the same effect as invoking the closure type’s function call operator.
W prostych słowach, do postaci wskaźnika na funkcję możemy konwertować tylko lambdę posiadającą pustą listę przechwytywań:
Na przykład:
#include <iostream>
void callWith10(void(* bar)(int))
{
bar(10);
}
int main()
{
struct
{
using f_ptr = void(*)(int);
void operator()(int s) const { return call(s); }
operator f_ptr() const { return &call; }
private:
static void call(int s) { std::cout << s << std::endl; };
} baz;
callWith10(baz);
callWith10([](int x) { std::cout << x << std::endl; });
}
Uruchom ten kod na Wandbox
Ulepszenia w C++14
Link do standardu: N4140 oraz do rozdziału z lambdami: [expr.prim.lambda].
C++14 dodało dwa znaczące ulepszenia do wyrażeń lambda:
- Przechwytywanie za pośrednictem inicjatorów
- Lambdy generyczne
Te funkcjonalności poprawiają kilka problemów, które zostały zaobserwowane w C++11.
Zwracany typ
Dedukcja typu zwracanego przez lambdę została zaktualizowana według zasad dedukcji typu auto
działających w funkcjach.
The lambda return type is auto, which is replaced by the trailing-return-type if provided and/or deduced from return statements as described in [dcl.spec.auto].
Przechwytywanie z inicjatorem
W skrócie, możemy utworzyć nową właściwość typu closure i korzystać z niej wewnątrz lambdy.
Na przykład:
int main() {
int x = 10;
int y = 11;
auto foo = [z = x+y]() { std::cout << z << '\n'; };
foo();
}
Rozwiązuje to kilka problemów, na przykład te związane z typami movable
.
Move
Od teraz możemy przesuwać obiekty do postaci właściwości typu closure
:
#include <memory>
int main()
{
std::unique_ptr<int> p(new int{10});
auto foo = [x=10] () mutable { ++x; };
auto bar = [ptr=std::move(p)] {};
auto baz = [p=std::move(p)] {};
}
Optymalizacja
Kolejnym pomysłem wykorzystania tej mechaniki jest potencjalna technika optymalizacyjna. Zamiast obliczania wartości z każdym wywołaniem lambdy, możemy obliczyć ją raz w jej inicjatorze:
#include <iostream>
#include <algorithm>
#include <vector>
#include <memory>
#include <iostream>
#include <string>
int main()
{
using namespace std::string_literals;
std::vector<std::string> vs;
std::find_if(vs.begin(), vs.end(), [](std::string const& s) {
return s == "foo"s + "bar"s; });
std::find_if(vs.begin(), vs.end(), [p="foo"s + "bar"s](std::string const& s) { return s == p; });
}
Przechwytywanie właściwości klas
Inicjator może być wykorzystywany również do przechwytywania właściwości klas. Możemy w ten sposób przechwycić kopię właściwości, nie przejmując się przy tym problemem ze zgubionymi referencjami:
Na przykład
struct Baz
{
auto foo()
{
return [s=s] { 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
Wewnątrz funkcji foo()
przechwytujemy właściwość klasy przez kopiowanie jej do środka typu closure
. Dodatkowo, korzystamy ze słowa kluczowego auto
w celu dedukcji typu całej funkcji (wcześniej, w C++11, korzystaliśmy z std::function
).
Lambdy generyczne
Innym znaczącym usprawnieniem lambd są lambdy generyczne.
Od C++14 możemy napisać:
auto foo = [](auto x) { std::cout << x << '\n'; };
foo(10);
foo(10.1234);
foo("hello world");
Jest to ekwiwalent do deklaracji szablonowej wewnątrz operatora typu closure
:
struct {
template<typename T>
void operator()(T x) const {
std::cout << x << '\n';
}
} someInstance;
Taka lambda generyczna może być bardzo pomocna, kiedy wydedukowanie przez nas typu stanie się trudne.
Na przykład:
std::map<std::string, int> numbers {
{ "one", 1 }, {"two", 2 }, { "three", 3 }
};
// za każdym razem wpis jest kopiowany z pair<const string, int>!
std::for_each(std::begin(numbers), std::end(numbers),
[](const std::pair<std::string, int>& entry) {
std::cout << entry.first << " = " << entry.second << '\n';
}
);
Czy popełniliśmy tutaj błąd? Czy entry
posiada prawidłowy typ?
.
.
.
Prawdopodobnie nie, jeżeli typem wartości wewnątrz std::map
jest std::pair<const Key, T>
. Zatem nasz kod wykona dodatkowe kopiowanie stringów…
To może być poprawione przez wykorzystanie słowa auto
:
std::for_each(std::begin(numbers), std::end(numbers),
[](auto& entry) {
std::cout << entry.first << " = " << entry.second << '\n';
}
);
Uruchom ten kod na Wandbox
Podsumowanie
Co za historia!
Ten wpis rozpoczęliśmy od wczesnych dni wyrażeń lambda w C++03 i C++11. Następnie przenieśliśmy się do ich poprawionej wersji w C++14.
Zobaczyliśmy, jak stworzyć lambdę, jaka jest podstawowa struktura tego wyrażenia, czym jest lista przechwytywania i wiele więcej.
W następnej części wpisu przeniesiemy się do C++17, a następnie spojrzymy w przyszłość standardu C++20.
Czy coś pominąłem?
Może macie ciekawe przykłady, które chcielibyście udostępnić?
Dajcie mi znać w komentarzach!
Linki do źródeł
- 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]
- Lambda Expressions in C++ | Microsoft Docs
- Demystifying C++ lambdas - Sticky Bits - Powered by FeabhasSticky Bits – Powered by Feabhas
- The View from Aristeia: Lambdas vs. Closures