« Wyrażenia lambda (C++11), lekcja »
Rozdział 51. Wyrażenia lambda (C++11) (lekcja)
Panel użytkownika
Nazwa użytkownika:
Hasło:
Nie masz jeszcze konta?
Zarejestruj się!
Autor: pekfos
Kurs C++

Wyrażenia lambda (C++11)

[lekcja] Rozdział 51. Wyrażenia lambda (C++11)

Wstęp

Wszystkie rzeczy omówione poniżej wymagają C++11. Wyrażenie lambda (jak greckie λ, nie "lambada") pozwala zdefiniować anonimową funkcję, bądź obiekt funkcyjny, w miejscu użycia. Po co? Powiedzmy że chcemy sobie, na przykład spartycjonować liczby według ich reszty z dzielenia przez 5:
C/C++
#include <iostream>
#include <vector>
#include <algorithm>


bool fn( int l, int r )
{
    return l % 5 < r % 5;
}

int main()
{
    std::vector < int > liczby( 15 );
    std::iota( liczby.begin(), liczby.end(), 0 ); // Wypełnij liczbami 0, 1, 2, ..
   
    std::sort( liczby.begin(), liczby.end(), fn );
   
    for( auto it = liczby.begin(); it != liczby.end(); ++it )
         std::cout << * it << ' ';
   
}
0 5 10 1 6 11 2 7 12 3 8 13 4 9 14
Zwróć uwagę, że żeby to osiągnąć, trzeba było zdefiniować funkcję. C++ nie pozwala tworzyć funkcji lokalnie (a przynajmniej nie zwykłych funkcji), więc funkcja pomocnicza robiąca za komparator dla sortowania musi tak niezręcznie wystawać poza kod, w którym jest potrzebna. Z pomocą przychodzą wyrażenia lambda, dzięki którym można to zapisać tak:
C/C++
std::sort( liczby.begin(), liczby.end(),[]( int l, int r ) { return l % 5 < r % 5; } );

Wyrażenie lambda

Wyrażenie lambda w C++ składa się z 5 elementów, z czego większość jest opcjonalna. Pozwól, że przeprowadzę Cię przez nie w kolejności, w jakiej się je zapisuje w kodzie.
  • [] - kwadratowe nawiasy oznaczają początek wyrażenia lambda. Między nie można wpisać listę przechwytywanych nazw, o czym później.
  • () - nawiasy okrągłe, analogicznie jak przy zwykłej funkcji, między nie podajemy argumenty, jakie ma przyjmować wyrażenie lambda. (Opcjonalne)
  • atrybuty wyrażenia lambda, z możliwych atrybutów w tym momencie najistotniejszy jest mutable, który sprawia że zmienne przechwycone przez wartość mogą być modyfikowane wewnątrz ciała wyrażenia. (Opcjonalne)
  • -> T - typ zwracany wyrażenia lambda. (Opcjonalne)
  • {} - ciało wyrażenia lambda, znowu analogicznie jak w zwykłej funkcji, kod do wykonania gdy wyrażenie zostanie wywołane.
Kolejność jest ściśle zdefiniowana. Przykładowo:
C/C++
[] { }();
To jest wyrażenie lambda, które niczego nie przechwytuje, nie przyjmuje żadnych argumentów i nic nie robi. Dodatkowo te wyrażenie zostało natychmiast wywołane. Nawiasy okrągłe na końcu to wywołanie, a nie lista argumentów. Lista argumentów została w tym przypadku pominięta, bo jest opcjonalna.
C/C++
std::cout <<[]( int a, int b, int c ) { return a + b * c; }( 1, 3, 5 );
Pamiętaj, że mimo że nazywa się to 'wyrażeniem', w nawiasach klamrowych znajduje się zwykły w świecie blok kodu, więc pamiętaj o średnikach! Zwróć uwagę, że zwracamy tu liczbę typu int, ale nie określiliśmy typu zwracanego. Definiowane zwracanego typu jest opcjonalne nawet wtedy, gdy lambda coś zwraca. Kompilator wtedy dedukuje typ zwracany na podstawie typu wyrażenia podanego w return. Jeśli nie ma żadnego return, lub jest podany bez wartości, to typ zwracany to void.
C/C++
std::cout <<[]( int a )
{
    if( a < 0 )
         return 0;
   
    return a * 0.5f; // Błąd
}( 2 );
Ta lambda (licząca lekko zmodyfikowane ReLU) jest błędna, bo zawiera 2 różne punkty return i w każdym typ zwracany jest inny. Powoduje to błąd kompilacji, który można rozwiązać na 2 sposoby: albo sprowadzić wszystkie returny do jednego typu, albo jawnie zdefiniować typ zwracany i wszystkie zwracane wartości zostaną przekonwertowane.
C/C++
std::cout <<[]( int a )->float
{
    if( a < 0 )
         return 0;
   
    return a * 0.5f; // OK
}( 2 );
Taki sposób zapisywania typu wartości zwracanej może być stosowany także w zwykłych funkcjach:
C/C++
auto fun( int a )->int
{
    return a * 2;
}
Piszemy wtedy auto jako typ zwracany, a faktyczny typ zwracany piszemy po strzałce. Istnieją przypadki, w których typ zwracany można zapisać krócej, gdy pisze się go po nazwie funkcji, niż gdy pisze się go przed. Poza tym, czy auto nie powinno oznaczać automatycznej dedukcji typu zwracanego, podobnie jak w wyrażeniach lambda? W C++11 jawne określenie typu zwracanego w tym zapisie wciąż jest obowiązkowe. C++14 wprowadza automatyczną dedukcję i typ po strzałce jest już opcjonalny.

Przechwytywanie nazw

Nazwy, czy raczej, poza paroma wyjątkami - zmienne automatyczne, można przechwytywać na 2 sposoby: przez referencję lub przez wartość. Zmienne przechwycone przez wartość są stałe, chyba że lambda została utworzona z atrybutem mutable.
C/C++
int a = 4, b = 0;
std::cout <<[ a, & b ] { return a + b; }();
Zmienna a została przechwycona przez wartość, a b przez referencję. Możemy więc modyfikować b i nasze zmiany będą widoczne poza lambdą. Nie możemy modyfikować a, bo jest stałe.
C/C++
#include <iostream>

int main()
{
    int a = 4, b = 0;
   
    [ a, & b ]
    {
        ++a; // Bład
        ++b;
    }();
   
    std::cout << a << ' ' << b << '\n';
}
Błąd możemy naprawić używając atrybutu mutable, ale wciąż będziemy operować na lokalnej kopii zmiennej:
C/C++
#include <iostream>

int main()
{
    int a = 4, b = 0;
   
    [ a, & b ]() mutable
    {
        ++a; // OK
        ++b;
    }();
   
    std::cout << a << ' ' << b << '\n';
}
4 1
Podawanie atrybutów spowoduje błąd kompilacji, jeśli nie ma podanej listy argumentów, więc w tym wypadku nawiasy okrągłe są obowiązkowe.
Alternatywną metodą przechwytywania zmiennych jest użycie zapisu [=] (przechwyć wszystko przez wartość), oraz [&] (przechwyć wszystko przez referencję). Jeśli naprawdę chcesz przechwycić wszystko, to te zapisy ułatwiają życie, ale jeśli chcesz przechwycić jedną, czy dwie zmienne, to lepiej wymienić je z nazwy - lepiej oddaje to Twoje intencje i kod jest czytelniejszy. Jeśli chcesz przechwycić wybrane elementy w jeden sposób i całą resztę w drugi, wciąż możesz to zrobić wymieniając te wybrane elementy z nazwy i nadając im odpowiedni sposób przechwytywania (to jest, przeciwny niż użyty do przechwycenia wszystkiego).
C/C++
int a = 0, b = 1, c = 2;

[ & ] { }; // &a, &b, &c
[ &, a ] { }; // a, &b, &c
[ = ] { }; // a, b, c
[ =, & b ] { }; // a, &b, c

Typ wyrażenia lambda

Wyrażenie jak każde inne, więc musi mieć jakiś typ, prawda? Prawda, ale to nie jest typ, jaki da się w ogóle zapisać, bo oficjalnie typ jest nieokreślony. Dlatego najlepiej skorzystać ze zmiennej o typie dedukowanym, auto:
C/C++
auto mojaLambda =[]( int x ) { std::cout << "moja lambda mowi " << x << "!\n"; };

mojaLambda( 0 );
mojaLambda( 4 );
moja lambda mowi 0!
moja lambda mowi 4!
Zwróć uwagę, że nie piszemy już dodatkowych nawiasów na końcu, bo do zmiennej chcemy zapisać samą lambdę, a nie to co zwróciła po jej wywołaniu. Wróćmy teraz do słówka mutable - jeśli zastanawiałeś się, dlaczego trzeba coś takiego jawnie podać, podczas gdy korzystając z analogii do zwykłej funkcji, argumenty przekazane przez kopię można było swobodne modyfikować bez zapowiedzi, to brawo - masz punkt. Zmienne przechwycone i zmienne przekazane przez argumenty to dwie zupełnie różne rzeczy. Można je traktować, jakby takie nie były i to jest domyślne zachowanie, jednak mutable to zmienia. Wróćmy do przykładu mutable, ale wywołajmy lambdę więcej niż raz, teraz gdy wiemy, jak to zrobić:
C/C++
#include <iostream>

int main()
{
    int a = 4, b = 0;
   
    auto lambda =[ a, & b ]() mutable
    {
        ++a;
        ++b;
       
        std::cout << "lambda: a: " << a << " b: " << b << '\n';
    };
   
    lambda();
    lambda();
   
    std::cout << "main: a: " << a << " b: " << b << '\n';
}
lambda: a: 5 b: 1
lambda: a: 6 b: 2
main: a: 4 b: 2
Zmienna przechwycona przez referencję zachowuje się zgodnie z oczekiwaniami - dwie inkrementacje, wszystkie widoczne na zewnątrz. Zwróć uwagę jednak na zmienną przechwyconą przez wartość. Modyfikacja tej zmiennej nie jest widoczna poza lambdą, ale jest widoczna w drugim wywołaniu lambdy. Dzieje się tak dlatego, że lambda nie jest funkcją, lecz obiektem funkcyjnym - czymś co lepiej poznasz przy programowaniu obiektowym. Teraz wystarczy Ci wiedzieć, że obiekt funkcyjny to funkcja + stan. Dzięki temu, że lambda ma stan, w przeciwieństwie do zwykłej funkcji, może pamiętać takie modyfikacje między swoimi wywołaniami, podczas gdy są one całkowicie niewidoczne poza lambdą.
Jeśli chcemy nie tyle przechwycić istniejącą zmienną, ale utworzyć zmienną w stanie lambdy, to C++14 wprowadza taką możliwość:
C/C++
auto raport =[ lp = 0 ]( const char * wiadomosc ) mutable
{
    std::cout << "Wiadomosc nr " << ++lp << ": " << wiadomosc << '\n';
};

raport( "Hello world" );
raport( "Error!" );
Wiadomosc nr 1: Hello world
Wiadomosc nr 2: Error!
W C++11, trzeba by utworzyć zmienną lp, nadać jej wartość początkową i przechwycić. A wiec zmienna nie służy nam niczym poza swoją nazwą i wartością początkową. W C++14 można oba te elementy zapisać w samym wyrażeniu lambda i nie tworzyć niepotrzebnej zmiennej.

Przekazywanie do funkcji

auto niewiele daje w przypadku definiowania argumentu funkcji, który ma przyjmować lambdę. Tu należy użyć czegoś innego, również wprowadzonego w C++11: std::function<> z nagłówka <functional>.
C/C++
#include <iostream>
#include <functional>

void fun( std::function < int( int ) > f )
{
    std::cout << "4 razy:\n";
   
    for( int i = 0; i < 4; ++i )
         std::cout << "f(" << i << ") = " << f( i ) << '\n';
   
}

int main()
{
    fun([]( int x ) { return x; } );
    fun([]( int x ) { return x * 2 + 1; } );
    fun([]( int x ) { return x * x - 1; } );
}
4 razy:
f(0) = 0
f(1) = 1
f(2) = 2
f(3) = 3
4 razy:
f(0) = 1
f(1) = 3
f(2) = 5
f(3) = 7
4 razy:
f(0) = -1
f(1) = 0
f(2) = 3
f(3) = 8
W tym przykładzie lambdy nie przechwytują żadnych zmiennych, więc działają jak zwykłe funkcje. Takie wyrażenia lambda można przekazać również jako wskaźnik na funkcję.

Przykład

Rozwinięty program ze wstępu, używający wyrażeń lambda do stworzenia parametryzowanego predykatu sortowania
C/C++
#include <iostream>
#include <vector>
#include <algorithm>


int main()
{
    std::vector < int > liczby( 15 );
    std::iota( liczby.begin(), liczby.end(), 0 ); // Wypełnij liczbami 0, 1, 2, ..
   
    for( int part = 2; part < 6; ++part )
    {
        std::cout << "Podzial wg wartosci % " << part << ":\n";
        std::sort( liczby.begin(), liczby.end(),[ part ]( int l, int r ) { return l % part < r % part; } );
       
        int tmp = 0;
       
        for( auto it = liczby.begin(); it != liczby.end(); ++it )
        {
            if( tmp % part != * it % part )
                 std::cout << " | ";
            else
                 std::cout << ' ';
           
            tmp = * it;
            std::cout << tmp;
        }
       
        std::cout << '\n';
    }
}
Podzial wg wartosci % 2:
 0 2 4 6 8 10 12 14 | 1 3 5 7 9 11 13
Podzial wg wartosci % 3:
 0 6 12 3 9 | 4 10 1 7 13 | 2 8 14 5 11
Podzial wg wartosci % 4:
 0 12 4 8 | 9 1 13 5 | 6 10 2 14 | 3 7 11
Podzial wg wartosci % 5:
 0 5 10 | 1 6 11 | 12 2 7 | 8 13 3 | 4 9 14