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ęść II


2019-04-25, 00:00

W pierwszej części serii przeszliśmy przez lambdy w perspektywie od C++03, przez C++11 aż do C++14. Opisałem motywację do wprowadzenia tej potężnej funkcjonalności języka C++, podstawowe scenariusze użycia, składnię oraz jej udoskonalenia, które pojawiały się z każdą kolejną wersją standardu. Wspomniałem również o kilku scenariuszach brzegowych.

Teraz pora na przeniesienie się do standardu C++17 oraz spojrzenie w przyszłość (całkiem bliską!): C++20.

Wstęp

Jako słowo wstępu wspomnę, że pomysł na tę serię powstał na jednym z ostatnich spotkań naszej lokalnej grupy C++ w Krakowie. Mieliśmy krótką sesję live coding związaną z historią wyrażeń lambda. Spotkanie było prowadzone przez Eksperta C++, Tomasza Kamińskiego (zachęcam do odwiedzenia jego profilu na LinkedIn). Link do naszego eventu:

Lambdy: Od C++11 do C++20 - C++ User Group Krakow

Zdecydowałem, że wezmę kod Tomka (za jego zgodą!) opiszę i opublikuję jako wpis na blogu.

Jak dotąd, w pierwszym wpisie opisałem następujące elementy wyrażeń lambda:

  • Podstawowa składnia
  • Typ lambda
  • Operator wywoływania
  • Przechwytywanie (mutable, zmienne globalne, zmienne statyczne, właściwości klasy oraz wskaźnik this, obiekty typu move-able-only, zachowywanie stałych)
  • Zwracany typ
  • IIFE - Immediately Invoked Function Expression
  • Konwersja do wskaźnika na funkcję
  • Udoskonalenia standardu C++14
  • Dedukcja zwracanego typu
  • Przechwytywanie z inicjalizerem
  • Przechwytywanie właściwości klas
  • Lambdy generyczne

Powyższa lista to tylko mała część całej historii o lambdach!

Sprawdźmy teraz, co w ich kontekście zmienił standard C++17 oraz co przyniesie nam standard C++20?

Usprawnienia w C++17

Link do standardu C++17 (ostatnia wersja draftu przed publikacją) N659 oraz do sekcji z opisem lambd: [expr.prim.lambda].

C++17 dodało dwa znaczące ulepszenia do wyrażeń lambda:

  • lambdy constexpr
  • Przechwytywanie *this

Jakie to ma dla nas znaczenie? Zapraszam do dalszej lektury.

Lambdy constexpr

Od C++17, jeśli lambda spełnia pewne wymagania, standard definiuje operator() jako constexpr.

Cytat z expr.prim.lambda #4:

The function call operator is a constexpr function if either the corresponding lambda-expression’s parameter-declaration-clause is followed by constexpr, or it satisfies the requirements for a constexpr function..

Na przykład:

constexpr auto Square = [] (int n) { return n*n; }; // niejawny constexpr
static_assert(Square(2) == 4);

Przypomnijmy sobie wszystkie reguły, które dla funkcji constexpr wprowadza standard C++17:

  • nie powinna być wirtualna;
    • typ przez nią zwracany powinien być literałem;
    • każdy z jej parametrów powinien być typu literalnego;
    • jej ciało powinno być = delete, = default, lub instrukcją złożoną, która nie zawiera:
      • definicji asm,
      • instrukcji goto,
      • identyfikatora,
      • bloku try-catch lub
      • definicji zmiennej typu nieliteralnego, statycznej, o czasie życia wątku lub dla której żadna inicjalizacja nie została wykonana.

Co powiecie na praktyczny przykład?

template<typename Range, typename Func, typename T>
constexpr T SimpleAccumulate(const Range& range, Func func, T init) {
    for (auto &&elem: range) {
        init += func(elem);
    }
    return init;
}

int main() {
    constexpr std::array arr{ 1, 2, 3 };

    static_assert(SimpleAccumulate(arr, [](int i) {
            return i * i;
        }, 0) == 14);
}

Uruchom ten kod na @Wandbox

Kod ten wykorzystuje lambdę constexpr, która następnie jest przekazana do algorytmu SimpleAccumulate. Algorytm ten również wykorzystuje kilka elementów C++17: dodatek constexpr dla std::array, std::begin oraz std::end (wykorzystywane w pętli for). Dzięki temu, że wszystkie te elementy są typu constexpr cały kod może zostać uruchomiony w trakcie procesu kompilacji.

Oczywiście, to nie wszystko.

Możemy również przechwytywać zmienne (zakładając, że są one także wyrażeniami stałymi):

constexpr int add(int const& t, int const& u) {
    return t + u;
}

int main() {
    constexpr int x = 0;
    constexpr auto lam = [x](int n) { return add(x, n); };

    static_assert(lam(10) == 10);
}

Jest jeden bardzo interesujący przypadek, kiedy nie możemy przechwycić takiej zmiennej:

constexpr int x = 0;
constexpr auto lam = [x](int n) { return n + x };

W tym przypadku kompilator Clang powinien zwrócić nam ostrzeżenie:

warning: lambda capture 'x' is not required to be captured for this use

Dzieje się tak najprawdopodobniej dlatego, że zmienna x zostaje zastąpiona w locie przy każdym jej użyciu (dopóki nie przekażecie jej dalej lub pobierzecie jej adresu).

Jednakże, dajcie mi znać, jeśli znacie przyczyny takiego zachowania. Ja znalazłem jedynie informację (na cppreference), ale nie mogłem nigdzie tego znaleźć w drafcie…

A lambda expression can read the value of a variable without capturing it if the variable

  • has const non-volatile integral or enumeration type and has been initialised with a constant expression, or
  • is constexpr and has no mutable members.

Bądźcie przygotowani na niedaleką przyszłość:

W C++20 będziemy mieli standardowe algorytmy (oraz możliwe, że niektóre z kontenerów), które również będą constexpr. Dzięki temu constexpr lambdy będą bardzo poręczne. Twój kod będzie wyglądał identycznie w wersji runtime jak i w wersji compilation time!

W skrócie:

Lambdy constexpr lepiej pracują z programowaniem szablonowym, dając możliwie jak najkrótszy kod wynikowy.

Przejdźmy teraz do drugiej ważnej nowości dostępnej od C++17:

Przechwytywanie *this

Czy pamiętacie problem, kiedy chcieliśmy przechwycić właściwość klasy?

Domyślnie, mogliśmy przechwytujemy this (jako wskaźnik) i to właśnie dlatego mogliśmy popadać w kłopoty, kiedy obiekty tymczasowe zakończyły swój cykl życiowy… Możemy poprawić to wykorzystując przechwytywanie z inicjatorem (zobacz w pierwszej części wpisu).

Teraz, od C++17 mamy inną możliwość. Możemy wrapować kopię *this:

#include <iostream>

struct Baz {
    auto foo() {
        return [*this] { 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

Przechwytywanie wymaganej właściwości chroni nas przed potencjalnymi błędami związanymi z wartościami tymczasowymi, ale nie możemy zrobić tego samego z wywoływaniem metody znajdującej się wewnątrz typu:

Na przykład:

struct Baz {
    auto foo() {
        return [this] { print(); };
    }

    void print() const { std::cout << s << '\n'; }

    std::string s;
};

W C++14 jedynym wyjściem gwarantującym nam bezpieczeństwo było przechwycenie this:

auto foo() {
    return [self=*this] { self.print(); };
}

Ale w C++17 możemy zrobić to samo, o wiele ładniej:

auto foo() {
    return [*this] { print(); };
}

Jeszcze jedna rzecz:

Zwróćcie uwagę, że jeżeli przechwycicie “wszystko” używając [=] w metodzie wewnątrz klasy, wtedy this również zostanie (niejawnie) przechwycony! Może to powodować w przyszłości błędy… dlatego zostanie to oznaczone jako deprecated w C++20.

Tym oto akcentem przenosimy się do kolejnej sekcji: przyszłości.

Przyszłość z C++20

Wraz z C++20 przychodzą następujące nowości:

  • Zezwalanie na [=, this] przy przechwytywaniu - P0409R2 oraz deprecate domniemanego przechwytywania this przy [=] - P0806
  • Pakowanie w liście przechwytywania wewnątrz lambdy: ...args = std::move(args)](){} - P0780
  • static, thread_local oraz przechwytywanie dla structured binding - P1091
  • lambdy szablonowe (wraz z konceptami) - P0428R2
  • Uproszczenia dla przechwytywania domniemanego - P0588R1
  • Domyślnie konstuowalne oraz przypisywalne bezstanowe lambdy - P0624R2
  • Lambdy w unevaluated contexts - P0315R4

W większości przypadków nowo dodane funkcjonalności trochę “oczyszczają” lambdy oraz zezwalają na kilka bardziej zaawansowanych przypadków użycia.

Na przykład wraz z P1091 możemy przechwytywać structured bindings.

Mamy również wyjaśnienia w sprawie przechwytywania this. W C++20 będziesz otrzymwał ostrzeżenie, jeżli użyjesz przechwytywania [=] wewnątrz metody:

struct Baz {
    auto foo() {
        return [=] { std::cout << s << std::endl; };
    }

    std::string s;
};

Ostrzeżenie wygenerowane przez kompilator GCC 9:

warning: implicit capture of 'this' via '[=]' is deprecated in C++20

Uruchom ten kod na @Wandbox

Jeżeli na prawdę potrzebujecie przechwycić this, powinniście korzystać z [=, this].

Mamy również zmiany związane z zaawansowanymi użyciami takimi jak unevaluated contexts oraz domyślnie konstruktowalne lambdy bezstanowe.

Dzięki tym dwóm zmianom będziemy mogli napisać:

std::map<int, int, decltype([](int x, int y) { return x > y; })> map;

Ale jest jeszcze jedna nowa, bardzo interesująca funkcja: lambdy szablonowe.

Lambdy szablonowe

Wraz z C++14 otrzymaliśmy lambdy generyczne, co oznacza, że parametry zadeklarowane jako auto zachowują się jako parametry szablonowe.

Przykładowo, dla lambdy:

[](auto x) { x; }

kompilator wygeneruje operator wywołania, który odpowiada metodzie szablonowej:

template<typename T>
void operator(T x) { x; }

Niestety, nie mamy możliwości zmiany typu tego parametru, wykorzystując typy szablonowe. Jednak z C++20 będzie to możliwe.

Na przykład, w jaki sposób możemy ograniczyć naszą lambdę do działania jedynie z wektorami określonego typu?

Możemy stworzyć lambdę generyczną:

auto foo = []<typename T>(const auto& vec) {
        std::cout<< std::size(vec) << '\n';
        std::cout<< vec.capacity() << '\n';
    };

Jeżeli wywołamy ją z parametrem typu int (np. foo(10);), to otrzymamy trudny do zrozumienia komunikat o błędzie:

prog.cc: In instantiation of 'main()::<lambda(const auto:1&)> [with auto:1 = int]':
prog.cc:16:11:   required from here
prog.cc:11:30: error: no matching function for call to 'size(const int&)'
   11 |         std::cout<< std::size(vec) << '\n';

W C++20 będziemy mogli napisać:

auto foo = []<typename T>(std::vector<T> const& vec) {
        std::cout<< std::size(vec) << '\n';
        std::cout<< vec.capacity() << '\n';
    };

Powyższa lambda rozwija się do następującego operatora:

<typename T>
void operator(std::vector<T> const& s) { ... }

Parametr szablonowy pojawi się za listą przechwytywania [].

Jeśli wywołacie tą lambdę z parametrem int (foo(10);), to otrzymacie ładniejszy komunikat:

note:   mismatched types 'const std::vector<T>' and 'int'

Uruchom ten kod na@Wandbox

W powyższym przykładzie kompilator ostrzeże nas o niedopasowaniu interfejsu lambdy niż o błędach pojawiających się w jej ciele.

Innym ważnym aspektem związanym z lambdami generycznymi jest to, że mamy do czynienia ze zwykłą zmienną, której typ nie jest typem szablonowym. Zatem, jeśli chcemy mieć dostęp do typu szablonowego, musimy skorzystać z decltype(x) (dla lambdy z argumentem (auto x)). Spowoduje to, że nasz kod będzie nieco bardziej rozwlekły i skomplikowany.

Na przykład (korzystamy z kodu z P0428):

auto f = [](auto const& x) {
    using T = std::decay_t<decltype(x)>;
    T copy = x;
    T::static_function();
    using Iterator = typename T::iterator;
}

Możemy teraz napisać to samo, ale w odrobinę prostszy sposób:

auto f = []<typename T>(T const& x) {
    T::static_function();
    T copy = x;
    using Iterator = typename T::iterator;
}

W tej sekcji zrobiliśmy mały przegląd standardu C++20, ale mam jeden dodatkowy scenariusz dla Was. Ta technika jest dostępna nawet w C++14. Czytajcie zatem dalej.

Bonus - LIFTing z lambdami

Obecnie mamy problem, kiedy próbujemy przeładować funkcję, do której chcemy przekazać algorytm standardowy (lub cokolwiek innego, co wymaga obiektu callable):

// dwa przeładowania:
void foo(int) {}
void foo(float) {}

int main()
{
  std::vector<int> vi;
  std::for_each(vi.begin(), vi.end(), foo);
}

W GCC9 (trunk) pojawi się następujący komunikat o błędzie:

error: no matching function for call to 
for_each(std::vector<int>::iterator, std::vector<int>::iterator,
 <unresolved overloaded function type>)
   std::for_each(vi.begin(), vi.end(), foo);
                                       ^^^^^

Jest jednakże jeden trick, dzięki któremu możemy użyć lambdy, a następnie wywołać żądane przeładowanie.

W podstawowej postaci, dla prostych typów, nasze dwie funkcje mogą zostać zapisane następująco:

std::for_each(vi.begin(), vi.end(), [](auto x) { return foo(x); });

Czyli pakujemy wywołanie naszych funkcji w generyczną lambdę.

W najbardziej generycznej postaci potrzebujemy odrobinę więcej typowania:

#define LIFT(foo) \
  [](auto&&... x) \
    noexcept(noexcept(foo(std::forward<decltype(x)>(x)...))) \
   -> decltype(foo(std::forward<decltype(x)>(x)...)) \
  { return foo(std::forward<decltype(x)>(x)...); }

Skomplikowane, co nie? :)

Spróbujmy to rozszyfrować:

Tworzymy generyczną lambdę, a następnie przekazujemy do niej wszystkie argumenty. Aby zdefiniować ją poprawnie, potrzebujemy określić ją jako noexcept i zwrócić wartość, która określi jej typ. To dlatego potrzebujemy zduplikować kod wywołania: aby dostać się do odpowiedniego typu.

To makro nazywane LIFT działa na każdym kompilatorze, który wspiera C++14.

Uruchom ten kod na @Wandbox

Podsumowanie

W tym wpisie poznaliśmy znaczące zmiany w C++17 oraz zobiliśmy przegląd nowych funkcjonalności w C++20.

Mogliśmy zaobserwować, że każda iteracja języka łączy lambdy z pozostałymi elementami C++. Na przkład, przed C++17 nie mogliśmy wykorzystać ich w kontekście constexpr. Teraz jest to już możliwe. Podobnie z lambdami generycznymi z C++14, oraz ich ewolucją w C++20 w postaci lambd szablonowych.

Czy coś pominąłem?
Może macie jakieś ciekawe przykłady do udostępnienia?
Dajcie mi znać w komentarzach!


Referencje



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