Wyrażenia lambda

Wyrażenia lambda

Wyrażenie lambda, czasami nazywane funkcją lambda lub (niepoprawnie, jeśli ściśle trzymać się terminologii) po prostu lambdą, jest uproszczoną notacją do definiowania i używania anonimowych obiektów funkcyjnych. Zamiast definiować nazwaną klasę z funkcją operator() i później tworzyć jej obiekt, a następnie go wywoływać, można zastosować metodę na skróty. Jest to szczególnie przydatne, gdy trzeba przekazać operację jako argument do algorytmu. W kontekście graficznych interfejsów użytkownika (a także innych) operacje takie nazywa się wywołaniami zwrotnymi (ang. callback). W tym podrozdziale koncentruję się na technicznych
aspektach budowy lambd. Przykłady i techniki ich używania są opisane w innych miejscach.

Wyrażenie lambda składa się z kilku części:

  • Potencjalnie pustej listy zmiennych lokalnych (ang. capture list), określającej, które nazwy ze środowiska definicji mogą być używane wewnątrz wyrażenia lambda oraz czy dostępne są poprzez skopiowanie, czy referencję. Listą zmiennych lokalnych oznacza się znakami [].
  • Opcjonalnej listy parametrów, określającej argumenty wymagane przez lambdę. Listę parametrów oznacza się znakami ().
  • Opcjonalnego specyfikatora mutable, oznaczającego, że w treści wyrażenia lambda można modyfikować stan lambdy (tzn. zmieniać kopie zmiennych pobranych przez kopiowanie).
  • Opcjonalnego specyfikatora noexcept.
  • Opcjonalnej deklaracji typu zwrotnego w postaci ->typ.
  • Treści właściwej, stanowiącej kod do wykonania. Treść właściwą oznacza się znakami {}. 

Zasady przekazywania argumentów, zwracania wyników oraz definiowania treści są takie same jak dla funkcji. W funkcjach nie ma tylko możliwości „łapania” zmiennych lokalnych. To znaczy, że lambda może pełnić rolę funkcji lokalnej, podczas gdy funkcja nie. 

Model implementacji

Wyrażenia lambda można zaimplementować na wiele sposobów i jest sporo sposobów na ich efektywne zoptymalizowanie. Jednak moim zdaniem semantykę lambd łatwiej jest zrozumieć, jeśli uważa się je za skrót do definiowania i używania obiektów funkcyjnych. Spójrz na poniższy względnie prosty przykład:

void print_modulo(const vector<int>& v, ostream& os, int m)
    // wysyła v[i] do os, jeśli v[i]%m==0
{
    for_each(begin(v),end(v),
        [&os,m](int x) { if (x%m==0) os << x << ' '; }
    );
}

Poniżej znajduje się definicja równoważnego obiektu funkcyjnego:

class Modulo_print {
    ostream& os; // składowe do przechowywania listy argumentów lokalnych
    int m;
public:
    Modulo_print(ostream& s, int mm) :os(s), m(mm) {} // lista zmiennych
    void operator()(int x) const
        { if (x%m==0) os << x << ' '; }
};

Lista zmiennych lokalnych [&os,m] została zamieniona na dwie zmienne składowe i konstruktor służący do ich inicjacji. Znak & przed os oznacza, że powinniśmy zapisać referencję, a brak tego znaku przed m oznacza, że powinniśmy zapisać kopię. Ten sposób użycia & odzwierciedla zastosowanie tego znaku w deklaracjach argumentów funkcji.

Treść lambdy staje się treścią funkcji operator()(). Jako że lambda nie zwraca wartości, funkcja operator()() jest typu void. Domyślnie operator()() jest const, dzięki czemu lambda nie modyfikuje przechwyconych zmiennych. Jest to zdecydowanie najczęściej spotykany przypadek. Gdybyśmy chcieli zmienić stan lambdy z wnętrza jej treści właściwej, moglibyśmy ją zadeklarować jako mutable. Odpowiada to zdefiniowaniu funkcji operator()() jako nie const. Obiekt klasy wygenerowany przez lambdę nazywa się zamknięciem (ang. closure). Teraz oryginalną funkcję możemy napisać następująco:

void print_modulo(const vector& v, ostream& os, int m)
    // wysyła v[i] do os, jeśli v[i]%m==0
{
    for_each(begin(v),end(v),Modulo_print{os,m});
}

Jeśli lambda może przechwycić wszystkie zmienne lokalne przez referencję (przy użyciu listy [&]), zamknięcie można zoptymalizować poprzez umieszczenie w nim po prostu wskaźnika do otaczającej ramki na stosie.

Alternatywy dla lambd

Ostateczna wersja funkcji print_modulo() jest nawet całkiem atrakcyjna, a nadawanie nazw niebanalnym operacjom to ogólnie rzecz biorąc bardzo dobry pomysł. Ponadto osobna klasa udostępnia więcej miejsca na komentarze niż lambda osadzona w jakiejś liście argumentów. Jednak wiele lambd to bardzo małe i jednorazowe konstrukcje. W takich przypadkach realną alternatywą jest zdefiniowanie lokalnej klasy bezpośrednio przed jej użyciem. Na przykład: 

void print_modulo(const vector<int>& v, ostream& os, int m)
    // wysyła v[i] do os, jeśli v[i]%m==0
{
    class Modulo_print {
        ostream& os; // składowe do przechowywania listy zmiennych lokalnych
        int m;
    public:
        Modulo_print (ostream& s, int mm) :os(s), m(mm) {} // przechwyt
        void operator()(int x) const
            { if (x%m==0) os << x << ' '; }
    };
    for_each(begin(v),end(v),Modulo_print{os,m});
}

W porównaniu z tym wersja z lambdą jest bez wątpienia lepsza. Jeśli bardzo potrzebujemy nazwy, to możemy ją nadać lambdzie:

void print_modulo(const vector<int>& v, ostream& os, int m)
    // wysyła v[i] do os, jeśli v[i]%m==0
{
    auto Modulo_print = [&os,m] (int x) { if (x%m==0) os << x << ' '; };
    for_each(begin(v),end(v),Modulo_print);
}

Nadanie nazwy lambdzie to często bardzo dobry pomysł. Zmusza to nas do dokładniejszego przemyślenia projektu operacji. Ponadto upraszcza układ kodu i umożliwia stosowanie rekurencji. Alternatywą dla lambdy z for_each() jest użycie pętli for. Na przykład:

void print_modulo(const vector<int>& v, ostream& os, int m)
    // wysyła v[i] do os, jeśli v[i]%m==0
{
    for (auto x : v)
        if (x%m==0) os << x << ' ';
}

Dla wielu ta wersja jest bardziej czytelna niż którakolwiek z wersji z lambdą. Jednak for_each() to raczej specjalny algorytm, a vector to konkretny kontener. Spójrz na poniższe uogólnienie funkcji print_modulo(), obsługujące dowolny kontener:

template<class C>
void print_modulo(const C& v, ostream& os, int m)
    // wysyła v[i] do os, jeśli v[i]%m==0
{
    for (auto x : v)
        if (x%m==0) os << x << ' ';
}

Ta wersja działa bardzo dobrze dla mapy. Zakresowa instrukcja for języka C++ jest specjalnie przeznaczona do przeglądania sekwencji od początku do końca. Przeglądanie w ten sposób kontenerów z biblioteki STL jest łatwe i ogólne. Na przykład za pomocą pętli for mapę przegląda się metodą w głąb (ang. depth-first). W jaki sposób wykonać przeglądanie wszerz? Wersja
funkcji print_modulo() z pętlą for nie da się zmienić, więc musimy ją przepisać jako algorytm. Na przykład:

template<class C>
void print_modulo(const C& v, ostream& os, int m)
    // wysyła v[i] do os, jeśli v[i]%m==0
{
    breadth_first(begin(v),end(v),
        [&os,m](int x) { if (x%m==0) os << x << ' '; }
    );
}

Zatem lambdy można używać jako „ciała” ogólnej konstrukcji pętlowej/przeglądającej reprezentowanej jako algorytm. Efektem użycia for_each zamiast breadth_first byłoby przeglądanie w głąb. Wydajność przekazania lambdy jako argumentu do algorytmu przeglądającego jest porównywalna (przeważnie identyczna) z wydajnością analogicznej pętli. Z doświadczenia wiem, że jest to prawdą w różnych implementacjach i na różnych platformach. To oznacza, że wyboru między algorytmem z lambdą a instrukcją for z „ciałem” należy dokonywać na podstawie stylu oraz możliwości ewentualnego rozszerzania i łatwości utrzymania kodu.

Lista zmiennych

Najważniejszym zastosowaniem lambd jest definiowanie kodu do przekazania jako argument. Dzięki lambdom można to robić bezpośrednio „w wierszu kodu” bez potrzeby tworzenia dodatkowej nazwanej funkcji (albo obiektu funkcyjnego) w celu użycia jej w innym miejscu. Niektóre lambdy nie wymagają w ogóle dostępu do lokalnego środowiska. W ich definicji stosuje się pustą listę zmiennych lokalnych []. Na przykład:

void algo(vector<int>& v)
{
    sort(v.begin(),v.end()); // sortowanie wartości
    //...
    sort(v.begin(),v.end(),[](int x, int y) { return abs(x)<abs(y); }); // sortowanie wartości
    // bezwzględnych
    //...
}

Jeśli potrzebny jest dostęp do nazw lokalnych, należy o tym wprost poinformować albo spowoduje się błąd:

void f(vector<int>& v)
{
    bool sensitive = true;
    //...
    sort(v.begin(),v.end(),
        [](int x, int y) { return sensitive ? x<y : abs(x)<abs(y); } // błąd: nie ma dostępu
        // do sensitive
    );
}

W tym przykładzie użyłem prezentera lambdy (ang. lambda introducer) []. Jest to najprostszy możliwy prezenter, który nie pozwala na odwoływanie się do jakichkolwiek nazw z otaczającego środowiska. Pierwszym znakiem każdego wyrażenia lambda jest [. Prezenter lambdy może występować w kilku różnych postaciach:

  • []: pusta lista zmiennych lokalnych. Ten zapis oznacza, że w lambdzie nie można używać żadnych nazw z otaczającego ją kontekstu. W takich wyrażeniach dane są pobierane z argumentów lub zmiennych nielokalnych.
  • [&]: niejawne przechwytywanie przez referencję. Można używać wszystkich nazw lokalnych i są one dostępne przez referencję.
  • [=]: niejawne przechwytywanie przez wartość. Można używać wszystkich nazw lokalnych i odnoszą się one do kopii zmiennych lokalnych pobranych w miejscu wywołania wyrażenia lambda.
  • [lista-zmiennych]: bezpośrednie określenie listy nazw zmiennych do przechwycenia (tzn. zapisania w obiekcie) przez referencję lub wartość. Zmienne, których nazwy zostały poprzedzone znakiem &, są przechwytywane przez referencję. Pozostałe są przechwytywane przez wartość. Na liście zmiennych może też znajdować się słowo this oraz nazwy z operatorem ....
  • [&, lista-zmiennych]: niejawne przechwycenie przez referencję wszystkich zmiennych lokalnych, których nazwy nie zostały wymienione na liście. Lista zmiennych może zawierać słowo kluczowe this. Przed nazwami na liście nie może być znaku &. Zmienne wymienione na liście są przechwytywane przez wartość.
  • [=, lista-zmiennych]: niejawne przechwycenie przez wartość wszystkich zmiennych lokalnych, których nazwy nie zostały wymienione na liście. Lista zmiennych nie może zawierać słowa kluczowego this. Przed nazwami na liście musi być znak &. Zmienne wymienione na liście są przechwytywane przez referencję.

Należy podkreślić, że zmienne lokalne poprzedzone znakiem & zawsze są przechwytywane przez referencję, a zmienne pozbawione tego znaku zawsze są przechwytywane przez wartość. Tylko zmienne przechwycone przez referencję można modyfikować w środowisku wywołującym. Listy zmiennych pozwalają szczegółowo określić, które nazwy ze środowiska wywołującego mają być dostępne i w jaki sposób można ich używać. Na przykład:

void f(vector<int>& v)
{
    bool sensitive = true;
    //...
    sort(v.begin(),v.end()
        [sensitive](int x, int y) { return sensitive ? x<y : abs(x)<abs(y); }
    );
}

Umieszczając nazwę sensitive na liście zmiennych, sprawiamy, że jest ona dostępna wewnątrz lambdy. Ponieważ nie określiliśmy inaczej, zmienna sensitive jest przechwytywana przez wartość — podobnie jak przy przekazywaniu argumentów domyślnie zmienne przekazywane są właśnie przez wartość. Gdybyśmy chcieli przechwycić zmienną sensitive przez referencję, moglibyśmy przed jej nazwą dodać znak &: [&sensitive].

Zasady wyboru między przekazaniem zmiennej przez wartość a przez referencję są zasadniczo takie same jak przy przekazywaniu argumentów do funkcji. Referencji używa się, gdy trzeba coś zapisać w przechwytywanym obiekcie albo gdy jest on duży. Jednak w przypadku lambd w grę wchodzi dodatkowa kwestia tego, że lambda może istnieć dłużej niż to, co
ją wywołało. Przy przekazywaniu lambdy do innego wątku zazwyczaj najlepszym rozwiązaniem jest przechwytywanie przez wartość ([=]): próba uzyskania dostępu do stosu innego wątku przez referencję lub wskaźnik może mieć fatalne skutki (dla wydajności i poprawności), a próba uzyskania dostępu do stosu już zakończonego wątku może powodować niezwykle trudne do zdiagnozowania problemy. Jeśli trzeba przechwycić argument będący szablonem zmiennym (28.6), należy użyć operatora .... Na przykład:

template<typename ... Var>
void algo(int s, Var... v)
{
    auto helper = [&s,&v...] { return s*(h1(v...)+h2(v...)); }
    //...
}

Pamiętaj, że łatwo się zapomnieć przy ustalaniu listy zmiennych do przechwycenia. Często można wybierać między przechwytywaniem a przekazywaniem argumentów. W takich przypadkach przechwytywanie zwykle wymaga mniej pisania, ale też może prowadzić do nieporozumień.

Lambdy i cykl istnienia

Lambda może istnieć dłużej niż to, co ją wywołało. Taka sytuacja może się np. zdarzyć przy przekazaniu lambdy do innego wątku albo gdy wywoływany kod przechowuje lambdę, aby użyć jej kiedy indziej. Na przykład:

void setup(Menu& m)
{
    //...
    Point p1, p2, p3;
    // oblicza pozycje dla p1, p2 oraz p3
    m.add("Narysuj trójkąt",[&]{ m.draw(p1,p2,p3); }); // potencjalna katastrofa
    //...
}

Zakładając, że add() dodaje pary (nazwa, akcja) do menu oraz że operacja draw() jest jakoś sensownie zaimplementowana, powyższy kod jest bombą z opóźnionym zapłonem: funkcja setup() kończy działanie i później — może za kilka minut — użytkownik naciska przycisk Narysuj trójkąt, co powoduje, że lambda próbuje uzyskać dostęp do już dawno nieistniejących zmiennych lokalnych. W sytuacji takiej lambda zapisująca dane w zmiennej przechwyconej przez referencję byłaby jeszcze gorsza.
Jeśli istnieje możliwość, że lambda będzie istniała dłużej niż wywołująca ją konstrukcja, należy wszystkie informacje lokalne (jeśli takie są) skopiować do obiektu zamknięcia i zwracać je poprzez mechanizm return lub odpowiednie argumenty. W przypadku funkcji setup() jest to łatwe:

m.add("Narysuj trójkąt",[=]{ m.draw(p1,p2,p3); });

Listę zmiennych do przechwycenia można traktować jak listę inicjacyjną dla obiektu zamknięcia, a [=] i [&] jak skrócone formy zapisu.

Nazwy przestrzeni nazw

Nie trzeba przechwytywać zmiennych z przestrzeni nazw (wliczając zmienne globalne), bo są
one zawsze dostępne (pod warunkiem że znajdują się w zakresie). Na przykład:

template<typename U, typename V>
ostream& operator<<(ostream& os, const pair<U,V>& p)
{
    return os << '{' << p.first << ',' << p.second << '}';
}
void print_all(const map<string,int>& m, const string& label)
{
    cout << label << ": { ";
    for_each(m.begin(),m.end(),
        [](const pair<string,int>& p) { cout << p << ' '; }
    );
    cout << "} ";
}

W kodzie tym nie trzeba przechwytywać nazw cout ani operatora wyjściowego dla pair.

Lambdy i słowo kluczowe this

Jak uzyskuje się dostęp do składowych obiektu klasowego z lambdy używanej w funkcji składowej? Można włączyć składowe klasy do zbioru nazw, które mogą zostać przechwycone poprzez dodanie słowa this do listy zmiennych do przechwycenia. Technikę tę stosuje się, gdy trzeba użyć lambdy w implementacji funkcji składowej. Na przykład możemy mieć klasę do konstruowania żądań i pobierania wyników:

class Request {
    function<map<string,string>(const map<string,string>&)> oper; // operacja
    map<string,string> values; // argumenty
    map<string,string> results; // cele
public:
    Request(const string& s); // parsuje i zapisuje żądanie
    void execute()
    {
        [this]() { results=oper(values); } // wywołanie oper na values w celu otrzymania wyników
    }
};

Składowe są zawsze przechwytywane przez referencję. Inaczej mówiąc, [this] oznacza, że składowe w lambdzie będą dostępne poprzez this, a nie skopiowane. Niestety konstrukcje [this] i [=] są niezgodne ze sobą. Jeśli więc ktoś używający ich w programach wielowątkowych nie będzie ostrożny, może doprowadzić do wystąpienia wyścigów.

Zmienne lambdy

Zwykle stan obiektu funkcyjnego (zamknięcia) nie powinien być zmieniany i standardowo nie ma takiej możliwości. To znaczy funkcja operator()() dla wygenerowanego obiektu funkcyjnego jest funkcją składową const. Jeśli jednak ktoś chce zmodyfikować stan takiego obiektu (nie mylić z modyfikowaniem stanu zmiennej przechwyconej przez referencję), to może lambdę zadeklarować jako mutable. Na przykład:

void algo(vector<int>& v)
{
    int count = v.size();
    std::g enerate(v.begin(),v.end(),
        [count]()mutable{ return −−count; }
    );
}

Operacja --count powoduje zmniejszenie rozmiaru kopii obiektu v zapisanego w zamknięciu.

 Język C++. Kompendium wiedzy. Wydanie IV, Autor: Bjarne Stroustrup, Wydawnictwo: Helion 

Podobne artykuły

Podziel się ze znajomymi tym artykułem - udostępnij na FB lub wyślij e-maila korzystając z poniższych opcji:

wszystkie oferty