Wyrażenia lambda – użyteczna nowość C++11.

Czas czytania: < 1 minutę

Nowsze standardy C++ wprowadzają wiele udogodnień, które sprawiają że nam – programistom – żyje się wygodniej. Musimy pisać coraz mniej kodu, otrzymując tę samą funkcjonalność. Sprawia to, że uzyskujemy większą wydajność. Poza tym, mniejsza ilość lepiej i zwięźlej napisanego kodu zwiększa jego czytelność. Dzięki temu ludzie, którzy obejmą projekt po nas nie będą wyrywali sobie włosów z głowy zastanawiając się, jak to w ogóle działa. Dzisiaj poznamy coś, co istotnie może uprościć kod. To ułatwienie zwie się wyrażeniami lambda.

Lambda to nowa rzecz, która została po raz pierwszy wprowadzona wraz ze standardem C++11. Wyrażenia te zostały potem zaadaptowane przez inne języki, takie jak Javascript. Jeśli kiedykolwiek programowałeś w ES6, z pewnością spotkałeś się z funkcjami strzałkowymi, które są dalekim kuzynem lambdy z C++. Zanim przejdziemy dalej, spójrz na przykładowy kod, na którym będziemy pracowali.

Niezoptymalizowany kod

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
#include <string>
#include <algorithm>
#include <vector>
using namespace std;
struct ScRegister {
    int id;
    string name;
    string surname;
    int grade;
};
struct {
    bool operator()(ScRegister a,ScRegister b) const {
        return a.id<b.id;
    }
} IDSort;
struct {
    bool operator() (ScRegister a,ScRegister b) const {
        return a.surname<b.surname;
    }
} SurnameSort;
void printList(vector<ScRegister> people_list) {
       for(ScRegister person: people_list) {
            cout<<"Nr: "<<person.id<<endl;
            cout<<"imie: "<<person.name<<endl;
            cout<<"nazwisko: "<<person.surname<<endl;
            cout<<"ocena: "<<person.grade<<endl;
            cout<<endl;
        }
 
}
int main()
{
    vector<ScRegister> people_list = {{1,"Jan","Kowalski",5},{4,"Marcin","Pomagier",4},{2,"Patryk","Programista",3},{7,"Teodor","Mufflon",2},{3,"Adam","Nowak",4},{5,"Karol","Zębkiewicz",2}};
    cout<<"Przed sortowaniem"<<endl;
    printList(people_list);
    sort(people_list.begin(),people_list.end(),IDSort);
    cout<<"Posortowano wg indeksow"<<endl;
    printList(people_list);
    sort(people_list.begin(),people_list.end(),SurnameSort);
    cout<<"Posortowano wg nazwisk"<<endl;
    printList(people_list);
    return 0;
}

Program na powyższym listingu jest dosyć długi. Na początku zadeklarowaliśmy strukturę „ScRegister” która przechowuje ID, imię, nazwisko i ocene. Następnie zauważamy dwie „anonimowe” struktury. Zawierają one jeden przeciążony operator (). Zajmuje się on relacją elementów struktury „ScRegister”. Struktury te mówią funkcji sort z biblioteki algorithm w jaki sposób rozróżnić który element po sortowaniu powinien znaleźć się wcześniej, a który później. Funkcja printList() służy do wypisania elementów vectora, który przechowuje osoby zapisane w dzienniku.

Przechodzimy do funkcji main. W linijce 34 powinieneś zauważyć użycie „uniform initialization”. W ten sposób wypełniamy vector przykładowymi danymi. Następnie używamy wcześniej zdefiniowanych struktur i funkcji do sortowania danych na różne sposoby.

Kod nie jest skomplikowany, ale jak na pracę którą wykonuje jest trochę długi. Czy możemy go jakoś zoptymalizować, skrócić?

Oczywiście! Właśnie w tym celu użyjemy wyrażeń lambda 🙂

Lambda – co to jest?

Wyrażenia lambda w języku C++ są takimi „anonimowymi funkcjami”. Są podobne zarówno do zwykłych zmiennych, jak i do funkcji. Wyrażenia lambda posiadają swoje ciało, w którym mogą wykonywać jakieś działanie. Mogą przyjmować parametry oraz zwracać wartości. Lecz, w przeciwieństwie do funkcji nie posiadają nazwy. Wyrażenia lambda powinny być przypisane do zmiennej. Oczywiście, wcale nie muszą. Wszystko zależy od tego, co chcemy osiągnąć.

Jak wygląda podstawowe wyrażenie? Składa się ono z trzech części:

  • „capture list”[] – wskazujemy, które zmienne mają być dostępne wewnątrz lambdy
  • „parameter list”() – część opcjonalna. W tym miejscu możemy przekazać jakieś zmienne do lambdy
  • „body”{} – czyli esencja lambdy. Wewnątrz „ciała” lambda wykonuje zaplanowane przez programistę operacje.

Połączmy całą powyższą teorię w jeden zapis. Wyglądałby on tak: [](){}. Prawda, że krótko i zwięźle?

No dobra. Spróbujmy zastosować lambdę w praktyce. Pozbądźmy się funkcji printList. Chcemy aby to zrobiło wyrażenie lambda. Skopiuj zawartość funkcji printList do schowka, a następnie wykasuj ją.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <string>
#include <algorithm>
#include <vector>
using namespace std;
struct ScRegister {
    int id;
    string name;
    string surname;
    int grade;
};
struct {
    bool operator()(ScRegister a,ScRegister b) const {
        return a.id<b.id;
    }
} IDSort;
struct {
    bool operator() (ScRegister a,ScRegister b) const {
        return a.surname<b.surname;
    }
} SurnameSort;
int main()
{
    vector<ScRegister> people_list = {{1,"Jan","Kowalski",5},{4,"Marcin","Pomagier",4},{2,"Patryk","Programista",3},{7,"Teodor","Mufflon",2},{3,"Adam","Nowak",4},{5,"Karol","Zębkiewicz",2}};
    auto printList = [people_list](){
        for(ScRegister person: people_list) {
            cout<<"Nr: "<<person.id<<endl;
            cout<<"imie: "<<person.name<<endl;
            cout<<"nazwisko: "<<person.surname<<endl;
            cout<<"ocena: "<<person.grade<<endl;
            cout<<endl;
        }
    };
    cout<<"Przed sortowaniem"<<endl;
    printList();
    sort(people_list.begin(),people_list.end(),IDSort);
    cout<<"Posortowano wg indeksow"<<endl;
    printList();
    sort(people_list.begin(),people_list.end(),SurnameSort);
    cout<<"Posortowano wg nazwisk"<<endl;
    printList();
    return 0;
}

W linijce 25 znajduje się clue naszej pierwszej lambdy. Zapisujemy ją do zmiennej typu auto. W części „capture list” przechwytujemy kopię zmiennej lista_osob. „Parameter list” zostawiamy puste. W ciele znajduje się dokładnie to samo, co wcześniej znajdowało się w funkcji „printList”

Jak wygląda wywołanie takiej lambdy? Po prostu:

printList();

Co zyskaliśmy? W tej chwili niewiele. Ten przykład był stworzony nieco na siłę aby pokazać ci najprostszą możliwą lambdę. Prawdziwą siłę lambda pokazuje przy callbackach.

Czym jest callback? Jest to taki „wskaźnik na funkcję”, który przekazujemy innej funkcji. Idealnym przykładem jest sort w linijkach 36 i 39. Tam trzecim argumentem jest właśnie taki callback. Sort wykorzystuje „anonimową strukturę” w której stworzyliśmy przeciążony operator() aby posortować elementy wg naszego życzenia. Rozwiązanie to działa, ale po co komplikować sobie życie, skoro można to zrobić prościej za pomocą wyrażeń lambda?

Lambda – sort – praktyczne zastosowanie

Lamba najczęściej jest wykorzystywana właśnie w celu inline’owania kodu. Poprawny nasze funkcje sortujące tak, aby zajmowały mniej miejsca. W sumie, zmienimy tylko dwie linijki. (jedną wspólnie, drugą analogicznie poprawisz samodzielnie).
Linijka 36 przed zmianami wygląda tak:

sort(people_list.begin(),people_list.end(),IDSort)

W IDSort znajduje się przeciążony operator(), który mówi o tym jak należy posortować elementy. Jak za pomocą lambdy możemy utworzyć anonimową funkcję, która pozwoli nam skasować zajmującą kilka linijek strukturę?

sort(people_list.begin(),people_list.end(),[](ScRegister a,ScRegister b){return a.id>b.id;});

Tak wygląda wywołanie funkcji sort() po dokonaniu zmian. Trzecim argumentem, zamiast IDSort jest lambda. Tym razem nie przechwytuje ona żadnych parametrów („capture list” jest puste). Przyjmuje za to dwa argumenty, podobnie jak operator() w strukturze IDSort. Ciało również jest podobne. Jeśli nie wierzysz że ten kod działa, wprowadź zmiany i skompiluj.
W tym momencie możesz wywalić anonimowe strukturki IDSort i SurnameSort, które zdefiniowaliśmy wcześniej 🙂 One nie są już do niczego potrzebne.

W ramach ćwiczeń zalecam analogiczne poprawienie sortowania wg nazwisk. Poprawiony kod źródłowy jest dostępny tutaj.

Lambda – budowa wyrażenia

Wiesz już, jak działa lambda i kiedy ją stosujemy. Teraz zajmijmy się dokładniejszym przeanalizowaniem dwóch z trzech części lambdy: „Capture list” i „Parameter list”

„Capture list” []

„Capture list” jest pierwszą częścią lambdy, zamkniętą w nawiasach kwadratowych. Użyliśmy tego wtedy, kiedy tworzyliśmy wersję lambda funkcji wypiszListe. Ale co właściwie robi captureList?

Domyślnie, anonimowa funkcja zwana lambdą nie ma dostępu do zmiennych lokalnych zadeklarowanych wewnątrz otaczającej lambdę funkcji. Aby zapewnić taki dostęp, musimy wskazać które zmienne mają być dostępne. Robimy to właśnie wewnątrz „capture list”. Tylko do zmiennych wypisanych w „capture list” lambda będzie miała dostęp. Spójrz na poniższy kod:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
 
using namespace std;
 
int main()
{
    int a = 4, b = 6, c = 9;
    auto lam1 = [](){a+=6;};
    auto lam2 = [b](){cout<<b<<endl;};
    auto lam3 = [b](){b+=9;};
    auto lam4 = [&c](){c+=12;};
    auto lam5 = [&](){a+=3; b+=6;};
    return 0;
}

Widzimy tu 4 lambdy. Wewnątrz pierwszej z nich, lam1, chcemy zwiększyć wartość zmiennej a o 6. Zmienna a jest zmienną lokalną funkcji a. Kompilator nie pozwoli na takie coś. Wystąpi błąd kompilacji mówiący o tym, że zmienna a nie została przechwycona przez lambdę.

W drugiej lambdzie, lam2, przechwytujemy zmienną b. Wewnątrz ciała lambdy używamy funkcji cout aby wypisać wartość przechowywaną w b na ekran. Kompilator nie będzie protestował, gdyż jedynie wykorzystujemy zawartość zmiennej b, nie wprowadzając w niej zmian.

Trzecia lambda wygląda poprawnie. Przechwytujemy zmienną b i próbujemy zwiększyć ją o 9. Mimo, że lambda wygląda poprawnie to kompilator nie pozwoli nam skompilować tej linijki kodu. Dlaczego? Ponieważ zmiennych przechwyconych „przez wartość” domyślnie nie możemy modyfikować w lambdzie. Możemy je tylko odczytywać. To kolejna właściwość tej mechaniki, o której warto pamiętać.

W czwartej lambdzie używamy referencji do przechwycenia zmiennej c. Wewnątrz ciała zwiększamy ją o 12. Tym razem wszystko się powiedzie, gdyż wzięliśmy pod uwagę szczegół o którym mówiliśmy w poprzednim akapicie.

Ostatnia lambda wygląda ciekawie. Przechwytujemy tylko referencję? Czy takie coś w ogóle się skompiluje? Okazuje się, że tak. A co oznacza taki zapis? Jeśli w „capture list” wstawimy jedynie symbol referencji, to wewnątrz ciała lambdy będziemy mieli dostęp do referencji wszystkich zmiennych lokalnych utworzonych w otaczającej lambdę funkcji.

Jeśli zamiast znaku & wstawimy znak =, uzyskamy prawie ten sam efekt. Wewnątrz lambdy będziemy mieli wtedy dostęp do kopii każdej zmiennej lokalnej.

„Parameter list” ()

W „Parameter list” możemy przekazać listę argumentów, które będzie pobierała lambda. Argumenty te będziemy musieli jawnie przekazać lambdzie podczas jej wywoływania.

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
 
using namespace std;
 
int main()
{
    auto lam1 = [](int arg1,int arg2){return arg1+arg2;};
    int wynik = lam1(5,3);
    cout<<"wynik: "<<wynik<<endl;
    return 0;
}

W powyższym przykładzie znajduje się prosty przykład lambdy przyjmującej argumenty. Lam1 pobiera dwie liczby typu int. Wewnątrz ciała lambdy sumujemy te liczby je zwracamy.

1
2
3
4
5
6
7
8
9
10
#include <iostream>
 
using namespace std;
 
int main()
{
    auto lam2 = []{cout<<"Lambda bez parameterlist";};
    lam2()
    return 0;
}

Ten przykład wygląda ciekawie. Czy programista o czymś nie zapomniał? W definicji lambdy brakuje „parameter list”. Czy takie coś się skompiluje? Owszem. Jeśli lambda nie pobiera żadnych argumentów, możemy bez problemu pominąć nawiasy okrągłe „parameter list” i skrócić zapis o te kilka znaków.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
 
using namespace std;
 
int main()
{
    auto lam1 = [](int a,int b){return a<b;};
    auto lam2 = [](auto a,auto b){return a<b;};
    cout<<lam1(4,3)<<endl;
    cout<<lam2(4,3)<<endl;
    cout<<lam1("Bolek","Lolek")<<endl;
    cout<<lam2("Bolek","Lolek")<<endl;
    return 0;
}

Podobnie jak w funkcjach, także i w liście argumentów możemy używać „auto” jako typu zmiennej. Dzięki temu możemy stworzyć bardziej „uogólnioną” lambdę. Pokazuje to powyższy przykład. Lam1 potrafi porównywać tylko liczby całkowite. Linijka 11 nie skompiluje się, gdyż lam1 nie potrafi porównywać stringów. Takiego ograniczenia nie ma lam2. Kompilator automatycznie dopasuje sobie typ zmiennej i wykona prawidłowe porównanie.

Lambda mutable

Lambda ma jedną bardzo ciekawą własność. Jeśli przechwycimy zmienną lokalną przez „wartość”, nie będziemy mogli jej zmodyfikować w lambdzie. Jednym z możliwych rozwiązań tego problemu jest „przechwycenie” zmiennej jako referencji. Czy istnieje inny sposób? Tak. Jest nim słówko kluczowe „mutable”.

W definicji lambdy, po „parameter list” możemy umieścić słowo kluczowe mutable.

auto lam3 = [b]() mutable {b+=9;}

Powyższa linijka tworzy lambdę, która zwiększa zawartość zmiennej lokalnej b o 9. Normalnie nie moglibyśmy tego zrobić. Ale dzięki umieszczeniu słówka kluczowego mutable kompilator nie będzie protestował i posłusznie wykona żądaną przez programistę czynność.

Lambda w klasach

C++ jest językiem obiektowym. A skoro obiektowym, to pewnie zastanawiasz się jak zachowa się lambda zdefiniowana wewnątrz ciała klasy? Czy taka lambda ma dostęp do właściwości prywatnych klasy czy może korzystać jedynie z jej publicznych elementów? A może wcale nie może używać właściwości klasy? Zaraz się przekonamy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
 
using namespace std;
class Foo {
    public:
    void bar(float arg) {
        auto lamb = [](float c,Foo& f){f.a+=c;};
        lamb(arg,*this);
    }
    void print() {
        cout<<"a: "<<a<<endl<<"b: "<<b<<endl;
    }
    private:
    float a = 4;
    float b = 6;
};
int main()
{
    Foo cl;
    cl.print();
    cout<<endl;
    cl.bar(4);
    cl.print();
    return 0;
}

W powyższym listingu mamy przykład bardzo prostej klasy. Foo posiada dwie zmienne prywatne. Klasa posiada dwie publiczne metody: print, która wypisuje zawartość zmiennych oraz bar, która będzie nas interesować najbardziej.

Wewnątrz metody bar definiujemy lambdę. Lambda ta przyjmuje dwa argumenty: zmienną typu float oraz referencję na klasę Foo. Wewnątrz lambdy modyfikujemy właściwość prywatną klasy Foo. Czy tak można? Okazuje się, że tak. Każda lambda zdefiniowana wewnątrz klasy jest „z automatu” zaprzyjaźniona z tą klasą.

W powyższym przykładzie przekazaliśmy lambdzie referencję do klasy Foo. A czy lambda może przechwycić wskaźnik this? Oczywiście 🙂 Zmodyfikujmy nieco metodę bar:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
 
using namespace std;
class Foo {
    public:
    void bar(float arg) {
        auto lamb = [this](float c){this->a+=c;};
        lamb(arg);
    }
    void print() {
        cout<<"a: "<<a<<endl<<"b: "<<b<<endl;
    }
    private:
    float a = 4;
    float b = 6;
};
int main()
{
    Foo cl;
    cl.print();
    cout<<endl;
    cl.bar(4);
    cl.print();
    return 0;
}

Tym razem na liście argumentów lambdy nie widzimy referencji do klasy Foo. Zauważamy za to wskaźnik „this” w „capture list”. Wskaźnik przechwytujemy przez wartość. Nasuwa to pytanie, dlaczego możemy zmienić wartość zmiennej prywatnej klasy? Zgodnie z tym, co wcześniej ustaliliśmy, lambda nie może modyfikować zmiennych przechwyconych przez wartość. Rozwiązanie jest proste. Istnieje tutaj tylko pozorna sprzeczność. Nie możemy modyfikować wskaźnika „this”. Ale nie blokuje to możliwości modyfikowania zmiennych wskazywanych przez ten wskaźnik. Dokładnie tak samo zadziała to w przypadku zwykłych wskaźników zadeklarowanych przez ciebie. Jeśli nie wierzysz, spróbuj!

Lambda – kilka zagadek na zakończenie

Na koniec dam ci dwa ciekawe zadania. Postaraj się odpowiedzieć nad zamieszczone pod listingami pytania bez uruchamiania programów. Skompiluj je dopiero wtedy, gdy będziesz chciał sobie sprawdzić odpowiedzi. W zadaniach dla utrudnienia użyłem pewnej konstrukcji, której nie wyjaśniałem w tym artykule. Po analizie powinieneś się dowiedzieć, o co chodzi 🙂

1
2
3
4
5
6
7
8
9
10
#include <iostream>
 
using namespace std;
 
int main()
{
    int a = 15;
    [a]mutable{a+=4;};
    cout<<"a: "<<a<<endl;
}

1. Czy program się skompiluje?
2. Po poprawieniu błędów, jaką wartość będzie miała zmienna a?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
 
using namespace std;
 
int main()
{
    int a = 0,b=4,c=0;
    a=[&a]{return a+5;}();
    b=[=]{return a+b;}();
    [=,&c]{c=a+b;};
    cout<<"a: "<<a<<endl;
    cout<<"b: "<<b<<endl;
    cout<<"c: "<<c<<endl;
    return 0;
}

1. Czy program się skompiluje?
2. Po poprawieniu błędów, jaką wartość będzie miała zmienna a,b i c?

Podsumowując

Lambda jest bardzo przydatną funkcjonalnością języka. Kiedy tylko wyczujesz, kiedy należy jej używać, znacznie uprościsz sobie swoje programistyczne życie. Oczywiście, wymaga to treningu. Ale kto powiedział, że życie jest proste?

Dzięki za przeczytanie artykułu i zainteresowanie tematem 🙂 Jeśli masz jakieś pytania, wątpliwości – napisz o nich w komentarzu lub na fanpage, do którego polubienia zachęcam.
Standardowo, wszystkie kody źródłowe zamieszczone w tym artykule są dostępne także w moim repozytorium na platformie GitHub.

Opublikowany w C++

1 komentarz do “Wyrażenia lambda – użyteczna nowość C++11.

  1. bonobo Odpowiedz

    Wyrażenie lambda to anonimowa klasa a nie funkcja. Obiekty utworzone na podstawie tej klasy nazywane są obiektami funkcyjnymi ponieważ klasa ta ma operator operator()(..) w którym zawarty jest kod/body.
    Staje się to oczywiste, kiedy rozważysz kwestię, w którym momencie ustalana jest w kodzie lambdy wartość zmiennych zewnętrznych dostępnych za pomocą „capture list” w trybie „przez wartość” oraz jak te wartości są przechowywane w czasie „życia” obiektu utworzonego z tej klasy. Piszę ci o tym, ponieważ właśnie sprawdzam prace egzaminacyjne i widzę, jak moi studenci na egzaminie napisali w odpowiedziach tak jak ty napisałeś w swoim tekście i niestety musiałem ująć im za to punktów.
    Dodam, że jeżeli „capture list” jest pusta, to w tym szczególnym przypadku klasę tę faktycznie można uprościć do funkcji. Ale nie robisz nigdzie takiego zastrzeżenia, więc czytelnik ma powody uważać, że piszesz o wszystkich przypadkach.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.