sábado, 21 de abril de 2012

Funções lambda em C++ a fundo

O que é uma função lambda em C++ afinal? Como ela afeta a linguagem e o que é possível fazer com elas? Estas são algumas dúvidas que muita gente ainda tem com relação a essa nova funcionalidade da linguagem, introduzida no padrão C++11.

Em meu primeiro post aqui no blog apresentei um problema que eu encontrei ao tentar encarar as funções lambda de C++ exatamente como os lambdas de outras linguagens modernas. Naquele momento, após algumas tentativas frustradas, me convenci que lambdas não eram nada mais do que syntatic sugar para declarações de funções localmente, apenas para dar maior contexto ao código. Pouco tempo após o post ir ao ar, o colega Paulo Pires mostrou que aquilo que eu queria era possível, o que lhe custou algumas horas de sono. Prometo ao Paulo que seguirei o conselho de RTFM e tentarei não lhe causar mais insônias.

O que é uma função lambda, afinal?

Acredite, uma coisa não tem nada a ver com a outra.

Funções lambda são elementos matemáticos teóricos que surgiram com a noção de cálculo lambda. Este modelo sugeria que qualquer coisa pode ser calculada a partir da definição de funções, utilizando as chamadas expressões lambda. Tais funções não precisam sequer ter nome, e podem conter chamadas a si próprias em sua definição, no processo determinado como recursão.

O contexto das funções definidas por uma expressão lambda é denominado closure. Este elemento, que determina não apenas as operações que serão realizadas sobre o alvo da função, como também todos os outros elementos ao qual a função tem acesso, é que é utilizado pela linguagem e tratado como um objeto de primeira classe. Então, muitas vezes, quando nos referimos a uma função lambda, na verdade estamos nos referindo à closure que a define.

Este modelo formal é a base das chamadas linguagens funcionais, que utilizam funções declaradas através de expressões lambda para realizar todo trabalho computacional do problema que o programador quer modelar. Pertencem a essa categoria linguagens como Lisp, Scheme, Haskell e F#.

Apesar de serem muito populares entre matemáticos e físicos, linguagens funcionais nunca "decolaram" fora do ambiente acadêmico. Talvez em muito porque os programadores são expostos tarde demais a elas. Como eles já tem o vício de pensar e modelar seus programas em termos de linguagens estruturadas, ou orientadas a objetos, voltar ao básico e reaprender a pensar de forma funcional torna a curva de aprendizado destas linguagens bem maior.

Ainda assim, a ideia de tratar funções como objetos de primeira classe, ou seja, como elementos básicos da linguagem que podem ser manipulados livremente, é extremamente útil e encontrou lugar em várias linguagens modernas. Com uma visão mais pragmática, estas linguagens mesclam vários paradigmas de programação, incluindo a programação funcional. Entre elas, temos Python, Ruby, Scala e, agora, C++.

Nestas linguagens, funções definidas por expressões lambda são comumente utilizadas para aplicar um conjunto de operações sobre um conjunto determinado de dados, como imprimir o valor do dobro de cada componente de um vetor. Enquanto isso, tarefas que tem um escopo de projeto, que definem o modelo do software, e cujo nome é usado para identificar o fluxo de trabalho, continuam escritas em funções ou métodos "puros".

Funções lambda em C++

Transformar funções em objetos de primeira classe em C++ exigiu algumas mudanças semânticas na linguagem. Segundo o padrão, uma closure definida por uma expressão lambda é um tipo de dados único, próprio e sem nome. Mesmo que duas expressões possuam os mesmos argumentos e tipo de retorno, elas definem tipos distintos. Ao usuário comum, está disponível o tipo std::function, definido no cabeçalho functional da biblioteca padrão, como um wrapper sobre tipo criado.

Curiosamente, std::function não é definido sobre o tipo da closure em si, mas sobre o tipo do valor de retorno e argumentos da função que a definiu. Assim, mesmo que uma expressão lambda tenha sido atribuída a uma variável, esta última poderá apontar para outra closure desde que as duas funções possuam os mesmos argumentos e tipo de retorno.

auto a = [] { return 1 + 1; };
a = [] { return 2 + 2; };        // Ok, a é do tipo std::function<int()>
a = [](int x) { return x + x; }; // Erro, a closure tem uma função 
                                 // de tipo int (int)

Os tipos de cada closure, obrigatoriamente, devem possuir conversões para ponteiros de função com os mesmos tipos declarados na expressão lambda. Assim, mesmo que você possua uma função, ou método, escrito em uma versão anterior ao padrão C++11 que tome como argumento um ponteiro para uma função,  você pode usar uma expressão lambda para definir esta função, in-place.

/* Código C89 válido */
int sum_then_callback(int x, int y, (int *)callback_func(int)) {
    return callback_func(x+y);
}

// Código C++11 válido
int a = sum_then_callback(1, 2, [](int x){ return 4 * x;});

Sintaxe de expressões lambda

A expressão lambda que define uma closure é uma expressão comum, que pode ser utilizada em todos os contextos nos quais uma outra expressão qualquer é utilizada em C++, como soma ou subtração. Uma expressão lambda tem a seguinte sintaxe:

[captura] (lista-de-argumentos) mutable -> tipo-de-retorno { bloco-de-código }

Com exceção da instrução de captura e do bloco de código que define a função, todo o resto é opcional, inclusive o tipo de retorno. Se sua lambda não vai tomar nenhum argumento, tanto a lista de argumentos quanto os parênteses que a envolvem podem ser omitidos da expressão. Se ela não irá modificar nenhum elemento em escopo externo, a palavra-chave mutable pode ser omitida. O tipo de retorno é completamente opcional, e, se for omitido, o compilador irá inferi-lo automaticamente.

Talvez o elemento que mais mereça cuidado, além de mutable, que vou explicar mais tarde, é a captura. Apesar dos colchetes serem obrigatórios, a captura pode ficar completamente vazia. Sua função é  especificar que variáveis externas ao bloco de código da expressão lambda farão parte da closure, e de que forma.


Dentro da captura, você pode especificar exatamente quais variáveis farão parte da closure e se a lambda irá acessá-las por cópia ou referência. Caso você vá capturar a maioria das variáveis de uma forma ou de outra, pode usar o simbolos = (para capturar por cópia) ou & (para capturar por referência). Nesta situação, o compilador irá analisar quais variáveis externas à closure são utilizadas no bloco de código da expressão lambda e capturá-las da forma solicitada.

Como expressões lambda podem ser utilizadas como qualquer outra expressão em C++, inclusive para inicializar variáveis, acabamos ganhando de bônus uma outra sintaxe para a definição de funções. Se você vem de alguma linguagem de scripting, provavelmente vai adorar. Caso contrário, como eu, sofrerá calafrios na espinha.

#include <iostream>

auto printdouble = [] (double x) {
    std::cout << x << std::endl;
};

int main() {
    printdouble(2.0);
}

Como a closure atribuida a printdouble não captura nada, o compilador irá, efetivamente, criar uma função regular e atribuí-la a printdouble. Apesar desta sintaxe gerar uma função regular, seu acesso é feito através de um ponteiro para função. Note no entanto, que, justamente por isso, a mesma técnica não pode ser aplicada à função main, uma vez que a função gerada pela closure não possui nome. Isso acaba gerando até mesmo uma inconsistência no comportamento de expressões lambda. Não acho difícil que a sintaxe seja revisada na próxima versão do padrão para permitir que main seja declarada através de uma closure, desde que também possua um modificador const na declaração.

Por dentro do closure type e o papel de mutable

Você se lembra que eu comecei este post falando que as expressões lambda eram apenas um syntatic sugar? Pois é, elas são isso mesmo, também são muito poderosas e versáteis.

Cabe ao compilador determinar como ele vai representar um closure type declarado por uma expressão lambda. No caso em que a expressão não faz nenhuma captura, é muito provável que o closure type, no final, seja reduzido a uma função regular. Apenas é feita a cópia do bloco de código da expressão para uma outra parte do fonte, declarando uma função com um nome único (compiladores são muito bons em dar nomes a coisas anônimas) e atribuir o endereço desta função a um ponteiro. Como o padrão determina que esta função deve ser inline, é possível até que nenhuma função seja criada, apenas copiando o bloco de código e montando o quebra-cabeças de chamadas como se fossem macros (copiar e montar blocos de código como quebra-cabeça é outra coisa em que compiladores são muito bons).

Mas e no caso de uma closure que faça captura de variáveis? Bom, neste caso a closure será reduzida a um functor.

Para explicar melhor o que acontece, vou voltar ao exemplo do meu post anterior: uma função que calcula o próximo número da sequência de Fibonacci a cada chamada. No fim, acabei recorrendo a um functor. O que eu não sabia é que o mesmo functor poderia ser gerado pela expressão lambda.

A closure que eu gerei ao tentar passar objetos por referência foi a seguinte:

function<int()> fib() {
    int n1 = 1, n2 = 1;
    return [&] () {
        int temp = n1;
        n1 = n2;
        n2 += temp;
        return temp;
    };
}

Note que a expressão lambda captura n1 e n2 por referência. Como há captura de variáveis, o compilador gerará um closure type que é um functor. Ao final, o que a sintaxe vai gerar é a seguinte construção:

class FunctorObj01: public function_interface<(int*)()> {
    int &n1_, &n2_;
public:
    FunctorObj01(int& n1, int& n2): n1_(n1), n2_(n2) { }
    inline int operator()() const {
        int temp = n1_;
        n1_ = n2_;
        n2_ += temp;
        return temp;
    }
};

function<int()> fib() {
    int n1 = 1, n2 = 1;
    return FunctorObj01(n1, n2);
}

function_interface<T> seria uma interface interna do compilador que geraria todos os operadores de conversão do functor para ponteiros de função com os mesmos argumentos de operator().

Analisando friamente, você observa que foi exatamente isso que aconteceu. O meu functor trabalhou em cima de referências a variáveis locais. Assim, quando elas saíram de escopo, o comportamento do código se tornou indefinido. Repare também que o código da closure ficou dentro de operator(), declarado como inline e const. Isso significa que ele não pode modificar nenhuma variável da closure, e de fato, não modificamos as referências a n1 e n2. Mesmo modificando o conteúdo para o qual as referências apontam, a referência se mantém constante e, portanto, não há modificação do contexto da closure.

A solução que o Paulo encontrou, e publicou nos comentários, foi a seguinte construção:

function<int()> fib() {
    int n1 = 1, n2 = 1;
    return [=] () mutable {
        int temp = n1;
        n1 = n2;
        n2 += temp;
        return temp;
    };
}

Note que, desta vez estamos capturando n1 e n2 por cópia, além de adicionar o qualificador mutable, que nos permite modificar os valores dentro da closure. O compilador, após encontrar o código acima, vai gerar o seguinte functor.

class FunctorObj01: public function_interface<(int*)()> {
    int n1_, n2_;
public:
    FunctorObj01(int n1, int n2): n1_(n1), n2_(n2) { }
    inline int operator()() {
        int temp = n1_;
        n1_ = n2_;
        n2_ += temp;
        return temp;
    }
};

function<int()> fib() {
    int n1 = 1, n2 = 1;
    return FunctorObj01(n1, n2);
}

Note a ausência de const e a transformação de n1 e n2 como variáveis privadas dentro do functor. Como ambas existem fora do operator(), elas mantém seus valores entre as chamadas. Como existem dentro de um objeto, possuem áreas de memória separadas de outras instâncias. A presença da palavra-chave mutable alterou o contexto da closure e removeu o qualificador const da definição do operator(). Afinal, o functor gerado pelo compilador usando expressões lambda tem o mesmo efeito do functor que criei "na unha" no post anterior.

Conclusão

Sim, ainda é C++. No final, ainda teremos functors, ponteiros, e funções de nível mais baixo. Mas o efeito de utilizar expressões lambda para construí-los é simplesmente libertador. É incrível observar como os membros do comitê de padronização conseguiram adicionar um conceito tão alienígena e de alto nível a C++ mantendo a mesma flexibilidade e poder de expressão da linguagem.

Também é surpreendente ver como estes elementos podem ser utilizados mesmo em cima de código legado, que não tem qualquer conhecimento sobre lambdas, closures ou functors. Sem dúvida nenhuma, este é um trabalho de primeiro nível dos profissionais envolvidos com o comitê.

Lambdas podem até não fazer mágica, mas o comitê de padronização ISO-C++ fez.

16 comentários:

  1. Xi... Eu escrevi um longo comentário há poucos minutos, quando estava na casa do meu pai, que eu poderia jurar que tinha enviado com sucesso!

    Pelo visto, me enganei.

    Lá vamos nós de novo... ;)

    ResponderExcluir
    Respostas
    1. Estranho, eu cheguei a receber o e-mail com esse comentário, mas não há qualquer sinal dele no blogger. Que bizarro.

      Excluir
  2. Parabéns Homero. Mais um ótimo texto, agora minuciando a teoria das funções Lambdas. Inicialmente é complicado entender a sintaxe delas, hehehe.

    ResponderExcluir
    Respostas
    1. Este comentário foi removido pelo autor.

      Excluir
    2. Obrigado, Bruno.

      É complicado no início, principalmente porque tem muita coisa opcional. Mas ela fica mais clara conforme você analisa a proposta e mudanças do novo padrão.

      Uma das mudanças foi uma nova sintaxe para declarações de funções, colocando o tipo de retorno ao final da declaração. Por exemplo:

      auto foo(int x, int y) -> double { /* ... */ }

      É uma função que retorna um double. É uma sintaxe comum em outras linguagens e que facilita DEMAIS a vida dos compiladores, já que ele não tem que fazer mágica para identificar onde termina o tipo de retorno e onde começa o nome da função, eliminando ambiguidades. Um exemplo clássico são funções que retornam tipos aninhados:

      classeA::TAninhado1 classeB::metodox() { ... }

      Ao encontrar essa linha, o compilador não sabe se TAninhado1 é um tipo ou uma variável estática de classeA. Na verdade, até encontrar as chaves, ele sequer sabia que estava declarando uma função. Para ajudar ele, somos obrigados, atualmente, a usar a palavra chave typename, explicitando que estamos usando um tipo.

      typename classeA::Taninhado1 classeB::metodox() { ... }

      Com a nova sintaxe o typename se torna desnecessário, já que ele sabe que deve procurar um tipo com aquele nome.

      auto classeB::metodox() -> classeA::TAninhado1 { ... }

      A nova sintaxe também tem outras vantagens, especialmente em conjunto com a nova palavra chave decltype. Mas isso é assunto pra um post inteiro. ;)

      Excluir
  3. Em primeiro lugar, não se sinta constrangido por eu ter mencionado anteriormente que fiz pesquisas durante a madrugada; se o assunto também não me interessasse pessoalmente, eu certamente não o teria estudado só para debater em vão. Além disso, que hora melhor para estudar, para quem tem filhos pequenos, do que a hora em que eles estão dormindo? ;)

    Das leituras que eu fiz enquanto estava estudando sobre lambdas, motivado pela sua abordagem do assunto, eu entendi que, no contexto de linguagens herdeiras de Lisp e outras linguagens funcionais (ou que tenham implementado alguma forma de programação funcional junto com outros paradigmas) e que usam alocações e desalocações em memória associada a garbage collectors, closures têm esse nome porque (na prática, já que eu não domino a teoria) elas "fecham" para o garbage collector as variáveis declaradas fora da função lambda mas que são usadas dentro dela, mesmo quando o fluxo de execução do programa sai do escopo natural em que tais variáveis são declaradas (na prática, novamente, esse "fechamento" se dá porque o uso dessas variáveis no corpo da função lambda incrementa os contadores de referência dos ponteiros a elas subjacentes). Desse modo, nem toda função lambda implica uma closure.

    Acho interessante notar que, assim sendo, a relação entre o closure type do C++11 e closures no estilo tradicional de Lisp é ("meramente" -- poderíamos dizer) analógica, especialmente quando usado da forma como você e eu usamos nos testes que fizemos com o exemplo da função lambda que funciona como função-geradora de números na sequência de Fibonacci.

    Por que digo isso? Porque o closure type que resulta da definição de uma função lambda usando cópias trabalha, obviamente, com cópias, que são membros ocultos da classe anônima que representa o closure type. Sim, é verdade que esses membros ocultos estão "fechados" enquanto o objeto existir, mas eles são totalmente distintos das variáveis originais dos quais foram copiados.

    Isso possivelmente não é um problema para variáveis de tipos nativos ou UDTs de baixa complexidade, mas é importante notar que existe a cópia de cada variável que for "fechada". No caso de UDTs, implica a chamada de construtores de cópia do objeto, e tais chamadas podem ser tanto custosas, no caso de um objeto de uma classe complexa, quanto inseguras, principalmente quando a classe tem um membro de um tipo ponteiro e o compilador gera um construtor de cópia default, que copia o valor do ponteiro em lugar de copiar o conteúdo para um lugar distinto na memória, de modo que quando o objeto externo for destruído, a closure vai apontar para uma região de memória inválida.

    (Continua)

    ResponderExcluir
  4. Nos testes que eu fiz (descomentando, em main(), a declaração que usa func3<nint>() e comentando as demais), o problema se mostrou ainda um pouco mais pernicioso: o construtor de cópia é chamado duas vezes para cada variável copiada. Eu não sei o motivo dessas duas chamadas, mas vi com clareza que a que ficou valendo para os membros "fechados" foram as respectivas segundas chamadas, pois os destrutores das variáveis locais originais e das primeiras cópias eram invocados ao final de func3<nint>(). Eu cheguei a imaginar se essa dupla cópia poderia ser um comportamento imperfeito do G++ 4.6.1 (ou pelo menos do seu otimizador de código), que eventualmente geraria um rvalue a partir das variáveis (primeiras cópias) e então chamaria o construtor para os membros do closure type usando esses rvalues, mas não fui ao ponto de mandar gerar o assembly correspondente para o analisar. Também conjecturei se haveria, com o novo padrão do C++, alguma construção sintática que eventualmente pudesse ajudar o compilador a evitar a primeira cópia, e também se o uso de referências de rvalues e um move constructor poderiam evitar a segunda cópia, transformando-as em movimentação de valores já construídos de um objeto velho para outro novo, mas também não fiz qualquer pesquisa a respeito desses assuntos (que tal você escrever sobre eles?).

    O que eu fiz, porém, foi uma tentativa de evitar completamente qualquer cópia. Essa tentativa resultou em algo que me parece muito mais diretamente comparável com closures de Lisp. Ela está mostrada, no mesmo programa, na implementação de func5(). Ali eu substituí as variáveis originais por ponteiros envelopados com std::shared_ptr que apontavam para objetos construídos manualmente, ao mesmo tempo em que mantive a função lambda com cópia de variáveis externas. Obviamente os acessos diretos aos valores das variáveis originais tiveram que ser substituídas por derreferência dos smart pointers, mas a cópia dos shared_ptrs pela função lambda acarretava apenas o incremento do contador de uso do ponteiro. Com isso, eu consegui evitar chamadas aos construtores do meu UDT (o que, me parece, é sempre mais seguro, e em particular se viermos a trabalhar com templates) e também contornei qualquer vazamento de memória que decorreria da alocação manual da maneira como eu tinha aventado na primeira resposta que enviei à sua primeira postagem, pois os devidos destrutores serão chamados quando a variável à qual o lambda foi atribuído receber um novo valor ou sair de seu escopo (graças ao shared_ptr).

    Só que é claro que há custos associados (presentes também nas linguagens funcionais, embora ocultos por suas sintaxes). Além da maior verborragia no código fonte em C++, decorrente de trabalhar explicitamente com ponteiros e, ainda além, por envelopá-los com std::shared_ptr, há o custo da derreferência cada vez que as variáveis forem manipuladas (potencialmente agravado por passar por std::shared_ptr<T>::operator*()). Outro problema (que eu ainda não testei, até porque acabou de vir à mente) é que o uso de shared_ptr possivelmente fará com que eu não consiga copiar a variável à qual o lambda foi atribuído sem que uma interfira na outra (e não sei, aliás, como Lisp, Haskell e outras linguagens lidam com esse tipo de coisa). E nada disso faz sentido se for garantidamente barato e seguro chamar os construtores de cópia dos objetos a serem "fechados".

    Acabamos voltando ao que você disse no título da primeira postagem: funções lambda definitivamente não fazem mágica.

    ResponderExcluir
    Respostas
    1. Este merece uma resposta mais longa. Vou ler com mais cuidado mais tarde ;)

      Excluir
    2. Eu fiz o teste usando shared pointers e confirmei que se eu atribuir a uma segunda variável aquela que já tinha recebido a closure, confirmando, como eu esperava, que ambas modificam os mesmos valores.

      Eu fiquei na dúvida de se o comportamento seria o mesmo em outras linguagens. Eu testei em Perl (que é mais fácil para mim de testar), e vi que ela tem o mesmo comportamento (i.e.: se uma variável recebe uma closure e uma segunda variável recebe o valor da primeira, ambas apontam para a mesma closure, e operam sobre os mesmos dados).

      Eu cheguei a baixar tanto ML quanto Scheme, que são linguagens funcionais propriamente ditas, para ver se têm o mesmo comportamento, mas está difícil parar para aprender uma linguagem nova agora.

      Excluir
    3. Sobre a chamada dupla de construtores: Fiz alguns testes aqui e vi que, para as variáveis na closure, na primeira vez é criado um construtor de cópia (esperado, já que estamos capturando por cópia) e na segunda vez é chamado o move construtor. Creio que a primeira chamada seja feita ao criar o functor object, e a segunda na transferência deste (se o objeto não tem um move constructor definido, o copy constructor é chamado).

      Quanto à dúvida se os closure types são closures de verdade, ao meu entender sim. Veja que a definição de closure não está atrelada ao fato de existir ou não um garbage colector, mas sim à existência da função lambda e das variáveis que fazem parte do seu contexto. Mesmo que seja feita uma cópia destas variáveis, o contexto é preservado. Então, conceitualmente (e não analogamente), são a mesma coisa, embora através de implementações diferentes.

      Se você se lembra do exemplo de Go, vai ver que mesmo naquele caso há uma cópia envolvida, já que a closure retornada pela primeira chamada a fib não interfere na closure da segunda chamada.

      Agora, em um exemplo mais complexo, em que duas lambdas compartilham do mesmo contexto, realmente encontramos uma limitação das closures em C++.

      Fiz alguns testes aqui em Go e em C++. Como esperado, por fazer captura por cópia e estar limitado a um functor, duas lambdas em C++ não compartilham exatamente o mesmo contexto no qual foram criadas. Já em Go, o compartilhamento acontece de forma transparente.

      Seja como for, não creio que isso deva ser visto como uma "invalidação" de closures em C++, apenas como uma limitação destas.

      Excluir
    4. Claro que, a solução para esta limitação reside no uso de shared_ptr.

      Excluir
    5. OK. Cópia e movimentação confirma minha hipótese de ter sido criado um rvalue a partir de cada variável copiada. Eu só não entendi para quê fazer isso, em lugar de fazer diretamente uma cópia definitiva, logo de cara, usando apenas o construtor de cópia.

      Eu testei também com Go aquela história de atribuir a uma segunda variável o valor de uma primeira que já tenha recebido uma closure (isto é: f:=fib() seguido de g:=f), e também em Go o comportamento foi de que as duas trabalham com os mesmos dados. Em C++, eu só consegui esse comportamento quando usei shared pointers para os dados "fechados".

      Não entendi onde está a cópia no caso do exemplo do código em Go (que, aliás, eu usei como base para o teste a que me referi acima). Quero dizer: as variáveis declaradas dentro do bloco de fib() são criadas na heap (e desta vez eu fiz o dever de casa, gerando o assembly a partir do código em Go e rodando o programa dentro do GDB), ao contrário de C e C++, que as teriam criado na pilha. Como Go usa GC, a presença delas na definição da função lambda incrementa o contador de referência de cada uma delas (aqui eu não consegui ver com o GDB, mas normalmente é assim que a coisa funciona, inclusive no caso do C++ com std::shared_ptr). Eventuais chamadas posteriores a fib() vão criar novas variáveis em posições naturalmente distintas da heap. Desse modo, não consegui enxergar qualquer cópia.

      Excluir
    6. A questão, para mim, não é que a abordagem de C++ seja errada (ou mesmo limitada), ainda que o meu linguajar em mensagens anteriores tenha de algum modo sugerido isso. Talvez eu esteja até pecando por ficar discutindo um assunto no qual em não tenho nem base teórica (em Cálculo Lambda) nem experiência prática (com linguagens funcionais), mas o ponto que está me incomodando é o fazer em C++ o equivalente mais próximo do que se encontra em outras linguagens.

      Veja: você partiu de um exemplo em Go e explorou possíveis semelhanças e diferenças em C++. Eu fiquei curioso e comecei a investigar o que poderia explicá-las. No caminho, acabei aprendendo um pouquinho mais de C++ e também de teoria (o que por si só é positivo, embora certamente ainda falte muito a aprender). Mas só depois de várias tentativa é que eu consegui chegar a um código realmente equivalente ao que você mostrou, no princípio, em Go, porque, embora eu estivesse fazendo pleno uso do closure type do C++, o comportamento natural dele não é idêntico ao de closures noutras linguagens.

      Eu estou longe de poder pontificar se o comportamento do C++ está certo ou errado ou se atende ou não eventuais requisitos possivelmente definidos no Cálculo Lambda. Esse pode até ser um próximo passo, mas o que já discutimos até agora me capacitou a entender com alguma segurança como a coisa funciona e o que seria necessário fazer caso eu algum dia precise portar um algortimo baseado em closures para C++ com o mínimo possível de perda ou alteração de funcionalidade.

      Excluir
  5. Algumas outras considerações aleatórias que me vêm à mente:

    1) O functor deve provavelmente ser feito explicitamente sempre que ele for usado em mais de um lugar do programa. Eu acho improvável, embora não seja impossível, que o compilador identifique que duas ou mais funções lambda, em partes distintas do programa (talvez até em arquivos compilados separadamente), executem código equivalente e que, por isso, consiga aglutiná-las. Como cada função lambda vai gerar um closure type (isto é: uma classe anônima distinta), acho que essas classes anônimas permacerão distintas até mesmo depois de passarem pelo linker.

    2) Variáveis não-estáticas só devem ser passadas por referẽncia para a função lambda se esta nunca for retornada (ou devolvida por referência) pela função onde ela é declarada (e talvez esse "nunca" se aplique também ao caso de simplesmente passar de um bloco de código externo para outro mais externo). A passagem por referência é interessante para ter uma função lambda como argumento para um for_each (ou com a nova sintaxe de for), mas um perigo eminente e iminente em casos onde possa haver mudanças de escopo para outros mais externos.

    3) As restrições em (2) me levam a crer que funções lambda que recebam variáveis por referência não são objetos de primeira classe (já uma função lambda em que todos as variáveis sejam passadas por cópia provavelmente o é).

    4) Mesmo sem base teórica, eu me arrisco a dizer, com base em (2) e (3) que não se pode falar de closures se a função lambda receber qualquer argumento por referência, mesmo que o compilador eventualmente crie a classe anônima referida como closure type.

    ResponderExcluir
    Respostas
    1. 1) Isso é correto. Mesmo que as funções lambda possuam código equivalente, apenas o fato de serem declaradas distintamente já as torna tipos distintos. É claro que um bom compilador pode acabar aglutinando as duas, possivelmente se estiverem no mesmo módulo, mas o padrão declara que são tipos distintos. Um bom compilador também pode, em certos casos, sequer criar um functor se a função definida for simples o suficiente para ser inline. Um std::for_each que tem uma função lambda como parâmetro, por exemplo, deve ser avaliado até chegar a um laço for simples.

      2) Concordo. Passagem por referência também pode ser útil se duas lambdas distintas precisarem acessar a mesma variável em momentos distintos, como no caso de uma pilha de callbacks (delegates).

      3) Humm... essa é uma questão mais "filosófica". Essencialmente, ela continua sendo um objeto de primeira classe, mas que pode ter um comportamento errático se feito de forma errada. Afinal, ela ainda pode ser tratada como um objeto comum. E nem sempre o objeto que foi passado por referência vai sumir de escopo tão facilmente. Considere um método que retorna uma função que captura uma variável privada por referência, por exemplo.

      4) Discordo. No caso, a closure existe, as variáveis foram capturadas a partir do contexto da declaração lambda. No entanto, por falta de conhecimento do programador (até pela complexidade da linguagem) ela contém erros fatais. Elas definitivamente não são closures seguras como as que temos em linguagens funcionais, mas existem.

      Excluir
  6. E já uma autocrítica. Se, de fato, o compilador honrar o inline da função lambda, então minha consideração aleatória (1) só terá sentido se o programador quiser diminuir o tamanho do código e gerar o seu functor sem inlining.

    ResponderExcluir