2010-07-20

Visual C++ 2010 - wyrażenia Lambda

Wyrażenie lambda w C++ pozwala nam stworzyć obiekt (zmienną), który jest funkcją. Istnieje tutaj duże podobieństwo do wyrażeń Lambda z C#, które są tak naprawdę anonimowymi delegatami, a te są zwykłymi delegatami, które kompilator tworzy podczas generowania kodu. Można to zaobserwować używając narzędzia Reflector. Podobnie jest w wyrażeniami Lambda w C++. każde wyrażenie lambda jest tak naprawdę strukturą, która przeładowuje operator(). Mając do dyspozycji wyrażenia Lambda chcąc wywołać jakąś funkcję wymagającą jako parametru obiektu funkcyjnego nie musimy tworzyć odpowiedniej struktury albo klasy, tylko możemy zapisać to w jednej linijce kodu definiując wyrażenie Lambda jako parametr funkcji. Wyrażenia Lambda pojawiły się w VC2010, jako efekt tworzenia się nowego standardu C++0x. VC2010 implementuje wiele z elementów tego standardu, w tym wyrażenia Lambda.

Przykład prostego wyrażenia lambda:

auto lmb1 = [] (int a) { return a + 2; };int r1 = lmb1(6)

Słowo kluczowe auto to też nowy element C++0x. Jest on odpowiednikiem var z C#. Kompilator sam w tym przypadku ustali typ zmiennej. Auto pozwala na uproszczenie kodu, zwłaszcza jeśli jeśli jest to jakaś skomplikowana klasa szablonowa, której pełnej definicji nawet możemy nie poznać bez wgłębienia się w kod. Poza tym w tym przypadku nie mamy innej możliwości, typ jest nam nieznany, jest to anonimowa struktura wygenerowana przez kompilator:

'anonymous-namespace'::<lambda0>

Nasze wyrażenie lambda, o którym możemy myśleć jako o funkcji zwiększa liczbę o dwa. I teraz jeszcze jeden przykład:

int r2 = [] (int a) { return a + 2; }(6);

Blok [] to z dokumentacji MSDN lambda-parameter-declaration-list (referred to as parameter list later in this topic). Za polską wikipedią to domknięcie. Ja będę go nazywać blokiem przechwytującym. Więcej o tym dalej.

Za blokiem przechwytującym w nawiasach mamy listę parametrów wyrażenia Lambda. I tutaj mamy trzy ograniczenia: nie ma parametrów domyślnych, zmiennej liczby parametrów i parametrów nie nazwanych. W przypadku gdy nasza funkcja nie ma parametrów () możemy pominąć.

Dalej w nawiasach klamrowych znajduje się dowolny kod funkcji.

Powyższe wyrażenie zwraca int. Typ zwracany możemy zdefiniować bezpośrednio, albo jak wyżej pozostawić to domyślności kompilatora.
Lambda nie musi koniecznie zwracać wyniku:

[] (int a) { a + 2; }(4);

W tym wypadku zwracany jest void.

Co w takim wypadku:

auto L3 = [] (int x) { return (x % 2 == 0) ? (x * 2) : (x / 2.0); };

Wynikiem wyrażenia Lambda będzie int gdyż on został napotkany podczas kompilacji jako pierwszy. Double podlega automatycznej konwersji do int więc nie ma tutaj żadnego błędu. Osobiście nie podoba mi się takie zachowanie, według mnie taka niejednoznaczność powinna wygenerować błąd kompilacji i zmusić użytkownika do podania zwracanego rezultatu, a robi się to tak:
auto L3 = [] (int x) -> double { return (x % 2 == 0) ? (x * 2) : (x / 2.0); };
Jeszcze jeden przykład:

auto L2 = [](int x) { x = x + 1; return x; };

Tutaj też mamy błąd. Kompilator założył po analizie pierwszej linii, że funkcja zwraca void. Czyli tak naprawdę nie podawanie typu wyniku jest zarezerwowane dla najprostszych funkcji.

Kolejny błędny przykład:

auto L2 = [](int x) 
{ 
    if (x ==2) 
        return 3;
    else 
    { 
        if (x == 3) 
            return x + 2; 
        else
            return 5; 
    }
};

W tym wypadku ponieważ każda ścieżka przepływu zwraca int-a wydaje się, że kompilator powinien się domyśleć zwracanego wyrażenia.

Kolejny blok throw() o którym tylko wspomnę:

auto L3 = [] (int x) throw() -> int { test(); return (x % 2 == 0) ? (x * 2) : throw 4; };

Takie wyrażenie jest błędne, zadeklarowaliśmy, że Lambda nie będzie wyrzucać wyjątków, a to robi. Więcej informacji: Exception Specifications

Do omówienia pozostał nam jeszcze blok przechwytujący. Funkcja Lambda w swoim zasięgu ma tylko zmienne i funkcje globalne (w tym statyczne klas).

Przykład:

class Test
{
    private:    

        static int w;
        int z;

    public:

        void test()
        {
            auto L2 = [](int x) { return x + z; };
            auto L3 = [](int x) { return x + w; };
        }
};

int w;

void  main()
{ 
    int z;
    auto L2 = [](int x) { return x + z; };

    int* p = new int[5];
    auto L4 = [](int x) { return x + p[1]; };

    auto L3 = [](int x) { return x + w; };
};

Poprawne są tylko wyrażenia starające się odwołać do zmiennych w. Wyrażenie lambda to anonimowa struktura, jej operator(), który zawiera ciało naszej funkcji nie ma dostępu do nie-globalnych składników. Chcąc wykorzystać w wyrażeniu Lambda zmienne nie-globalne musimy je przechwycić. Są one przechwytywane do pól lokalnych struktury podczas konstruowania jej.

Przechwytywać możemy przez wartość i przez referencję:

int z;
int* p = new int[5];

auto L3 = [&](int x) { return x + z + p[1]; };
auto L4 = [&, z](int x) { return x + z + p[1]; };
//auto L5 = [&, &z](int x) { return x + z + p[1]; }; // Błąd
auto L5 = [&, z, p](int x) { return x + z + p[1]; };
auto L6 = [=](int x) { return x + z + p[1]; };
//auto L7 = [=, p](int x) { return x + z + p[1]; }; // Błąd
auto L8 = [=, &p](int x) { return x + z + p[1]; };
auto L9 = [=, &p, &z](int x) { return x + z + p[1]; };
auto L10 = [&p, z](int x) { return x + z + p[1]; };

Linia L3 Przechwytuje wszystko jako referencje zmiennych, zaś linia L6 przechwytuje wszystko jako wartości. Pozostałe przypadki są kombinacją przechwytywania przez referencje i wartość. Np. linia L5 przechwytuje wszystko przez referencje, za wyjątkiem dwóch innych zmiennych. W linii L10 trzeba podać wszystkie zmienne przechwytywane. Przypadki błędne to swego rodzaju tożsamości, pierwszy błąd mówi przechwyć wszystko przez referencje i z przez referencję.

Tak przechwytuje się dostęp do składników klasy:

class Test
{
    private:    

        int z;

        void test_priv()
        {
        }

    public:

        void test()
        {
            auto L2 = [this](int x) -> int { test_priv(); return x + z; };
        }
};

Przy okazji widzimy, że wyrażenie Lambda zdefiniowane we wnętrzu metody klasy ma dostęp do jego składników prywatnych (staje się przyjacielem klasy). Taki sam efekt daje zastosowanie [&] albo [=]. [&] oznacza, że this łapiemy przez wartość. Zapisanie [&this] jest błędem.

Jaka jest różnica pomiędzy przechwyceniem przez wartość i przez referencję?

void main()
{
    byte z = 0;
    byte* p = new byte[5];
    memset(p, 0, 5);
    byte* cp = p;
    printf("z: %d, p: %08X, cp: %08X, p[1]: %d, cp[1]: %d\n", 
            z, p, cp, p[1], cp[1]);
    auto L3 = [=](int x) mutable -> int { z = x; p[1] = x; p = new byte[5]; 
        memset(p, 11, 5); return x + z + p[1]; };
    L3(5);
    printf("z: %d, p: %08X, cp: %08X, p[1]: %d, cp[1]: %d\n", 
            z, p, cp, p[1], cp[1]);
    auto L4 = [&](int x) -> int { z = x; p[1] = x; p = new byte[5]; 
        memset(p, 12, 5); return x + z + p[1]; };
    L4(6);
    printf("z: %d, p: %08X, cp: %08X, p[1]: %d, cp[1]: %d\n", 
            z, p, cp, p[1], cp[1]);
};

Wyniki:

z: 0, p: 00251370, cp: 00251370, p[1]: 0, cp[1]: 0
z: 0, p: 00251370, cp: 00251370, p[1]: 5, cp[1]: 5
z: 6, p: 00251400, cp: 00251370, p[1]: 12, cp[1]: 6


Słowo mutable pozwala nam na zmienianie parametrów przechwyconych przez wartość.

Widzimy więc, że L3 operuje na kopiach, zaś L4 na referencjach.

Przechwycenie następuje w momencie zdefiniowania wyrażenia Lambda, nie jego wykonania, takie wyrażenie posiada swój stan, który może się zmieniać.

int z = 0;
auto L1 = [=] ()  { return z; };
z++;
auto L2 = [=] ()  { return z; };
z++;
int a1 = L1();
z++;
int a2 = L2();

Wyniki to a1 = 0 i a2 = 1.

int z = 0;
auto L1 = [&] ()  { return z; };
z++;
auto L2 = [&] ()  { return z; };
z++;
int a1 = L1();
z++;
int a2 = L2();

Wyniki to a1 = 2 i a2 = 3.

Przechwytując referencje albo jakiś wskaźnik zawsze trzeba uważać bo można łatwo coś namieszać. Staje się to tym ważniejsze jeśli wyrażenie Lambda zostanie zrównoleglone w czymś w rodzaju for_each_parallel.

Poza tym warto zauważyć, że definicja wyrażenia Lambda nie oznacza jego wykonania. Mówimy tutaj o opóźnionym wykonaniu, wyrażenie jest wykonywane dopiero w momencie gdy jest potrzebne. W momencie definiowania tworzona jest struktura, która stanowi obudowę wyrażenia Lambda i przechwytywane są zmienne do tej struktury.

Opóźnione wykonywanie rodzi tutaj problemy. Np. skasowanie p przed wywołaniem L3 spowoduje błąd. Czyli pierwszy rodzaj kłopotów to trwałość zmiennych alokowanych dynamicznie, a przechwyconych do wyrażenia Lambda i skasowanych przed wywołaniem wyrażenia Lambda. W językach z GC nie mamy takich problemów.

Drugi rodzaj możliwych błędów to zmienienie stanu obiektów przechwyconych przed wywołaniem wyrażenia Lambda, np. zamknięcie pliku.

Różnorakich błędów może być mnóstwo, trzeba po prostu uważać.

Spójrzmy na poniższy przykład zastosowania wyrażenia Lambda:

int _tmain(int argc, _TCHAR* argv[])
{
    vector<int> v;
    for (int i = 0; i < 10; ++i) 
        v.push_back(i);
    for_each(v.begin(), v.end(), [](int n) { cout << n << " "; } );

 return 0;
}

Przekazaliśmy do funkcji for_each jako parametr wyrażenie Lambda, która jest anonimowo zdefiniowaną strukturą. Tak naprawdę for_each jest funkcją szablonową i spodziewa się, że trzeci parametr ma przeładowany operator (). Co gdybyśmy chcieli zwrócić jako wynik funkcji wyrażenie Lambda. W C# możemy do tego wykorzystać delegaty, najlepiej w postaci Action i Func. W C++ mamy odpowiednik function.

using namespace std;
using namespace std::tr1;

void round(function<int(double)> round_impl)
{
    double x = 0.1;
    int y = round_impl(0.1);
}

int _tmain(int argc, _TCHAR* argv[])
{
    auto round_impl = [&] (double g ) { return (int)(g + 0.5); };
    function<int(double)> f = round_impl;

    round(round_impl);

 return 0;
}

Użycie function zwiększa naszą elastyczność, pozwalając nam przekazywać funkcje jako parametry, ale kosztem wydajności. Teraz na koniec możemy spóbować co się stanie jeśli przechwycimy przez referencję zmienną lokalną funkcji i spróbujemy jej użyć po wyjściu z jej zakresu w wyrażeniu Lambda.

function<int()> CreateF()
{
    int x = 111;
    return [&] () -> int { x++; return x; };
}

int _tmain(int argc, _TCHAR* argv[])
{
    int y = CreateF()();
    return 0;
}

Wartość zmiennej y jest nieustalona, samo wyrażenie Lambda modyfikuje pamięć poza wskaźnikiem stosu. W pewnych okolicznościach może to prowadzić do losowych i fatalnych błędów. I jeszcze jeden przykład:

[](){}();
[]{}();
function(5)();


Skompiluje się, ale podczas działania ostatni przykład wyrzuci wyjątek.

Podsumowanie

Wszystko co możemy zrobić za pomocą wyrażeń Lambda mogliśmy zrobić wcześniej używając obiektów funkcyjnych. Z tym, że teraz możemy to zrobić prościej i szybciej. A z tym wiąże się fakt: jeśli coś jest ciężkie do wykorzystania to się z reguły z tego nie skorzysta, bo się nie chce tyle pisać, zaciemniać kod, goni nas czas. Innymi słowy wyrażenia Lambda przybliżają nas do tego by programować bardziej funkcyjnie.

Korzystałem z:

Lambda Expressions in C++
Function Objects
Lambdas, auto, and static_assert: C++0x Features in VC10, Part 1
C++0x - Wikipedia Lambda Expression Syntax
Examples of Lambda Expressions

Brak komentarzy:

Prześlij komentarz