Czy wiesz, że jesteśmy również na Slacku? Dołącz do nas już teraz klikając tutaj!

Historia Wyrażeń Lambda: od C++03 do C++20, część I


2019-03-21, 00:00

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 zmiennej x przez wartość oraz zmiennej y 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.

[expr.prim.lambda#4]

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ł



Bartłomiej Filipek

Programista i pasjonat C++ z ponad 11-letnim doświadczeniem. Bloguje od wielu lat, głównie o naszym ulubionym języku programowania. Autor ksiązki C++17 In Detail.

Blog Bartka
Profil na LinkedIn
Pssst! Używamy Cookies. Poprzez używanie naszego serwisu zgadzasz się na odczytywanie i zapisywanie Cookies w swojej przeglądarce.
Polityka Prywatności