Generowanie kodu Lambda w C ++ za pomocą funkcji przechwytywania inicjałów w C ++ 14

9

Próbuję zrozumieć / wyjaśnić kod kodu, który jest generowany, gdy przechwytywania są przekazywane do lambdas, szczególnie w uogólnionych przechwytywaniach init dodanych w C ++ 14.

Podaj poniższe przykłady kodu wymienione poniżej. Moje obecne rozumienie tego, co wygeneruje kompilator.

Przypadek 1: przechwytywanie według wartości / przechwytywanie domyślne według wartości

int x = 6;
auto lambda = [x]() { std::cout << x << std::endl; };

Odpowiada to:

class __some_compiler_generated_name {
public:
    __some_compiler_generated_name(int x) : __x{x}{}
    void operator()() const { std::cout << __x << std::endl;}
private:
    int __x;
};

Istnieje więc wiele kopii, jedna do skopiowania do parametru konstruktora, a druga do elementu członkowskiego, co byłoby drogie w przypadku typów takich jak wektor itp.

Przypadek 2: przechwytywanie przez odniesienie / domyślne przechwytywanie przez odniesienie

int x = 6;
auto lambda = [&x]() { std::cout << x << std::endl; };

Odpowiada to:

class __some_compiler_generated_name {
public:
    __some_compiler_generated_name(int& x) : x_{x}{}
    void operator()() const { std::cout << x << std::endl;}
private:
    int& x_;
};

Parametr jest referencją, a element jest referencją, więc nie ma kopii. Ładne dla typów takich jak wektor itp.

Przypadek 3:

Uogólnione przechwytywanie init

auto lambda = [x = 33]() { std::cout << x << std::endl; };

Rozumiem, że jest to podobne do przypadku 1 w tym sensie, że zostało skopiowane do członka.

Domyślam się, że kompilator generuje kod podobny do ...

class __some_compiler_generated_name {
public:
    __some_compiler_generated_name() : __x{33}{}
    void operator()() const { std::cout << __x << std::endl;}
private:
    int __x;
};

Również jeśli mam następujące elementy:

auto l = [p = std::move(unique_ptr_var)]() {
 // do something with unique_ptr_var
};

Jak wyglądałby konstruktor? Czy przenosi to również do członka?

Blair Davidson
źródło
1
@ rafix07 W takim przypadku wygenerowany kod wglądu nawet się nie skompiluje (próbuje skopiować-zainicjować unikalny element ptr z argumentu). cppinsights jest przydatny do uzyskania ogólnej treści, ale najwyraźniej nie jest w stanie odpowiedzieć na to pytanie tutaj.
Max Langhof,
Wydaje się, że pierwszym krokiem kompilacji jest tłumaczenie lambda na funktory, czy po prostu szukasz równoważnego kodu (tj. Takiego samego zachowania)? Sposób, w jaki określony kompilator generuje kod (i który kod generuje) będzie zależeć od kompilatora, wersji, architektury, flag itp. Więc pytasz o konkretną platformę? Jeśli nie, twoje pytanie nie jest naprawdę odpowiedzialne. Inny niż faktycznie wygenerowany kod będzie prawdopodobnie bardziej wydajny niż wymienione funktory (np. Wbudowane konstruktory, unikanie niepotrzebnych kopii itp.).
Sander De Dycker,
2
Jeśli jesteś zainteresowany tym, co ma do powiedzenia standard C ++, zapoznaj się z [expr.prim.lambda] . To zbyt wiele, by podsumować tutaj jako odpowiedź.
Sander De Dycker,

Odpowiedzi:

2

Odpowiedź na to pytanie nie jest możliwa w kodzie. Możesz być w stanie napisać nieco „równoważny” kod, ale standard nie jest określony w ten sposób.

Zejdźmy na bok [expr.prim.lambda]. Pierwszą rzeczą do odnotowania jest to, że konstruktory są wymienione tylko w [expr.prim.lambda.closure]/13:

Typ zamknięcia skojarzony z wyrażeniem lambda nie ma domyślnego konstruktora, jeśli wyrażenie lambda ma przechwytywanie lambda, a domyślny domyślny konstruktor jest inny. Ma domyślny konstruktor kopiowania i domyślny konstruktor przenoszenia ([class.copy.ctor]). Ma on operator przypisania usuniętej kopii, jeśli wyrażenie lambda ma funkcję przechwytywania lambda i domyślnie operatory przypisania kopiowania i przenoszenia w przeciwnym razie ([class.copy.assign]). [ Uwaga: Te specjalne funkcje składowe są domyślnie zdefiniowane jak zwykle i dlatego mogą zostać zdefiniowane jako usunięte. - uwaga końcowa ]

Tak więc od samego początku powinno być jasne, że konstruktorzy nie określają formalnie sposobu przechwytywania obiektów. Możesz podejść bardzo blisko (patrz odpowiedź cppinsights.io), ale szczegóły różnią się (zwróć uwagę, że kod w tej odpowiedzi dla przypadku 4 nie kompiluje się).


Oto główne standardowe klauzule potrzebne do omówienia przypadku 1:

[expr.prim.lambda.capture]/10

[...]
Dla każdego obiektu przechwyconego przez kopię deklarowany jest nienazwany niestatyczny element danych w rodzaju zamknięcia. Kolejność deklaracji tych członków jest nieokreślona. Typem takiego elementu danych jest typ odwołania, jeśli encja jest odwołaniem do obiektu, odwołanie do wartości typu referencyjnego typu funkcji, jeśli encja jest odwołaniem do funkcji, lub typem odpowiadającego obiektu przechwyconego w innym przypadku. Członek anonimowego związku nie może zostać schwytany przez kopię.

[expr.prim.lambda.capture]/11

Każde wyrażenie id w wyrażeniu złożonym wyrażenia lambda, które jest odrowym użyciem encji przechwyconej przez kopię, jest przekształcane w dostęp do odpowiedniego nienazwanego elementu danych typu zamknięcia. [...]

[expr.prim.lambda.capture]/15

Gdy ocenia się wyrażenie lambda, jednostki, które są przechwytywane przez kopię, są używane do bezpośredniej inicjalizacji każdego odpowiedniego niestatycznego elementu danych wynikowego obiektu zamknięcia, a niestatyczne elementy danych odpowiadające przechwyceniu init są inicjowane jako wskazane przez odpowiedni inicjator (który może być inicjalizacją kopiowania lub bezpośrednią). [...]

Zastosujmy to do twojego przypadku 1:

Przypadek 1: przechwytywanie według wartości / przechwytywanie domyślne według wartości

int x = 6;
auto lambda = [x]() { std::cout << x << std::endl; };

Typ zamknięcia tej lambdy będzie miał nienazwany element danych niestatycznych (nazwijmy go __x) typu int(ponieważ xnie jest ani odniesieniem, ani funkcją), a dostęp do xwewnątrz ciała lambda jest przekształcany w dostęp do __x. Kiedy oceniamy wyrażenie lambda (tj. Przy przypisywaniu do lambda), inicjalizujemy bezpośrednio za __x pomocą x.

Krótko mówiąc, ma miejsce tylko jedna kopia . Konstruktor typu zamknięcia nie jest zaangażowany i nie jest możliwe wyrażenie tego w „normalnym” języku C ++ (należy zauważyć, że typ zamknięcia również nie jest typem zagregowanym ).


Przechwytywanie referencji obejmuje [expr.prim.lambda.capture]/12:

Jednostka jest przechwytywana przez odniesienie, jeśli jest ona dorozumiana lub jawna, ale nie jest przechwytywana przez kopię. Nie jest określone, czy dodatkowe nienazwane elementy danych niestatycznych są zadeklarowane w typie zamknięcia dla encji przechwyconych przez odniesienie. [...]

Jest jeszcze jeden akapit na temat przechwytywania referencji, ale nigdzie tego nie robimy.

W przypadku 2:

Przypadek 2: przechwytywanie przez odniesienie / domyślne przechwytywanie przez odniesienie

int x = 6;
auto lambda = [&x]() { std::cout << x << std::endl; };

Nie wiemy, czy członek jest dodawany do typu zamknięcia. xw ciele lambda może po prostu bezpośrednio odnosić się na xzewnątrz. To zależy od kompilatora, aby to rozgryźć i zrobi to w jakiejś formie języka pośredniego (który różni się od kompilatora do kompilatora), a nie w źródłowej transformacji kodu C ++.


Przechwytywanie początkowe opisano szczegółowo w [expr.prim.lambda.capture]/6:

Przechwytywanie init zachowuje się tak, jakby deklarowało i jawnie przechwytywało zmienną formy, auto init-capture ;której region deklaratywny jest wyrażeniem złożonym wyrażenia lambda, z wyjątkiem tego, że:

  • (6.1) jeśli przechwytywanie odbywa się za pomocą kopiowania (patrz poniżej), element danych niestatycznych zadeklarowany do przechwytywania i zmienna są traktowane jako dwa różne sposoby odwoływania się do tego samego obiektu, który ma okres istnienia danych niestatycznych członka i nie jest wykonywana żadna dodatkowa kopia i zniszczenie, oraz
  • (6.2) jeśli przechwytywanie odbywa się przez odniesienie, czas życia zmiennej kończy się, gdy kończy się czas życia obiektu zamknięcia.

Biorąc to pod uwagę, spójrzmy na przypadek 3:

Przypadek 3: Uogólnione przechwytywanie inicjujące

auto lambda = [x = 33]() { std::cout << x << std::endl; };

Jak powiedziano, wyobraź sobie, że jest to zmienna tworzona przez auto x = 33;i jawnie przechwytywana przez kopię. Ta zmienna jest „widoczna” tylko w ciele lambda. Jak zauważono [expr.prim.lambda.capture]/15wcześniej, inicjalizacja odpowiedniego elementu typu zamknięcia ( __xdla potomności) następuje przez podany inicjator po ocenie ekspresji lambda.

Aby uniknąć wątpliwości: nie oznacza to, że rzeczy są tutaj inicjowane dwukrotnie. auto x = 33;Jest „jak gdyby” dziedziczyć semantyki prostych przechwytywania i opisane inicjalizacji modyfikacja tych semantyki. Następuje tylko jedna inicjalizacja.

Dotyczy to również przypadku 4:

auto l = [p = std::move(unique_ptr_var)]() {
  // do something with unique_ptr_var
};

Element typu zamknięcia jest inicjowany przez __p = std::move(unique_ptr_var)obliczenie wyrażenia lambda (tj. Kiedy ljest przypisany). Dostęp do pw ciele lambda przekształca się w dostęp do __p.


TL; DR: Wykonywana jest tylko minimalna liczba kopii / inicjalizacji / ruchów (jak można się spodziewać / oczekiwać). Zakładam, że lambdas nie są określone w kategoriach transformacji źródłowej (w przeciwieństwie do innych cukru syntaktycznego) właśnie dlatego , że wyrażanie rzeczy w kategoriach konstruktorów wymagałoby zbędnych operacji.

Mam nadzieję, że to rozwiąże obawy wyrażone w pytaniu :)

Max Langhof
źródło
9

Przypadek 1 [x](){} : Wygenerowany konstruktor zaakceptuje swój argument poprzez ewentualnie constkwalifikowane odwołanie, aby uniknąć niepotrzebnych kopii:

__some_compiler_generated_name(const int& x) : x_{x}{}

Przypadek 2 [x&](){} : Twoje założenia tutaj są prawidłowe, xsą przekazywane i przechowywane przez odniesienie.


Przypadek 3 [x = 33](){} : Znowu poprawny, xjest inicjowany przez wartość.


Przypadek 4 [p = std::move(unique_ptr_var)] : Konstruktor będzie wyglądał następująco:

    __some_compiler_generated_name(std::unique_ptr<SomeType>&& x) :
        x_{std::move(x)}{}

więc tak, unique_ptr_var„przenosi się” do zamknięcia. Zobacz także Scott Meyer Item 32 w Effective Modern C ++ („Użyj funkcji przechwytywania init, aby przenosić obiekty do zamknięć”).

lubgr
źródło
const-kwalifikowany” Dlaczego?
cpplearner,
@cpplearner Mh, dobre pytanie. Wydaje mi się, że wstawiłem to, ponieważ jeden z tych automatyzm umysłowych uruchomił się ^^ Przynajmniej constnie może tu zaszkodzić z powodu niejasności / lepszego dopasowania, gdy nie jest constitp. W każdym razie, czy uważasz, że powinienem usunąć const?
lubgr
Myślę, że const powinien pozostać, co jeśli argument przekazany do tak naprawdę jest const?
Aconcagua
Mówisz więc, że zdarzają się tutaj dwie konstrukcje przenoszące (lub kopiujące)?
Max Langhof,
Przepraszam, mam na myśli przypadek 4 (dla ruchów) i przypadek 1 (dla kopii). Kopiowanie części mojego pytania nie ma sensu w oparciu o twoje stwierdzenia (ale kwestionuję te stwierdzenia).
Max Langhof
5

Nie ma potrzeby spekulacji przy użyciu cppinsights.io .

Przypadek 1:
Kod

#include <memory>

int main() {
    int x = 33;
    auto lambda = [x]() { std::cout << x << std::endl; };
}

Kompilator generuje

#include <iostream>

int main()
{
  int x = 6;

  class __lambda_5_16
  {
    int x;
    public: 
    inline void operator()() const
    {
      std::cout.operator<<(x).operator<<(std::endl);
    }

    // inline /*constexpr */ __lambda_5_16(const __lambda_5_16 &) = default;
    // inline /*constexpr */ __lambda_5_16(__lambda_5_16 &&) noexcept = default;
    public: __lambda_5_16(int _x)
    : x{_x}
    {}

  };

  __lambda_5_16 lambda = __lambda_5_16(__lambda_5_16{x});
}

Przypadek 2:
Kod

#include <iostream>
#include <memory>

int main() {
    int x = 33;
    auto lambda = [&x]() { std::cout << x << std::endl; };
}

Kompilator generuje

#include <iostream>

int main()
{
  int x = 6;

  class __lambda_5_16
  {
    int & x;
    public: 
    inline void operator()() const
    {
      std::cout.operator<<(x).operator<<(std::endl);
    }

    // inline /*constexpr */ __lambda_5_16(const __lambda_5_16 &) = default;
    // inline /*constexpr */ __lambda_5_16(__lambda_5_16 &&) noexcept = default;
    public: __lambda_5_16(int & _x)
    : x{_x}
    {}

  };

  __lambda_5_16 lambda = __lambda_5_16(__lambda_5_16{x});
}

Przypadek 3:
Kod

#include <iostream>

int main() {
    auto lambda = [x = 33]() { std::cout << x << std::endl; };
}

Kompilator generuje

#include <iostream>

int main()
{

  class __lambda_4_16
  {
    int x;
    public: 
    inline void operator()() const
    {
      std::cout.operator<<(x).operator<<(std::endl);
    }

    // inline /*constexpr */ __lambda_4_16(const __lambda_4_16 &) = default;
    // inline /*constexpr */ __lambda_4_16(__lambda_4_16 &&) noexcept = default;
    public: __lambda_4_16(int _x)
    : x{_x}
    {}

  };

  __lambda_4_16 lambda = __lambda_4_16(__lambda_4_16{33});
}

Przypadek 4 (nieoficjalnie):
kod

#include <iostream>
#include <memory>

int main() {
    auto x = std::make_unique<int>(33);
    auto lambda = [x = std::move(x)]() { std::cout << *x << std::endl; };
}

Kompilator generuje

// EDITED output to minimize horizontal scrolling
#include <iostream>
#include <memory>

int main()
{
  std::unique_ptr<int, std::default_delete<int> > x = 
      std::unique_ptr<int, std::default_delete<int> >(std::make_unique<int>(33));

  class __lambda_6_16
  {
    std::unique_ptr<int, std::default_delete<int> > x;
    public: 
    inline void operator()() const
    {
      std::cout.operator<<(x.operator*()).operator<<(std::endl);
    }

    // inline __lambda_6_16(const __lambda_6_16 &) = delete;
    // inline __lambda_6_16(__lambda_6_16 &&) noexcept = default;
    public: __lambda_6_16(std::unique_ptr<int, std::default_delete<int> > _x)
    : x{_x}
    {}

  };

  __lambda_6_16 lambda = __lambda_6_16(__lambda_6_16{std::unique_ptr<int, 
                                                     std::default_delete<int> >
                                                         (std::move(x))});
}

I wierzę, że ten ostatni fragment kodu odpowiada na twoje pytanie. Ruch występuje, ale nie [technicznie] w konstruktorze.

Same zdjęcia nie są const, ale widać, że operator()funkcja jest. Oczywiście, jeśli chcesz zmodyfikować przechwytywania, oznacz lambda jako mutable.

Sweenish
źródło
Kod wyświetlany dla ostatniej sprawy nawet się nie kompiluje. Wniosek „ruch występuje, ale nie [technicznie] w konstruktorze” nie może być obsługiwany przez ten kod.
Max Langhof
Kod z przypadku 4 z pewnością nie skompilować na moim Macu. Dziwi mnie, że wygenerowany rozszerzony kod z cppinsights nie kompiluje się. Do tej pory strona była dla mnie dość niezawodna. Porozmawiam z nimi. EDYCJA: Potwierdziłem, że wygenerowany kod się nie kompiluje; to nie byłoby jasne bez tej edycji.
sweenish
1
Link do problemu w przypadku zainteresowania: github.com/andreasfertig/cppinsights/issues/258 Nadal polecam stronę do testowania SFINAE i tego, czy nastąpi niejawna obsada.
sweenish