Implementacja lambda i model pamięci w C ++ 11

97

Chciałbym uzyskać informacje o tym, jak poprawnie myśleć o domknięciach std::functionw C ++ 11 oraz o tym, jak są one implementowane i jak obsługiwana jest pamięć.

Chociaż nie wierzę w przedwczesną optymalizację, mam zwyczaj uważnego rozważania wpływu moich wyborów na wydajność podczas pisania nowego kodu. Zajmuję się również sporą ilością programowania w czasie rzeczywistym, np. Na mikrokontrolerach i systemach audio, gdzie należy unikać niedeterministycznych przerw w przydzielaniu / zwalnianiu pamięci.

Dlatego chciałbym lepiej zrozumieć, kiedy używać lambd języka C ++, a kiedy nie.

Obecnie rozumiem, że lambda bez przechwyconego zamknięcia jest dokładnie taka sama, jak wywołanie zwrotne w C. Jednak gdy środowisko jest przechwytywane przez wartość lub przez odniesienie, na stosie jest tworzony anonimowy obiekt. Kiedy zamknięcie wartości musi zostać zwrócone z funkcji, należy je opakować std::function. Co dzieje się z pamięcią zamknięcia w tym przypadku? Czy jest kopiowany ze stosu na stos? Czy jest uwalniany za każdym razem, gdy std::functionzostanie uwolniony, tj. Czy jest liczony jako odniesienie std::shared_ptr?

Wyobrażam sobie, że w systemie czasu rzeczywistego mógłbym ustawić łańcuch funkcji lambda, przekazując B jako argument kontynuacji do A, aby utworzyć potok przetwarzania A->B. W takim przypadku zamknięcia A i B zostaną przydzielone raz. Chociaż nie jestem pewien, czy zostaną one przydzielone na stosie, czy na stercie. Jednak ogólnie wydaje się to bezpieczne w użyciu w systemie czasu rzeczywistego. Z drugiej strony, jeśli B konstruuje jakąś funkcję lambda C, którą zwraca, wówczas pamięć dla C byłaby wielokrotnie przydzielana i zwalniana, co byłoby nie do przyjęcia w przypadku użycia w czasie rzeczywistym.

W pseudokodzie, pętla DSP, która moim zdaniem będzie bezpieczna w czasie rzeczywistym. Chcę wykonać blok przetwarzania A, a następnie B, gdzie A wywołuje swój argument. Obie te funkcje zwracają std::functionobiekty, więc fbędzie to std::functionobiekt, którego środowisko jest przechowywane na stercie:

auto f = A(B);  // A returns a function which calls B
                // Memory for the function returned by A is on the heap?
                // Note that A and B may maintain a state
                // via mutable value-closure!
for (t=0; t<1000; t++) {
    y = f(t)
}

I myślę, że może być zły w kodzie czasu rzeczywistego:

for (t=0; t<1000; t++) {
    y = A(B)(t);
}

I taki, w którym myślę, że pamięć stosu jest prawdopodobnie używana do zamknięcia:

freq = 220;
A = 2;
for (t=0; t<1000; t++) {
    y = [=](int t){ return sin(t*freq)*A; }
}

W tym drugim przypadku zamknięcie jest konstruowane przy każdej iteracji pętli, ale w przeciwieństwie do poprzedniego przykładu jest tanie, ponieważ jest podobne do wywołania funkcji, nie są dokonywane alokacje sterty. Co więcej, zastanawiam się, czy kompilator mógłby „podnieść” zamknięcie i wprowadzić optymalizacje wbudowane.

Czy to jest poprawne? Dziękuję Ci.

Steve
źródło
4
W przypadku używania wyrażenia lambda nie ma narzutu. Innym wyborem byłoby samodzielne napisanie takiego obiektu funkcyjnego, który byłby dokładnie taki sam. Przy okazji, w pytaniu wbudowanym, ponieważ kompilator ma wszystkie potrzebne informacje, z pewnością może po prostu wstawić wywołanie do operator(). Nie ma żadnego „podnoszenia” do wykonania, lambdy nie są niczym specjalnym. Są tylko krótką ręką dla obiektu funkcji lokalnej.
Xeo
Wydaje się, że jest to pytanie o to, czy std::functionprzechowuje swój stan na stercie, czy nie, i nie ma nic wspólnego z lambdami. Czy to prawda?
Mooing Duck
8
Aby przeliterować to w przypadku jakichkolwiek nieporozumień: wyrażenie lambda nie jest std::function!!
Xeo,
1
Tylko komentarz boczny: zachowaj ostrożność zwracając lambdę z funkcji, ponieważ wszelkie zmienne lokalne przechwycone przez odniesienie stają się nieprawidłowe po opuszczeniu funkcji, która utworzyła lambdę.
Giorgio
2
@Steve od C ++ 14 możesz zwrócić lambdę z funkcji o autozwracanym typie.
Oktalist

Odpowiedzi:

104

Obecnie rozumiem, że lambda bez przechwyconego zamknięcia jest dokładnie taka sama, jak wywołanie zwrotne w C. Jednak gdy środowisko jest przechwytywane przez wartość lub przez odniesienie, na stosie jest tworzony anonimowy obiekt.

Nie; jest to zawsze obiekt C ++ o nieznanym typie, utworzony na stosie. Lambda bez przechwytywania może zostać przekonwertowana na wskaźnik funkcji (chociaż to, czy jest odpowiednia dla konwencji wywoływania języka C, zależy od implementacji), ale to nie znaczy, że jest wskaźnikiem funkcji.

Gdy zamknięcie wartości musi zostać zwrócone z funkcji, należy opakować je w std :: function. Co dzieje się z pamięcią zamknięcia w tym przypadku?

Lambda nie jest niczym specjalnym w C ++ 11. To obiekt jak każdy inny przedmiot. Wyrażenie lambda daje w wyniku tymczasowy, którego można użyć do zainicjowania zmiennej na stosie:

auto lamb = []() {return 5;};

lambjest obiektem stosu. Posiada konstruktora i destruktora. I będzie przestrzegać wszystkich reguł C ++. Typ lambbędzie zawierał przechwycone wartości / odniesienia; będą członkami tego obiektu, tak jak wszystkie inne składowe obiektu dowolnego innego typu.

Możesz go przekazać std::function:

auto func_lamb = std::function<int()>(lamb);

W takim przypadku otrzyma kopię wartości lamb. Gdyby lambuchwycić cokolwiek według wartości, powstałyby dwie kopie tych wartości; jeden w lambi jeden w func_lamb.

Po zakończeniu bieżącego zakresu func_lambzostanie zniszczony, a po nim nastąpi lamb, zgodnie z zasadami czyszczenia zmiennych stosu.

Możesz równie łatwo przydzielić jeden na stercie:

auto func_lamb_ptr = new std::function<int()>(lamb);

Dokładnie miejsce, w którym pamięć na zawartość std::functionprzechodzi, jest zależne od implementacji, ale wymazywanie typu używane przez std::functionzazwyczaj wymaga co najmniej jednej alokacji pamięci. Dlatego std::functionkonstruktor może przyjąć alokator.

Czy jest zwalniany za każdym razem, gdy funkcja std :: jest zwalniana, tj. Czy jest liczona jako odwołanie jak std :: shared_ptr?

std::functionprzechowuje kopię swojej zawartości. Podobnie jak praktycznie każdy typ biblioteki standardowej C ++, functionużywa semantyki wartości . Dlatego można go skopiować; kiedy jest kopiowany, nowy functionobiekt jest całkowicie oddzielny. Jest również przenośny, więc wszelkie wewnętrzne przydziały mogą być odpowiednio przenoszone bez potrzeby dalszego przydzielania i kopiowania.

Dlatego nie ma potrzeby liczenia referencji.

Wszystko inne, co podasz, jest poprawne, zakładając, że „alokacja pamięci” oznacza „złe użycie w kodzie czasu rzeczywistego”.

Nicol Bolas
źródło
1
Doskonałe wyjaśnienie, dziękuję. Tak więc tworzenie std::functionjest punktem, w którym pamięć jest przydzielana i kopiowana. Wydaje się, że nie ma możliwości zwrócenia zamknięcia (ponieważ są one przydzielone na stosie) bez uprzedniego skopiowania do a std::function, tak?
Steve
3
@Steve: Tak; musisz owinąć lambdę w jakiś kontener, aby opuściła zakres.
Nicol Bolas
Czy cały kod funkcji jest kopiowany, czy też pierwotna funkcja została przydzielona w czasie kompilacji i przekazała zamknięte wartości?
Llamageddon
Chcę dodać, że norma mniej lub bardziej pośrednio nakazuje (§ 20.8.11.2.1 [func.wrap.func.con] ¶ 5), że jeśli lambda niczego nie przechwytuje, to można ją przechowywać w std::functionobiekcie bez pamięci dynamicznej alokacja trwa.
5gon12eder
2
@Yakk: Jak definiujesz „duży”? Czy obiekt z dwoma wskaźnikami stanu jest „duży”? A może 3 lub 4? Również rozmiar obiektu nie jest jedynym problemem; jeśli obiekt nie jest przenoszony przez nothrow, musi być przechowywany w alokacji, ponieważ functionma konstruktor przenoszenia noexcept. Cały sens powiedzenia „ogólnie wymaga” polega na tym, że nie mówię „ zawsze wymaga”: że istnieją okoliczności, w których nie zostanie dokonana alokacja.
Nicol Bolas
1

C ++ lambda jest po prostu lukrem składniowym wokół (anonimowej) klasy Functor z przeciążeniem operator()i std::functionjest po prostu opakowaniem wokół wywołań (tj. Funktorów, lambd, funkcji c, ...), które kopiuje według wartości "stały obiekt lambda" z bieżącej zakres stosu - do sterty .

Aby sprawdzić liczbę rzeczywistych konstruktorów / relokatów, wykonałem test (używając innego poziomu zawijania do shared_ptr, ale tak nie jest). Sam zobacz:

#include <memory>
#include <string>
#include <iostream>

class Functor {
    std::string greeting;
public:

    Functor(const Functor &rhs) {
        this->greeting = rhs.greeting;
        std::cout << "Copy-Ctor \n";
    }
    Functor(std::string _greeting="Hello!"): greeting { _greeting } {
        std::cout << "Ctor \n";
    }

    Functor & operator=(const Functor & rhs) {
        greeting = rhs.greeting;
        std::cout << "Copy-assigned\n";
        return *this;
    }

    virtual ~Functor() {
        std::cout << "Dtor\n";
    }

    void operator()()
    {
        std::cout << "hey" << "\n";
    }
};

auto getFpp() {
    std::shared_ptr<std::function<void()>> fp = std::make_shared<std::function<void()>>(Functor{}
    );
    (*fp)();
    return fp;
}

int main() {
    auto f = getFpp();
    (*f)();
}

robi to wyjście:

Ctor 
Copy-Ctor 
Copy-Ctor 
Dtor
Dtor
hey
hey
Dtor

Dokładnie taki sam zestaw ctors / dtors zostałby wywołany dla obiektu lambda przydzielonego stosowi! (Teraz wywołuje Ctor do alokacji stosu, Copy-ctor (+ alokacja sterty), aby skonstruować go w std :: function i inny do tworzenia alokacji sterty shared_ptr + konstrukcji funkcji)

Barney
źródło