Implementação de lambda C ++ 11 e modelo de memória

92

Gostaria de obter algumas informações sobre como pensar corretamente sobre encerramentos de C ++ 11 e std::functionem termos de como eles são implementados e como a memória é tratada.

Embora eu não acredite em otimização prematura, tenho o hábito de considerar cuidadosamente o impacto de minhas escolhas no desempenho enquanto escrevo um novo código. Eu também faço uma boa quantidade de programação em tempo real, por exemplo, em microcontroladores e para sistemas de áudio, onde as pausas não determinísticas de alocação / desalocação de memória devem ser evitadas.

Portanto, gostaria de desenvolver um melhor entendimento de quando usar ou não lambdas C ++.

Meu entendimento atual é que um lambda sem encerramento capturado é exatamente como um retorno de chamada C. No entanto, quando o ambiente é capturado por valor ou por referência, um objeto anônimo é criado na pilha. Quando um fechamento de valor deve ser retornado de uma função, ele o envolve std::function. O que acontece com a memória de fechamento neste caso? É copiado da pilha para a pilha? Ele é liberado sempre que o std::functioné liberado, ou seja, é contado por referência como um std::shared_ptr?

Imagino que, em um sistema de tempo real, eu pudesse configurar uma cadeia de funções lambda, passando B como um argumento de continuação para A, de modo que um pipeline de processamento A->Bseja criado. Nesse caso, os fechamentos A e B seriam alocados uma vez. Embora eu não tenha certeza se eles seriam alocados na pilha ou no heap. No entanto, em geral, isso parece seguro para uso em um sistema de tempo real. Por outro lado, se B construir alguma função lambda C, que retorna, a memória para C seria alocada e desalocada repetidamente, o que não seria aceitável para uso em tempo real.

Em pseudo-código, um loop DSP, que acho que será seguro em tempo real. Quero realizar o processamento do bloco A e, em seguida, B, onde A chama seu argumento. Ambas as funções retornam std::functionobjetos, então fserá um std::functionobjeto, onde seu ambiente é armazenado no heap:

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)
}

E um que eu acho que pode ser ruim para usar em código em tempo real:

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

E um onde eu acho que a memória da pilha é provavelmente usada para o fechamento:

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

No último caso, o encerramento é construído a cada iteração do loop, mas, ao contrário do exemplo anterior, é barato porque é como uma chamada de função, nenhuma alocação de heap é feita. Além disso, eu me pergunto se um compilador poderia "suspender" o encerramento e fazer otimizações in-line.

Isso está correto? Obrigado.

Steve
fonte
4
Não há sobrecarga ao usar uma expressão lambda. A outra opção seria escrever esse objeto de função você mesmo, que seria exatamente o mesmo. A propósito, sobre a questão sequencial, uma vez que o compilador tem todas as informações de que precisa, ele com certeza pode embutir apenas a chamada para o operator(). Não há "levantamento" a ser feito, lambdas não são nada de especial. Eles são apenas um atalho para um objeto de função local.
Xeo
Esta parece ser uma questão sobre se std::functionarmazena seu estado no heap ou não, e não tem nada a ver com lambdas. Isso está certo?
Mooing Duck
8
Apenas para esclarecer caso haja algum mal-entendido: uma expressão lambda não é um std::function!!
Xeo
1
Apenas um comentário lateral: tome cuidado ao retornar um lambda de uma função, uma vez que qualquer variável local capturada por referência torna-se inválida após deixar a função que criou o lambda.
Giorgio
2
@Steve desde C ++ 14 você pode retornar um lambda de uma função com um autotipo de retorno.
Oktalista

Respostas:

100

Meu entendimento atual é que um lambda sem encerramento capturado é exatamente como um retorno de chamada C. No entanto, quando o ambiente é capturado por valor ou por referência, um objeto anônimo é criado na pilha.

Não; é sempre um objeto C ++ com um tipo desconhecido, criado na pilha. Um lambda sem captura pode ser convertido em um ponteiro de função (embora seja adequado para as convenções de chamada de C depender da implementação), mas isso não significa que seja um ponteiro de função.

Quando um fechamento de valor deve ser retornado de uma função, ele o envolve em std :: function. O que acontece com a memória de fechamento neste caso?

Um lambda não é nada especial em C ++ 11. É um objeto como qualquer outro objeto. Uma expressão lambda resulta em um temporário, que pode ser usado para inicializar uma variável na pilha:

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

lambé um objeto de pilha. Tem um construtor e um destruidor. E seguirá todas as regras do C ++ para isso. O tipo de lambconterá os valores / referências que são capturados; eles serão membros daquele objeto, assim como quaisquer outros membros de objeto de qualquer outro tipo.

Você pode dar a std::function:

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

Nesse caso, ele obterá uma cópia do valor de lamb. Se lambtivesse capturado qualquer coisa por valor, haveria duas cópias desses valores; um dentro lambe um dentro func_lamb.

Quando o escopo atual terminar, func_lambserá destruído, seguido por lamb, de acordo com as regras de limpeza das variáveis ​​da pilha.

Você poderia facilmente alocar um na pilha:

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

Exatamente onde vai a memória para o conteúdo de um std::functioné dependente da implementação, mas o apagamento de tipo empregado por std::functiongeralmente requer pelo menos uma alocação de memória. É por isso que std::functiono construtor de pode usar um alocador.

Ele é liberado sempre que a função std :: é liberada, ou seja, é contada por referência como um std :: shared_ptr?

std::functionarmazena uma cópia de seu conteúdo. Como praticamente todo tipo de biblioteca padrão C ++, functionusa semântica de valor . Portanto, é copiável; quando é copiado, o novo functionobjeto é completamente separado. Ele também é móvel, portanto, quaisquer alocações internas podem ser transferidas apropriadamente sem a necessidade de mais alocação e cópia.

Portanto, não há necessidade de contagem de referência.

Tudo o mais que você declara está correto, assumindo que "alocação de memória" é igual a "ruim para usar em código em tempo real".

Nicol Bolas
fonte
1
Excelente explicação, obrigado. Portanto, a criação de std::functioné o ponto em que a memória é alocada e copiada. Parece que não há como retornar um fechamento (uma vez que eles estão alocados na pilha), sem primeiro copiar em um std::function, sim?
Steve
3
@Steve: Sim; você tem que embrulhar um lambda em algum tipo de recipiente para que ele saia do escopo.
Nicol Bolas
O código da função inteira é copiado ou a função original é alocada no tempo de compilação e passada os valores fechados?
Llamageddon
Quero acrescentar que o padrão exige mais ou menos indiretamente (§ 20.8.11.2.1 [func.wrap.func.con] ¶ 5) que se um lambda não capturar nada, ele pode ser armazenado em um std::functionobjeto sem memória dinâmica alocação em andamento.
5gon12eder
2
@Yakk: Como você define "grande"? Um objeto com dois ponteiros de estado é "grande"? Que tal 3 ou 4? Além disso, o tamanho do objeto não é o único problema; se o objeto não puder ser movido pelo nothrow, ele deve ser armazenado em uma alocação, pois functiontem um construtor de movimento noexcept. O objetivo de dizer "geralmente exige" é que não estou dizendo " sempre exige": que há circunstâncias em que nenhuma alocação será realizada.
Nicol Bolas
0

C ++ lambda é apenas um açúcar sintático em torno da classe Functor (anônima) com sobrecarga operator()e std::functioné apenas um invólucro em torno de chamáveis ​​(ou seja, functores, lambdas, funções c, ...) que copia por valor o "objeto lambda sólido" do atual escopo da pilha - para a pilha .

Para testar o número de construtores / relocatons reais, fiz um teste (usando outro nível de empacotamento para shared_ptr, mas não é o caso). Veja por si mesmo:

#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)();
}

faz esta saída:

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

Exatamente o mesmo conjunto de ctors / dtors seria chamado para o objeto lambda alocado na pilha! (Agora ele chama Ctor para alocação de pilha, Copy-ctor (+ alocação de pilha) para construí-lo em std :: função e outro para fazer alocação de pilha shared_ptr + construção de função)

barney
fonte